defmodule GitGudWeb.AdminRunnersLive.Index do
@moduledoc """
Every runner on the instance, for instance admins.
Global runners — not a repository's, an organization's or a user's
own, so they take jobs from any repository — had no page of their
own: they were minted with `mix gitgud.runner.create --scope global`
and managed with `bin/git_gud eval`. Here an admin mints one, and
sets the architecture of, or removes, any runner, whatever its scope.
Each change is audited where the runner belongs.
A global runner's credential reaches every repository's jobs and
their secrets, so the changes re-check sudo mode; the list itself
holds no secrets and doesn't.
"""
use GitGudWeb, :live_view
alias GitGud.Accounts.User
alias GitGud.Organizations.Organization
alias GitGud.Repositories.Repository
alias GitGud.Repositories.Storage
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
if connected?(socket), do: Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(:all))
{:ok,
socket
|> assign(:page_title, "Runners")
|> assign(:new_runner, nil)
|> assign(:create_form, create_form(%{"name" => "", "labels" => @default_labels}))
|> load_runners()}
end
defp load_runners(socket) do
runners = Runners.list_all()
socket
|> assign(:runners, runners)
|> assign(:architecture_forms, Map.new(runners, &{&1.id, architecture_form(&1)}))
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(create_runner delete_runner set_architecture),
do:
UserAuth.with_sudo(socket, ~p"/admin/runners", fn ->
handle_sudo_event(event, params, socket)
end)
defp handle_sudo_event("create_runner", %{"new_runner" => params}, socket) do
name = params["name"] |> to_string() |> String.trim()
labels = parse_labels(params["labels"])
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}}) end,
&runner_attrs(&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 find_runner(id) do
%Runner{} = runner ->
{:ok, _} =
GitGud.Audit.transact(
socket.assigns.current_scope,
"runner.deleted",
fn -> Runners.delete_runner(runner) end,
fn _ -> runner_attrs(runner, %{}) end
)
{:noreply,
socket |> load_runners() |> put_flash(:info, "Runner '#{runner.name}' removed.")}
nil ->
{:noreply, put_flash(socket, :error, "No such runner.")}
end
end
defp handle_sudo_event(
"set_architecture",
%{"runner" => %{"id" => id, "architecture" => architecture}},
socket
) do
with %Runner{} = runner <- find_runner(id),
{:ok, updated} <-
GitGud.Audit.transact(
socket.assigns.current_scope,
"runner.architecture_changed",
fn -> Runners.set_architecture(runner, architecture) end,
fn _ -> runner_attrs(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, "No such runner.")}
{: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
defp find_runner(id) do
with id when is_integer(id) <- GitGudWeb.RepoAdmin.parse_id(id),
%Runner{} = runner <- GitGud.Repo.get(Runner, id) do
GitGud.Repo.preload(runner,
repository: [:owner, :organization],
organization: [],
owner: []
)
else
_ -> nil
end
end
# Audited in the runner's own scope: a repo's or an org's log, its
# user's security log, or the instance's for a global runner.
defp runner_attrs(runner, metadata) do
(runner.repository || runner.organization || runner.owner || :global)
|> 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-4xl">
<div>
<h1 class="text-2xl font-semibold">Runners</h1>
<p class="text-sm opacity-70 mt-1">
Every runner on this instance. Global runners take jobs from any
repository; repository and organization runners are also managed
from their own settings pages, and a user's runners — for the
repositories they own personally — from theirs.
</p>
</div>
<section class="border border-base-300 rounded-lg p-4 space-y-3">
<header class="space-y-1">
<h2 class="font-semibold">Create a global runner</h2>
<p class="text-xs opacity-70">
Mints the runner here; save the generated <code>config.yml</code>
— preconfigured for rootless <strong>Podman</strong>
—
and start <code>forgejo-runner daemon</code>. Labels are fixed
once the runner is created.
</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="builder-1" 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>
</div>
</section>
<section class="space-y-2">
<h2 class="font-semibold">All runners</h2>
<ul id="runners" class="divide-y divide-base-300 border border-base-300 rounded-lg">
<li :if={@runners == []} class="px-3 py-3 text-sm opacity-60">
No runners registered yet.
</li>
<li
:for={r <- @runners}
id={"runner-row-#{r.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">
<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>
<.link
:if={link = scope_link(r)}
navigate={elem(link, 1)}
class="ml-1 link link-hover"
>
{elem(link, 0)}
</.link>
<span class="ml-1">labels: {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 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 be created again to come back."}
>
Remove
</button>
</div>
</li>
</ul>
</section>
</div>
</Layouts.app>
"""
end
defp scope_class(%{repository_id: r}) when not is_nil(r), do: "badge-accent"
defp scope_class(%{organization_id: o}) when not is_nil(o), do: "badge-secondary"
defp scope_class(%{owner_id: u}) when not is_nil(u), do: "badge-primary"
defp scope_class(_), do: "badge-ghost"
# The repository or organization a runner belongs to, as `{name, path}`
# to its own runners page, or the user's profile for a user's runner
# (their settings page is theirs alone); nil for a global runner.
defp scope_link(%{repository: %Repository{} = repo}) do
path = "#{Storage.repo_handle(repo)}/#{repo.name}"
{path, ~p"/r/#{Storage.repo_handle(repo)}/#{repo.name}/settings/runners"}
end
defp scope_link(%{organization: %Organization{} = org}),
do: {org.handle, ~p"/orgs/#{org.handle}/settings/runners"}
defp scope_link(%{owner: %User{handle: handle}}) when is_binary(handle),
do: {handle, ~p"/u/#{handle}"}
defp scope_link(_), do: nil
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 format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
defp format_dt(_), do: ""
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
11.7 KiB · text
History
b868907