swipely/docker-api

View on GitHub
lib/docker/util.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require 'set'

# This module holds shared logic that doesn't really belong anywhere else in the
# gem.
module Docker::Util
  # http://www.tldp.org/LDP/GNU-Linux-Tools-Summary/html/x11655.htm#STANDARD-WILDCARDS
  GLOB_WILDCARDS = /[\?\*\[\{\]\}]/

  include Docker::Error

  module_function

  # Attaches to a HTTP stream
  #
  # @param block
  # @param msg_stack [Docker::Messages]
  # @param tty [boolean]
  def attach_for(block, msg_stack, tty = false)
    # If TTY is enabled expect raw data and append to stdout
    if tty
      attach_for_tty(block, msg_stack)
    else
      attach_for_multiplex(block, msg_stack)
    end
  end

  def attach_for_tty(block, msg_stack)
    messages = Docker::Messages.new
    lambda do |c,r,t|
      messages.stdout_messages << c
      messages.all_messages << c
      msg_stack.append(messages)

      block.call c if block
    end
  end

  def attach_for_multiplex(block, msg_stack)
    messages = Docker::Messages.new
    lambda do |c,r,t|
      messages = messages.decipher_messages(c)

      unless block.nil?
        messages.stdout_messages.each do |msg|
          block.call(:stdout, msg)
        end
        messages.stderr_messages.each do |msg|
          block.call(:stderr, msg)
        end
      end

      msg_stack.append(messages)
    end
  end

  def debug(msg)
    Docker.logger.debug(msg) if Docker.logger
  end

  def hijack_for(stdin, block, msg_stack, tty)
    attach_block = attach_for(block, msg_stack, tty)

    lambda do |socket|
      debug "hijack: hijacking the HTTP socket"
      threads = []

      debug "hijack: starting stdin copy thread"
      threads << Thread.start do
        debug "hijack: copying stdin => socket"
        IO.copy_stream stdin, socket

        debug "hijack: closing write end of hijacked socket"
        close_write(socket)
      end

      debug "hijack: starting hijacked socket read thread"
      threads << Thread.start do
        debug "hijack: reading from hijacked socket"

        begin
          while chunk = socket.readpartial(512)
            debug "hijack: got #{chunk.bytesize} bytes from hijacked socket"
            attach_block.call chunk, nil, nil
          end
        rescue EOFError
        end

        debug "hijack: killing stdin copy thread"
        threads.first.kill
      end

      threads.each(&:join)
    end
  end

  def close_write(socket)
    if socket.respond_to?(:close_write)
      socket.close_write
    elsif socket.respond_to?(:io)
      socket.io.close_write
    else
      raise IOError, 'Cannot close socket'
    end
  end

  def parse_json(body)
    MultiJson.load(body) unless body.nil? || body.empty? || (body == 'null')
  rescue MultiJson::ParseError => ex
    raise UnexpectedResponseError, ex.message
  end

  def parse_repo_tag(str)
    if match = str.match(/\A(.*):([^:]*)\z/)
      match.captures
    else
      [str, '']
    end
  end

  def fix_json(body)
    parse_json("[#{body.gsub(/}\s*{/, '},{')}]")
  end

  def create_tar(hash = {})
    output = StringIO.new
    Gem::Package::TarWriter.new(output) do |tar|
      hash.each do |file_name, file_details|
        permissions = file_details.is_a?(Hash) ? file_details[:permissions] : 0640
        tar.add_file(file_name, permissions) do |tar_file|
          content = file_details.is_a?(Hash) ? file_details[:content] : file_details
          tar_file.write(content)
        end
      end
    end
    output.tap(&:rewind).string
  end

  def create_dir_tar(directory)
    tempfile = create_temp_file
    directory += '/' unless directory.end_with?('/')

    create_relative_dir_tar(directory, tempfile)

    File.new(tempfile.path, 'r')
  end


  # return the set of files that form the docker context
  # implement this logic https://docs.docker.com/engine/reference/builder/#dockerignore-file
  def docker_context(directory)
    all_files = glob_all_files(File.join(directory, "**/*"))
    dockerignore = File.join(directory, '.dockerignore')
    return all_files unless all_files.include?(dockerignore)

    # Iterate over valid lines, starting with the initial glob as working set
    File
      .read(dockerignore)                # https://docs.docker.com/engine/reference/builder/#dockerignore-file
      .each_line                         # "a newline-separated list of patterns"
      .map(&:strip)                      # "A preprocessing step removes leading and trailing whitespace"
      .reject(&:empty?)                  # "Lines that are blank after preprocessing are ignored"
      .reject { |p| p.start_with?('#') } # "if [a line starts with `#`], then this line is considered as a comment"
      .each_with_object(Set.new(all_files)) do |p, working_set|
        # determine the pattern (p) and whether it is to be added or removed from context
        add = p.start_with?("!")
        # strip leading "!" from pattern p, then prepend the base directory
        matches = dockerignore_compatible_glob(File.join(directory, add ? p[1..-1] : p))
        # add or remove the matched items as indicated in the ignore file
        add ? working_set.merge(matches) : working_set.replace(working_set.difference(matches))
      end
    .to_a
  end

  def create_relative_dir_tar(directory, output)
    Gem::Package::TarWriter.new(output) do |tar|
      files = docker_context(directory)

      files.each do |prefixed_file_name|
        stat = File.stat(prefixed_file_name)
        next unless stat.file?

        unprefixed_file_name = prefixed_file_name[directory.length..-1]
        add_file_to_tar(
          tar, unprefixed_file_name, stat.mode, stat.size, stat.mtime
        ) do |tar_file|
          IO.copy_stream(File.open(prefixed_file_name, 'rb'), tar_file)
        end
      end
    end
  end

  def add_file_to_tar(tar, name, mode, size, mtime)
    tar.check_closed

    io = tar.instance_variable_get(:@io)

    name, prefix = tar.split_name(name)

    header = Gem::Package::TarHeader.new(:name => name, :mode => mode,
                                         :size => size, :prefix => prefix,
                                         :mtime => mtime).to_s

    io.write header
    os = Gem::Package::TarWriter::BoundedStream.new io, size

    yield os if block_given?

    min_padding = size - os.written
    io.write("\0" * min_padding)

    remainder = (512 - (size % 512)) % 512
    io.write("\0" * remainder)

    tar
  end

  def create_temp_file
    tempfile_name = Dir::Tmpname.create('out') {}
    File.open(tempfile_name, 'wb+')
  end

  def extract_id(body)
    body.lines.reverse_each do |line|
      if (id = line.match(/Successfully built ([a-f0-9]+)/)) && !id[1].empty?
        return id[1]
      end
    end
    raise UnexpectedResponseError, "Couldn't find id: #{body}"
  end

  # Convenience method to get the file hash corresponding to an array of
  # local paths.
  def file_hash_from_paths(local_paths)
    local_paths.each_with_object({}) do |local_path, file_hash|
      unless File.exist?(local_path)
        raise ArgumentError, "#{local_path} does not exist."
      end

      basename = File.basename(local_path)
      if File.directory?(local_path)
        tar = create_dir_tar(local_path)
        file_hash[basename] = {
          content: tar.read,
          permissions: filesystem_permissions(local_path)
        }
        tar.close
        FileUtils.rm(tar.path)
      else
        file_hash[basename] = {
          content: File.read(local_path, mode: 'rb'),
          permissions: filesystem_permissions(local_path)
        }
      end
    end
  end

  def filesystem_permissions(path)
    mode = sprintf("%o", File.stat(path).mode)
    mode[(mode.length - 3)...mode.length].to_i(8)
  end

  def build_auth_header(credentials)
    credentials = MultiJson.dump(credentials) if credentials.is_a?(Hash)
    encoded_creds = Base64.urlsafe_encode64(credentials)
    {
      'X-Registry-Auth' => encoded_creds
    }
  end

  def build_config_header(credentials)
    if credentials.is_a?(String)
      credentials = MultiJson.load(credentials, symbolize_keys: true)
    end

    header = MultiJson.dump(
      credentials[:serveraddress].to_s => {
        'username' => credentials[:username].to_s,
        'password' => credentials[:password].to_s,
        'email' => credentials[:email].to_s
      }
    )

    encoded_header = Base64.urlsafe_encode64(header)

    {
      'X-Registry-Config' => encoded_header
    }
  end

  # do a directory glob that matches .dockerignore behavior
  # specifically: matched directories are considered a recursive match
  def dockerignore_compatible_glob(pattern)
    begin
      some_dirs, some_files = glob_all_files(pattern).partition { |f| File.directory?(f) }
      # since all directories will be re-processed with a /**/* glob, we can preemptively
      # eliminate any whose parent directory is already in this set.  This saves significant time.
      some_files + some_dirs.reject { |d| some_dirs.any? { |pd| d.start_with?(pd) && d != pd } }
    end.each_with_object(Set.new) do |f, acc|
      # expand any directories by globbing; flatten results
      acc.merge(File.directory?(f) ? glob_all_files("#{f}/**/*") : [f])
    end
  end

  def glob_all_files(pattern)
    # globs of "a_dir/**/*" can return "a_dir/.", so explicitly reject those
    (Dir.glob(pattern, File::FNM_DOTMATCH) - ['..', '.']).reject { |p| p.end_with?("/.") }
  end

end