class Harrison::Package

Public Class Methods

new(opts={}) click to toggle source
Calls superclass method Harrison::Base::new
# File lib/harrison/package.rb, line 3
def initialize(opts={})
  # Config helpers for Harrisonfile.
  self.class.option_helper(:via)
  self.class.option_helper(:dockerfiles)
  self.class.option_helper(:host)
  self.class.option_helper(:commit)
  self.class.option_helper(:purge)
  self.class.option_helper(:destination)
  self.class.option_helper(:remote_dir)
  self.class.option_helper(:exclude)

  # Command line opts for this action. Will be merged with common opts.
  arg_opts = [
    [ :commit, "Specific commit to be packaged. Accepts anything that `git rev-parse` understands.", :type => :string, :default => "HEAD" ],
    [ :purge, "Remove all previously packaged commits and working copies from the build host when finished.", :type => :boolean, :default => false ],
    [ :destination, "Local or remote folder to save package to. Remote syntax is: (user@)host:/path", :type => :string, :default => "pkg" ],
    [ :remote_dir, "Remote working folder.", :type => :string, :default => "~/.harrison" ],
  ]

  super(arg_opts, opts)
end

Public Instance Methods

remote_exec(cmd) click to toggle source
Calls superclass method Harrison::Base#remote_exec
# File lib/harrison/package.rb, line 25
def remote_exec(cmd)
  ensure_remote_dir("#{remote_project_dir}/package")

  if @_remote_context
    super("cd #{@_remote_context} && #{cmd}")
  else
    super("cd #{remote_project_dir}/package && #{cmd}")
  end
