defmodule GitGudWeb.RepoLive.SettingsRunners do
@moduledoc """
Repo-scoped runner management.
Lets a repo owner / org admin:
* see runners eligible to pick up this repo's jobs (repo-scoped,
org-scoped if the repo belongs to an org, and global runners)
* mint / regenerate the **repo registration token**
* remove a repo-scoped runner
Org-scoped + global runners are shown but their lifecycle isn't
manageable from here — that lives at the org or operator scope.
"""
use GitGudWeb, :live_view
alias GitGud.Organizations
alias GitGud.Repositories
alias GitGud.Repositories.Storage
alias GitGud.Workflows.Runners
@impl true
def mount(%{"owner" => owner, "name" => name}, _session, socket) do
repo =
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])
user = socket.assigns.current_scope && socket.assigns.current_scope.user
cond do
is_nil(user) ->
{:ok,
socket
|> put_flash(:error, "Sign in to manage runners.")
|> redirect(to: ~p"/users/log-in")}
not can_admin?(user, repo) ->
{:ok,
socket
|> put_flash(:error, "You don't have access to manage this repository.")
|> redirect(to: ~p"/r/#{owner}/#{name}")}
true ->
{:ok,
socket
|> assign(:repo, repo)
|> assign(:handle, Storage.repo_handle(repo))
|> GitGudWeb.RepoLive.Header.assign_chrome(repo)
|> assign(:page_title, "Runners — #{owner}/#{name}")
|> assign(:new_token, nil)
|> assign(:new_runner, nil)
|> load_runners()}
end
end
defp load_runners(socket) do
runners = Runners.list_for_scope(socket.assigns.repo)
assign(socket, :runners, runners)
end
defp can_admin?(user, %{owner_id: uid, organization_id: nil}), do: user.id == uid
defp can_admin?(user, %{organization: %{} = org}),
do: Organizations.org_admin?(org, user)
defp can_admin?(_, _), do: false
@impl true
def handle_event("mint_token", _params, socket) do
case Runners.mint_registration_token(socket.assigns.repo) do
{:ok, token} ->
repo = GitGud.Repo.preload(socket.assigns.repo, [:owner, :organization], force: true)
{:noreply,
socket
|> assign(:repo, repo)
|> 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
def handle_event("create_runner", params, socket) do
name = params["name"] |> to_string() |> String.trim()
labels = parse_labels(params["labels"])
cond do
name == "" ->
{:noreply, put_flash(socket, :error, "Runner name is required.")}
true ->
case Runners.create_offline(
%{name: name, labels: %{"labels" => labels}},
socket.assigns.repo
) 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()
})
|> load_runners()
|> put_flash(:info, "Runner '#{runner.name}' minted. Save the token — shown once.")}
{:error, cs} ->
{:noreply, put_flash(socket, :error, "Could not create runner: #{inspect(cs.errors)}")}
end
end
end
def handle_event("delete_runner", %{"id" => id}, socket) do
runner = GitGud.Repo.get!(GitGud.Workflows.Runner, String.to_integer(id))
if runner.repository_id == socket.assigns.repo.id do
{:ok, _} = Runners.delete_runner(runner)
{:noreply, socket |> load_runners() |> put_flash(:info, "Runner removed.")}
else
{:noreply, put_flash(socket, :error, "Cannot remove runners outside this repo's scope.")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-8">
<GitGudWeb.RepoLive.Header.header
repo={@repo}
handle={@handle}
current_scope={@current_scope}
has_packages?={@has_packages?}
can_admin?={@can_admin?}
latest_run={@latest_run}
/>
<.repo_settings_header handle={@handle} repo={@repo} current={:runners} />
<p class="text-sm opacity-70 max-w-3xl">
Self-hosted runners scoped to this repo. A runner registered with
the token below only picks up jobs from this repository.
</p>
<section class="border border-base-300 rounded p-4 space-y-3 max-w-3xl">
<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 phx-submit="create_runner" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end">
<label class="text-xs space-y-1">
<span class="opacity-70">Name</span>
<input
type="text"
name="name"
required
placeholder={@repo.name <> "-runner"}
class="input input-sm input-bordered w-full font-mono"
/>
</label>
<label class="text-xs space-y-1">
<span class="opacity-70">Labels (comma-separated)</span>
<input
type="text"
name="labels"
value="self-hosted,ubuntu-latest"
class="input input-sm input-bordered w-full font-mono"
/>
</label>
<button type="submit" class="btn btn-sm btn-primary">Create</button>
</form>
<%= if @new_runner do %>
<div class="text-sm space-y-2 mt-3">
<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>
<% end %>
</section>
<section class="border border-base-300 rounded p-4 space-y-3 max-w-3xl">
<header class="flex items-baseline justify-between">
<h2 class="font-semibold">Legacy registration token</h2>
<button type="button" phx-click="mint_token" class="btn btn-sm btn-ghost">
{if Runners.has_token?(@repo), 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
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">
Pick a deployment style:
</p>
<.runner_register_commands
token={@new_token}
instance_url={GitGudWeb.Endpoint.url()}
runner_name={@repo.name <> "-runner"}
/>
</div>
<% Runners.has_token?(@repo) -> %>
<p class="text-sm opacity-70">
A token has been minted previously. Regenerate to invalidate the old token.
</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 max-w-3xl">
<h2 class="font-semibold">Runners eligible for this repo</h2>
<ul class="divide-y divide-base-300 border border-base-300 rounded">
<li :if={@runners == []} class="px-3 py-3 text-sm opacity-60">
No runners registered yet.
</li>
<li :for={r <- @runners} class="px-3 py-3 flex items-baseline justify-between gap-3">
<div class="space-y-0.5">
<p class="font-mono text-sm">{r.name}</p>
<p class="text-xs opacity-60">
<span class={["badge badge-xs", scope_class(r)]}>
{GitGud.Workflows.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 :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>
<button
:if={r.repository_id == @repo.id}
type="button"
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>
</li>
</ul>
<p class="text-xs opacity-60">
Runners scoped to the parent organization or registered with the
operator's global token are listed for visibility but managed elsewhere.
</p>
</section>
</div>
</Layouts.app>
"""
end
defp scope_class(%{repository_id: r}) when not is_nil(r), do: "badge-primary"
defp scope_class(%{organization_id: o}) when not is_nil(o), do: "badge-secondary"
defp scope_class(_), do: "badge-ghost"
defp parse_labels(nil), do: ["ubuntu-latest"]
defp parse_labels(str) when is_binary(str) do
str
|> 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: ""
end
11.4 KiB · text
5af55d9