neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Admin: a runners page, global runners included
5f79844 · Gabriel Morell · 2026-10-27 05:04
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