end
run(&block) click to toggle source
Calls superclass method Harrison::Base#run
# File lib/harrison/package.rb, line 35
    def run(&block)
      return super if block_given?

      # Find the URL of the remote in case it differs from git_src.
      remote_url = find_remote(self.commit)

      # Resolve commit ref to an actual short SHA.
      resolve_commit!

      return run_docker if self.via == :docker

      if self.host.respond_to?(:call)
        resolved_host = self.host.call(self)
        self.host = resolved_host
      end

      # Require at least one host.
      if !self.host || self.host.empty?
        abort("ERROR: Unable to resolve build host.")
      end

      puts "Packaging #{commit} from #{remote_url} for \"#{project}\" on #{host}..."

      # Make sure the folder to save the artifact to locally exists.
      ensure_destination(destination)

      # To avoid collisions, we use a version of the full URL as remote name.
      remote_cache_name = remote_url.gsub(/[^a-z0-9_]/i, '_')

      # Fetch/clone git repo on remote host.
      remote_exec <<~ENDCMD
        if [ -d cached ]
        then
          cd cached
          if [ -d .git/refs/remotes/#{remote_cache_name} ]
          then
            git fetch #{remote_cache_name} -p
          else
            git remote add -f #{remote_cache_name} #{remote_url}
          fi
        else
          git clone -o #{remote_cache_name} #{remote_url} cached
        fi
      ENDCMD

      build_dir = remote_cache_name + '-' + artifact_name(commit)

      # Clean up any stale build folder of the target remote/commit.
      remote_exec("rm -rf #{build_dir} && mkdir -p #{build_dir}")

      # Check out target commit into the build_dir.
      checkout_failure = catch :failure do
        remote_exec("cd cached && GIT_WORK_TREE=../#{build_dir} git checkout --detach -f #{commit} && git checkout -f -") # TODO: When git is upgraded: --ignore-other-worktrees

        # We want "checkout_failure" to be false if nothing was caught.
        false
      end

      if checkout_failure
        abort("ERROR: Unable to checkout requested git reference '#{commit}' on build server, ensure you have pushed the requested branch or tag to the remote repo.")
      end

      # Run user supplied build code in the context of the checked out code.
      begin
        @_remote_context = "#{remote_project_dir}/package/#{build_dir}"
        super
      ensure
        @_remote_context = nil
      end

      # Package build folder into tgz.
      remote_exec("rm -f #{artifact_name(commit)}.tar.gz && cd #{build_dir} && tar #{excludes_for_tar} -czf ../#{artifact_name(commit)}.tar.gz .")

      if match = remote_regex.match(destination)
        # Copy artifact to remote destination.
        dest_user, dest_host, dest_path = match.captures
        dest_user ||= self.user

        remote_exec("scp #{artifact_name(commit)}.tar.gz #{dest_user}@#{dest_host}:#{dest_path}")
      else
        # Download (Expand remote path since Net::SCP doesn't expand ~)
        download(remote_exec("readlink -m #{artifact_name(commit)}.tar.gz"), "#{destination}/#{artifact_name(commit)}.tar.gz")
      end

      if purge
        remote_exec("rm -rf #{build_dir}")
        remote_exec("rm #{artifact_name(commit)}.tar.gz")
      end

      puts "Sucessfully packaged #{commit} to #{destination}/#{artifact_name(commit)}.tar.gz"
    end

Protected Instance Methods

artifact_name(commit) click to toggle source
# File lib/harrison/package.rb, line 295
def artifact_name(commit)
  @_timestamp ||= Time.new.utc.strftime('%Y%m%d%H%M%S')
  "#{@_timestamp}-#{commit}"
end
ensure_destination(destination) click to toggle source
# File lib/harrison/package.rb, line 300
def ensure_destination(destination)
  if match = remote_regex.match(destination)
    dest_user, dest_host, dest_path = match.captures
    dest_user ||= self.user

    ensure_remote_dir(dest_path, Harrison::SSH.new(host: dest_host, user: dest_user))
  else
    ensure_local_dir(destination)
  end
end
excludes_for_tar() click to toggle source
# File lib/harrison/package.rb, line 289
def excludes_for_tar
  return '' if !exclude || exclude.empty?

  "--exclude \"#{exclude.join('" --exclude "')}\""
end
find_remote(ref) click to toggle source
# File lib/harrison/package.rb, line 259
def find_remote(ref)
  remote = nil
  remote_url = nil

  catch :failure do
    # If it's a branch, try to resolve what it's tracking.
    # This will exit non-zero (and throw :failure) if the ref is
    # not a branch.
    remote = exec("git rev-parse --symbolic-full-name #{ref}@{upstream} 2>/dev/null")&.match(/\Arefs\/remotes\/(.+)\/.+\Z/i)&.captures.first
  end

  # Fallback to 'origin' if not deploying a branch with a tracked
  # upstream.
  remote ||= 'origin'

  catch :failure do
    # Look for a URL for whatever remote we have. git-config exits
    # non-zero if the requested value doesn't exist.
    remote_url = exec("git config remote.#{remote}.url 2>/dev/null")
  end

  # If we found a remote_url, return that, otherwise fall back to
  # configured git_src.
  return remote_url || self.git_src
end
remote_project_dir() click to toggle source
# File lib/harrison/package.rb, line 255
def remote_project_dir
  "#{remote_dir}/#{project}"
end
resolve_commit!() click to toggle source
# File lib/harrison/package.rb, line 285
def resolve_commit!
  self.commit = exec("git rev-parse --short #{self.commit} 2>/dev/null")
end
run_docker() click to toggle source
# File lib/harrison/package.rb, line 129
def run_docker
  require 'open3'

  packages = []

  git_worktree_prune_argv = [
    "git",
    "worktree",
    "prune",
  ].join(' ')

  if Harrison::DEBUG
    system(git_worktree_prune_argv) || (throw :failure)
  else
    _, gwtp_err, gwtp_status = Open3.capture3(git_worktree_prune_argv)

    if gwtp_status != 0
      puts gwtp_err
      throw :failure
    end
  end

  begin
    tmp_dir = Dir.mktmpdir("harrison-#{project}", "/tmp")
    tmp_src_dir = File.join(tmp_dir, 'src')

    git_worktree_add_argv = [
      "git",
      "worktree",
      "add",
      "--force", # allow new worktree to check out duplicate branch
      tmp_src_dir,
      commit,
    ].join(' ')

    git_worktree_add_env = {
      "OVERCOMMIT_DISABLE" => "1",
    }

    if Harrison::DEBUG
      system(git_worktree_add_env, git_worktree_add_argv) || (throw :failure)
    else
      _, gwta_err, gwta_status = Open3.capture3(git_worktree_add_env, git_worktree_add_argv)

      if gwta_status != 0
        puts gwta_err
        throw :failure
      end
    end

    self.dockerfiles.each do |df|
      df_basename = File.basename(df, '.Dockerfile')
      docker_image_tag = "#{project}-harrison-#{df_basename}:latest"

      docker_build_argv = [
        'docker', 'build',
        '--platform', 'linux/amd64',
        '--file', df,
        '--tag', docker_image_tag,
        '.',
      ].join(' ')

      puts "Running: #{docker_build_argv}"

      if Harrison::DEBUG
        system(docker_build_argv) || (throw :failure)
      else
        _, build_err, build_status = Open3.capture3(docker_build_argv)

        if build_status != 0
          puts build_err
          throw :failure
        end
      end

      docker_run_argv = [
        "docker", "run",
        "--platform", "linux/amd64",
        "--mount", "type=bind,source=#{tmp_src_dir},target=/src,readonly",
        "--mount", "type=bind,source=\"$(pwd)/pkg\",target=/pkg",
        docker_image_tag,
        commit,
      ].join(' ')

      puts "Running: #{docker_run_argv}"

      if Harrison::DEBUG
        system(docker_run_argv) || (throw :failure)
      else
        pkg_out, pkg_err, pkg_status = Open3.capture3(docker_run_argv)

        if pkg_status != 0
          puts pkg_err
          throw :failure
        end
        pkg_out_lines = pkg_out.split("\n")

        packages << pkg_out_lines[-1]
      end
    end

    git_worktree_remove_argv = [
      "git",
      "worktree",
      "remove",
      "--force", # don't care if worktree is unclean
      tmp_src_dir,
    ].join(' ')

    if Harrison::DEBUG
      system(git_worktree_remove_argv) || (throw :failure)
    else
      _, gwtr_err, gwtr_status = Open3.capture3(git_worktree_remove_argv)

      if gwtr_status != 0
        puts gwtr_err
        throw :failure
      end
    end
  ensure
    FileUtils.rm_rf(tmp_dir, secure: true)
  end

  puts "\n#{packages.join("\n")}"
end