7.7 KiB · text 5af55d9
defmodule GitGudWeb.RegistryTokenLive.Index do
@moduledoc """
Repo settings page for registry deploy tokens — long-lived,
repo-scoped credentials for pulling/pushing the repo's images outside
of CI (Argo/keel pull secrets, external pipelines). The plaintext is
shown once at creation; only the hash is stored.
"""
use GitGudWeb, :live_view
alias GitGud.Packages
alias GitGud.Repositories
alias GitGud.Repositories.Storage
@impl true
def mount(%{"owner" => owner, "name" => name}, _session, socket) do
repo =
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])
{:ok,
socket
|> assign(:repo, repo)
|> assign(:handle, Storage.repo_handle(repo))
|> GitGudWeb.RepoLive.Header.assign_chrome(repo)
|> assign(:registry_host, Application.get_env(:git_gud, :registry_host))
|> assign(:new_token, nil)
|> assign(:all_scopes, Packages.RegistryToken.scopes())
|> load()
|> assign(:page_title, "Registry tokens — #{owner}/#{name}")}
end
defp load(socket) do
assign(socket, :tokens, Packages.list_registry_tokens(socket.assigns.repo))
end
@impl true
def handle_event("create_token", params, socket) do
cond do
not socket.assigns.can_admin? ->
{:noreply, put_flash(socket, :error, "Not allowed.")}
String.trim(params["name"] || "") == "" ->
{:noreply, put_flash(socket, :error, "Name required.")}
(params["scopes"] || []) == [] ->
{:noreply, put_flash(socket, :error, "Pick at least one scope.")}
true ->
name = String.trim(params["name"])
scopes = params["scopes"]
case Packages.create_registry_token(socket.assigns.repo, name, scopes) do
{:ok, _token, plaintext} ->
{:noreply,
socket
|> load()
|> assign(:new_token, %{name: name, secret: plaintext})
|> put_flash(:info, "Token created — copy it now, it won't be shown again.")}
{:error, cs} ->
{:noreply, put_flash(socket, :error, error_message(cs))}
end
end
end
def handle_event("delete_token", %{"id" => id}, socket) do
if socket.assigns.can_admin? do
:ok = Packages.delete_registry_token(socket.assigns.repo, String.to_integer(id))
{:noreply, socket |> load() |> assign(:new_token, nil) |> put_flash(:info, "Token revoked.")}
else
{:noreply, put_flash(socket, :error, "Not allowed.")}
end
end
defp error_message(%Ecto.Changeset{} = cs) do
cs
|> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
|> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end)
end
defp image_ref(assigns), do: "#{assigns.registry_host}/#{assigns.handle}/#{assigns.repo.name}"
# Ready-to-apply kubernetes.io/dockerconfigjson secret for pulling this
# repo's images with the freshly-minted token.
defp pull_secret_yaml(assigns) do
%{registry_host: host, repo: repo, new_token: %{name: user, secret: pass}} = assigns
auth = Base.encode64("#{user}:#{pass}")
dockercfg =
Jason.encode!(%{
"auths" => %{host => %{"username" => user, "password" => pass, "auth" => auth}}
})
"""
apiVersion: v1
kind: Secret
metadata:
name: #{repo.name}-pull
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: #{Base.encode64(dockercfg)}
"""
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-8">
<GitGudWeb.RepoLive.Header.header
repo={@repo}
handle={@handle}
current_scope={@current_scope}
has_packages?={@has_packages?}
can_admin?={@can_admin?}
latest_run={@latest_run}
/>
<.repo_settings_header handle={@handle} repo={@repo} current={:registry_tokens} />
<section class="max-w-2xl space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-semibold">Registry deploy tokens</h2>
<p class="text-sm opacity-70">
Long-lived credentials to <code>pull</code>/<code>push</code>
this repo's container images outside CI. CI itself doesn't need one —
it uses the per-job token automatically.
</p>
</div>
<.link
:if={@has_packages?}
navigate={~p"/r/#{@handle}/#{@repo.name}/packages"}
class="link link-hover text-sm whitespace-nowrap"
>
View packages →
</.link>
</div>
<div :if={@new_token} class="rounded border border-success/40 bg-success/10 p-3 space-y-2">
<p class="text-sm font-semibold">New token “{@new_token.name}” — copy it now:</p>
<pre class="text-xs bg-black text-green-300 p-2 rounded overflow-auto select-all">{@new_token.secret}</pre>
<p :if={@registry_host} class="text-xs opacity-80">
<span class="font-mono select-all">podman login {@registry_host} -u {@new_token.name} -p {@new_token.secret}</span>
</p>
<details :if={@registry_host} class="text-xs">
<summary class="cursor-pointer opacity-80">Kubernetes imagePullSecret</summary>
<pre class="bg-black text-green-300 p-2 rounded overflow-auto select-all mt-1">{pull_secret_yaml(assigns)}</pre>
<p class="opacity-70 mt-1">
Apply it, then set <code>imagePullSecrets</code> to
<code>{@repo.name}-pull</code> for image
<code>{image_ref(assigns)}:thetag</code>.
</p>
</details>
</div>
<form :if={@can_admin?} phx-submit="create_token" class="flex flex-wrap items-end gap-3">
<label class="form-control">
<span class="text-xs opacity-70">Name</span>
<input name="name" placeholder="argo-pull" class="input input-sm input-bordered" />
</label>
<label :for={s <- @all_scopes} class="label cursor-pointer gap-1 text-sm">
<input type="checkbox" name="scopes[]" value={s} checked={s == "pull"} class="checkbox checkbox-sm" />
<span class="font-mono">{s}</span>
</label>
<button class="btn btn-sm btn-primary">Create token</button>
</form>
<table class="table table-sm">
<thead>
<tr><th>Name</th><th>Scopes</th><th>Last used</th><th></th></tr>
</thead>
<tbody>
<tr :for={t <- @tokens}>
<td class="font-mono">{t.name}</td>
<td class="font-mono text-xs">{Enum.join(t.scopes, ", ")}</td>
<td class="text-xs opacity-70">{t.last_used_at && Calendar.strftime(t.last_used_at, "%Y-%m-%d %H:%M")}</td>
<td class="text-right">
<button
:if={@can_admin?}
phx-click="delete_token"
phx-value-id={t.id}
data-confirm={"Revoke token #{t.name}? Anything using it will lose access."}
class="btn btn-xs btn-ghost text-error"
>Revoke</button>
</td>
</tr>
<tr :if={@tokens == []}><td colspan="4" class="text-sm opacity-50">No tokens yet.</td></tr>
</tbody>
</table>
<div :if={@registry_host} class="text-xs opacity-70 space-y-1">
<p>Pull this repo's image with:</p>
<pre class="bg-base-200 p-2 rounded select-all">podman pull {image_ref(assigns)}:thetag</pre>
</div>
</section>
</div>
</Layouts.app>
"""
end
end