chef-metal-docker 0.1

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,215 @@
1
+ require 'chef_metal/provisioner'
2
+ require 'chef_metal/convergence_strategy/no_converge'
3
+ require 'chef_metal/convergence_strategy/install_cached'
4
+ require 'chef_metal_docker/helpers/container'
5
+ require 'chef_metal_docker/docker_transport'
6
+ require 'chef_metal_docker/docker_convergence_strategy'
7
+ require 'chef_metal_docker/docker_unix_machine'
8
+ require 'docker'
9
+
10
+ module ChefMetalDocker
11
+ class DockerProvisioner < ChefMetal::Provisioner
12
+
13
+ include ChefMetalDocker::Helpers::Container
14
+
15
+ def initialize(credentials = nil, connection = Docker.connection)
16
+ @credentials = credentials
17
+ @connection = connection
18
+ end
19
+
20
+ attr_reader :credentials
21
+ attr_reader :connection
22
+
23
+ # Inflate a provisioner from node information; we don't want to force the
24
+ # driver to figure out what the provisioner really needs, since it varies
25
+ # from provisioner to provisioner.
26
+ #
27
+ # ## Parameters
28
+ # node - node to inflate the provisioner for
29
+ #
30
+ # returns a DockerProvisioner
31
+ def self.inflate(node)
32
+ self.new
33
+ end
34
+
35
+ #
36
+ # Acquire a machine, generally by provisioning it. Returns a Machine
37
+ # object pointing at the machine, allowing useful actions like setup,
38
+ # converge, execute, file and directory. The Machine object will have a
39
+ # "node" property which must be saved to the server (if it is any
40
+ # different from the original node object).
41
+ #
42
+ # ## Parameters
43
+ # action_handler - the action_handler object that plugs into the host.
44
+ # node - node object (deserialized json) representing this machine. If
45
+ # the node has a provisioner_options hash in it, these will be used
46
+ # instead of options provided by the provisioner. TODO compare and
47
+ # fail if different?
48
+ # node will have node['normal']['provisioner_options'] in it with any options.
49
+ # It is a hash with this format:
50
+ #
51
+ # -- provisioner_url: docker:<URL of Docker API endpoint>
52
+ # -- base_image: Base image name to use, or repository_name:tag_name to use a specific tagged revision of that image
53
+ # -- command: command to run (if unspecified or nil, will spin up the container. If false, will not run anything and will just leave the image alone.)
54
+ # -- container_options: options for container create (see http://docs.docker.io/en/latest/reference/api/docker_remote_api_v1.10/#create-a-container)
55
+ # -- host_options: options for container start (see http://docs.docker.io/en/latest/reference/api/docker_remote_api_v1.10/#start-a-container)
56
+ # -- convergence_strategy: :no_converge or :install_cached (former will not converge, latter will set up chef-client and converge)
57
+ #
58
+ # node['normal']['provisioner_output'] will be populated with information
59
+ # about the created machine. For lxc, it is a hash with this
60
+ # format:
61
+ #
62
+ # -- provisioner_url: docker:<URL of Docker API endpoint>
63
+ # -- container_name: docker container name
64
+ # -- repository_name: docker image repository name from which container was inflated
65
+ #
66
+ def acquire_machine(action_handler, node)
67
+ # Set up the modified node data
68
+ provisioner_options = node['normal']['provisioner_options']
69
+ provisioner_output = node['normal']['provisioner_output'] || {
70
+ 'provisioner_url' => "docker:", # TODO put in the Docker API endpoint
71
+ 'repository_name' => "#{node['name']}_image", # TODO disambiguate with chef_server_url/path!
72
+ 'container_name' => node['name'] # TODO disambiguate with chef_server_url/path!
73
+ }
74
+
75
+ repository_name = provisioner_output['repository_name']
76
+ container_name = provisioner_output['container_name']
77
+ base_image_name = provisioner_options['base_image']
78
+ raise "base_image not specified in provisioner options!" if !base_image_name
79
+
80
+ # Tag the initial image. We aren't going to actually DO anything yet.
81
+ # We will start up after we converge!
82
+ base_image = Docker::Image.get(base_image_name)
83
+ begin
84
+ repository_image = Docker::Image.get("#{repository_name}:latest")
85
+ # If the current image does NOT have the base_image as an ancestor,
86
+ # we are going to have to re-tag it and rebuild.
87
+ if repository_image.history.any? { |entry| entry['Id'] == base_image.id }
88
+ tag_base_image = false
89
+ else
90
+ tag_base_image = true
91
+ end
92
+ rescue Docker::Error::NotFoundError
93
+ tag_base_image = true
94
+ end
95
+ if tag_base_image
96
+ action_handler.perform_action "Tag base image #{base_image_name} as #{repository_name}" do
97
+ base_image.tag('repo' => repository_name, 'force' => true)
98
+ end
99
+ end
100
+
101
+ node['normal']['provisioner_output'] = provisioner_output
102
+
103
+ # Nothing else needs to happen until converge. We already have the image we need!
104
+ machine_for(node)
105
+ end
106
+
107
+ def connect_to_machine(node)
108
+ machine_for(node)
109
+ end
110
+
111
+ def delete_machine(action_handler, node)
112
+ if node['normal'] && node['normal']['provisioner_output']
113
+ container_name = node['normal']['provisioner_output']['container_name']
114
+ ChefMetal.inline_resource(action_handler) do
115
+ docker_container container_name do
116
+ action [:kill, :remove]
117
+ end
118
+ end
119
+ end
120
+ convergence_strategy_for(node).cleanup_convergence(action_handler, node)
121
+ end
122
+
123
+ def stop_machine(action_handler, node)
124
+ if node['normal'] && node['normal']['provisioner_output']
125
+ container_name = node['normal']['provisioner_output']['container_name']
126
+ ChefMetal.inline_resource(action_handler) do
127
+ docker_container container_name do
128
+ action [:stop]
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ # This is docker-only, not Metal, at the moment.
135
+ # TODO this should be metal. Find a nice interface.
136
+ def snapshot(action_handler, node, name=nil)
137
+ container_name = node['normal']['provisioner_output']['container_name']
138
+ ChefMetal.inline_resource(action_handler) do
139
+ docker_container container_name do
140
+ action [:commit]
141
+ end
142
+ end
143
+ end
144
+
145
+ # Output Docker tar format image
146
+ # TODO this should be metal. Find a nice interface.
147
+ def save_repository(action_handler, node, path)
148
+ container_name = node['normal']['provisioner_output']['container_name']
149
+ ChefMetal.inline_resource(action_handler) do
150
+ docker_container container_name do
151
+ action [:export]
152
+ end
153
+ end
154
+ end
155
+
156
+ # Load Docker tar format image into Docker repository
157
+ def load_repository(path)
158
+ end
159
+
160
+ # Push an image back to Docker
161
+ def push_image(name)
162
+ end
163
+
164
+ # Pull an image from Docker
165
+ def pull_image(name)
166
+ end
167
+
168
+ private
169
+
170
+ def machine_for(node)
171
+ ChefMetalDocker::DockerUnixMachine.new(node, transport_for(node), convergence_strategy_for(node))
172
+ end
173
+
174
+ def convergence_strategy_for(node)
175
+ provisioner_output = node['normal']['provisioner_output']
176
+ provisioner_options = node['normal']['provisioner_options']
177
+ strategy = case provisioner_options['convergence_strategy']
178
+ when 'no_converge'
179
+ ChefMetal::ConvergenceStrategy::NoConverge.new
180
+ else
181
+ options = {}
182
+ provisioner_options = node['normal']['provisioner_options'] || {}
183
+ options[:chef_client_timeout] = provisioner_options['chef_client_timeout'] if provisioner_options.has_key?('chef_client_timeout')
184
+ ChefMetal::ConvergenceStrategy::InstallCached.new(options)
185
+ end
186
+ container_configuration = provisioner_options['container_configuration'] || {}
187
+ if provisioner_options['command']
188
+ command = provisioner_options['command']
189
+ command = command.split(/\s+/) if command.is_a?(String)
190
+ container_configuration['Cmd'] = command
191
+ elsif provisioner_options['command'] == false
192
+ container_configuration = nil
193
+ else
194
+ # TODO how do we get things started? runit? cron? wassup here.
195
+ container_configuration['Cmd'] = %w(while 1; sleep 1000; end)
196
+ end
197
+ ChefMetalDocker::DockerConvergenceStrategy.new(strategy,
198
+ provisioner_output['repository_name'],
199
+ provisioner_output['container_name'],
200
+ container_configuration,
201
+ provisioner_options['host_configuration'] || {},
202
+ credentials,
203
+ connection)
204
+ end
205
+
206
+ def transport_for(node)
207
+ provisioner_output = node['normal']['provisioner_output']
208
+ ChefMetalDocker::DockerTransport.new(
209
+ provisioner_output['repository_name'],
210
+ provisioner_output['container_name'],
211
+ credentials,
212
+ connection)
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,244 @@
1
+ require 'chef_metal/transport'
2
+ require 'docker'
3
+ require 'archive/tar/minitar'
4
+ require 'shellwords'
5
+ require 'uri'
6
+ require 'socket'
7
+
8
+ module ChefMetalDocker
9
+ class DockerTransport < ChefMetal::Transport
10
+ def initialize(repository_name, container_name, credentials, connection)
11
+ @repository_name = repository_name
12
+ @container_name = container_name
13
+ @image = Docker::Image.get("#{repository_name}:latest", connection)
14
+ @credentials = credentials
15
+ @connection = connection
16
+ end
17
+
18
+ attr_reader :container_name
19
+ attr_reader :repository_name
20
+ attr_reader :image
21
+ attr_reader :credentials
22
+ attr_reader :connection
23
+
24
+ def execute(command, options={})
25
+ Chef::Log.debug("execute '#{command}' with options #{options}")
26
+ begin
27
+ connection.post("/containers/#{container_name}/stop?t=0", '')
28
+ Chef::Log.debug("stopped /containers/#{container_name}")
29
+ rescue Docker::Error::NotFoundError
30
+ end
31
+ begin
32
+ # Delete the container if it exists and is dormant
33
+ connection.delete("/containers/#{container_name}?v=true&force=true")
34
+ Chef::Log.debug("deleted /containers/#{container_name}")
35
+ rescue Docker::Error::NotFoundError
36
+ end
37
+ Chef::Log.debug("Creating #{container_name} from #{repository_name}:latest")
38
+ @container = Docker::Container.create({
39
+ 'name' => container_name,
40
+ 'Image' => "#{repository_name}:latest",
41
+ 'Cmd' => (command.is_a?(String) ? Shellwords.shellsplit(command) : command),
42
+ 'AttachStdout' => true,
43
+ 'AttachStderr' => true,
44
+ 'TTY' => false
45
+ }, connection)
46
+
47
+ read_timeout = execute_timeout(options)
48
+ read_timeout = nil if read_timeout == 0
49
+ Docker.options[:read_timeout] = read_timeout
50
+ begin
51
+ stdout = ''
52
+ stderr = ''
53
+
54
+ attach_thread = Thread.new do
55
+ Chef::Log.debug("Setting timeout to 15 minutes")
56
+ Docker.options[:read_timeout] = (15 * 60)
57
+
58
+ Chef::Log.debug("Attaching to #{container_name}")
59
+ # Capture stdout / stderr
60
+ @container.attach do |type, str|
61
+ case type
62
+ when :stdout
63
+ stdout << str
64
+ stream_chunk(options, stdout, nil)
65
+ when :stderr
66
+ stderr << str
67
+ stream_chunk(options, nil, stderr)
68
+ else
69
+ raise "unexpected message type #{type}"
70
+ end
71
+ end
72
+
73
+ Chef::Log.debug("Removing temporary read timeout")
74
+ Docker.options.delete(:read_timeout)
75
+ end
76
+
77
+ begin
78
+ Chef::Log.debug("Starting #{container_name}")
79
+ # Start the container
80
+ @container.start
81
+
82
+ Chef::Log.debug("Grabbing exit status from #{container_name}")
83
+ # Capture exit code
84
+ exit_status = @container.wait(read_timeout)
85
+ attach_thread.join
86
+
87
+ unless options[:read_only]
88
+ Chef::Log.debug("Committing #{container_name} as #{repository_name}")
89
+ @image = @container.commit('repo' => repository_name)
90
+ end
91
+
92
+ Chef::Log.debug("Execute complete: status #{exit_status['StatusCode']}")
93
+ DockerResult.new(command, options, stdout, stderr, exit_status['StatusCode'])
94
+ ensure
95
+ Thread.kill(attach_thread) if attach_thread.alive?
96
+ end
97
+ ensure
98
+ Chef::Log.debug("Removing temporary read timeout")
99
+ Docker.options.delete(:read_timeout)
100
+ end
101
+ end
102
+
103
+ def read_file(path)
104
+ container = Docker::Container.create({
105
+ 'Image' => "#{repository_name}:latest",
106
+ 'Cmd' => %w(echo true)
107
+ }, connection)
108
+ begin
109
+ tarfile = ''
110
+ # NOTE: this would be more efficient if we made it a stream and passed that to Minitar
111
+ container.copy(path) do |block|
112
+ tarfile << block
113
+ end
114
+ rescue Docker::Error::ServerError
115
+ if $!.message =~ /500/
116
+ return nil
117
+ else
118
+ raise
119
+ end
120
+ ensure
121
+ container.delete
122
+ end
123
+
124
+ output = ''
125
+ Archive::Tar::Minitar::Input.open(StringIO.new(tarfile)) do |inp|
126
+ inp.each do |entry|
127
+ while next_output = entry.read
128
+ output << next_output
129
+ end
130
+ entry.close
131
+ end
132
+ end
133
+
134
+ output
135
+ end
136
+
137
+ def write_file(path, content)
138
+ # TODO hate tempfiles. Find an in memory way.
139
+ Tempfile.open('metal_docker_write_file') do |file|
140
+ file.write(content)
141
+ file.close
142
+ @image = @image.insert_local('localPath' => file.path, 'outputPath' => path, 't' => "#{repository_name}:latest")
143
+ end
144
+ end
145
+
146
+ def download_file(path, local_path)
147
+ # TODO stream
148
+ file = File.open(local_path, 'w')
149
+ begin
150
+ file.write(read_file(path))
151
+ file.close
152
+ rescue
153
+ File.delete(file)
154
+ end
155
+ end
156
+
157
+ def upload_file(local_path, path)
158
+ @image = @image.insert_local('localPath' => local_path, 'outputPath' => path, 't' => "#{repository_name}:latest")
159
+ end
160
+
161
+ def make_url_available_to_remote(url)
162
+ # The host is already open to the container. Just find out its address and return it!
163
+ uri = URI(url)
164
+ host = Socket.getaddrinfo(uri.host, uri.scheme, nil, :STREAM)[0][3]
165
+ if host == '127.0.0.1'
166
+ result = execute('ip route ls', :read_only => true)
167
+ if result.stdout =~ /default via (\S+)/
168
+ uri.host = $1
169
+ return uri.to_s
170
+ else
171
+ raise "Cannot forward port: ip route ls did not show default in expected format.\nSTDOUT: #{result.stdout}"
172
+ end
173
+ end
174
+ url
175
+ end
176
+
177
+ def disconnect
178
+ end
179
+
180
+ def available?
181
+ end
182
+
183
+ private
184
+
185
+ # Copy of container.attach with timeout support
186
+ def attach_with_timeout(container, options = {}, read_timeout, &block)
187
+ opts = {
188
+ :stream => true, :stdout => true, :stderr => true
189
+ }.merge(options)
190
+ # Creates list to store stdout and stderr messages
191
+ msgs = Docker::Messages.new
192
+ connection.post(
193
+ "/containers/#{container.id}/attach",
194
+ opts,
195
+ :response_block => attach_for(block, msgs),
196
+ :read_timeout => read_timeout
197
+ )
198
+ [msgs.stdout_messages, msgs.stderr_messages]
199
+ end
200
+
201
+ # Method that takes chunks and calls the attached block for each mux'd message
202
+ def attach_for(block, msg_stack)
203
+ messages = Docker::Messages.new
204
+ lambda do |c,r,t|
205
+ messages = messages.decipher_messages(c)
206
+ msg_stack.append(messages)
207
+
208
+ unless block.nil?
209
+ messages.stdout_messages.each do |msg|
210
+ block.call(:stdout, msg)
211
+ end
212
+ messages.stderr_messages.each do |msg|
213
+ block.call(:stderr, msg)
214
+ end
215
+ end
216
+ end
217
+ end
218
+
219
+ class DockerResult
220
+ def initialize(command, options, stdout, stderr, exitstatus)
221
+ @command = command
222
+ @options = options
223
+ @stdout = stdout
224
+ @stderr = stderr
225
+ @exitstatus = exitstatus
226
+ end
227
+
228
+ attr_reader :command
229
+ attr_reader :options
230
+ attr_reader :stdout
231
+ attr_reader :stderr
232
+ attr_reader :exitstatus
233
+
234
+ def error!
235
+ if exitstatus != 0
236
+ msg = "Error: command '#{command}' exited with code #{exitstatus}.\n"
237
+ msg << "STDOUT: #{stdout}" if !options[:stream] && !options[:stream_stdout] && Chef::Config.log_level != :debug
238
+ msg << "STDERR: #{stderr}" if !options[:stream] && !options[:stream_stderr] && Chef::Config.log_level != :debug
239
+ raise msg
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end