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.
@@ -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