Skip to content

Commit 1085880

Browse files
committed
feat(admin-console): signed compute resource-policy editor
New ComputePolicies page: full policy-version history per pool with one-click activate (including reactivating a retired version, the rollback path), a draft composer for hardLimits/per-priority workloadClassLimits with a live effective-policy preview matching the schedulers own merge logic, a download-draft-JSON step, and a paste- signed-policy submission step. Signing stays strictly offline — the Ed25519 private key never touches the browser or the server. Also adds the GET half of /compute/pools/:poolId/quota (only PUT existed before — a pool quota could be set blind but never read back) and the fair-share/quota utilization card on the existing Compute fleet page.
1 parent 9075d02 commit 1085880

7 files changed

Lines changed: 258 additions & 3 deletions

File tree

‎admin-console/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Backup from "@/pages/Backup";
1919
import Inference from "@/pages/Inference";
2020
import McpRegistry from "@/pages/McpRegistry";
2121
import Compute from "@/pages/Compute";
22+
import ComputePolicies from "@/pages/ComputePolicies";
2223

2324
// createHashRouter, not createBrowserRouter: zero-config-safe — works from
2425
// any static host with no SPA-fallback rewrite rule needed. Isolated to
@@ -62,6 +63,7 @@ const router = createHashRouter([
6263
{ path: "inference", element: <Inference /> },
6364
{ path: "mcp-registry", element: <McpRegistry /> },
6465
{ path: "compute", element: <Compute /> },
66+
{ path: "compute-policies", element: <ComputePolicies /> },
6567
],
6668
},
6769
],

‎admin-console/src/components/layout.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ export default function Layout() {
131131
{allow("compute:list") && (
132132
<NavItem to={`/organizations/${organizationId}/compute`} icon={<Gauge className="size-4" />} label="Compute fleet" />
133133
)}
134+
{allow("compute:list") && (
135+
<NavItem to={`/organizations/${organizationId}/compute-policies`} icon={<ShieldCheck className="size-4" />} label="Compute policies" />
136+
)}
134137
</aside>
135138
<main className="flex-1 overflow-y-auto">
136139
<Outlet />

‎admin-console/src/lib/api/client.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ const {
4040
setMcpRegistryEntryStatus,
4141
getComputeQuota,
4242
setComputeQuota,
43+
listComputePoliciesForPool,
44+
createComputePolicy,
45+
activateComputePolicy,
4346
} = await import("./client");
4447

4548
function jsonResponse(status: number, body: unknown): Response {
@@ -347,4 +350,31 @@ describe("api/client", () => {
347350
expect(JSON.parse(init?.body as string)).toEqual(request);
348351
});
349352
});
353+
354+
describe("compute resource policies", () => {
355+
const signed = { name: "guardrails", poolId: "pool-1", hardLimits: { maxCpuThreads: 32 }, workloadClassLimits: {}, signature: "c2ln", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2026-02-01T00:00:00.000Z" };
356+
357+
it("lists policies filtered to a pool", async () => {
358+
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(200, [{ id: "policy-1", ...signed, version: 1, status: "active" }]));
359+
await listComputePoliciesForPool("org-1", "pool-1");
360+
expect(String(vi.mocked(fetch).mock.calls[0][0])).toContain("/organizations/org-1/compute/policies?poolId=pool-1");
361+
});
362+
363+
it("submits an already-signed policy without modifying it", async () => {
364+
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(201, { id: "policy-1", ...signed, version: 1, status: "draft" }));
365+
await createComputePolicy("org-1", signed);
366+
const [url, init] = vi.mocked(fetch).mock.calls[0];
367+
expect(String(url)).toContain("/organizations/org-1/compute/policies");
368+
expect(init?.method).toBe("POST");
369+
expect(JSON.parse(init?.body as string)).toEqual(signed);
370+
});
371+
372+
it("activates a policy version (also the rollback path for a retired version)", async () => {
373+
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(200, { id: "policy-1", ...signed, version: 1, status: "active" }));
374+
await activateComputePolicy("org-1", "policy-1");
375+
const [url, init] = vi.mocked(fetch).mock.calls[0];
376+
expect(String(url)).toContain("/organizations/org-1/compute/policies/policy-1/activate");
377+
expect(init?.method).toBe("POST");
378+
});
379+
});
350380
});

