defmodule GitGud.Settings do
@moduledoc """
Instance-wide levers, with their defaults in one place.
Config in `config/*.exs` needs a deploy to change and can't be edited
by an admin who has a browser and no shell. These can, so anything an
operator might reasonably want to turn at 2am lives here instead.
Every key is declared below with its type, default and bounds. A key
that isn't declared can't be read or written — a typo in an admin form
should be a validation error, not a silently stored setting that
nothing consults.
## The keys
* `packages.retention_enforced` — whether the pruner deletes
anything at all. **On by default**: CI publishes on every push,
so a registry left alone is a disk that fills. Turn it off for
reporting mode, which computes the same plan and shows it without
deleting — the admin packages page has the switch and the number.
* `packages.retention_mode` / `packages.retention_keep` /
`packages.retention_bytes` — the instance policy, and the ceiling
a package's own policy is clamped to. A package may keep *fewer*
versions or claim *less* disk than the instance allows; it may
not claim more. See `GitGud.Packages.Retention`.
* `packages.max_pins` — how many versions one package may pin out
of retention's reach. Pins are the escape hatch; without a limit
they are also the way retention gets quietly disabled.
"""
import Ecto.Query, warn: false
alias GitGud.Repo
alias GitGud.Settings.InstanceSetting
@keys %{
"packages.retention_enforced" => %{type: :boolean, default: true},
"packages.retention_mode" => %{type: :string, default: "keep_last", in: ~w(keep_last quota)},
"packages.retention_keep" => %{type: :integer, default: 24, min: 1, max: 10_000},
"packages.retention_bytes" => %{type: :integer, default: nil, min: 0, nullable: true},
"packages.max_pins" => %{type: :integer, default: 12, min: 0, max: 1_000}
}
@doc "Every declared key, with its type, default and bounds."
def keys, do: @keys
@doc "The declaration for one key, or `nil`."
def spec(key), do: Map.get(@keys, key)
@doc """
Read a setting, falling back to its declared default.
Unknown keys raise: a caller asking for a key nobody declared is a
bug in the caller, and returning nil would hide it.
"""
def get(key) do
spec = spec!(key)
case Repo.get_by(InstanceSetting, key: key) do
%InstanceSetting{value: %{"value" => value}} -> cast(value, spec)
_ -> spec.default
end
end
@doc "Read several settings at once — one query, not one per key."
def get_all(keys) do
stored =
from(s in InstanceSetting, where: s.key in ^keys, select: {s.key, s.value})
|> Repo.all()
|> Map.new()
Map.new(keys, fn key ->
spec = spec!(key)
value =
case Map.get(stored, key) do
%{"value" => value} -> cast(value, spec)
_ -> spec.default
end
{key, value}
end)
end
@doc """
Write a setting. Validates against the key's declaration, so an
out-of-range number is refused here rather than surfacing later as a
policy nobody can explain.
"""
def put(key, value, actor \\ nil) do
spec = spec!(key)
with {:ok, value} <- validate(value, spec) do
%InstanceSetting{}
|> InstanceSetting.changeset(%{
key: key,
value: %{"value" => value},
updated_by_id: actor && actor.id
})
|> Repo.insert(
on_conflict: [set: [value: %{"value" => value}, updated_at: DateTime.utc_now(:second)]],
conflict_target: :key
)
end
end
@doc "Drop a setting, returning it to its declared default."
def reset(key) do
_ = spec!(key)
Repo.delete_all(from s in InstanceSetting, where: s.key == ^key)
:ok
end
defp spec!(key) do
case spec(key) do
nil -> raise ArgumentError, "unknown instance setting #{inspect(key)}"
spec -> spec
end
end
# ── validation ───────────────────────────────────────────────────────
defp validate(nil, %{nullable: true}), do: {:ok, nil}
defp validate(value, %{type: :boolean}) when is_boolean(value), do: {:ok, value}
defp validate("true", %{type: :boolean}), do: {:ok, true}
defp validate("false", %{type: :boolean}), do: {:ok, false}
defp validate(value, %{type: :integer} = spec) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{n, ""} -> validate(n, spec)
_ -> {:error, :not_a_number}
end
end
defp validate(value, %{type: :integer} = spec) when is_integer(value) do
cond do
Map.has_key?(spec, :min) and value < spec.min -> {:error, {:too_small, spec.min}}
Map.has_key?(spec, :max) and value > spec.max -> {:error, {:too_large, spec.max}}
true -> {:ok, value}
end
end
defp validate(value, %{type: :string} = spec) when is_binary(value) do
allowed = Map.get(spec, :in)
if is_nil(allowed) or value in allowed,
do: {:ok, value},
else: {:error, {:not_allowed, allowed}}
end
defp validate(_value, _spec), do: {:error, :invalid}
# Stored values round-trip through JSON, so an integer written as a
# string stays readable rather than becoming a surprise later.
defp cast(value, %{type: :integer}) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n
_ -> nil
end
end
defp cast(value, _spec), do: value
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
5.4 KiB · text
History
b868907