class Kitchen::Driver::Dokken
@author Sean OMeara <sean@sean.io>
Dokken driver for Kitchen.
def add_dns_config(endpoint_config)
def add_dns_config(endpoint_config) return unless self[:dns] || self[:dns_search] endpoint_config["DNSConfig"] = {} endpoint_config["DNSConfig"]["Nameservers"] = self[:dns] if self[:dns] endpoint_config["DNSConfig"]["Search"] = self[:dns_search] if self[:dns_search] end
def api_retries
def api_retries config[:api_retries] end
def authenticate!
def authenticate! # No need to authenticate if the credentials are empty return if docker_creds.empty? ::Docker.authenticate! docker_creds end
def build_work_image(state)
def build_work_image(state) info("Building work image..") return if ::Docker::Image.exist?(work_image, { "platform" => oci_platform(config[:platform]) }, docker_connection) begin @intermediate_image = ::Docker::Image.build( work_image_dockerfile, { "t" => work_image, "platform" => config[:platform], }, docker_connection ) # credit to https://github.com/someara/kitchen-dokken/issues/95#issue-224697526 rescue Docker::Error::UnexpectedResponseError => e msg = "work_image build failed: " msg += JSON.parse(e.to_s.split("\r\n").last)["error"].to_s msg += ". The common scenarios are incorrect intermediate " msg += "instructions such as not including `-y` on an `apt-get` " msg += "or similar. The other common scenario is a transient " msg += "error such as an unresponsive mirror." raise msg # fallback rescue above should catch most of the errors rescue => e raise "work_image build failed: #{e}" end state[:work_image] = work_image end
def calc_volumes_binds
def calc_volumes_binds volumes = Array.new(Array(config[:volumes])) binds = Array.new(Array(config[:binds])) # Binds is mutated in-place, volumes *may* be. volumes = coerce_volumes(volumes, binds) binds_ret = [] binds_ret << "#{dokken_kitchen_sandbox}:#{resolved_root_path}" unless dokken_kitchen_sandbox.nil? || remote_docker_host? || running_inside_docker? binds_ret << "#{dokken_verifier_sandbox}:/opt/verifier" unless dokken_verifier_sandbox.nil? || remote_docker_host? || running_inside_docker? binds_ret << binds unless binds.nil? [volumes, binds_ret.flatten] end
def chef_container_name
def chef_container_name config[:platform] != "" ? "chef-#{chef_version}-" + config[:platform].sub("/", "-") : "chef-#{chef_version}" end
def chef_image
def chef_image "#{config[:chef_image]}:#{chef_version}" end
def chef_version
def chef_version return "latest" if config[:chef_version] == "stable" config[:chef_version] end
def coerce_tmpfs(v)
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, binds)
def coerce_volumes(v, binds) case v when PartialHash, nil v when Hash PartialHash[v] else b = [] v.delete_if do |x| parts = x.split(":") b << x if parts.length > 1 end b = nil if b.empty? binds.push(b) unless binds.include?(b) || b.nil? return PartialHash.new if v.empty? v.each_with_object(PartialHash.new) { |volume, h| h[volume] = {} } end end
def container_exist?(name)
def container_exist?(name) true if ::Docker::Container.get(name, {}, docker_connection) rescue StandardError, ::Docker::Error::NotFoundError false end
def container_state
def container_state @container ? @container.info["State"] : {} end
def create(state)
def create(state) # Authenticate the private registry authenticate! # 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? || running_inside_docker? 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 create_chef_container(state)
def create_chef_container(state) lockfile = Lockfile.new "#{home_dir}/.dokken-#{chef_container_name}.lock" begin lockfile.lock with_retries do # TEMPORARY FIX - docker-api 2.0.0 has a buggy Docker::Container.get - use .all instead # https://github.com/swipely/docker-api/issues/566 # ::Docker::Container.get(chef_container_name, {}, docker_connection) found = ::Docker::Container.all({ all: true }, docker_connection).select { |c| c.info["Names"].include?("/#{chef_container_name}") } raise ::Docker::Error::NotFoundError.new(chef_container_name) if found.empty? debug "Chef container already exists, continuing" end rescue ::Docker::Error::NotFoundError debug "Chef container does not exist, creating a new Chef container" with_retries do debug "driver - creating volume container #{chef_container_name} from #{chef_image}" config = { "name" => chef_container_name, "Cmd" => ["true"], "Image" => registry_image_path(chef_image), "HostConfig" => { "NetworkMode" => self[:network_mode], }, } chef_container = create_container(config) state[:chef_container] = chef_container.json rescue ::Docker::Error, StandardError => e raise "driver - #{chef_container_name} failed to create #{e}" end ensure lockfile.unlock end end
def create_container(args)
def create_container(args) with_retries { @container = ::Docker::Container.get(args["name"], {}, docker_connection) } rescue with_retries do args["Env"] = [] if args["Env"].nil? args["Env"] << "TEST_KITCHEN=1" args["Env"] << "CI=#{ENV["CI"]}" if ENV.include? "CI" args["Platform"] = config[:platform] info "Creating container #{args["name"]}" debug "driver - create_container args #{args}" with_retries do @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 rescue ::Docker::Error => e debug "driver - error :#{e}:" raise "driver - failed to create_container #{args["name"]}" end end
def data_container_name
def data_container_name "#{instance_name}-data" end
def data_image
def data_image config[:data_image] end
def data_port_bindings
def data_port_bindings return port_bindings unless config[:data_ssh_port] # If data_ssh_port is specified, use it for SSH port mapping ssh_port_binding = { "22/tcp" => [ { "HostIp" => "0.0.0.0", "HostPort" => config[:data_ssh_port].to_s, }, ], } # Merge with any existing port bindings if port_bindings port_bindings.merge(ssh_port_binding) else ssh_port_binding end end
def delete_chef_container
def delete_chef_container debug "driver - deleting container #{chef_container_name}" delete_container chef_container_name end
def delete_container(name)
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 delete_data_container
def delete_data_container debug "driver - deleting container #{data_container_name}" delete_container data_container_name end
def delete_image(name)
def delete_image(name) with_retries { @image = ::Docker::Image.get(name, { "platform" => oci_platform(config[:platform]) }, docker_connection) } with_retries { @image.remove(force: true) } rescue ::Docker::Error puts "Image #{name} not found. Nothing to delete." end
def delete_runner_container
def delete_runner_container debug "driver - deleting container #{runner_container_name}" delete_container runner_container_name end
def delete_work_image
def delete_work_image return unless ::Docker::Image.exist?(work_image, { "platform" => oci_platform(config[:platform]) }, docker_connection) with_retries { @work_image = ::Docker::Image.get(work_image, { "platform" => oci_platform(config[:platform]) }, docker_connection) } with_retries do with_retries { @work_image.remove(force: true) } rescue ::Docker::Error::ConflictError debug "driver - #{work_image} cannot be removed" end end
def destroy(_state)
def destroy(_state) if remote_docker_host? || running_inside_docker? stop_data_container delete_data_container end stop_runner_container delete_runner_container delete_work_image dokken_delete_sandbox end
def docker_config_creds
def docker_config_creds return @docker_config_creds if @docker_config_creds @docker_config_creds = {} config_file = ::File.join(::Dir.home, ".docker", "config.json") if ::File.exist?(config_file) config = JSON.load_file!(config_file) if config["auths"] config["auths"].each do |k, v| next if v["auth"].nil? username, password = Base64.decode64(v["auth"]).split(":") @docker_config_creds[k] = { serveraddress: k, username:, password: } end end if config["credHelpers"] config["credHelpers"].each do |k, v| @docker_config_creds[k] = Proc.new do c = JSON.parse(`echo #{k} | docker-credential-#{v} get`) { serveraddress: c["ServerURL"], username: c["Username"], password: c["Secret"] } end end end else debug("~/.docker/config.json does not exist") end @docker_config_creds end
def docker_connection
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 docker_creds
def docker_creds @docker_creds ||= if config[:creds_file] JSON.parse(IO.read(config[:creds_file])) else {} end end
def docker_creds_for_image(image)
def docker_creds_for_image(image) return docker_creds if config[:creds_file] image_registry = image.split("/").first # NOTE: Try to use DockerHub auth if exact registry match isn't found default_registry = "https://index.docker.io/v1/" if docker_config_creds.key?(image_registry) c = docker_config_creds[image_registry] c.respond_to?(:call) ? c.call : c elsif docker_config_creds.key?(default_registry) c = docker_config_creds[default_registry] c.respond_to?(:call) ? c.call : c end end
def dokken_tmpfs
def dokken_tmpfs coerce_tmpfs(config[:tmpfs]) end
def dokken_volumes_from
def dokken_volumes_from ret = [] ret << chef_container_name ret << data_container_name if remote_docker_host? || running_inside_docker? ret end
def image_prefix
def image_prefix config[:image_prefix] end
def instance_platform_name
def instance_platform_name instance.platform.name end
def make_data_image
def make_data_image debug "driver - calling create_data_image" create_data_image(config[:docker_registry]) end
def make_dokken_network
def make_dokken_network return unless self[:network_mode] == "dokken" lockfile = Lockfile.new "#{home_dir}/.dokken-network.lock" begin lockfile.lock with_retries { ::Docker::Network.get("dokken", {}, docker_connection) } rescue ::Docker::Error::NotFoundError begin with_retries { ::Docker::Network.create("dokken", network_settings) } rescue ::Docker::Error => e debug "driver - error :#{e}:" end ensure lockfile.unlock end end
def oci_platform(platform)
def oci_platform(platform) if !platform.nil? && platform.include?("/") os, arch = platform.split("/") platform = { os: os, architecture: arch }.to_json end platform end
def parse_image_name(image)
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 parse_registry_host(val)
def parse_registry_host(val) val.sub(%r{https?://}, "").split("/").first end
def platform_image
def platform_image config[:image] || platform_image_from_name end
def platform_image_from_name
def platform_image_from_name platform, release = instance.platform.name.split("-") release ? [platform, release].join(":") : platform end
def pull_chef_image
def pull_chef_image debug "driver - pulling #{short_image_path(chef_image)}" config[:pull_chef_image] ? pull_image(chef_image) : pull_if_missing(chef_image) end
def pull_if_missing(image)
def pull_if_missing(image) return if ::Docker::Image.exist?(registry_image_path(image), { "platform" => oci_platform(config[:platform]) }, docker_connection) pull_image image end
def pull_image(image)
def pull_image(image) path = registry_image_path(image) with_retries do if Docker::Image.exist?(path, { "platform" => oci_platform(config[:platform]) }, docker_connection) original_image = Docker::Image.get(path, { "platform" => oci_platform(config[:platform]) }, docker_connection) end new_image = Docker::Image.create({ "fromImage" => path, "platform" => config[:platform] }, docker_creds_for_image(image), docker_connection) !(original_image&.id&.start_with?(new_image.id)) end end
def pull_platform_image
def pull_platform_image debug "driver - pulling #{short_image_path(platform_image)}" config[:pull_platform_image] ? pull_image(platform_image) : pull_if_missing(platform_image) end
def registry_image_path(image)
-
(String)- The most fully-qualified registry path we cn make
Parameters:
-
image(String) -- the docker image path to parse
def registry_image_path(image) if config[:docker_registry] "#{config[:docker_registry]}/#{short_image_path(image)}" else short_image_path(image) end end
def repo(image)
-
(String)- the repo portion of `image`
Parameters:
-
image(String) -- the docker image path to parse
def repo(image) parse_image_name(image)[0] end
def run_container(args)
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 runner_container_name
def runner_container_name instance_name.to_s end
def save_misc_state(state)
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 short_image_path(image)
-
(String)- `repo`:`tag`
Parameters:
-
image(String) -- the docker image path to parse
def short_image_path(image) "#{repo(image)}:#{tag(image)}" end
def start_data_container(state)
def start_data_container(state) debug "driver - creating #{data_container_name}" config = { "name" => data_container_name, # locally built image, must use short-name "Image" => short_image_path(data_image), "HostConfig" => { "PortBindings" => data_port_bindings, "PublishAllPorts" => self[:data_ssh_port].nil?, "NetworkMode" => "bridge", }, } unless %w{host bridge}.include?(self[:network_mode]) endpoint_config = { "Aliases" => Array(self[:hostname]), } add_dns_config(endpoint_config) config["NetworkingConfig"] = { "EndpointsConfig" => { self[:network_mode] => endpoint_config, }, } end data_container = run_container(config) state[:data_container] = data_container.json end
def start_runner_container(state)
def start_runner_container(state) debug "driver - starting #{runner_container_name}" volumes, binds = calc_volumes_binds config = { "name" => runner_container_name, "Cmd" => Shellwords.shellwords(self[:pid_one_command]), # locally built image, must use short-name "Image" => short_image_path(work_image), "Hostname" => self[:hostname], "Env" => self[:env], "ExposedPorts" => exposed_ports, "Volumes" => volumes, "HostConfig" => { "Privileged" => self[:privileged], "VolumesFrom" => dokken_volumes_from, "Binds" => 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, "Memory" => self[:memory_limit], }, } unless %w{host bridge}.include?(self[:network_mode]) endpoint_config = { "Aliases" => Array(self[:hostname]).concat(Array(self[:hostname_aliases])), } add_dns_config(endpoint_config) config["NetworkingConfig"] = { "EndpointsConfig" => { self[:network_mode] => endpoint_config, }, } end unless self[:entrypoint].to_s.empty? config["Entrypoint"] = self[:entrypoint] end if self[:cgroupns_host] config["HostConfig"]["CgroupnsMode"] = "host" end if self[:userns_host] config["HostConfig"]["UsernsMode"] = "host" end if self[:privileged] if self[:user_ns_mode] != "host" debug "driver - privileged mode is not supported with user namespaces enabled" debug "driver - changing UsernsMode from '#{self[:user_ns_mode]}' to 'host'" end config["HostConfig"]["UsernsMode"] = "host" end runner_container = run_container(config) state[:runner_container] = runner_container.json end
def stop_container(name)
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 stop_data_container
def stop_data_container debug "driver - stopping container #{data_container_name}" stop_container data_container_name end
def stop_runner_container
def stop_runner_container debug "driver - stopping container #{runner_container_name}" stop_container runner_container_name end
def tag(image)
-
(String)- the tag of `image`
Parameters:
-
image(String) -- the docker image path to parse
def tag(image) parse_image_name(image)[1] end
def wait_running_state(name, v)
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 with_retries
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
def work_image
def work_image [image_prefix, instance_name].compact.join("/").downcase end
def work_image_dockerfile
def work_image_dockerfile from = registry_image_path(platform_image) debug("driver - Building work image from #{from}") dockerfile_contents = [ "FROM #{from}", "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