‎admin-console/src/lib/api/client.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,19 @@ export const getComputeQuota = async (orgId: string, poolId: string): Promise<T.
344344
};
345345
export const setComputeQuota = (orgId: string, poolId: string, body: T.SetComputeQuotaRequest): Promise<T.ComputeQuota> =>
346346
authorizedRequest(`/organizations/${orgId}/compute/pools/${poolId}/quota`, { method: "PUT", body: JSON.stringify(body) }).then((r) => parseOrThrow<T.ComputeQuota>(r));
347+
348+
export const listComputePoliciesForPool = (orgId: string, poolId: string): Promise<T.ComputePolicy[]> =>
349+
authorizedRequest(`/organizations/${orgId}/compute/policies?poolId=${encodeURIComponent(poolId)}`).then((r) => parseOrThrow<T.ComputePolicy[]>(r));
350+
351+
/** The signed body must already carry a valid Ed25519 signature produced
352+
* offline by server/scripts/sign-compute-policy.js — this call never signs
353+
* anything itself, and the server rejects (400/503) anything it can't
354+
* verify against the organization's configured public key. */
355+
export const createComputePolicy = (orgId: string, body: T.SignedComputePolicyRequest): Promise<T.ComputePolicy> =>
356+
authorizedRequest(`/organizations/${orgId}/compute/policies`, { method: "POST", body: JSON.stringify(body) }).then((r) => parseOrThrow<T.ComputePolicy>(r));
357+
358+
/** Activating any retained (non-expired) earlier version is how a rollback
359+
* is expressed — there is no separate rollback endpoint (see
360+
* docs/COMPUTE_CONTROL_PLANE.md's "Signed resource policies" section). */
361+
export const activateComputePolicy = (orgId: string, policyId: string): Promise<T.ComputePolicy> =>
362+
authorizedRequest(`/organizations/${orgId}/compute/policies/${policyId}/activate`, { method: "POST" }).then((r) => parseOrThrow<T.ComputePolicy>(r));

