Admin: a runners page, global runners included

5f79844 · Gabriel Morell · 2026-10-27 05:04

15 files +487 -8
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/workflows/runners.ex
+22 −8
@@ -388,8 +388,9 @@ defmodule GitGud.Workflows.Runners do
388 388
389 389 @doc """
390 390 Record that `runner` was just heard from. The first time — a runner
391 − that has never connected — is announced on its scope's `topic/1` as
392 − `{:runner_connected, %{id: id, name: name}}`, once: the update only
391 + that has never connected — is announced on its scope's `topic/1`, and
392 + on `topic(:all)`, as `{:runner_connected, %{id: id, name: name}}`,
393 + once: the update only
393 394 matches while `last_seen_at` is still nil, so of two polls racing on
394 395 a new runner only one announces it.
395 396 """
@@ -403,11 +404,10 @@ defmodule GitGud.Workflows.Runners do
403 404 {1, _} ->
404 405 runner = %{runner | last_seen_at: now}
405 406
406 − Phoenix.PubSub.broadcast(
407 − GitGud.PubSub,
408 − topic_for(runner),
409 − {:runner_connected, %{id: runner.id, name: runner.name}}
410 − )
407 + message = {:runner_connected, %{id: runner.id, name: runner.name}}
408 +
409 + for topic <- [topic_for(runner), topic(:all)],
410 + do: Phoenix.PubSub.broadcast(GitGud.PubSub, topic, message)
411 411
412 412 {:ok, runner}
413 413
@@ -424,11 +424,13 @@ defmodule GitGud.Workflows.Runners do
424 424
425 425 @doc """
426 426 The PubSub topic a runners page follows for the runners of `scope`:
427 − a repository's, an organization's, or the global ones (`:global`).
427 + a repository's, an organization's, the global ones (`:global`), or
428 + every runner on the instance (`:all`).
428 429 """
429 430 def topic(%Repository{id: id}), do: "runners:repo:#{id}"
430 431 def topic(%Organization{id: id}), do: "runners:org:#{id}"
431 432 def topic(:global), do: "runners:global"
433 + def topic(:all), do: "runners:all"
432 434
433 435 defp topic_for(%Runner{repository_id: rid}) when is_integer(rid), do: "runners:repo:#{rid}"
434 436 defp topic_for(%Runner{organization_id: oid}) when is_integer(oid), do: "runners:org:#{oid}"
@@ -499,4 +501,16 @@ defmodule GitGud.Workflows.Runners do
499 501 order_by: [desc: r.last_seen_at]
500 502 )
501 503 end
504 +
505 + @doc """
506 + Every runner on the instance, whatever its scope, with the repository
507 + or organization it belongs to — for the instance admins' page.
508 + """
509 + def list_all do
510 + Repo.all(
511 + from r in Runner,
512 + order_by: [desc_nulls_last: r.last_seen_at, asc: r.name],
513 + preload: [repository: [:owner, :organization], organization: []]
514 + )
515 + end
502 516 end
modified lib/git_gud_web/components/landing_components.ex
+6 −0
@@ -205,6 +205,12 @@ defmodule GitGudWeb.LandingComponents do
205 205 label="Audit log"
206 206 navigate={~p"/admin/audit"}
207 207 />
208 + <.quick_link
209 + :if={@user.is_admin}
210 + icon="hero-server-stack"
211 + label="Runners"
212 + navigate={~p"/admin/runners"}
213 + />
208 214 </section>
209 215 """
210 216 end
added lib/git_gud_web/live/admin_runners_live/index.ex
+321 −0
@@ -0,0 +1,321 @@
1 +defmodule GitGudWeb.AdminRunnersLive.Index do
2 + @moduledoc """
3 + Every runner on the instance, for instance admins.
4 +
5 + Global runners — neither repo- nor org-scoped, so they take jobs from
6 + any repository — had no page of their own: they were minted with
7 + `mix gitgud.runner.create --scope global` and managed with
8 + `bin/git_gud eval`. Here an admin mints one, and sets the
9 + architecture of, or removes, any runner, whatever its scope. Each
10 + change is audited where the runner belongs.
11 +
12 + A global runner's credential reaches every repository's jobs and
13 + their secrets, so the changes re-check sudo mode; the list itself
14 + holds no secrets and doesn't.
15 + """
16 + use GitGudWeb, :live_view
17 +
18 + alias GitGud.Organizations.Organization
19 + alias GitGud.Repositories.Repository
20 + alias GitGud.Repositories.Storage
21 + alias GitGud.Workflows.Runner
22 + alias GitGud.Workflows.Runners
23 + alias GitGudWeb.UserAuth
24 +
25 + @default_labels "self-hosted,ubuntu-latest"
26 +
27 + @impl true
28 + def mount(_params, _session, socket) do
29 + if connected?(socket), do: Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(:all))
30 +
31 + {:ok,
32 + socket
33 + |> assign(:page_title, "Runners")
34 + |> assign(:new_runner, nil)
35 + |> assign(:create_form, create_form(%{"name" => "", "labels" => @default_labels}))
36 + |> load_runners()}
37 + end
38 +
39 + defp load_runners(socket) do
40 + runners = Runners.list_all()
41 +
42 + socket
43 + |> assign(:runners, runners)
44 + |> assign(:architecture_forms, Map.new(runners, &{&1.id, architecture_form(&1)}))
45 + end
46 +
47 + defp create_form(params), do: to_form(params, as: :new_runner, id: "new-runner")
48 +
49 + defp architecture_form(runner) do
50 + to_form(%{"id" => runner.id, "architecture" => runner.architecture},
51 + as: :runner,
52 + id: "runner-#{runner.id}"
53 + )
54 + end
55 +
56 + @impl true
57 + def handle_event(event, params, socket)
58 + when event in ~w(create_runner delete_runner set_architecture),
59 + do:
60 + UserAuth.with_sudo(socket, ~p"/admin/runners", fn ->
61 + handle_sudo_event(event, params, socket)
62 + end)
63 +
64 + defp handle_sudo_event("create_runner", %{"new_runner" => params}, socket) do
65 + name = params["name"] |> to_string() |> String.trim()
66 + labels = parse_labels(params["labels"])
67 +
68 + if name == "" do
69 + {:noreply,
70 + socket
71 + |> assign(:create_form, create_form(params))
72 + |> put_flash(:error, "Runner name is required.")}
73 + else
74 + created =
75 + GitGud.Audit.transact(
76 + socket.assigns.current_scope,
77 + "runner.created",
78 + fn -> Runners.create_offline(%{name: name, labels: %{"labels" => labels}}) end,
79 + &runner_attrs(&1, %{labels: labels})
80 + )
81 +
82 + case created do
83 + {:ok, runner, uuid, hex_token} ->
84 + {:noreply,
85 + socket
86 + |> assign(:new_runner, %{
87 + name: runner.name,
88 + uuid: uuid,
89 + token: hex_token,
90 + labels: labels,
91 + url: GitGudWeb.Endpoint.url()
92 + })
93 + |> assign(:create_form, create_form(%{"name" => "", "labels" => @default_labels}))
94 + |> load_runners()
95 + |> put_flash(
96 + :info,
97 + "Runner '#{runner.name}' minted. Save the token — it's only shown once."
98 + )}
99 +
100 + {:error, cs} ->
101 + {:noreply, put_flash(socket, :error, "Could not create runner: #{inspect(cs.errors)}")}
102 + end
103 + end
104 + end
105 +
106 + defp handle_sudo_event("delete_runner", %{"id" => id}, socket) do
107 + case find_runner(id) do
108 + %Runner{} = runner ->
109 + {:ok, _} =
110 + GitGud.Audit.transact(
111 + socket.assigns.current_scope,
112 + "runner.deleted",
113 + fn -> Runners.delete_runner(runner) end,
114 + fn _ -> runner_attrs(runner, %{}) end
115 + )
116 +
117 + {:noreply,
118 + socket |> load_runners() |> put_flash(:info, "Runner '#{runner.name}' removed.")}
119 +
120 + nil ->
121 + {:noreply, put_flash(socket, :error, "No such runner.")}
122 + end
123 + end
124 +
125 + defp handle_sudo_event(
126 + "set_architecture",
127 + %{"runner" => %{"id" => id, "architecture" => architecture}},
128 + socket
129 + ) do
130 + with %Runner{} = runner <- find_runner(id),
131 + {:ok, updated} <-
132 + GitGud.Audit.transact(
133 + socket.assigns.current_scope,
134 + "runner.architecture_changed",
135 + fn -> Runners.set_architecture(runner, architecture) end,
136 + fn _ -> runner_attrs(runner, %{from: runner.architecture, to: architecture}) end
137 + ) do
138 + {:noreply,
139 + socket
140 + |> load_runners()
141 + |> put_flash(:info, "Runner '#{updated.name}' is now #{updated.architecture}.")}
142 + else
143 + nil -> {:noreply, put_flash(socket, :error, "No such runner.")}
144 + {:error, _} -> {:noreply, put_flash(socket, :error, "Unknown architecture.")}
145 + end
146 + end
147 +
148 + @impl true
149 + def handle_info({:runner_connected, %{name: name}}, socket) do
150 + {:noreply,
151 + socket
152 + |> load_runners()
153 + |> put_flash(:info, "Runner '#{name}' connected.")}
154 + end
155 +
156 + defp find_runner(id) do
157 + with id when is_integer(id) <- GitGudWeb.RepoAdmin.parse_id(id),
158 + %Runner{} = runner <- GitGud.Repo.get(Runner, id) do
159 + GitGud.Repo.preload(runner, repository: [:owner, :organization], organization: [])
160 + else
161 + _ -> nil
162 + end
163 + end
164 +
165 + # Audited in the runner's own scope: a repo's or an org's log, or the
166 + # instance's for a global runner.
167 + defp runner_attrs(runner, metadata) do
168 + (runner.repository || runner.organization || :global)
169 + |> Runners.audit_scope()
170 + |> Map.merge(%{target_id: runner.id, target_label: runner.name, metadata: metadata})
171 + end
172 +
173 + @impl true
174 + def render(assigns) do
175 + ~H"""
176 + <Layouts.app flash={@flash} current_scope={@current_scope}>
177 + <div class="space-y-8 max-w-4xl">
178 + <div>
179 + <h1 class="text-2xl font-semibold">Runners</h1>
180 + <p class="text-sm opacity-70 mt-1">
181 + Every runner on this instance. Global runners take jobs from any
182 + repository; repository and organization runners are also managed
183 + from their own settings pages.
184 + </p>
185 + </div>
186 +
187 + <section class="border border-base-300 rounded-lg p-4 space-y-3">
188 + <header class="space-y-1">
189 + <h2 class="font-semibold">Create a global runner</h2>
190 + <p class="text-xs opacity-70">
191 + Mints the runner here; save the generated <code>config.yml</code>
192 + — preconfigured for rootless <strong>Podman</strong>
193 + —
194 + and start <code>forgejo-runner daemon</code>. Labels are fixed
195 + once the runner is created.
196 + </p>
197 + </header>
198 +
199 + <.form
200 + for={@create_form}
201 + id="new-runner-form"
202 + phx-submit="create_runner"
203 + class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end"
204 + >
205 + <.input field={@create_form[:name]} label="Name" placeholder="builder-1" required />
206 + <.input field={@create_form[:labels]} label="Labels (comma-separated)" />
207 + <button type="submit" id="new-runner-submit" class="btn btn-sm btn-primary mb-2">
208 + Create
209 + </button>
210 + </.form>
211 +
212 + <div :if={@new_runner} id="new-runner-config" class="text-sm space-y-2">
213 + <p class="text-warning">Save this token now — it's only shown once.</p>
214 + <pre
215 + class="text-xs bg-base-200 p-3 rounded overflow-x-auto"
216 + phx-no-curly-interpolation
217 + ><%= Runners.config_yaml(@new_runner) %></pre>
218 + </div>
219 + </section>
220 +
221 + <section class="space-y-2">
222 + <h2 class="font-semibold">All runners</h2>
223 + <ul id="runners" class="divide-y divide-base-300 border border-base-300 rounded-lg">
224 + <li :if={@runners == []} class="px-3 py-3 text-sm opacity-60">
225 + No runners registered yet.
226 + </li>
227 + <li
228 + :for={r <- @runners}
229 + id={"runner-row-#{r.id}"}
230 + class="px-3 py-3 flex items-baseline justify-between gap-3 transition-colors hover:bg-base-200/50"
231 + >
232 + <div class="space-y-0.5 min-w-0">
233 + <p class="font-mono text-sm truncate">{r.name}</p>
234 + <p class="text-xs opacity-60">
235 + <span class={["badge badge-xs", scope_class(r)]}>{Runner.scope_label(r)}</span>
236 + <.link
237 + :if={link = scope_link(r)}
238 + navigate={elem(link, 1)}
239 + class="ml-1 link link-hover"
240 + >
241 + {elem(link, 0)}
242 + </.link>
243 + <span class="ml-1">labels: {labels_label(r)}</span>
244 + <span class="ml-1">· {r.architecture}</span>
245 + <span :if={r.version} class="ml-1">· v{r.version}</span>
246 + <span :if={r.last_seen_at} class="ml-1">
247 + · last seen {format_dt(r.last_seen_at)}
248 + </span>
249 + <span :if={!r.last_seen_at} class="ml-1">· never connected</span>
250 + </p>
251 + </div>
252 + <div class="flex items-center gap-2 shrink-0">
253 + <.form
254 + for={@architecture_forms[r.id]}
255 + id={"runner-architecture-#{r.id}"}
256 + phx-change="set_architecture"
257 + >
258 + <.input field={@architecture_forms[r.id][:id]} type="hidden" />
259 + <.input
260 + field={@architecture_forms[r.id][:architecture]}
261 + type="select"
262 + options={Runner.architectures()}
263 + class="select select-xs font-mono"
264 + aria-label={"Architecture of #{r.name}"}
265 + />
266 + </.form>
267 + <button
268 + type="button"
269 + id={"runner-remove-#{r.id}"}
270 + phx-click="delete_runner"
271 + phx-value-id={r.id}
272 + class="btn btn-xs btn-ghost text-error"
273 + data-confirm={"Remove runner #{r.name}? It will need to be created again to come back."}
274 + >
275 + Remove
276 + </button>
277 + </div>
278 + </li>
279 + </ul>
280 + </section>
281 + </div>
282 + </Layouts.app>
283 + """
284 + end
285 +
286 + defp scope_class(%{repository_id: r}) when not is_nil(r), do: "badge-accent"
287 + defp scope_class(%{organization_id: o}) when not is_nil(o), do: "badge-secondary"
288 + defp scope_class(_), do: "badge-ghost"
289 +
290 + # The repository or organization a runner belongs to, as `{name, path}`
291 + # to its own runners page; nil for a global runner.
292 + defp scope_link(%{repository: %Repository{} = repo}) do
293 + path = "#{Storage.repo_handle(repo)}/#{repo.name}"
294 + {path, ~p"/r/#{Storage.repo_handle(repo)}/#{repo.name}/settings/runners"}
295 + end
296 +
297 + defp scope_link(%{organization: %Organization{} = org}),
298 + do: {org.handle, ~p"/orgs/#{org.handle}/settings/runners"}
299 +
300 + defp scope_link(_), do: nil
301 +
302 + defp parse_labels(str) do
303 + str
304 + |> to_string()
305 + |> String.split(",", trim: true)
306 + |> Enum.map(&String.trim/1)
307 + |> Enum.reject(&(&1 == ""))
308 + |> case do
309 + [] -> ["ubuntu-latest"]
310 + list -> list
311 + end
312 + end
313 +
314 + defp labels_label(%{labels: %{"labels" => ls}}) when is_list(ls) and ls != [],
315 + do: Enum.join(ls, ", ")
316 +
317 + defp labels_label(_), do: "—"
318 +
319 + defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
320 + defp format_dt(_), do: ""
321 +end
modified lib/git_gud_web/router.ex
+3 −0
@@ -256,6 +256,9 @@ defmodule GitGudWeb.Router do
256 256 on_mount: [{GitGudWeb.UserAuth, :require_admin}] do
257 257 live "/admin/federation", AdminFederationLive.Index, :index
258 258 live "/admin/packages", AdminPackagesLive.Index, :index
259 + # Every runner, global ones included; changes re-check sudo mode
260 + # in the LiveView.
261 + live "/admin/runners", AdminRunnersLive.Index, :index
259 262 # Everything, with client addresses — in sudo mode, checked by the
260 263 # LiveView, which needs the path to come back to.
261 264 live "/admin/audit", AuditLive.Admin, :index
added test/git_gud_web/live/admin_runners_test.exs
+135 −0
@@ -0,0 +1,135 @@
1 +defmodule GitGudWeb.AdminRunnersTest do
2 + @moduledoc """
3 + The instance admins' runners page: every runner whatever its scope,
4 + global runners minted here, any runner's architecture changed or the
5 + runner removed — each audited where the runner belongs, and each in
6 + sudo mode.
7 + """
8 + use GitGudWeb.ConnCase, async: false
9 +
10 + import Ecto.Query
11 + import Phoenix.LiveViewTest
12 + import GitGud.AccountsFixtures
13 + import GitGud.ForgeFixtures
14 +
15 + alias GitGud.Audit.Entry
16 + alias GitGud.Organizations
17 + alias GitGud.Repo
18 + alias GitGud.Workflows.Runner
19 + alias GitGud.Workflows.Runners
20 +
21 + setup do
22 + admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
23 + %{admin: admin}
24 + end
25 +
26 + defp entries, do: Repo.all(from e in Entry, order_by: e.id)
27 +
28 + test "lists every runner, with its scope", %{conn: conn, admin: admin} do
29 + {_owner, repo} = repository_fixture()
30 + handle = "ar#{System.unique_integer([:positive])}"
31 + {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle, "name" => handle})
32 +
33 + {:ok, in_repo, _, _} = Runners.create_offline(%{name: "in-repo"}, repo)
34 + {:ok, in_org, _, _} = Runners.create_offline(%{name: "in-org"}, org)
35 + {:ok, global, _, _} = Runners.create_offline(%{name: "pool"})
36 +
37 + {:ok, lv, _} = conn |> log_in_user(admin) |> live(~p"/admin/runners")
38 +
39 + assert has_element?(lv, "#runner-row-#{in_repo.id}", repo.name)
40 + assert has_element?(lv, "#runner-row-#{in_org.id}", handle)
41 + assert has_element?(lv, "#runner-row-#{global.id}", "global")
42 + end
43 +
44 + test "mints a global runner, shown once and audited in the instance's log", %{
45 + conn: conn,
46 + admin: admin
47 + } do
48 + {:ok, lv, _} = conn |> log_in_user(admin) |> live(~p"/admin/runners")
49 +
50 + lv
51 + |> form("#new-runner-form", %{
52 + "new_runner" => %{"name" => "house", "labels" => "self-hosted, arc-runner-set"}
53 + })
54 + |> render_submit()
55 +
56 + runner = Repo.get_by!(Runner, name: "house")
57 + assert {runner.repository_id, runner.organization_id} == {nil, nil}
58 + assert runner.labels == %{"labels" => ["self-hosted", "arc-runner-set"]}
59 + assert has_element?(lv, "#new-runner-config", runner.uuid)
60 + assert has_element?(lv, "#runner-row-#{runner.id}", "never connected")
61 +
62 + assert [%Entry{action: "runner.created", repository_id: nil, organization_id: nil} = e] =
63 + entries()
64 +
65 + assert e.target_id == runner.id
66 + refute inspect(e.metadata) =~ "token"
67 + end
68 +
69 + test "changes an org runner's architecture, audited in the org's log", %{
70 + conn: conn,
71 + admin: admin
72 + } do
73 + owner = user_fixture()
74 + handle = "ar#{System.unique_integer([:positive])}"
75 + {:ok, org} = Organizations.create_organization(owner, %{"handle" => handle, "name" => handle})
76 + {:ok, runner, _, _} = Runners.create_offline(%{name: "e52c"}, org)
77 +
78 + {:ok, lv, _} = conn |> log_in_user(admin) |> live(~p"/admin/runners")
79 +
80 + lv
81 + |> form("#runner-architecture-#{runner.id}", %{"runner" => %{"architecture" => "aarch64"}})
82 + |> render_change()
83 +
84 + assert Repo.reload!(runner).architecture == "aarch64"
85 + assert [%Entry{action: "runner.architecture_changed", organization_id: oid}] = entries()
86 + assert oid == org.id
87 + end
88 +
89 + test "removes a runner", %{conn: conn, admin: admin} do
90 + {:ok, runner, _, _} = Runners.create_offline(%{name: "gone"})
91 + {:ok, lv, _} = conn |> log_in_user(admin) |> live(~p"/admin/runners")
92 +
93 + lv |> element("#runner-remove-#{runner.id}") |> render_click()
94 +
95 + refute Repo.get(Runner, runner.id)
96 + refute has_element?(lv, "#runner-row-#{runner.id}")
97 + assert [%Entry{action: "runner.deleted"}] = entries()
98 + end
99 +
100 + test "a runner connecting for the first time shows, with a toast", %{
101 + conn: conn,
102 + admin: admin
103 + } do
104 + {_owner, repo} = repository_fixture()
105 + {:ok, runner, _, _} = Runners.create_offline(%{name: "caprica"}, repo)
106 + {:ok, lv, _} = conn |> log_in_user(admin) |> live(~p"/admin/runners")
107 +
108 + {:ok, _} = Runners.touch_last_seen(runner)
109 +
110 + assert has_element?(lv, "#runner-row-#{runner.id}", "last seen")
111 + assert has_element?(lv, "#flash-info", "Runner 'caprica' connected.")
112 + end
113 +
114 + test "changes need sudo mode", %{conn: conn, admin: admin} do
115 + {:ok, runner, _, _} = Runners.create_offline(%{name: "kept"})
116 + stale = DateTime.add(DateTime.utc_now(:second), -30, :minute)
117 +
118 + {:ok, lv, _} =
119 + conn |> log_in_user(admin, token_authenticated_at: stale) |> live(~p"/admin/runners")
120 +
121 + assert {:error, {:redirect, %{to: to}}} =
122 + lv |> element("#runner-remove-#{runner.id}") |> render_click()
123 +
124 + assert to =~ "/users/log-in"
125 + assert Repo.get(Runner, runner.id)
126 + assert entries() == []
127 + end
128 +
129 + test "not for anyone else", %{conn: conn} do
130 + user = user_fixture()
131 +
132 + assert {:error, {:redirect, %{to: "/"}}} =
133 + conn |> log_in_user(user) |> live(~p"/admin/runners")
134 + end
135 +end

Parents: 8b605ac