chef-metal-docker 0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/README.md +3 -0
- data/Rakefile +6 -0
- data/lib/chef/provider/docker_container.rb +127 -0
- data/lib/chef/resource/docker_container.rb +58 -0
- data/lib/chef_metal/provisioner_init/docker_init.rb +4 -0
- data/lib/chef_metal_docker.rb +4 -0
- data/lib/chef_metal_docker/docker_convergence_strategy.rb +55 -0
- data/lib/chef_metal_docker/docker_provisioner.rb +215 -0
- data/lib/chef_metal_docker/docker_transport.rb +244 -0
- data/lib/chef_metal_docker/docker_unix_machine.rb +9 -0
- data/lib/chef_metal_docker/helpers.rb +146 -0
- data/lib/chef_metal_docker/helpers/container.rb +15 -0
- data/lib/chef_metal_docker/helpers/container/actions.rb +313 -0
- data/lib/chef_metal_docker/helpers/container/helpers.rb +156 -0
- data/lib/chef_metal_docker/version.rb +3 -0
- metadata +131 -0
@@ -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
|