ManageIQ/multi_repo

View on GitHub
lib/multi_repo/service/docker.rb

Summary

Maintainability
B
4 hrs
Test Coverage
module MultiRepo::Service
  class Docker
    def self.registry
      @registry ||= ENV.fetch("DOCKER_REGISTRY")
    end

    def self.registry=(endpoint)
      @registry = endpoint
    end

    def self.clear_cache
      FileUtils.rm_f(Dir.glob("/tmp/docker-*"))
    end

    SMALL_IMAGE = "hello-world:latest".freeze

    def self.ensure_small_image
      return @has_small_image if defined?(@has_small_image)

      return false unless system?("docker pull #{SMALL_IMAGE} &>/dev/null")

      @has_small_image = true
    end

    def self.tag_small_image(fq_path)
      return false unless ensure_small_image

      system?("docker tag #{SMALL_IMAGE} #{fq_path}") &&
        system?("docker push #{fq_path}") &&
        system?("docker rmi #{fq_path}")
    end

    def self.system?(command, dry_run: false, verbose: true)
      if dry_run
        puts "+ dry_run: #{command}".light_black
        true
      else
        puts "+ #{command}".light_black
        system(command)
      end
    end

    def self.system!(command, **kwargs)
      exit($?.exitstatus) unless system?(command, **kwargs)
    end

    attr_accessor :registry, :default_headers, :cache, :dry_run

    def initialize(registry: self.class.registry, default_headers: nil, cache: true, dry_run: false)
      require "rest-client"
      require "fileutils"
      require "json"

      @registry = registry
      @default_headers = default_headers

      @cache   = cache
      @dry_run = dry_run

      self.class.clear_cache unless cache
    end

    def tags(image, **kwargs)
      path = File.join("v2", image, "tags/list")
      cache_file = "/tmp/docker-tags-#{image.tr("/", "-")}-raw-#{Date.today}.json"
      request(:get, path, **kwargs).tap do |data|
        File.write(cache_file, JSON.pretty_generate(data))
      end["tags"]
    end

    def retag(image, new_image)
      system?("skopeo copy --multi-arch all docker://#{image} docker://#{new_image}", dry_run: dry_run)
    end

    def delete_registry_tag(image, tag, **kwargs)
      path = File.join("v2", image, "manifests", tag)
      request(:delete, path, **kwargs)
      true
    rescue RestClient::NotFound => err
      # Ignore deletes on 404s because they are either already deleted or the tag is orphaned.
      raise unless err.http_code == 404
      false
    end

    def force_delete_registry_tag(image, tag, **kwargs)
      return true if delete_registry_tag(image, tag, **kwargs)

      # The tag is likely orphaned, so recreate the tag with a new image, then immediately delete it
      fq_path = File.join(registry, "#{image}:#{tag}")
      self.class.tag_small_image(fq_path) &&
        delete_registry_tag(image, tag, **kwargs)
    end

    def run(image, command, platform: nil)
      system_capture!("docker run --rm -it #{"--platform=#{platform} " if platform} #{image} #{command}")
    end

    def fetch_image_by_sha(source_image, tag: nil, platform: nil)
      source_image_name, _source_image_sha = source_image.split("@")

      system!("docker pull #{"--platform=#{platform} " if platform}#{source_image}")
      system!("docker tag #{source_image} #{source_image_name}:#{tag}") if tag

      true
    end

    def remove_images(*images)
      command = "docker rmi #{images.join(" ")}"

      # Don't use system_capture! as this is expected to fail if the image does not exist.
      if dry_run
        puts "+ dry_run: #{command}".light_black
      else
        puts "+ #{command}".light_black
        `#{command} 2>/dev/null`
      end
    end

    def manifest_inspect(image)
      command = "docker manifest inspect #{image}"

      cache_file = "/tmp/docker-manifest-#{image.split("@").last}.txt"
      if cache && File.exist?(cache_file)
        puts "+ cached: #{command}".light_black
        data = File.read(cache_file)
      else
        data = system_capture(command)
        File.write(cache_file, data)
      end

      data.blank? ? {} : JSON.parse(data)
    end

    private

    def request(verb, path, body: nil, headers: {}, verbose: true)
      path = File.join(registry, path)

      headers = default_headers.merge(headers) if default_headers

      if dry_run && %i[delete put post patch].include?(verb)
        puts "+ dry_run: #{verb.to_s.upcase} #{path}".light_black if verbose
        {}
      else
        puts "+ #{verb.to_s.upcase} #{path}".light_black if verbose
        response =
          if %i[put post patch].include?(verb)
            RestClient.send(verb, path, body, headers)
          else
            RestClient::Request.execute(:method => verb, :url => path, :headers => headers, :read_timeout => 300) do |response, request, result|
              if verb == :delete && response.code == 301 # Moved Permanently
                response.follow_redirection
              else
                response.return!
              end
            end
          end
        response.empty? ? {} : JSON.parse(response)
      end
    end

    def system?(command, **kwargs)
      self.class.system?(command, **kwargs)
    end

    def system!(command, **kwargs)
      self.class.system!(command, **kwargs)
    end

    def system_capture(command)
      puts "+ #{command}".light_black
      `#{command}`.chomp
    end

    def system_capture!(command)
      system_capture(command).tap do
        exit($?.exitstatus) if $?.exitstatus != 0
      end
    end
  end
end