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