class Kitchen::Driver::Dokken

@author Sean OMeara <sean@sean.io>
Dokken driver for Kitchen.

def api_retries

def api_retries
  config[:api_retries]
end

def build_work_image(state)

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
    )
  # 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 scenerios are incorrect intermediate'
    msg += 'instructions such as not including `-y` on an `apt-get` '
    msg += 'or similar. The other common scenerio 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 chef_container_name

def chef_container_name
  "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)

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 container_exist?(name)

def container_exist?(name)
  return true if ::Docker::Container.get(name, {}, docker_connection)
rescue
  false
end

def container_state

def container_state
  @container ? @container.info['State'] : {}
end

def create(state)

(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 create_chef_container(state)

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 create_container(args)

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 data_container_name

def data_container_name
  "#{instance_name}-data"
end

def data_image

def data_image
  config[:data_image]
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, {}, 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, {}, 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 destroy(_state)

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

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 dokken_binds

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

def dokken_tmpfs
  coerce_tmpfs(config[:tmpfs])
end

def dokken_volumes

def dokken_volumes
  coerce_volumes(config[:volumes])
end

def dokken_volumes_from

def dokken_volumes_from
  ret = []
  ret << chef_container_name
  ret << data_container_name if remote_docker_host?
  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
end

def make_dokken_network

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 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)

https://github.com/docker/docker/blob/4fcb9ac40ce33c4d6e08d5669af6be5e076e2574/registry/auth.go#L231
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 #{chef_image} #{repo(chef_image)} #{tag(chef_image)}"
  pull_if_missing chef_image
end

def pull_if_missing(image)

def pull_if_missing(image)
  return if ::Docker::Image.exist?("#{repo(image)}:#{tag(image)}", {}, docker_connection)
  pull_image image
end

def pull_image(image)

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 pull_platform_image

def pull_platform_image
  debug "driver - pulling #{chef_image} #{repo(platform_image)} #{tag(platform_image)}"
  pull_image platform_image
end

def repo(image)

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 start_data_container(state)

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 start_runner_container(state)

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 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)

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
  return "#{image_prefix}/#{instance_name}" unless image_prefix.nil?
  instance_name
end

def work_image_dockerfile

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