11.9 KiB · text History 5ade69f
defmodule GitGud.Packages.Retention do
  @moduledoc """
  What a registry keeps, and what it lets go.

  Storage only grows on its own. Yanking deliberately keeps the bytes
  so existing pins keep resolving, publishing is cheap, and CI
  publishes on every push — so a registry left alone is a disk that
  fills. This decides what comes off it.

  ## The policy

  One policy per package, in one of two modes:

    * `keep_last` — keep the newest N versions, drop the rest
    * `quota` — keep dropping the oldest until the package's files fit
      in N bytes

  A package inherits the instance policy (`GitGud.Settings`) until it
  sets its own, and its own may only be **stricter**: fewer versions,
  or fewer bytes. That direction is the whole point — an instance
  operator setting a ceiling of 24 doesn't want a package opting itself
  up to 5,000, but a team that only wants the last 3 of their nightly
  builds should be able to say so.

  ## Pins

  A pinned version is never pruned, whatever the policy says. Pins are
  the escape hatch for "this one is referenced by something we can't
  change", and they're capped per package (`packages.max_pins`,
  default 12) because an uncapped escape hatch is just an off switch
  with extra steps.

  ## Reporting vs enforcing

  `plan/1` computes what would go; `apply/2` is what deletes. They are
  separate so an admin can see "here is what this would do" before it
  does it: `packages.retention_enforced` is on by default, and turning
  it off leaves the nightly pass reporting the plan without acting.
  """

  import Ecto.Query, warn: false

  alias GitGud.Packages.Package
  alias GitGud.Packages.PackageVersion
  alias GitGud.Repo
  alias GitGud.Settings

  @policy_keys ~w(packages.retention_mode packages.retention_keep packages.retention_bytes)

  @typedoc """
  An effective policy: the instance's, narrowed by the package's own.

  `source` says where each number came from, which is what the UI needs
  to explain why a package is keeping 3 when the instance says 24.
  """
  @type policy :: %{
          mode: String.t(),
          keep: pos_integer() | nil,
          bytes: non_neg_integer() | nil,
          source: %{mode: :instance | :package, limit: :instance | :package}
        }

  @doc "The instance-wide policy, before any package narrows it."
  @spec instance_policy() :: policy()
  def instance_policy do
    settings = Settings.get_all(@policy_keys)

    %{
      mode: settings["packages.retention_mode"],
      keep: settings["packages.retention_keep"],
      bytes: settings["packages.retention_bytes"],
      source: %{mode: :instance, limit: :instance}
    }
  end

  @doc """
  The policy actually in force for `package`.

  The package's own settings apply where they are *stricter* than the
  instance's; where they are looser they are ignored rather than
  rejected, so lowering the instance ceiling tightens every package
  under it immediately instead of leaving a set of rows quietly above
  the new limit.
  """
  @spec policy_for(Package.t()) :: policy()
  def policy_for(%Package{} = package) do
    instance = instance_policy()

    mode = package.retention_mode || instance.mode

    {keep, keep_source} =
      narrower(package.retention_keep, instance.keep)

    {bytes, bytes_source} =
      narrower(package.retention_bytes, instance.bytes)

    %{
      mode: mode,
      keep: keep,
      bytes: bytes,
      source: %{
        mode: if(package.retention_mode, do: :package, else: :instance),
        limit: if(mode == "quota", do: bytes_source, else: keep_source)
      }
    }
  end

  # "Stricter" is smaller for both modes. A nil instance limit means no
  # ceiling, so the package's own number stands.
  defp narrower(nil, instance), do: {instance, :instance}
  defp narrower(package, nil), do: {package, :package}

  defp narrower(package, instance) when package <= instance, do: {package, :package}
  defp narrower(_package, instance), do: {instance, :instance}

  @doc """
  Set a package's own policy.

  Refuses anything looser than the instance allows, with the ceiling in
  the error so a form can say what the limit actually is. `nil` on a
  field clears it back to inheriting.
  """
  @spec set_policy(Package.t(), map()) :: {:ok, Package.t()} | {:error, term()}
  def set_policy(%Package{} = package, attrs) do
    instance = instance_policy()
    attrs = normalize_attrs(attrs)

    with :ok <- within_ceiling(attrs[:retention_keep], instance.keep, :keep),
         :ok <- within_ceiling(attrs[:retention_bytes], instance.bytes, :bytes) do
      package
      |> Package.retention_changeset(attrs)
      |> Repo.update()
    end
  end

  defp within_ceiling(nil, _ceiling, _which), do: :ok
  defp within_ceiling(_value, nil, _which), do: :ok

  defp within_ceiling(value, ceiling, which) when value > ceiling,
    do: {:error, {:looser_than_instance, which, ceiling}}

  defp within_ceiling(_value, _ceiling, _which), do: :ok

  # Form values arrive as strings, and the ceiling check is a numeric
  # comparison — `"3" > 24` is *true* in Elixir's term ordering, so
  # comparing before casting silently refuses every valid number.
  defp normalize_attrs(attrs) do
    attrs
    |> Enum.map(fn {k, v} -> {to_atom(k), blank_to_nil(v)} end)
    |> Enum.map(fn
      {key, value} when key in [:retention_keep, :retention_bytes] -> {key, to_integer(value)}
      pair -> pair
    end)
    |> Enum.into(%{})
  end

  defp to_integer(nil), do: nil
  defp to_integer(value) when is_integer(value), do: value

  defp to_integer(value) when is_binary(value) do
    case Integer.parse(String.trim(value)) do
      {n, ""} -> n
      _ -> value
    end
  end

  defp to_integer(value), do: value

  defp to_atom(k) when is_atom(k), do: k
  defp to_atom("retention_mode"), do: :retention_mode
  defp to_atom("retention_keep"), do: :retention_keep
  defp to_atom("retention_bytes"), do: :retention_bytes
  defp to_atom(other), do: other

  defp blank_to_nil(""), do: nil
  defp blank_to_nil(v) when is_binary(v), do: if(String.trim(v) == "", do: nil, else: v)
  defp blank_to_nil(v), do: v

  # ── planning ─────────────────────────────────────────────────────────

  @typedoc """
  What retention would do to one package. `prune` is newest-first-order
  preserved: the oldest are at the end, and they are the ones that go.
  """
  @type plan :: %{
          package: Package.t(),
          policy: policy(),
          keep: [PackageVersion.t()],
          prune: [PackageVersion.t()],
          pinned: [PackageVersion.t()],
          bytes: non_neg_integer(),
          total_bytes: non_neg_integer()
        }

  @doc """
  What retention would remove from `package`, without removing it.

  Pinned versions are never in `prune`, and they still count toward a
  quota — a package pinned up to its budget prunes everything else and
  then stops, which is the honest answer rather than pruning pins.
  """
  @spec plan(Package.t()) :: plan()
  def plan(%Package{} = package) do
    policy = policy_for(package)
    versions = versions_newest_first(package)
    {pinned, prunable} = Enum.split_with(versions, &pinned?/1)

    prune =
      case policy.mode do
        "quota" -> quota_victims(pinned, prunable, policy.bytes)
        _ -> keep_last_victims(prunable, policy.keep)
      end
      |> protect_newest(versions)

    prune_ids = MapSet.new(prune, & &1.id)

    %{
      package: package,
      policy: policy,
      keep: Enum.reject(versions, &MapSet.member?(prune_ids, &1.id)),
      prune: prune,
      pinned: pinned,
      bytes: Enum.reduce(prune, 0, &(&2 + version_bytes(&1))),
      total_bytes: Enum.reduce(versions, 0, &(&2 + version_bytes(&1)))
    }
  end

  # A policy must never empty a package. In quota mode a single version
  # larger than the budget would otherwise take everything with it,
  # leaving a package row with no versions and a `latest_version` that
  # resolves to nothing. Whatever the policy says, the newest version
  # stays.
  defp protect_newest(prune, []), do: prune

  defp protect_newest(prune, [newest | _]),
    do: Enum.reject(prune, &(&1.id == newest.id))

  # No limit means keep everything — an instance that clears the
  # setting has turned retention off, not down to zero.
  defp keep_last_victims(_prunable, nil), do: []
  defp keep_last_victims(prunable, keep), do: Enum.drop(prunable, keep)

  defp quota_victims(_pinned, _prunable, nil), do: []

  defp quota_victims(pinned, prunable, budget) do
    used = Enum.reduce(pinned, 0, &(&2 + version_bytes(&1)))

    prunable
    |> Enum.reduce({used, []}, fn version, {used, victims} ->
      size = version_bytes(version)

      # Newest first: keep taking versions while they fit, and
      # everything from the first one that doesn't is a victim. Not
      # "skip the big one and keep going" — that would delete a recent
      # release to keep an older one.
      if victims == [] and used + size <= budget,
        do: {used + size, victims},
        else: {used, [version | victims]}
    end)
    |> elem(1)
    |> Enum.reverse()
  end

  defp versions_newest_first(%Package{id: id}) do
    from(v in PackageVersion,
      where: v.package_id == ^id,
      order_by: [desc_nulls_last: v.pushed_at, desc: v.id],
      preload: [:files]
    )
    |> Repo.all()
  end

  defp pinned?(%PackageVersion{pinned_at: nil}), do: false
  defp pinned?(%PackageVersion{}), do: true

  @doc """
  The disk one version accounts for.

  For formats with files, the sum of those files. For OCI, the mirrored
  manifest size — the bytes are zot's, but they are still bytes this
  instance is responsible for.
  """
  @spec version_bytes(PackageVersion.t()) :: non_neg_integer()
  def version_bytes(%PackageVersion{files: files} = version) when is_list(files) do
    case files do
      [] -> version.size || 0
      files -> Enum.reduce(files, 0, &((&1.size || 0) + &2))
    end
  end

  def version_bytes(%PackageVersion{size: size}), do: size || 0

  # ── pins ─────────────────────────────────────────────────────────────

  @doc """
  Pin a version out of retention's reach.

  Refused once the package is at `packages.max_pins`, with the limit in
  the error. Pinning something already pinned is a no-op rather than an
  error — the caller's intent is satisfied either way.
  """
  @spec pin(PackageVersion.t(), GitGud.Accounts.User.t() | nil) ::
          {:ok, PackageVersion.t()} | {:error, term()}
  def pin(version, actor \\ nil)

  def pin(%PackageVersion{pinned_at: %DateTime{}} = version, _actor), do: {:ok, version}

  def pin(%PackageVersion{} = version, actor) do
    limit = Settings.get("packages.max_pins")

    if pin_count(version.package_id) >= limit do
      {:error, {:pin_limit, limit}}
    else
      version
      |> Ecto.Changeset.change(
        pinned_at: DateTime.utc_now(:second),
        pinned_by_id: actor && actor.id
      )
      |> Repo.update()
    end
  end

  @doc "Release a pin, putting the version back under the policy."
  @spec unpin(PackageVersion.t()) :: {:ok, PackageVersion.t()} | {:error, term()}
  def unpin(%PackageVersion{} = version) do
    version
    |> Ecto.Changeset.change(pinned_at: nil, pinned_by_id: nil)
    |> Repo.update()
  end

  @doc "How many versions of this package are pinned."
  @spec pin_count(integer()) :: non_neg_integer()
  def pin_count(package_id) do
    from(v in PackageVersion,
      where: v.package_id == ^package_id and not is_nil(v.pinned_at),
      select: count(v.id)
    )
    |> Repo.one()
  end

  @doc "Pins allowed per package, and how many this one has used."
  @spec pin_budget(integer()) :: %{used: non_neg_integer(), limit: non_neg_integer()}
  def pin_budget(package_id),
    do: %{used: pin_count(package_id), limit: Settings.get("packages.max_pins")}

  @doc "Whether the pruner is allowed to delete anything at all."
  @spec enforced?() :: boolean()
  def enforced?, do: Settings.get("packages.retention_enforced")
end