chef-metal-docker 0.1

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