6.8 KiB · text 5af55d9
defmodule GitGudWeb.PackagesWebhookController do
@moduledoc """
Receives zot's [push/delete event notifications](https://zotregistry.dev/v2.1.0/admin-guide/admin-getting-started/#notifications)
and mirrors them into the local `packages` + `package_versions`
tables.
zot's outgoing payload looks roughly like:
{
"event": "PUSH",
"repository": "owner/repo-name/img",
"reference": "v1.2.3",
"digest": "sha256:...",
"size": 12345,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"timestamp": "2026-..."
}
Repository path layout we assume: `<owner>/<repo-name>/<image>`. The
first two segments resolve a `GitGud.Repositories.Repository`; the
rest is the package name (so `org/site/web` and `org/site/db` are
distinct packages on the same repo).
## Authentication
config :git_gud, GitGudWeb.PackagesWebhookController,
shared_secret: System.get_env("ZOT_WEBHOOK_SECRET")
Configure zot to send `Authorization: Bearer <secret>`. The
controller compares with `Plug.Crypto.secure_compare/2`.
"""
use GitGudWeb, :controller
alias GitGud.Packages
alias GitGud.Repositories
require Logger
def notify(conn, params) do
with :ok <- authenticate(conn) do
ce_type = cloudevent_type(conn)
Logger.debug(fn ->
"zot webhook: ce-type=#{inspect(ce_type)} name=#{inspect(params["name"])} ref=#{inspect(params["reference"])}"
end)
case classify(ce_type, params) do
{:push, payload} ->
handle_push(conn, payload)
{:delete, payload} ->
handle_delete(conn, payload)
:ignored ->
send_resp(conn, 204, "")
end
else
{:error, :unauthorized} ->
conn |> put_status(:unauthorized) |> json(%{"error" => "unauthorized"})
end
end
# ── dispatch ────────────────────────────────────────────────────────
# zot's v2.1 `events` extension sends CloudEvents in *binary* mode: the
# event type (`zotregistry.image.updated` / `.deleted`) is in the
# `ce-type` HTTP header, and the image fields (name/reference/digest/…)
# are the JSON body. Older `notifications` payloads carried the action
# as a body `event`/`type` field — fall back to that.
defp classify(ce_type, params) do
action = ce_type || params["type"] || params["event"] || ""
data = unwrap_data(params)
a = String.downcase(action)
cond do
a == "push" or String.contains?(a, "updated") or String.contains?(a, "created") ->
{:push, data}
a == "delete" or String.contains?(a, "deleted") ->
{:delete, data}
true ->
:ignored
end
end
defp cloudevent_type(conn) do
case get_req_header(conn, "ce-type") do
[t | _] -> t
_ -> nil
end
end
defp unwrap_data(%{"data" => %{} = data}), do: data
defp unwrap_data(p), do: p
defp handle_push(conn, p) do
with {:ok, repo, image} <- resolve_repo_and_image(repository(p)),
reference when is_binary(reference) <- reference(p) do
# Skip by-digest manifest pushes (the per-arch children of an
# image index) — only tags are worth surfacing.
unless digest_ref?(reference) do
attrs = %{
version: reference,
digest: digest(p) || "",
size: parse_size(p),
media_type: p["mediaType"] || p["MediaType"],
manifest_doc: decode_manifest(p["manifest"] || p["Manifest"]),
pushed_at: parse_timestamp(p["timestamp"] || p["time"] || p["Timestamp"])
}
_ = Packages.record_push(repo, "container", image, attrs)
end
send_resp(conn, 204, "")
else
_ -> send_resp(conn, 422, "unparseable payload")
end
end
defp handle_delete(conn, p) do
with {:ok, repo, image} <- resolve_repo_and_image(repository(p)),
reference when is_binary(reference) <- reference(p) do
_ = Packages.delete_version(repo, "container", image, reference)
send_resp(conn, 204, "")
else
_ -> send_resp(conn, 422, "unparseable payload")
end
end
# zot's field naming varies across the notifications/events payloads.
defp repository(p), do: p["repository"] || p["name"] || p["Name"] || p["Repository"]
defp reference(p), do: p["reference"] || p["tag"] || p["Reference"] || p["Tag"]
defp digest(p), do: p["digest"] || p["Digest"] || p["manifestDigest"]
defp digest_ref?(ref), do: String.starts_with?(ref, "sha256:")
# The events payload carries `manifest` as a JSON string; the schema
# field is a map.
defp decode_manifest(m) when is_binary(m) do
case Jason.decode(m) do
{:ok, map} when is_map(map) -> map
_ -> %{}
end
end
defp decode_manifest(m) when is_map(m), do: m
defp decode_manifest(_), do: %{}
# ── helpers ────────────────────────────────────────────────────────
defp resolve_repo_and_image(nil), do: :error
defp resolve_repo_and_image(path) when is_binary(path) do
case String.split(path, "/", trim: true) do
[owner, name | image_parts] when image_parts != [] ->
image = Enum.join(image_parts, "/")
try do
{:ok, Repositories.get_repository_by_path!(owner, name), image}
rescue
Ecto.NoResultsError -> :error
end
[owner, name] ->
# Path like `owner/repo` with no sub-image — treat the repo
# name itself as the package.
try do
{:ok, Repositories.get_repository_by_path!(owner, name), name}
rescue
Ecto.NoResultsError -> :error
end
_ ->
:error
end
end
defp parse_size(%{"size" => s}) when is_integer(s), do: s
defp parse_size(%{"size" => s}) when is_binary(s) do
case Integer.parse(s) do
{n, _} -> n
_ -> 0
end
end
defp parse_size(_), do: 0
defp parse_timestamp(nil), do: nil
defp parse_timestamp(s) when is_binary(s) do
case DateTime.from_iso8601(s) do
{:ok, dt, _} -> DateTime.truncate(dt, :second)
_ -> nil
end
end
defp parse_timestamp(_), do: nil
defp authenticate(conn) do
expected =
Application.get_env(:git_gud, __MODULE__, [])
|> Keyword.get(:shared_secret)
cond do
is_nil(expected) or expected == "" ->
# No secret configured = open. Useful in dev with zot on
# localhost only. Operators should set one in prod.
:ok
true ->
case get_req_header(conn, "authorization") do
["Bearer " <> token] ->
if Plug.Crypto.secure_compare(token, expected),
do: :ok,
else: {:error, :unauthorized}
_ ->
{:error, :unauthorized}
end
end
end
end