defmodule GitGudWeb.UserLive.Runners do
@moduledoc """
`/users/settings/runners` — a user's own runners.
A runner registered here takes jobs from every repository the user
owns personally, not from an organization's they belong to. Their own
machine, put to work for all their repositories without a token per
repository and without an admin. Minted offline (`config.yml`) or
with a single-use registration token, like a repository's or an
organization's; listed with the global runners, which are shown but
not this page's to change.
In sudo mode, like the token and SSH key pages: a runner's credential
runs code with the user's repositories' secrets. Each change re-checks
it and goes in the user's security log.
"""
use GitGudWeb, :live_view
on_mount {GitGudWeb.UserAuth, {:require_sudo_mode, "/users/settings/runners"}}
alias GitGud.Accounts.User
alias GitGud.Workflows.Runner
alias GitGud.Workflows.Runners
alias GitGudWeb.UserAuth
@default_labels "self-hosted,ubuntu-latest"
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
# One of this user's runners, or a global one, connecting for the
# first time appears here without a reload.
if connected?(socket) do
Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(user))
Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(:global))
end
{:ok,
socket
|> assign(:page_title, "Runners")
|> assign(:user, user)
|> assign(:new_token, nil)
|> assign(:new_runner, nil)
|> assign(:create_form, create_form(%{"name" => "", "labels" => @default_labels}))
|> load_runners()}
end
defp load_runners(socket) do
runners = Runners.list_for_scope(socket.assigns.user)
socket
|> assign(:architecture_forms, Map.new(runners, &{&1.id, architecture_form(&1)}))
|> stream(:runners, runners, reset: true)
end
defp create_form(params), do: to_form(params, as: :new_runner, id: "new-runner")
defp architecture_form(runner) do
to_form(%{"id" => runner.id, "architecture" => runner.architecture},
as: :runner,
id: "runner-#{runner.id}"
)
end
@impl true
def handle_event(event, params, socket)
when event in ~w(mint_token create_runner delete_runner set_architecture),
do:
UserAuth.with_sudo(socket, ~p"/users/settings/runners", fn ->
handle_sudo_event(event, params, socket)
end)
defp handle_sudo_event("mint_token", _params, socket) do
user = socket.assigns.user
minted =
GitGud.Audit.transact(
socket.assigns.current_scope,
"runner.registration_token_minted",
fn -> Runners.mint_registration_token(user) end,
fn _token ->
expires_at =
DateTime.add(DateTime.utc_now(:second), Runners.registration_token_ttl_seconds())
Map.put(Runners.audit_scope(user), :metadata, %{expires_at: expires_at})
end
)
case minted do
{:ok, token} ->
{:noreply,
socket
|> assign(:user, GitGud.Repo.get!(User, user.id))
|> assign(:new_token, token)
|> put_flash(:info, "Registration token minted. Save it now — it won't be shown again.")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Could not mint token.")}
end
end
defp handle_sudo_event("create_runner", %{"new_runner" => params}, socket) do
name = params["name"] |> to_string() |> String.trim()
labels = parse_labels(params["labels"])
user = socket.assigns.user
if name == "" do
{:noreply,
socket
|> assign(:create_form, create_form(params))
|> put_flash(:error, "Runner name is required.")}
else
created =
GitGud.Audit.transact(
socket.assigns.current_scope,
"runner.created",
fn -> Runners.create_offline(%{name: name, labels: %{"labels" => labels}}, user) end,
&runner_attrs(user, &1, %{labels: labels})
)
case created do
{:ok, runner, uuid, hex_token} ->
{:noreply,
socket
|> assign(:new_runner, %{
name: runner.name,
uuid: uuid,
token: hex_token,
labels: labels,
url: GitGudWeb.Endpoint.url()
})
|> assign(:create_form, create_form(%{"name" => "", "labels" => @default_labels}))
|> load_runners()
|> put_flash(
:info,
"Runner '#{runner.name}' minted. Save the token — it's only shown once."
)}
{:error, cs} ->
{:noreply, put_flash(socket, :error, "Could not create runner: #{inspect(cs.errors)}")}
end
end
end
defp handle_sudo_event("delete_runner", %{"id" => id}, socket) do
case own_runner(socket, id) do
%Runner{} = runner ->
{:ok, _} =
GitGud.Audit.transact(
socket.assigns.current_scope,
"runner.deleted",
fn -> Runners.delete_runner(runner) end,
fn _ -> runner_attrs(socket.assigns.user, runner, %{}) end
)
{:noreply,
socket |> load_runners() |> put_flash(:info, "Runner '#{runner.name}' removed.")}
nil ->
{:noreply, put_flash(socket, :error, "Cannot remove runners that aren't yours.")}
end
end
defp handle_sudo_event(
"set_architecture",
%{"runner" => %{"id" => id, "architecture" => architecture}},
socket
) do
with %Runner{} = runner <- own_runner(socket, id),
{:ok, updated} <-
GitGud.Audit.transact(
socket.assigns.current_scope,
"runner.architecture_changed",
fn -> Runners.set_architecture(runner, architecture) end,
fn _ ->
runner_attrs(socket.assigns.user, runner, %{
from: runner.architecture,
to: architecture
})
end
) do
{:noreply,
socket
|> load_runners()
|> put_flash(:info, "Runner '#{updated.name}' is now #{updated.architecture}.")}
else
nil -> {:noreply, put_flash(socket, :error, "Cannot change runners that aren't yours.")}
{:error, _} -> {:noreply, put_flash(socket, :error, "Unknown architecture.")}
end
end
@impl true
def handle_info({:runner_connected, %{name: name}}, socket) do
{:noreply,
socket
|> load_runners()
|> put_flash(:info, "Runner '#{name}' connected.")}
end
# Only a runner of this user's own: never a global one, or someone
# else's, whatever id the client sends.
defp own_runner(socket, id) do
uid = socket.assigns.user.id
with id when is_integer(id) <- GitGudWeb.RepoAdmin.parse_id(id),
%Runner{owner_id: ^uid} = runner <- GitGud.Repo.get(Runner, id) do
runner
else
_ -> nil
end
end
defp runner_attrs(user, runner, metadata) do
user
|> Runners.audit_scope()
|> Map.merge(%{target_id: runner.id, target_label: runner.name, metadata: metadata})
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-8 max-w-3xl">
<header class="space-y-1">
<h1 class="text-xl font-semibold">Runners</h1>
<p class="text-sm opacity-70">
Self-hosted runners of your own. They pick up jobs from every
repository you own personally — not from an organization's.
</p>
</header>
<section class="border border-base-300 rounded-lg p-4 space-y-3">
<header class="space-y-1">
<h2 class="font-semibold">Create runner (offline)</h2>
<p class="text-xs opacity-70">
Modern <code>forgejo-runner</code>
(v6+) reads its credentials from <code>config.yml</code>
directly. Pick a name + labels, then save the
generated <code>config.yml</code>
below — it's preconfigured to run
jobs in rootless <strong>Podman</strong>
— and start the daemon.
</p>
</header>
<.form
for={@create_form}
id="new-runner-form"
phx-submit="create_runner"
class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end"
>
<.input
field={@create_form[:name]}
label="Name"
placeholder={runner_name(@user)}
required
/>
<.input field={@create_form[:labels]} label="Labels (comma-separated)" />
<button type="submit" id="new-runner-submit" class="btn btn-sm btn-primary mb-2">
Create
</button>
</.form>
<div :if={@new_runner} id="new-runner-config" class="text-sm space-y-2">
<p class="text-warning">Save this token now — it's only shown once.</p>
<pre
class="text-xs bg-base-200 p-3 rounded overflow-x-auto"
phx-no-curly-interpolation
><%= Runners.config_yaml(@new_runner) %></pre>
<p class="text-xs opacity-70">
Save as <code>config.yml</code>, then run:
<code>forgejo-runner daemon --config config.yml</code>
</p>
</div>
</section>
<section id="reg-token" class="border border-base-300 rounded-lg p-4 space-y-3">
<header class="flex items-baseline justify-between">
<h2 class="font-semibold">Legacy registration token</h2>
<button
type="button"
id="mint-token"
phx-click="mint_token"
class="btn btn-sm btn-ghost"
>
{if Runners.has_token?(@user), do: "Regenerate", else: "Generate"}
</button>
</header>
<p class="text-xs opacity-60">
Pre-v6 <code>forgejo-runner register --instance --token</code> flow.
Prefer the offline mint above for new deployments.
</p>
<%= cond do %>
<% @new_token -> %>
<div class="text-sm space-y-3">
<p class="text-warning">Save this token now — it's only shown once.</p>
<pre
id="new-reg-token"
class="text-xs bg-base-200 p-3 rounded overflow-x-auto"
phx-no-curly-interpolation
><%= @new_token %></pre>
<p class="text-xs opacity-70" id="reg-token-terms">
Registers one runner, within {token_ttl_label()}.
</p>
<p class="text-xs opacity-70">Pick a deployment style:</p>
<.runner_register_commands
token={@new_token}
instance_url={GitGudWeb.Endpoint.url()}
runner_name={runner_name(@user)}
/>
</div>
<% Runners.has_token?(@user) -> %>
<p class="text-sm opacity-70">
An unused token is outstanding until {format_expiry(@user)}. Regenerate to invalidate it.
</p>
<% true -> %>
<p class="text-sm opacity-70">
No token yet. Generate one to register a self-hosted runner.
</p>
<% end %>
</section>
<section class="space-y-2">
<h2 class="font-semibold">Runners eligible for your repositories</h2>
<ul
id="runners"
phx-update="stream"
class="divide-y divide-base-300 border border-base-300 rounded-lg"
>
<li id="runners-empty" class="hidden only:block px-3 py-3 text-sm opacity-60">
No runners registered yet.
</li>
<li
:for={{dom_id, r} <- @streams.runners}
id={dom_id}
class="px-3 py-3 flex items-baseline justify-between gap-3 transition-colors hover:bg-base-200/50"
>
<div class="space-y-0.5 min-w-0" id={"runner-row-#{r.id}"}>
<p class="font-mono text-sm truncate">{r.name}</p>
<p class="text-xs opacity-60">
<span class={["badge badge-xs", scope_class(r)]}>{Runner.scope_label(r)}</span>
<span class="ml-1">labels: {labels_label(r)}</span>
<span :if={agent_labels_label(r) != "—"} class="ml-1 opacity-70">
(reports: {agent_labels_label(r)})
</span>
<span class="ml-1">· {r.architecture}</span>
<span :if={r.version} class="ml-1">· v{r.version}</span>
<span :if={r.last_seen_at} class="ml-1">
· last seen {format_dt(r.last_seen_at)}
</span>
<span :if={!r.last_seen_at} class="ml-1">· never connected</span>
</p>
</div>
<div :if={r.owner_id == @user.id} class="flex items-center gap-2 shrink-0">
<.form
for={@architecture_forms[r.id]}
id={"runner-architecture-#{r.id}"}
phx-change="set_architecture"
>
<.input field={@architecture_forms[r.id][:id]} type="hidden" />
<.input
field={@architecture_forms[r.id][:architecture]}
type="select"
options={Runner.architectures()}
class="select select-xs font-mono"
aria-label={"Architecture of #{r.name}"}
/>
</.form>
<button
type="button"
id={"runner-remove-#{r.id}"}
phx-click="delete_runner"
phx-value-id={r.id}
class="btn btn-xs btn-ghost text-error"
data-confirm={"Remove runner #{r.name}? It will need to re-register to come back."}
>
Remove
</button>
</div>
</li>
</ul>
<p class="text-xs opacity-60">
Runners registered with the operator's global token are listed for
visibility but managed elsewhere.
</p>
</section>
</div>
</Layouts.app>
"""
end
defp runner_name(%User{handle: h}) when is_binary(h), do: h <> "-runner"
defp runner_name(_), do: "my-runner"
defp scope_class(%{owner_id: u}) when not is_nil(u), do: "badge-primary"
defp scope_class(_), do: "badge-ghost"
defp parse_labels(str) do
str
|> to_string()
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> ["ubuntu-latest"]
list -> list
end
end
defp labels_label(%{labels: %{"labels" => ls}}) when is_list(ls) and ls != [],
do: Enum.join(ls, ", ")
defp labels_label(_), do: "—"
defp agent_labels_label(%{agent_labels: %{"labels" => ls}}) when is_list(ls) and ls != [],
do: Enum.join(ls, ", ")
defp agent_labels_label(_), do: "—"
defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
defp format_dt(_), do: ""
defp format_expiry(scope), do: format_dt(scope.runner_reg_token_expires_at) <> " UTC"
defp token_ttl_label do
case Runners.registration_token_ttl_seconds() do
s when rem(s, 3600) == 0 -> "#{div(s, 3600)} hours"
s -> "#{div(s, 60)} minutes"
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
15.2 KiB · text
History
b868907