hoosegow 1.2.0

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