hoosegow 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Dockerfile +46 -0
- data/Gemfile +9 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/Rakefile +37 -0
- data/bin/hoosegow +14 -0
- data/docs/dispatch-seq.txt +13 -0
- data/docs/dispatch.md +53 -0
- data/docs/dispatch.png +0 -0
- data/hoosegow.gemspec +32 -0
- data/lib/hoosegow.rb +160 -0
- data/lib/hoosegow/docker.rb +236 -0
- data/lib/hoosegow/exceptions.rb +27 -0
- data/lib/hoosegow/image_bundle.rb +111 -0
- data/lib/hoosegow/protocol.rb +133 -0
- data/script/proxy-integration-test +95 -0
- data/spec/hoosegow_docker_spec.rb +109 -0
- data/spec/hoosegow_spec.rb +170 -0
- data/spec/test_inmate/inmate.rb +7 -0
- metadata +159 -0
@@ -0,0 +1,236 @@
|
|
1
|
+
require 'yajl'
|
2
|
+
require 'docker'
|
3
|
+
require 'stringio'
|
4
|
+
|
5
|
+
require_relative 'exceptions'
|
6
|
+
|
7
|
+
class Hoosegow
|
8
|
+
# Minimal API client for Docker, allowing attaching to container
|
9
|
+
# stdin/stdout/stderr.
|
10
|
+
class Docker
|
11
|
+
::Docker.options[:read_timeout] = 3600
|
12
|
+
::Docker.options[:write_timeout] = 3600
|
13
|
+
DEFAULT_HOST = "127.0.0.1"
|
14
|
+
DEFAULT_PORT = 4243
|
15
|
+
DEFAULT_SOCKET = "/var/run/docker.sock"
|
16
|
+
|
17
|
+
# Initialize a new Docker API client.
|
18
|
+
#
|
19
|
+
# options - Connection options.
|
20
|
+
# :host - IP or hostname to connect to (unless using Unix
|
21
|
+
# socket).
|
22
|
+
# :port - TCP port to connect to (unless using Unix socket).
|
23
|
+
# :socket - Path to local Unix socket (unless using host and
|
24
|
+
# port).
|
25
|
+
# :after_create - A proc that will be called after a container is created.
|
26
|
+
# :after_start - A proc that will be called after a container is started.
|
27
|
+
# :after_stop - A proc that will be called after a container stops.
|
28
|
+
# :prestart - Start a new container after each `run_container` call.
|
29
|
+
# :volumes - A mapping of volumes to mount in the container. e.g.
|
30
|
+
# if the Dockerfile has `VOLUME /work`, where the container will
|
31
|
+
# write data, and `VOLUME /config` where read-only configuration
|
32
|
+
# is, you might use
|
33
|
+
# :volumes => {
|
34
|
+
# "/config" => "/etc/shared-config",
|
35
|
+
# "/work" => "/data/work:rw",
|
36
|
+
# }
|
37
|
+
# `:volumes => { "/work" => "/home/localuser/work/to/do" }`
|
38
|
+
# :Other - any option with a capitalized key will be passed on
|
39
|
+
# to the 'create container' call. See http://docs.docker.io/en/latest/reference/api/docker_remote_api_v1.9/#create-a-container
|
40
|
+
def initialize(options = {})
|
41
|
+
::Docker.url = docker_url options
|
42
|
+
@after_create = options[:after_create]
|
43
|
+
@after_start = options[:after_start]
|
44
|
+
@after_stop = options[:after_stop]
|
45
|
+
@volumes = options[:volumes]
|
46
|
+
@prestart = options.fetch(:prestart, true)
|
47
|
+
@container_options = options.select { |k,v| k =~ /\A[A-Z]/ }
|
48
|
+
end
|
49
|
+
|
50
|
+
# Public: Create and start a Docker container if one hasn't been started
|
51
|
+
# already, then attach to it its stdin/stdout.
|
52
|
+
#
|
53
|
+
# image - The image to run.
|
54
|
+
# data - The data to pipe to the container's stdin.
|
55
|
+
#
|
56
|
+
# Returns the data from the container's stdout.
|
57
|
+
def run_container(image, data, &block)
|
58
|
+
unless @prestart && @container
|
59
|
+
create_container(image)
|
60
|
+
start_container
|
61
|
+
end
|
62
|
+
|
63
|
+
begin
|
64
|
+
attach_container(data, &block)
|
65
|
+
ensure
|
66
|
+
wait_container
|
67
|
+
delete_container
|
68
|
+
if @prestart
|
69
|
+
create_container(image)
|
70
|
+
start_container
|
71
|
+
end
|
72
|
+
end
|
73
|
+
nil
|
74
|
+
end
|
75
|
+
|
76
|
+
# Public: Create a container using the specified image.
|
77
|
+
#
|
78
|
+
# image - The name of the image to start the container with.
|
79
|
+
#
|
80
|
+
# Returns nothing.
|
81
|
+
def create_container(image)
|
82
|
+
@container = ::Docker::Container.create @container_options.merge(
|
83
|
+
:StdinOnce => true,
|
84
|
+
:OpenStdin => true,
|
85
|
+
:Volumes => volumes_for_create,
|
86
|
+
:Image => image
|
87
|
+
)
|
88
|
+
callback @after_create
|
89
|
+
end
|
90
|
+
|
91
|
+
# Public: Start a Docker container.
|
92
|
+
#
|
93
|
+
# Returns nothing.
|
94
|
+
def start_container
|
95
|
+
@container.start :Binds => volumes_for_bind
|
96
|
+
callback @after_start
|
97
|
+
end
|
98
|
+
|
99
|
+
# Attach to a container, writing data to container's STDIN.
|
100
|
+
#
|
101
|
+
# Returns combined STDOUT/STDERR from container.
|
102
|
+
def attach_container(data, &block)
|
103
|
+
stdin = StringIO.new data
|
104
|
+
@container.attach :stdin => stdin, &block
|
105
|
+
end
|
106
|
+
|
107
|
+
# Public: Wait for a container to finish.
|
108
|
+
#
|
109
|
+
# Returns nothing.
|
110
|
+
def wait_container
|
111
|
+
@container.wait
|
112
|
+
callback @after_stop
|
113
|
+
end
|
114
|
+
|
115
|
+
# Public: Stop the running container.
|
116
|
+
#
|
117
|
+
# Returns response body or nil if no container is running.
|
118
|
+
def stop_container
|
119
|
+
return unless @container
|
120
|
+
@container.stop :timeout => 0
|
121
|
+
callback @after_stop
|
122
|
+
end
|
123
|
+
|
124
|
+
# Public: Delete the last started container.
|
125
|
+
#
|
126
|
+
# Returns response body or nil if no container was started.
|
127
|
+
def delete_container
|
128
|
+
return unless @container
|
129
|
+
@container.delete
|
130
|
+
end
|
131
|
+
|
132
|
+
# Public: Build a new image.
|
133
|
+
#
|
134
|
+
# name - The name to give the image.
|
135
|
+
# tarfile - Tarred data for creating image. See http://docs.docker.io/en/latest/api/docker_remote_api_v1.5/#build-an-image-from-dockerfile-via-stdin
|
136
|
+
#
|
137
|
+
# Returns Array of build result objects from the Docker API.
|
138
|
+
def build_image(name, tarfile)
|
139
|
+
# Setup parser to receive chunks and yield parsed JSON objects.
|
140
|
+
ret = []
|
141
|
+
error = nil
|
142
|
+
parser = Yajl::Parser.new
|
143
|
+
parser.on_parse_complete = Proc.new do |obj|
|
144
|
+
ret << obj
|
145
|
+
error = Hoosegow::ImageBuildError.new(obj) if obj["error"]
|
146
|
+
yield obj if block_given?
|
147
|
+
end
|
148
|
+
|
149
|
+
# Make API call to create image.
|
150
|
+
opts = {:t => name, :rm => '1'}
|
151
|
+
::Docker::Image.build_from_tar StringIO.new(tarfile), opts do |chunk|
|
152
|
+
parser << chunk
|
153
|
+
end
|
154
|
+
|
155
|
+
raise error if error
|
156
|
+
|
157
|
+
# Return Array of received objects.
|
158
|
+
ret
|
159
|
+
end
|
160
|
+
|
161
|
+
# Check if a Docker image exists.
|
162
|
+
#
|
163
|
+
# name - The name of the image to check for.
|
164
|
+
#
|
165
|
+
# Returns true/false.
|
166
|
+
def image_exist?(name)
|
167
|
+
::Docker::Image.exist? name
|
168
|
+
end
|
169
|
+
|
170
|
+
private
|
171
|
+
# Private: Get the URL to use for communicating with Docker. If a host and/or
|
172
|
+
# port a present, a TCP socket URL will be generated. Otherwise a Unix
|
173
|
+
# socket will be used.
|
174
|
+
#
|
175
|
+
# options - A Hash of options for building the URL.
|
176
|
+
# :host - The hostname or IP of a remote Docker daemon
|
177
|
+
# (optional).
|
178
|
+
# :port - The TCP port of the remote Docker daemon (optional).
|
179
|
+
# :socket - The path of a local Unix socket (optional).
|
180
|
+
#
|
181
|
+
# Returns a String url.
|
182
|
+
def docker_url(options)
|
183
|
+
if options[:host] || options[:port]
|
184
|
+
host = options[:host] || DEFAULT_HOST
|
185
|
+
port = options[:port] || DEFAULT_PORT
|
186
|
+
"tcp://#{host}:#{port}"
|
187
|
+
else
|
188
|
+
path = options[:socket] || DEFAULT_SOCKET
|
189
|
+
"unix://#{path}"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Private: Generate the `Volumes` argument for creating a container.
|
194
|
+
#
|
195
|
+
# Given a hash of container_path => local_path in @volumes, generate a
|
196
|
+
# hash of container_path => {}.
|
197
|
+
def volumes_for_create
|
198
|
+
result = {}
|
199
|
+
each_volume do |container_path, local_path, permissions|
|
200
|
+
result[container_path] = {}
|
201
|
+
end
|
202
|
+
result
|
203
|
+
end
|
204
|
+
|
205
|
+
# Private: Generate the `Binds` argument for starting a container.
|
206
|
+
#
|
207
|
+
# Given a hash of container_path => local_path in @volumes, generate an
|
208
|
+
# array of "local_path:container_path:rw".
|
209
|
+
def volumes_for_bind
|
210
|
+
result = []
|
211
|
+
each_volume do |container_path, local_path, permissions|
|
212
|
+
result << "#{local_path}:#{container_path}:#{permissions}"
|
213
|
+
end
|
214
|
+
result
|
215
|
+
end
|
216
|
+
|
217
|
+
# Private: Yields information about each `@volume`.
|
218
|
+
#
|
219
|
+
# each_volume do |container_path, local_path, permissions|
|
220
|
+
# end
|
221
|
+
def each_volume
|
222
|
+
if @volumes
|
223
|
+
@volumes.each do |container_path, local_path|
|
224
|
+
local_path, permissions = local_path.split(':', 2)
|
225
|
+
permissions ||= "ro"
|
226
|
+
yield container_path, local_path, permissions
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def callback(callback_proc)
|
232
|
+
callback_proc.call(@container.info) if callback_proc
|
233
|
+
rescue Object
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class Hoosegow
|
2
|
+
# General error for others to inherit from.
|
3
|
+
class Error < StandardError; end
|
4
|
+
|
5
|
+
# Errors while building the Docker image.
|
6
|
+
class ImageBuildError < Error
|
7
|
+
def initialize(message)
|
8
|
+
if message.is_a?(Hash)
|
9
|
+
@detail = message['errorDetail']
|
10
|
+
message = message['error']
|
11
|
+
end
|
12
|
+
super(message)
|
13
|
+
end
|
14
|
+
|
15
|
+
# The error details from docker.
|
16
|
+
#
|
17
|
+
# Example:
|
18
|
+
# {"code" => 127, "message" => "The command [/bin/sh -c boom] returned a non-zero code: 127"}
|
19
|
+
attr_reader :detail
|
20
|
+
end
|
21
|
+
|
22
|
+
# Errors while importing dependencies
|
23
|
+
class InmateImportError < Error; end
|
24
|
+
|
25
|
+
# Errors while running an inmate
|
26
|
+
class InmateRuntimeError < Error; end
|
27
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
class Hoosegow
|
4
|
+
class ImageBundle
|
5
|
+
# Public: The source for the Dockerfile. Defaults to Dockerfile in the hoosegow gem.
|
6
|
+
attr_accessor :dockerfile
|
7
|
+
|
8
|
+
# Public: The ruby version to install on the container.
|
9
|
+
attr_accessor :ruby_version
|
10
|
+
|
11
|
+
# Public: Include files in the bundle.
|
12
|
+
#
|
13
|
+
# To add all files in "root" to the root of the bundle:
|
14
|
+
# add("root/*")
|
15
|
+
#
|
16
|
+
# To add all files other than files that start with "." to the root of the bundle:
|
17
|
+
# add("root/*", :ignore_hidden => true)
|
18
|
+
#
|
19
|
+
# To add all files in "lib" to "vendor/lib":
|
20
|
+
# add("lib/*", :prefix => "vendor/lib")
|
21
|
+
# add("lib", :prefix => "vendor")
|
22
|
+
def add(glob, options)
|
23
|
+
definition << options.merge(:glob => glob)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Public: Exclude files from the bundle.
|
27
|
+
#
|
28
|
+
# To exclude "Gemfile.lock":
|
29
|
+
# exclude("Gemfile.lock")
|
30
|
+
def exclude(path)
|
31
|
+
excludes << path
|
32
|
+
end
|
33
|
+
|
34
|
+
# Public: The default name of the docker image, based on the tarball's hash.
|
35
|
+
def image_name
|
36
|
+
(tarball && @image_name)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Tarball of this gem and the inmate file. Used for building an image.
|
40
|
+
#
|
41
|
+
# Returns the tar file's bytes.
|
42
|
+
def tarball
|
43
|
+
return @tarball if defined? @tarball
|
44
|
+
|
45
|
+
require 'open3'
|
46
|
+
Dir.mktmpdir do |tmpdir|
|
47
|
+
definition.each do |options|
|
48
|
+
glob = options.fetch(:glob)
|
49
|
+
prefix = options[:prefix]
|
50
|
+
ignore_hidden = options[:ignore_hidden]
|
51
|
+
|
52
|
+
files = Dir[glob]
|
53
|
+
files.reject! { |f| f.start_with?('.') } if ignore_hidden
|
54
|
+
|
55
|
+
dest = prefix ? File.join(tmpdir, prefix) : tmpdir
|
56
|
+
|
57
|
+
FileUtils.mkpath(dest)
|
58
|
+
FileUtils.cp_r(files, dest)
|
59
|
+
end
|
60
|
+
|
61
|
+
excludes.each do |path|
|
62
|
+
full_path = File.join(tmpdir, path)
|
63
|
+
if File.file?(full_path)
|
64
|
+
File.unlink(File.join(tmpdir, path))
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Specify the correct ruby version in the Dockerfile.
|
69
|
+
bundle_dockerfile = File.join(tmpdir, "Dockerfile")
|
70
|
+
content = IO.read(bundle_dockerfile)
|
71
|
+
content = content.gsub("{{ruby_version}}", ruby_version)
|
72
|
+
IO.write bundle_dockerfile, content
|
73
|
+
|
74
|
+
if dockerfile
|
75
|
+
File.unlink bundle_dockerfile
|
76
|
+
FileUtils.cp dockerfile, bundle_dockerfile
|
77
|
+
end
|
78
|
+
|
79
|
+
# Find hash of all files we're sending over.
|
80
|
+
digest = Digest::SHA1.new
|
81
|
+
Dir[File.join(tmpdir, '**/*')].each do |path|
|
82
|
+
if File.file? path
|
83
|
+
open path, 'r' do |file|
|
84
|
+
digest.update file.read
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
@image_name = "hoosegow:#{digest.hexdigest}"
|
89
|
+
|
90
|
+
# Create tarball of the tmpdir.
|
91
|
+
stdout, stderr, status = Open3.capture3 'tar', '-c', '-C', tmpdir, '.'
|
92
|
+
|
93
|
+
raise Hoosegow::ImageBuildError, stderr unless stderr.empty?
|
94
|
+
|
95
|
+
@tarball = stdout
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
# The things to include in the tarfile.
|
102
|
+
def definition
|
103
|
+
@definition ||= []
|
104
|
+
end
|
105
|
+
|
106
|
+
# The things to exclude from the tarfile
|
107
|
+
def excludes
|
108
|
+
@excludes ||= []
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'msgpack'
|
2
|
+
require 'thread'
|
3
|
+
|
4
|
+
class Hoosegow
|
5
|
+
# See docs/dispatch.md for more information.
|
6
|
+
module Protocol
|
7
|
+
# Hoosegow, on the app side of the proxy.
|
8
|
+
#
|
9
|
+
# Sends data to and from an inmate, via a Docker container running `bin/hoosegow`.
|
10
|
+
class Proxy
|
11
|
+
# Options:
|
12
|
+
# * (optional) :yield - a block to call when the inmate yields
|
13
|
+
# * (optional) :stdout - an IO for writing STDOUT from the inmate
|
14
|
+
# * (optional) :stderr - an IO for writing STDERR from the inmate
|
15
|
+
def initialize(options)
|
16
|
+
@yield_block = options.fetch(:yield, nil)
|
17
|
+
@stdout = options.fetch(:stdout, $stdout)
|
18
|
+
@stderr = options.fetch(:stderr, $stderr)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Encodes a "send" method call for an inmate.
|
22
|
+
def encode_send(method_name, args)
|
23
|
+
MessagePack.pack([method_name, args])
|
24
|
+
end
|
25
|
+
|
26
|
+
# The return value
|
27
|
+
attr_reader :return_value
|
28
|
+
|
29
|
+
# Decodes a message from an inmate via docker.
|
30
|
+
def receive(type, msg)
|
31
|
+
if type == :stdout
|
32
|
+
@unpacker ||= MessagePack::Unpacker.new
|
33
|
+
@unpacker.feed_each(msg) do |decoded|
|
34
|
+
inmate_type, inmate_value = decoded
|
35
|
+
case inmate_type.to_s
|
36
|
+
when 'yield'
|
37
|
+
@yield_block.call(*inmate_value) if @yield_block
|
38
|
+
when 'return'
|
39
|
+
@return_value = inmate_value
|
40
|
+
when 'raise'
|
41
|
+
raise(*raise_args(inmate_value))
|
42
|
+
when 'stdout'
|
43
|
+
@stdout.write(inmate_value)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
elsif type == :stderr
|
47
|
+
@stderr.write(msg)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def raise_args(remote_error)
|
52
|
+
exception_class = Hoosegow::InmateRuntimeError
|
53
|
+
exception_message = "#{remote_error['class']}: #{remote_error['message']}"
|
54
|
+
if remote_backtrace = remote_error['backtrace']
|
55
|
+
exception_message << ("\n" + Array(remote_backtrace).join("\n"))
|
56
|
+
end
|
57
|
+
[exception_class, exception_message]
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# bin/hoosegow client (where the inmate code runs)
|
62
|
+
#
|
63
|
+
# Translates stdin into a method call on on inmate.
|
64
|
+
# Encodes yields and the return value onto a stream.
|
65
|
+
class Inmate
|
66
|
+
def self.run(options)
|
67
|
+
o = new(options)
|
68
|
+
o.intercepting do
|
69
|
+
o.run
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Options:
|
74
|
+
# * :stdout - real stdout, where we can write things that our parent process will see
|
75
|
+
# * :intercepted - where this process or child processes write STDOUT to
|
76
|
+
# * (optional) :inmate - the hoosegow instance to use as the inmate.
|
77
|
+
# * (optional) :stdin - where to read the encoded method call data.
|
78
|
+
def initialize(options)
|
79
|
+
@inmate = options.fetch(:inmate) { Hoosegow.new(:no_proxy => true) }
|
80
|
+
@stdin = options.fetch(:stdin, $stdin)
|
81
|
+
@stdout = options.fetch(:stdout)
|
82
|
+
@intercepted = options.fetch(:intercepted)
|
83
|
+
@stdout_mutex = Mutex.new
|
84
|
+
end
|
85
|
+
|
86
|
+
def run
|
87
|
+
name, args = MessagePack::Unpacker.new(@stdin).read
|
88
|
+
result = @inmate.send(name, *args) do |*yielded|
|
89
|
+
report(:yield, yielded)
|
90
|
+
nil # Don't return anything from the inmate's `yield`.
|
91
|
+
end
|
92
|
+
report(:return, result)
|
93
|
+
rescue => e
|
94
|
+
report(:raise, {:class => e.class.name, :message => e.message, :backtrace => e.backtrace})
|
95
|
+
end
|
96
|
+
|
97
|
+
def intercepting
|
98
|
+
start_intercepting
|
99
|
+
yield
|
100
|
+
ensure
|
101
|
+
stop_intercepting
|
102
|
+
end
|
103
|
+
|
104
|
+
def start_intercepting
|
105
|
+
@intercepting = true
|
106
|
+
@intercept_thread = Thread.new do
|
107
|
+
begin
|
108
|
+
loop do
|
109
|
+
if IO.select([@intercepted], nil, nil, 0.1)
|
110
|
+
report(:stdout, @intercepted.read_nonblock(100000))
|
111
|
+
elsif ! @intercepting
|
112
|
+
break
|
113
|
+
end
|
114
|
+
end
|
115
|
+
rescue EOFError
|
116
|
+
# stdout is closed, so we can stop checking it.
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def stop_intercepting
|
122
|
+
@intercepting = false
|
123
|
+
@intercept_thread.join
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def report(type, data)
|
129
|
+
@stdout_mutex.synchronize { @stdout.write(MessagePack.pack([type, data])) }
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|