11.7 KiB · text History b868907
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