#
# Author:: Sean OMeara (<sean@sean.io>)
#
# Copyright (C) 2015, Sean OMeara
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require 'digest'
require 'kitchen'
require 'tmpdir'
require 'docker'
require 'lockfile'
require_relative '../helpers'
include Dokken::Helpers
# FIXME: - make true
Excon.defaults[:ssl_verify_peer] = false
module Kitchen
module Driver
# Dokken driver for Kitchen.
#
# @author Sean OMeara <sean@sean.io>
class Dokken < Kitchen::Driver::Base
default_config :api_retries, 20
default_config :binds, []
default_config :cap_add, nil
default_config :cap_drop, nil
default_config :chef_image, 'chef/chef'
default_config :chef_version, 'latest'
default_config :data_image, 'dokken/kitchen-cache:latest'
default_config :dns, nil
default_config :dns_search, nil
default_config :docker_info, docker_info
default_config :entrypoint, nil
default_config :env, nil
default_config :ports, nil
default_config :docker_host_url, default_docker_host
default_config :hostname, 'dokken'
default_config :image_prefix, nil
default_config :links, nil
default_config :network_mode, 'dokken'
default_config :pid_one_command, 'sh -c "trap exit 0 SIGTERM; while :; do sleep 1; done"'
default_config :privileged, false
default_config :read_timeout, 3600
default_config :security_opt, nil
default_config :tmpfs, {}
default_config :volumes, nil
default_config :write_timeout, 3600
# (see Base#create)
def create(state)
# image to config
pull_platform_image
# network
make_dokken_network
# chef
pull_chef_image
create_chef_container state
# data
dokken_create_sandbox
if remote_docker_host?
make_data_image
start_data_container state
end
# work image
build_work_image state
# runner
start_runner_container state
# misc
save_misc_state state
end
def destroy(_state)
if remote_docker_host?
stop_data_container
delete_data_container
end
stop_runner_container
delete_runner_container
delete_work_image
dokken_delete_sandbox
end
private
class PartialHash < Hash
def ==(other)
other.is_a?(Hash) && all? { |key, val| other.key?(key) && other[key] == val }
end
end
def api_retries
config[:api_retries]
end
def docker_connection
opts = ::Docker.options
opts[:read_timeout] = config[:read_timeout]
opts[:write_timeout] = config[:write_timeout]
@docker_connection ||= ::Docker::Connection.new(config[:docker_host_url], opts)
end
def delete_work_image
return unless ::Docker::Image.exist?(work_image, {}, docker_connection)
with_retries { @work_image = ::Docker::Image.get(work_image, {}, docker_connection) }
with_retries do
begin
with_retries { @work_image.remove(force: true) }
rescue ::Docker::Error::ConflictError
debug "driver - #{work_image} cannot be removed"
end
end
end
def build_work_image(state)
info('Building work image..')
return if ::Docker::Image.exist?(work_image, {}, docker_connection)
begin
@intermediate_image = ::Docker::Image.build(
work_image_dockerfile,
{
't' => work_image,
},
docker_connection
)
rescue
raise 'work_image build failed'
end
state[:work_image] = work_image
end
def work_image_dockerfile
dockerfile_contents = [
"FROM #{platform_image}",
"LABEL X-Built-By=kitchen-dokken X-Built-From=#{platform_image}",
]
Array(config[:intermediate_instructions]).each do |c|
dockerfile_contents << c
end
dockerfile_contents.join("\n")
end
def save_misc_state(state)
state[:platform_image] = platform_image
state[:instance_name] = instance_name
state[:instance_platform_name] = instance_platform_name
state[:image_prefix] = image_prefix
end
def delete_chef_container
debug "driver - deleting container #{chef_container_name}"
delete_container chef_container_name
end
def delete_data_container
debug "driver - deleting container #{data_container_name}"
delete_container data_container_name
end
def delete_runner_container
debug "driver - deleting container #{runner_container_name}"
delete_container runner_container_name
end
def image_prefix
config[:image_prefix]
end
def instance_platform_name
instance.platform.name
end
def stop_runner_container
debug "driver - stopping container #{runner_container_name}"
stop_container runner_container_name
end
def stop_data_container
debug "driver - stopping container #{data_container_name}"
stop_container data_container_name
end
def work_image
return "#{image_prefix}/#{instance_name}" unless image_prefix.nil?
instance_name
end
def dokken_binds
ret = []
ret << "#{dokken_kitchen_sandbox}:/opt/kitchen" unless dokken_kitchen_sandbox.nil? || remote_docker_host?
ret << "#{dokken_verifier_sandbox}:/opt/verifier" unless dokken_verifier_sandbox.nil? || remote_docker_host?
ret << Array(config[:binds]) unless config[:binds].nil?
ret.flatten
end
def dokken_tmpfs
coerce_tmpfs(config[:tmpfs])
end
def dokken_volumes
coerce_volumes(config[:volumes])
end
def coerce_tmpfs(v)
case v
when Hash, nil
v
else
Array(v).each_with_object({}) do |y, h|
name, opts = y.split(':', 2)
h[name.to_s] = opts.to_s
end
end
end
def coerce_volumes(v)
case v
when PartialHash, nil
v
when Hash
PartialHash[v]
else
b = []
v = Array(v).to_a # in case v.is_A?(Chef::Node::ImmutableArray)
v.delete_if do |x|
parts = x.split(':')
b << x if parts.length > 1
end
b = nil if b.empty?
config[:binds].push(b) unless config[:binds].include?(b) || b.nil?
return PartialHash.new if v.empty?
v.each_with_object(PartialHash.new) { |volume, h| h[volume] = {} }
end
end
def dokken_volumes_from
ret = []
ret << chef_container_name
ret << data_container_name if remote_docker_host?
ret
end
def start_runner_container(state)
debug "driver - starting #{runner_container_name}"
config = {
'name' => runner_container_name,
'Cmd' => Shellwords.shellwords(self[:pid_one_command]),
'Image' => "#{repo(work_image)}:#{tag(work_image)}",
'Hostname' => self[:hostname],
'Env' => self[:env],
'ExposedPorts' => exposed_ports,
'Volumes' => dokken_volumes,
'HostConfig' => {
'Privileged' => self[:privileged],
'VolumesFrom' => dokken_volumes_from,
'Binds' => dokken_binds,
'Dns' => self[:dns],
'DnsSearch' => self[:dns_search],
'Links' => Array(self[:links]),
'CapAdd' => Array(self[:cap_add]),
'CapDrop' => Array(self[:cap_drop]),
'SecurityOpt' => Array(self[:security_opt]),
'NetworkMode' => self[:network_mode],
'PortBindings' => port_bindings,
'Tmpfs' => dokken_tmpfs,
},
'NetworkingConfig' => {
'EndpointsConfig' => {
self[:network_mode] => {
'Aliases' => Array(self[:hostname]),
},
},
},
}
unless self[:entrypoint].to_s.empty?
config['Entrypoint'] = self[:entrypoint]
end
runner_container = run_container(config)
state[:runner_container] = runner_container.json
end
def start_data_container(state)
debug "driver - creating #{data_container_name}"
config = {
'name' => data_container_name,
'Image' => "#{repo(data_image)}:#{tag(data_image)}",
'HostConfig' => {
'PortBindings' => port_bindings,
'PublishAllPorts' => true,
'NetworkMode' => 'bridge',
},
'NetworkingConfig' => {
'EndpointsConfig' => {
self[:network_mode] => {
'Aliases' => Array(self[:hostname]),
},
},
},
}
data_container = run_container(config)
state[:data_container] = data_container.json
end
def make_dokken_network
lockfile = Lockfile.new "#{home_dir}/.dokken-network.lock"
begin
lockfile.lock
with_retries { ::Docker::Network.get('dokken', {}, docker_connection) }
rescue
begin
with_retries { ::Docker::Network.create('dokken', {}) }
rescue ::Docker::Error => e
debug "driver - error :#{e}:"
end
ensure
lockfile.unlock
end
end
def make_data_image
debug 'driver - calling create_data_image'
create_data_image
end
def create_chef_container(state)
lockfile = Lockfile.new "#{home_dir}/.dokken-#{chef_container_name}.lock"
begin
lockfile.lock
with_retries { ::Docker::Container.get(chef_container_name, {}, docker_connection) }
rescue ::Docker::Error::NotFoundError
with_retries do
begin
debug "driver - creating volume container #{chef_container_name} from #{chef_image}"
config = {
'name' => chef_container_name,
'Cmd' => 'true',
'Image' => "#{repo(chef_image)}:#{tag(chef_image)}",
'HostConfig' => {
'NetworkMode' => self[:network_mode],
},
}
chef_container = create_container(config)
state[:chef_container] = chef_container.json
rescue ::Docker::Error => e
raise "driver - #{chef_container_name} failed to create #{e}"
end
end
ensure
lockfile.unlock
end
end
def pull_platform_image
debug "driver - pulling #{chef_image} #{repo(platform_image)} #{tag(platform_image)}"
pull_image platform_image
end
def pull_chef_image
debug "driver - pulling #{chef_image} #{repo(chef_image)} #{tag(chef_image)}"
pull_if_missing chef_image
end
def delete_image(name)
with_retries { @image = ::Docker::Image.get(name, {}, docker_connection) }
with_retries { @image.remove(force: true) }
rescue ::Docker::Error
puts "Image #{name} not found. Nothing to delete."
end
def container_exist?(name)
return true if ::Docker::Container.get(name, {}, docker_connection)
rescue
false
end
def parse_image_name(image)
parts = image.split(':')
if parts.size > 2
tag = parts.pop
repo = parts.join(':')
else
tag = parts[1] || 'latest'
repo = parts[0]
end
[repo, tag]
end
def repo(image)
parse_image_name(image)[0]
end
def create_container(args)
with_retries { @container = ::Docker::Container.get(args['name'], {}, docker_connection) }
rescue
with_retries do
begin
info "Creating container #{args['name']}"
debug "driver - create_container args #{args}"
with_retries do
begin
@container = ::Docker::Container.create(args.clone, docker_connection)
rescue ::Docker::Error::ConflictError
debug "driver - rescue ConflictError: #{args['name']}"
with_retries { @container = ::Docker::Container.get(args['name'], {}, docker_connection) }
end
end
rescue ::Docker::Error => e
debug "driver - error :#{e}:"
raise "driver - failed to create_container #{args['name']}"
end
end
end
def run_container(args)
create_container(args)
with_retries do
@container.start
@container = ::Docker::Container.get(args['name'], {}, docker_connection)
wait_running_state(args['name'], true)
end
@container
end
def container_state
@container ? @container.info['State'] : {}
end
def stop_container(name)
with_retries { @container = ::Docker::Container.get(name, {}, docker_connection) }
with_retries do
@container.stop(force: false)
wait_running_state(name, false)
end
rescue ::Docker::Error::NotFoundError
debug "Container #{name} not found. Nothing to stop."
end
def delete_container(name)
with_retries { @container = ::Docker::Container.get(name, {}, docker_connection) }
with_retries { @container.delete(force: true, v: true) }
rescue ::Docker::Error::NotFoundError
debug "Container #{name} not found. Nothing to delete."
end
def wait_running_state(name, v)
@container = ::Docker::Container.get(name, {}, docker_connection)
i = 0
tries = 20
until container_state['Running'] == v || container_state['FinishedAt'] != '0001-01-01T00:00:00Z'
i += 1
break if i == tries
sleep 0.1
@container = ::Docker::Container.get(name, {}, docker_connection)
end
end
def tag(image)
parse_image_name(image)[1]
end
def chef_container_name
"chef-#{chef_version}"
end
def chef_image
"#{config[:chef_image]}:#{chef_version}"
end
def chef_version
return 'latest' if config[:chef_version] == 'stable'
config[:chef_version]
end
def data_container_name
"#{instance_name}-data"
end
def data_image
config[:data_image]
end
def platform_image
config[:image] || platform_image_from_name
end
def platform_image_from_name
platform, release = instance.platform.name.split('-')
release ? [platform, release].join(':') : platform
end
def pull_if_missing(image)
return if ::Docker::Image.exist?("#{repo(image)}:#{tag(image)}", {}, docker_connection)
pull_image image
end
# https://github.com/docker/docker/blob/4fcb9ac40ce33c4d6e08d5669af6be5e076e2574/registry/auth.go#L231
def parse_registry_host(val)
val.sub(%r{https?://}, '').split('/').first
end
def pull_image(image)
with_retries do
if Docker::Image.exist?("#{repo(image)}:#{tag(image)}", {}, docker_connection)
original_image = Docker::Image.get("#{repo(image)}:#{tag(image)}", {}, docker_connection)
end
new_image = Docker::Image.create({ 'fromImage' => "#{repo(image)}:#{tag(image)}" }, docker_connection)
!(original_image && original_image.id.start_with?(new_image.id))
end
end
def runner_container_name
instance_name.to_s
end
def with_retries
tries = api_retries
begin
yield
# Only catch errors that can be fixed with retries.
rescue ::Docker::Error::ServerError, # 404
::Docker::Error::UnexpectedResponseError, # 400
::Docker::Error::TimeoutError,
::Docker::Error::IOError => e
tries -= 1
sleep 0.1
retry if tries > 0
debug "tries: #{tries} error: #{e}"
raise e
end
end
end
end
end