5.5 KiB · text History aa9e861
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. **Off by default**, and deliberately: an
      instance that upgrades into this slice has years of versions that
      the default policy would prune on its first pass. Reporting mode
      computes the same plan and shows it, so the decision to start
      deleting is one an admin makes after looking at 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: false},
    "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