‎admin-console/src/lib/api/types.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,25 @@ export interface ComputeNode { id: string; name: string; region: string; state:
435435
export interface ComputePool { id: string; name: string; region: string; status: "active" | "draining" | "disabled"; schedulingPolicy: string; nodeIds: string[] }
436436
export interface ComputeRequest { id: string; poolId: string; workloadKind: string; priority: string; profile: string; state: "queued" | "assigned" | "running" | "preempting" | "completed" | "failed" | "cancelled"; queuedAt: string }
437437
export interface ComputeLease { id: string; requestId: string; poolId: string; nodeId: string; acceleratorDeviceIds: string[]; cpuThreads: number; ramMB: number; fencingToken: string; state: "offered" | "acknowledged" | "running" | "released" | "expired" | "failed"; expiresAt: string; explanation: { score: number; scoreReasons: string[]; degradedToCpu: boolean; borrowedCapacity: boolean } }
438-
export interface ComputePolicy { id: string; name: string; poolId?: string; version: number; status: "draft" | "active" | "retired"; issuedAt: string; expiresAt: string; hardLimits: Record<string, unknown> }
438+
export interface ComputeResourceLimit {
439+
maxCpuThreads?: number; maxRamMB?: number; maxPinnedMemoryMB?: number; maxAccelerators?: number;
440+
maxVramMBPerDevice?: number; maxConcurrencyPerDevice?: number; maxTemperatureC?: number; maxPowerWatts?: number;
441+
allowCpuFallback?: boolean; allowedRuntimes?: string[]; allowedModelIds?: string[];
442+
}
443+
export type ComputePriorityClass = "interactive" | "imaging" | "scheduled" | "background" | "maintenance";
444+
export interface ComputePolicy {
445+
id: string; name: string; poolId?: string; version: number; status: "draft" | "active" | "retired";
446+
issuedAt: string; expiresAt: string; hardLimits: ComputeResourceLimit;
447+
workloadClassLimits: Partial<Record<ComputePriorityClass, ComputeResourceLimit>>;
448+
}
449+
/** The exact signed body POST /compute/policies expects — produced offline
450+
* by server/scripts/sign-compute-policy.js, never assembled or signed in
451+
* the browser (the Ed25519 private key never touches the admin console). */
452+
export interface SignedComputePolicyRequest {
453+
name: string; poolId?: string; hardLimits: ComputeResourceLimit;
454+
workloadClassLimits: Partial<Record<ComputePriorityClass, ComputeResourceLimit>>;
455+
signature: string; issuedAt: string; expiresAt: string;
456+
}
439457
export interface ComputeSummary { nodes: { total: number; online: number }; capacity: { cpuThreads: number; ramMB: number; accelerators: number }; pools: number; queuedRequests: number; activeLeases: number }
440458
export interface ComputeQuota { poolId: string; reservedCpuThreads: number; reservedRamMB: number; reservedAccelerators: number; burstCpuThreads: number; burstRamMB: number; burstAccelerators: number; weight: number; borrowingEnabled: boolean }
441459
export interface SetComputeQuotaRequest { reservedCpuThreads: number; reservedRamMB: number; reservedAccelerators: number; burstCpuThreads: number; burstRamMB: number; burstAccelerators: number; weight: number; borrowingEnabled: boolean }
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { Download, ShieldCheck } from "lucide-react";
3+
import { Badge } from "@/components/ui/badge";
4+
import { Button } from "@/components/ui/button";
5+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6+
import { Input } from "@/components/ui/input";
7+
import { Textarea } from "@/components/ui/textarea";
8+
import { EmptyState, InlineNotice, SectionHeader } from "@/components/ds";
9+
import { useOrg } from "@/lib/org-context";
10+
import { describeApiError } from "@/lib/authz/permissions";
11+
import { activateComputePolicy, createComputePolicy, listComputePoliciesForPool, listComputePools } from "@/lib/api/client";
12+
import type { ComputePolicy, ComputePool, ComputePriorityClass, ComputeResourceLimit, SignedComputePolicyRequest } from "@/lib/api/types";
13+
14+
const PRIORITIES: ComputePriorityClass[] = ["interactive", "imaging", "scheduled", "background", "maintenance"];
15+
16+
const EMPTY_LIMIT: ComputeResourceLimit = {};
17+
18+
function limitField(limit: ComputeResourceLimit, onChange: (next: ComputeResourceLimit) => void, disabled: boolean) {
19+
const numberField = (label: string, key: keyof ComputeResourceLimit) => (
20+
<label key={key} className="flex flex-col gap-1 text-xs text-muted-foreground">
21+
{label}
22+
<Input type="number" min={0} disabled={disabled} className="h-8 text-xs" value={(limit[key] as number) ?? ""}
23+
placeholder="unset"
24+
onChange={(e) => onChange({ ...limit, [key]: e.target.value === "" ? undefined : Number(e.target.value) })} />
25+
</label>
26+
);
27+
return <div className="grid gap-2 sm:grid-cols-4">
28+
{numberField("Max CPU threads", "maxCpuThreads")}
29+
{numberField("Max RAM (MB)", "maxRamMB")}
30+
{numberField("Max pinned mem (MB)", "maxPinnedMemoryMB")}
31+
{numberField("Max accelerators", "maxAccelerators")}
32+
{numberField("Max VRAM/device (MB)", "maxVramMBPerDevice")}
33+
{numberField("Max concurrency/device", "maxConcurrencyPerDevice")}
34+
{numberField("Max temperature (C)", "maxTemperatureC")}
35+
{numberField("Max power (W)", "maxPowerWatts")}
36+
<label className="flex flex-col gap-1 text-xs text-muted-foreground">Allow CPU fallback
37+
<select className="h-8 rounded-md border bg-background px-2 text-xs" disabled={disabled} value={limit.allowCpuFallback === undefined ? "unset" : String(limit.allowCpuFallback)}
38+
onChange={(e) => onChange({ ...limit, allowCpuFallback: e.target.value === "unset" ? undefined : e.target.value === "true" })}>
39+
<option value="unset">unset</option><option value="true">Allowed</option><option value="false">Forbidden</option>
40+
</select>
41+
</label>
42+
</div>;
43+
}
44+
45+
function mergeLimits(hard: ComputeResourceLimit, classLimit: ComputeResourceLimit | undefined): ComputeResourceLimit {
46+
return { ...hard, ...classLimit };
47+
}
48+
49+
export default function ComputePolicies() {
50+
const { organizationId, permissions } = useOrg();
51+
const canManage = permissions?.["compute:managePolicies"] ?? false;
52+
const [pools, setPools] = useState<ComputePool[]>([]);
53+
const [selectedPool, setSelectedPool] = useState<string>("");
54+
const [policies, setPolicies] = useState<ComputePolicy[]>([]);
55+
const [error, setError] = useState<string>();
56+
const [busy, setBusy] = useState<string>();
57+
58+
const [draftName, setDraftName] = useState("New resource policy");
59+
const [draftExpiresInDays, setDraftExpiresInDays] = useState(30);
60+
const [draftHardLimits, setDraftHardLimits] = useState<ComputeResourceLimit>(EMPTY_LIMIT);
61+
const [draftClassLimits, setDraftClassLimits] = useState<Partial<Record<ComputePriorityClass, ComputeResourceLimit>>>({});
62+
const [previewPriority, setPreviewPriority] = useState<ComputePriorityClass>("background");
63+
const [signedPaste, setSignedPaste] = useState("");
64+
65+
const load = useCallback(async () => {
66+
setError(undefined);
67+
try {
68+
const nextPools = await listComputePools(organizationId);
69+
setPools(nextPools);
70+
const poolId = selectedPool || nextPools[0]?.id || "";
71+
if (poolId && !selectedPool) setSelectedPool(poolId);
72+
if (poolId) setPolicies(await listComputePoliciesForPool(organizationId, poolId));
73+
} catch (reason) { setError(describeApiError(reason, organizationId)); }
74+
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedPool intentionally read, not a re-trigger dependency (see the explicit reload below)
75+
}, [organizationId]);
76+
77+
useEffect(() => { void load(); }, [load]);
78+
useEffect(() => {
79+
if (!selectedPool) return;
80+
listComputePoliciesForPool(organizationId, selectedPool).then(setPolicies).catch((reason) => setError(describeApiError(reason, organizationId)));
81+
}, [organizationId, selectedPool]);
82+
83+
const draftJson = useMemo(() => JSON.stringify({
84+
name: draftName, ...(selectedPool ? { poolId: selectedPool } : {}), expiresInDays: draftExpiresInDays,
85+
hardLimits: draftHardLimits, workloadClassLimits: draftClassLimits,
86+
}, null, 2), [draftName, selectedPool, draftExpiresInDays, draftHardLimits, draftClassLimits]);
87+
88+
function downloadDraft() {
89+
const blob = new Blob([draftJson], { type: "application/json" });
90+
const url = URL.createObjectURL(blob);
91+
const link = document.createElement("a");
92+
link.href = url; link.download = "compute-policy-draft.json";
93+
link.click();
94+
URL.revokeObjectURL(url);
95+
}
96+
97+
async function submitSigned() {
98+
setBusy("submit"); setError(undefined);
99+
try {
100+
const parsed = JSON.parse(signedPaste) as SignedComputePolicyRequest;
101+
await createComputePolicy(organizationId, parsed);
102+
setSignedPaste("");
103+
if (selectedPool) setPolicies(await listComputePoliciesForPool(organizationId, selectedPool));
104+
} catch (reason) { setError(reason instanceof SyntaxError ? "That isn't valid JSON." : describeApiError(reason, organizationId)); }
105+
finally { setBusy(undefined); }
106+
}
107+
108+
async function activate(policyId: string) {
109+
setBusy(policyId); setError(undefined);
110+
try {
111+
await activateComputePolicy(organizationId, policyId);
112+
if (selectedPool) setPolicies(await listComputePoliciesForPool(organizationId, selectedPool));
113+
} catch (reason) { setError(describeApiError(reason, organizationId)); }
114+
finally { setBusy(undefined); }
115+
}
116+
117+
return <div className="mx-auto flex max-w-6xl flex-col gap-5 p-6">
118+
<SectionHeader title="Compute resource policies" description="Signed hard CPU/GPU guardrails for the compute fleet. Signing happens offline (server/scripts/sign-compute-policy.js) — the private key never touches this console." />
119+
{error && <InlineNotice variant="destructive" title="Compute policy request failed">{error}</InlineNotice>}
120+
121+
<Card><CardHeader><CardTitle className="text-base">Pool</CardTitle></CardHeader><CardContent>
122+
<select className="h-9 w-full max-w-sm rounded-md border bg-background px-2 text-sm" value={selectedPool} onChange={(e) => setSelectedPool(e.target.value)}>
123+
{pools.length === 0 && <option value="">No pools</option>}
124+
{pools.map((pool) => <option key={pool.id} value={pool.id}>{pool.name}</option>)}
125+
</select>
126+
</CardContent></Card>
127+
128+
<Card><CardHeader><CardTitle className="text-base">Existing versions</CardTitle></CardHeader><CardContent className="space-y-2">
129+
{policies.length === 0 && <EmptyState icon={<ShieldCheck className="size-6" />} title="No policy versions for this pool" description="Compose a draft below, sign it offline, and submit it to create the first one." />}
130+
{policies.sort((a, b) => b.version - a.version).map((policy) => <div key={policy.id} className="flex flex-wrap items-center gap-2 rounded border p-3 text-sm">
131+
<strong>{policy.name}</strong><Badge variant={policy.status === "active" ? "default" : policy.status === "retired" ? "outline" : "secondary"}>{policy.status}</Badge>
132+
<span className="text-xs text-muted-foreground">v{policy.version} · issued {new Date(policy.issuedAt).toLocaleDateString()} · expires {new Date(policy.expiresAt).toLocaleDateString()}</span>
133+
{canManage && policy.status !== "active" && <Button size="sm" variant="outline" className="ml-auto" disabled={busy === policy.id} onClick={() => void activate(policy.id)}>{policy.status === "retired" ? "Activate (rollback)" : "Activate"}</Button>}
134+
</div>)}
135+
</CardContent></Card>
136+
137+
<Card><CardHeader><CardTitle className="text-base">Compose a draft</CardTitle></CardHeader><CardContent className="space-y-4">
138+
<div className="grid gap-2 sm:grid-cols-2">
139+
<label className="flex flex-col gap-1 text-xs text-muted-foreground">Name<Input className="h-8 text-xs" value={draftName} onChange={(e) => setDraftName(e.target.value)} /></label>
140+
<label className="flex flex-col gap-1 text-xs text-muted-foreground">Expires in (days)<Input type="number" min={1} className="h-8 text-xs" value={draftExpiresInDays} onChange={(e) => setDraftExpiresInDays(Math.max(1, Number(e.target.value)))} /></label>
141+
</div>
142+
143+
<div>
144+
<div className="mb-2 text-xs font-medium text-muted-foreground">Hard limits (apply to every workload class unless overridden below)</div>
145+
{limitField(draftHardLimits, setDraftHardLimits, false)}
146+
</div>
147+
148+
<div>
149+
<div className="mb-2 text-xs font-medium text-muted-foreground">Per-priority overrides</div>
150+
<div className="space-y-3">
151+
{PRIORITIES.map((priority) => <div key={priority} className="rounded border p-2">
152+
<div className="mb-1 text-xs font-medium capitalize">{priority}</div>
153+
{limitField(draftClassLimits[priority] ?? EMPTY_LIMIT, (next) => setDraftClassLimits((cur) => ({ ...cur, [priority]: next })), false)}
154+
</div>)}
155+
</div>
156+
</div>
157+
158+
<div className="rounded border bg-muted/30 p-3">
159+
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
160+
Effective policy preview for
161+
<select className="h-7 rounded-md border bg-background px-2 text-xs" value={previewPriority} onChange={(e) => setPreviewPriority(e.target.value as ComputePriorityClass)}>
162+
{PRIORITIES.map((p) => <option key={p} value={p}>{p}</option>)}
163+
</select>
164+
</div>
165+
<pre className="overflow-x-auto text-xs">{JSON.stringify(mergeLimits(draftHardLimits, draftClassLimits[previewPriority]), null, 2)}</pre>
166+
</div>
167+
168+
<Button size="sm" variant="outline" onClick={downloadDraft}><Download className="mr-2 size-3.5" />Download draft JSON to sign offline</Button>
169+
</CardContent></Card>
170+
171+
{canManage && <Card><CardHeader><CardTitle className="text-base">Submit a signed policy</CardTitle></CardHeader><CardContent className="space-y-3">
172+
<Textarea rows={6} placeholder="Paste the JSON produced by server/scripts/sign-compute-policy.js" value={signedPaste} onChange={(e) => setSignedPaste(e.target.value)} />
173+
<Button size="sm" disabled={!signedPaste.trim() || busy === "submit"} onClick={() => void submitSigned()}>{busy === "submit" ? "Submitting…" : "Create policy"}</Button>
174+
</CardContent></Card>}
175+
</div>;
176+
}

0 commit comments

Comments
 (0)