5.2 KiB · text 5af55d9
defmodule GitGud.Packages do
@moduledoc """
Container/package registry metadata mirror.
Authoritative storage lives in zot (the OCI registry sidecar). This
module keeps a Postgres mirror of (Package, PackageVersion) rows so
the UI can list packages, search by name, and link from repository
pages without per-request round trips to zot.
Mirror is populated by zot webhook notifications (TODO: wire the
endpoint) or a periodic sync against `/v2/_catalog`.
"""
import Ecto.Query, warn: false
alias GitGud.Packages.Package
alias GitGud.Packages.PackageVersion
alias GitGud.Packages.RegistryToken
alias GitGud.Repo
alias GitGud.Repositories.Repository
@registry_token_prefix "ggrt_"
# ── registry deploy tokens ───────────────────────────────────────────
@doc "Repo-scoped registry tokens, by name."
def list_registry_tokens(%Repository{id: rid}) do
from(t in RegistryToken, where: t.repository_id == ^rid, order_by: [asc: t.name])
|> Repo.all()
end
@doc """
Create a registry token for a repo with the given `scopes`
(subset of `pull`/`push`). Returns `{:ok, token, plaintext}`; the
plaintext (`ggrt_…`) is only available here — we store its hash.
"""
def create_registry_token(%Repository{id: rid}, name, scopes) do
plaintext = @registry_token_prefix <> (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false))
%RegistryToken{repository_id: rid}
|> RegistryToken.changeset(%{name: name, token_hash: token_hash(plaintext), scopes: scopes})
|> Repo.insert()
|> case do
{:ok, token} -> {:ok, token, plaintext}
{:error, _} = err -> err
end
end
def delete_registry_token(%Repository{id: rid}, id) do
Repo.delete_all(from t in RegistryToken, where: t.repository_id == ^rid and t.id == ^id)
:ok
end
@doc """
Verify a presented registry token. On a match, bumps `last_used_at`
and returns `{:ok, repository_id, scopes}`; otherwise `:error`.
"""
def verify_registry_token(@registry_token_prefix <> _ = plaintext) do
case Repo.get_by(RegistryToken, token_hash: token_hash(plaintext)) do
%RegistryToken{} = token ->
Repo.update_all(
from(t in RegistryToken, where: t.id == ^token.id),
set: [last_used_at: DateTime.utc_now(:second)]
)
{:ok, token.repository_id, token.scopes}
nil ->
:error
end
end
def verify_registry_token(_), do: :error
defp token_hash(plaintext), do: :crypto.hash(:sha256, plaintext)
def list_packages(%Repository{id: rid}) do
Package
|> where([p], p.repository_id == ^rid)
|> order_by([p], asc: p.name)
|> Repo.all()
end
def get_package!(id), do: Repo.get!(Package, id)
def get_package_by(repo_id, kind, name) do
Repo.get_by(Package, repository_id: repo_id, kind: kind, name: name)
end
def list_versions(%Package{id: pid}) do
PackageVersion
|> where([v], v.package_id == ^pid)
|> order_by([v], desc: v.pushed_at)
|> Repo.all()
end
@doc """
Record a new (or updated) package version. Idempotent on
`(package_id, version)` — re-pushing the same tag just bumps
`pushed_at` and refreshes the manifest snapshot.
"""
def record_push(%Repository{id: rid}, kind, name, version_attrs) do
Repo.transaction(fn ->
package =
case get_package_by(rid, kind, name) do
nil ->
{:ok, p} =
%Package{repository_id: rid}
|> Package.changeset(%{kind: kind, name: name})
|> Repo.insert()
p
existing ->
existing
end
attrs =
version_attrs
|> Map.put(:pushed_at, Map.get(version_attrs, :pushed_at) || DateTime.utc_now(:second))
version =
case Repo.get_by(PackageVersion, package_id: package.id, version: attrs[:version]) do
nil -> %PackageVersion{package_id: package.id}
existing -> existing
end
{:ok, v} = version |> PackageVersion.changeset(attrs) |> Repo.insert_or_update()
{:ok, _} = package |> Package.changeset(%{latest_version: v.version}) |> Repo.update()
{package, v}
end)
end
@doc """
Remove a single version. If it was the package's `latest_version`,
recompute against the next-most-recent push. Deletes the package
row entirely if the version was the last one.
"""
def delete_version(%Repository{id: rid}, kind, name, version) do
case get_package_by(rid, kind, name) do
nil ->
:ok
package ->
Repo.transaction(fn ->
Repo.delete_all(
from v in PackageVersion,
where: v.package_id == ^package.id and v.version == ^version
)
remaining =
Repo.all(
from v in PackageVersion,
where: v.package_id == ^package.id,
order_by: [desc: v.pushed_at],
limit: 1
)
case remaining do
[] ->
Repo.delete!(package)
:deleted
[latest | _] ->
package
|> Package.changeset(%{latest_version: latest.version})
|> Repo.update!()
:updated
end
end)
end
end
end