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 because the instance-wide default would prune a lot of
history the first time it ran on an instance that predates it, and
"here is what this would do" is the only honest way to ask an admin
to turn it on. `packages.retention_enforced` is off until they do.
"""
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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.0 KiB · text
History
aa9e861