2.5 KiB · text History b868907
defmodule GitGud.Packages.Zot do
  @moduledoc """
  The small part of zot's API this forge calls *out* to.

  Everything else about the OCI registry is inbound — zot asks us for a
  token, zot tells us what was pushed. Pruning is the exception: an OCI
  version's bytes are zot's, so deleting our mirror row frees nothing
  unless zot is told as well.

  Auth is the same RS256 token zot already trusts, minted here with a
  `delete` scope for the one repository being pruned. The pruner is
  server-side code acting as the instance, so it signs its own token
  rather than borrowing a user's.

  `zot` reclaims the blobs on its own schedule (its GC), so a
  successful delete here means "the manifest is gone and the bytes are
  now unreferenced", not "the disk is smaller already".
  """

  alias GitGud.Packages.Token
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage

  @doc """
  Delete one manifest by digest.

  `{:error, :no_registry}` when no OCI registry is configured, which is
  a perfectly normal deployment — the pruner treats it as "nothing to
  do here" rather than a failure.
  """
  @spec delete_manifest(Repository.t(), String.t()) :: :ok | {:error, term()}
  def delete_manifest(%Repository{} = repo, digest) when is_binary(digest) do
    with {:ok, host} <- registry_host(),
         {:ok, path} <- repo_path(repo) do
      url = "https://#{host}/v2/#{path}/manifests/#{digest}"
      token = Token.issue("gitgud-retention", ["repository:#{path}:delete"])

      case Req.delete(url,
             headers: [{"authorization", "Bearer #{token}"}],
             receive_timeout: 30_000
           ) do
        # 202 is the spec's answer; 404 means it is already gone, which
        # is the state we wanted.
        {:ok, %{status: status}} when status in [200, 202, 404] ->
          :ok

        {:ok, %{status: status, body: body}} ->
          {:error, {:zot_status, status, truncate(body)}}

        {:error, reason} ->
          {:error, {:zot_unreachable, reason}}
      end
    end
  end

  defp registry_host do
    case Application.get_env(:git_gud, :registry_host) do
      host when is_binary(host) and host != "" -> {:ok, host}
      _ -> {:error, :no_registry}
    end
  end

  defp repo_path(%Repository{} = repo) do
    repo = GitGud.Repo.preload(repo, [:owner, :organization])
    {:ok, "#{Storage.repo_handle(repo)}/#{repo.name}"}
  rescue
    _ -> {:error, :no_namespace}
  end

  defp truncate(body) when is_binary(body), do: String.slice(body, 0, 200)
  defp truncate(body), do: body |> inspect() |> String.slice(0, 200)
end