Skip to main content

lib/release_kit/package_builder.ex

defmodule ReleaseKit.PackageBuilder do
  @moduledoc """
  Packages a Mix release directory into a ReleaseKit artifact.
  """

  alias ReleaseKit.{
    Artifact,
    ArtifactInfo,
    BuildInfo,
    Checksum,
    Lifecycle,
    Manifest,
    Package,
    PackageCache,
    Telemetry
  }

  @enforce_keys [:release_dir, :out_dir, :name, :release, :version, :opts, :target, :assets]
  defstruct [:release_dir, :out_dir, :name, :release, :version, :opts, :target, :assets]

  @type t :: %__MODULE__{
          release_dir: Path.t(),
          out_dir: Path.t(),
          name: String.t(),
          release: String.t(),
          version: String.t(),
          opts: keyword(),
          target: ReleaseKit.Target.t(),
          assets: [ReleaseKit.AssetInfo.t()]
        }

  @type result :: {Path.t(), Path.t(), Manifest.t()}

  @doc """
  Packages the release directory and returns tarball, manifest path, and manifest.
  """
  @spec run(t()) :: result()
  def run(%__MODULE__{} = request) do
    Telemetry.span_with_metadata(:package, metadata(request), fn ->
      compression = package_compression(request.opts)
      tarball = Path.join(request.out_dir, "#{request.name}#{package_extension(compression)}")
      manifest_path = manifest_path(request)
      checksum_path = tarball <> ".sha256"
      build = BuildInfo.detect()

      Lifecycle.run(:before_package, request.opts)

      cache_path = PackageCache.path(manifest_path)
      paths = {request.release_dir, tarball, manifest_path, compression}
      release_info = {request.release, request.version}
      package_cache = package_cache(cache_path, request, paths, release_info, build, compression)

      {result, cache_hit?} =
        package(paths, release_info, request, build, package_cache, checksum_path)

      Lifecycle.run(:after_package, request.opts)
      {result, %{cache_hit?: cache_hit?}}
    end)
  end

  defp package(paths, release_info, request, build, package_cache, checksum_path) do
    {_release_dir, tarball, manifest_path, _compression} = paths
    cache_path = PackageCache.path(manifest_path)

    cond do
      package_cache_hit?(
        request.opts,
        cache_path,
        tarball,
        checksum_path,
        manifest_path,
        package_cache
      ) ->
        checksum = checksum_from_sidecar!(checksum_path)

        PackageCache.write!(
          cache_path,
          PackageCache.put_package(package_cache, tarball, checksum)
        )

        manifest = cached_manifest(tarball, checksum, release_info, request, build)
        {{tarball, manifest_path, manifest}, true}

      reusable_package?(request.opts, cache_path, package_cache) ->
        {reuse_package!(paths, release_info, request, build, package_cache), true}

      true ->
        {write_package!(paths, release_info, request, build, package_cache), false}
    end
  end

  defp package_cache(
         cache_path,
         request,
         {_release_dir, tarball, manifest_path, _compression},
         {release, version},
         build,
         compression
       ) do
    artifact_context = {
      Mix.Project.config()[:app],
      release,
      version,
      Mix.env(),
      Path.expand(tarball),
      Path.expand(manifest_path),
      Keyword.get(request.opts, :port),
      Keyword.get(request.opts, :health_path),
      Keyword.get(request.opts, :env_clear, %{}),
      Keyword.get(request.opts, :env_secret, []),
      request.target,
      build,
      request.assets,
      compression
    }

    PackageCache.fingerprint(cache_path, request.release_dir, artifact_context, compression)
  end

  defp cached_manifest(tarball, checksum, {release, version}, request, build) do
    compression = package_compression(request.opts)
    artifact = ArtifactInfo.new(tarball, checksum, tarball <> ".sha256", compression)
    new_manifest(tarball, release, version, request, build, artifact)
  end

  defp reusable_package?(opts, cache_path, package_cache) do
    opts
    |> Keyword.get(:package, [])
    |> Package.reuse() != false and
      match?(
        {:ok, _tarball, _checksum},
        PackageCache.reuse_source(cache_path, package_cache.content_fingerprint)
      )
  end

  defp reuse_package!(
         {_release_dir, tarball, manifest_path, _compression},
         release_info,
         request,
         build,
         package_cache
       ) do
    cache_path = PackageCache.path(manifest_path)

    {:ok, source_tarball, checksum} =
      PackageCache.reuse_source(cache_path, package_cache.content_fingerprint)

    copy_reused_tarball!(source_tarball, tarball, package_reuse(request.opts))
    checksum_path = Checksum.write_sidecar!(tarball, checksum)
    compression = package_compression(request.opts)
    artifact = ArtifactInfo.new(tarball, checksum, checksum_path, compression)
    {release, version} = release_info
    manifest = new_manifest(tarball, release, version, request, build, artifact)

    Manifest.write!(manifest, manifest_path)
    PackageCache.write!(cache_path, PackageCache.put_package(package_cache, tarball, checksum))

    {tarball, manifest_path, manifest}
  end

  defp write_package!(
         {release_dir, tarball, manifest_path, compression},
         {release, version},
         request,
         build,
         package_cache
       ) do
    Artifact.create_tarball!(release_dir, tarball, compression)
    checksum = Checksum.sha256_file!(tarball)
    checksum_path = Checksum.write_sidecar!(tarball, checksum)
    artifact = ArtifactInfo.new(tarball, checksum, checksum_path, compression)
    manifest = new_manifest(tarball, release, version, request, build, artifact)

    Manifest.write!(manifest, manifest_path)

    PackageCache.write!(
      PackageCache.path(manifest_path),
      PackageCache.put_package(package_cache, tarball, checksum)
    )

    {tarball, manifest_path, manifest}
  end

  defp new_manifest(tarball, release, version, request, build, artifact) do
    Manifest.new(
      app: Mix.Project.config()[:app] |> to_string(),
      release: release,
      version: version,
      mix_env: Mix.env(),
      tarball: tarball,
      port: Keyword.get(request.opts, :port),
      health_path: Keyword.get(request.opts, :health_path),
      env_clear: Keyword.get(request.opts, :env_clear, %{}),
      env_secret: Keyword.get(request.opts, :env_secret, []),
      artifact: artifact,
      target: request.target,
      build: build,
      assets: request.assets
    )
  end

  defp manifest_path(request) do
    Path.join(
      request.out_dir,
      "#{manifest_name(request.release, request.target, Keyword.get(request.opts, :target_suffix?, false))}.etf"
    )
  end

  defp package_compression(opts) do
    opts |> Keyword.get(:package, []) |> Package.compression()
  end

  defp package_reuse(opts) do
    opts |> Keyword.get(:package, []) |> Package.reuse()
  end

  defp checksum_from_sidecar!(checksum_path) do
    checksum_path |> File.read!() |> String.split() |> List.first()
  end

  defp copy_reused_tarball!(source, source, _reuse), do: :ok

  defp copy_reused_tarball!(source, target, :copy) do
    target |> Path.dirname() |> File.mkdir_p!()
    File.cp!(source, target)
  end

  defp copy_reused_tarball!(source, target, :hardlink) do
    target |> Path.dirname() |> File.mkdir_p!()
    File.rm(target)

    case File.ln(source, target) do
      :ok -> :ok
      {:error, _reason} -> File.cp!(source, target)
    end
  end

  defp package_extension(:gzip), do: ".tar.gz"
  defp package_extension(:none), do: ".tar"

  defp package_cache_hit?(opts, cache_path, tarball, checksum_path, manifest_path, fingerprint) do
    opts
    |> Keyword.get(:package, [])
    |> Package.cache?() and
      PackageCache.hit?(cache_path, tarball, checksum_path, manifest_path, fingerprint)
  end

  defp manifest_name(release, _target, false), do: release
  defp manifest_name(release, target, true), do: "#{release}-#{ReleaseKit.Target.suffix(target)}"

  defp metadata(request) do
    %{release: request.release, version: request.version, mix_env: Mix.env()}
  end
end