Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
234 linesCodeBlameRaw
1import { ArrowLeft } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import type { AdminRunner, IngestToken } from "@g1t/contracts";
5
6import type { Route } from "./+types/workspace-abuse";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
8import { parseSlug, text } from "~/lib/forms";
9import { admin, identity, runnersAdmin, telemetry, workspaceProjects } from "~/lib/services.server";
10import { settle } from "~/lib/settle";
11import { requireStaff } from "~/lib/staff";
12import { SUSPEND_WORKSPACE_ABOUT, isSuspensionHold, parseReason, suspensionHold } from "~/lib/suspension";
13
14export const meta: Route.MetaFunction = ({ params }) => [
15 { title: `Suspend and clean up · ${params.slug} · sudo` },
16 { name: "robots", content: "noindex, nofollow" },
17];
18
19function slugOf(raw: string): string {
20 const parsed = parseSlug(raw);
21 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
22 return parsed.value;
23}
24
25/** Each project's ingest tokens, the ones people made (g1t's own for deployments are not listed). */
26async function telemetryTokens(slug: string): Promise<{ project: { id: string; slug: string; name: string }; tokens: IngestToken[] }[]> {
27 const projects = await workspaceProjects(slug);
28 if (!projects.ok) throw new Error(projects.error.message);
29 return Promise.all(
30 projects.value.map(async (project) => ({
31 project: { id: project.id, slug: project.slug, name: project.name },
32 tokens: await telemetry.tokens({ project_id: project.id }).catch(() => [] as IngestToken[]),
33 })),
34 );
35}
36
37export async function loader({ params, request, context }: Route.LoaderArgs) {
38 requireStaff(context);
39 const slug = slugOf(params.slug);
40 const [detail, runners, tokens] = await Promise.all([
41 settle(identity.workspace(slug)),
42 settle(runnersAdmin.list(slug)),
43 settle(telemetryTokens(slug)),
44 ]);
45 if (detail.ok && !detail.value) throw data(`There is no workspace called ${slug}.`, { status: 404 });
46 const done = new URL(request.url).searchParams.get("done");
47 const DONE: Record<string, string> = {
48 suspended: `Suspended ${slug}, and put a hold on its compute.`,
49 lifted: `Lifted the suspension of ${slug}, and the hold it put on.`,
50 runners: "Removed the runners. What they were running failed; their credentials stopped at once.",
51 tokens: "Revoked the tokens. Anything sending with them is refused from now on.",
52 };
53 return {
54 slug,
55 suspended: detail.ok ? (detail.value?.suspended ?? null) : null,
56 runners: runners.ok ? runners.value : ([] as AdminRunner[]),
57 runnersError: runners.ok ? null : runners.error,
58 tokens: tokens.ok ? tokens.value : [],
59 tokensError: tokens.ok ? null : tokens.error,
60 error: detail.ok ? null : detail.error,
61 done: done ? (DONE[done] ?? null) : null,
62 };
63}
64
65/** Puts the suspension's hold on, or takes it off, keeping every other allowance. */
66async function setHold(slug: string, hold: string | null, staff: string, note: string): Promise<string | null> {
67 const account = await admin.account(slug);
68 if (!account.ok) return account.error.message;
69 const { allowances, id, kind } = account.value.summary.account;
70 if (kind === "enterprise") return null;
71 const current = allowances?.hold ?? null;
72 // Lifting takes off only a hold a suspension put there.
73 if (hold === null && !isSuspensionHold(current)) return null;
74 const set = await admin.setAllowances(id, { plan: false, ossRepoMicros: null, trialMicros: null, ...allowances, hold }, note, staff);
75 return set.ok ? null : set.error.message;
76}
77
78export async function action({ params, request, context }: Route.ActionArgs) {
79 const staff = requireStaff(context);
80 const slug = slugOf(params.slug);
81 const form = await request.formData();
82 const intent = text(form, "intent");
83 const back = (done: string) => redirect(`/workspaces/${encodeURIComponent(slug)}/abuse?done=${done}`);
84 if (intent === "suspend") {
85 const reason = parseReason(text(form, "reason"));
86 if (!reason.ok) return data({ error: reason.error, section: "suspend" }, { status: 422 });
87 const result = await identity.suspendWorkspace(slug, true, reason.value, staff.email);
88 if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 });
89 const held = await setHold(slug, suspensionHold(reason.value), staff.email, `Suspended: ${reason.value}`);
90 if (held) return data({ error: `Suspended, but the hold on its compute was not set: ${held}`, section: "suspend" }, { status: 422 });
91 throw back("suspended");
92 }
93 if (intent === "lift") {
94 const result = await identity.suspendWorkspace(slug, false, "", staff.email);
95 if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 });
96 const held = await setHold(slug, null, staff.email, "Suspension lifted");
97 if (held) return data({ error: `Lifted, but the hold on its compute was not taken off: ${held}`, section: "suspend" }, { status: 422 });
98 throw back("lifted");
99 }
100 if (intent === "remove-runners") {
101 const id = text(form, "id") || null;
102 const result = await runnersAdmin.remove(slug, id, staff.email);
103 if (!result.ok) return data({ error: result.error.message, section: "runners" }, { status: 422 });
104 throw back("runners");
105 }
106 if (intent === "revoke-tokens") {
107 const project = text(form, "project");
108 const only = text(form, "id");
109 const tokens = await telemetry.tokens({ project_id: project });
110 for (const token of tokens.filter((token) => !only || token.id === only)) {
111 await telemetry.revokeToken({ project_id: project, id: token.id });
112 }
113 console.log(`sudo: ${staff.email} revoked ingest tokens of ${slug}'s project ${project}`);
114 throw back("tokens");
115 }
116 return data({ error: "Unknown action.", section: "top" }, { status: 400 });
117}
118
119export default function WorkspaceAbuse({ loaderData, actionData }: Route.ComponentProps) {
120 const { slug, suspended, runners, runnersError, tokens, tokensError, error, done } = loaderData;
121 const errorFor = (section: string) => (actionData && "section" in actionData && actionData.section === section ? actionData.error : null);
122 return (
123 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
124 <Link to={`/workspaces/${slug}`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
125 <ArrowLeft size={14} /> {slug}
126 </Link>
127 <PageHeader title="Suspend and clean up" description={`What staff can stop in ${slug} after abuse: the workspace itself, its self-hosted runners, and its projects' telemetry tokens.`} />
128 <div className="mt-5 space-y-3 empty:hidden">
129 {done && <Notice tone="ok">{done}</Notice>}
130 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
131 </div>
132
133 <Section id="suspend" title="Suspension" description={SUSPEND_WORKSPACE_ABOUT} className="mt-6">
134 {suspended ? (
135 <Form method="post" className="space-y-3">
136 <Notice tone="error">
137 Suspended <When at={suspended.at} /> by {suspended.by}: {suspended.reason}
138 </Notice>
139 {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>}
140 <Button type="submit" name="intent" value="lift" variant="quiet">
141 Lift the suspension
142 </Button>
143 </Form>
144 ) : (
145 <Form method="post" className="space-y-3">
146 <Field label="Why" hint="Kept with the suspension and in the audit log.">
147 <Input name="reason" required maxLength={500} placeholder="e.g. Mining on self-hosted runners" />
148 </Field>
149 {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>}
150 <Button type="submit" name="intent" value="suspend" variant="danger">
151 Suspend {slug}
152 </Button>
153 </Form>
154 )}
155 </Section>
156
157 <Section id="runners" title="Self-hosted runners" description="Removing a runner fails what it was running and stops its credential at once. Removing all of them also ends the registration tokens the workspace has out." className="mt-6">
158 {runnersError && <Notice tone="warn">Actions did not answer: {runnersError}</Notice>}
159 {errorFor("runners") && <Notice tone="error">{errorFor("runners")}</Notice>}
160 {runners.length === 0 ? (
161 <EmptyState title="No runners">{slug} has no self-hosted runners.</EmptyState>
162 ) : (
163 <div className="space-y-2">
164 {runners.map((runner) => (
165 <Form method="post" key={runner.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-sm">
166 <span className="min-w-0">
167 <span className="font-mono">{runner.name}</span>
168 {runner.repo && <span className="text-muted"> · {runner.repo}</span>}
169 <span className="block text-xs text-faint">
170 {runner.os} · {runner.version} · last seen {runner.lastSeenAt ? <When at={runner.lastSeenAt} /> : "never"}
171 {runner.createdBy ? ` · added by ${runner.createdBy}` : ""}
172 </span>
173 </span>
174 <span className="flex items-center gap-2">
175 {runner.busy && <Badge tone="warn">Running a job</Badge>}
176 <input type="hidden" name="id" value={runner.id} />
177 <Button type="submit" name="intent" value="remove-runners" variant="danger">
178 Remove
179 </Button>
180 </span>
181 </Form>
182 ))}
183 <Form method="post">
184 <Button type="submit" name="intent" value="remove-runners" variant="danger">
185 Remove all {runners.length} and end registration tokens
186 </Button>
187 </Form>
188 </div>
189 )}
190 </Section>
191
192 <Section id="telemetry" title="Telemetry ingest tokens" description="Tokens people made to send OpenTelemetry to a project. Revoking one refuses anything sent with it from then on. g1t's own token for its deployments is not listed." className="mt-6">
193 {tokensError && <Notice tone="warn">Projects or telemetry did not answer: {tokensError}</Notice>}
194 {tokens.every((row) => row.tokens.length === 0) ? (
195 <EmptyState title="No ingest tokens">No project in {slug} has one.</EmptyState>
196 ) : (
197 <div className="space-y-4">
198 {tokens
199 .filter((row) => row.tokens.length > 0)
200 .map((row) => (
201 <div key={row.project.id} className="rounded-lg border border-line bg-surface p-3 text-sm">
202 <div className="flex flex-wrap items-center justify-between gap-2">
203 <span className="font-medium">{row.project.name}</span>
204 <Form method="post">
205 <input type="hidden" name="project" value={row.project.id} />
206 <Button type="submit" name="intent" value="revoke-tokens" variant="danger">
207 Revoke all {row.tokens.length}
208 </Button>
209 </Form>
210 </div>
211 <ul className="mt-2 space-y-1">
212 {row.tokens.map((token) => (
213 <li key={token.id} className="flex flex-wrap items-center justify-between gap-2">
214 <span>
215 {token.name} <span className="font-mono text-xs text-faint">{token.hint}</span>
216 </span>
217 <Form method="post">
218 <input type="hidden" name="project" value={row.project.id} />
219 <input type="hidden" name="id" value={token.id} />
220 <Button type="submit" name="intent" value="revoke-tokens" variant="quiet">
221 Revoke
222 </Button>
223 </Form>
224 </li>
225 ))}
226 </ul>
227 </div>
228 ))}
229 </div>
230 )}
231 </Section>
232 </main>
233 );
234}