hoosegow 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|