feat: add CI deployment and live progress

This commit is contained in:
2026-09-05 12:09:16 +00:00 Unverified
parent 321f4e807a
commit aa02826dbb
27 changed files with 2711 additions and 180 deletions
+51
View File
@@ -4,8 +4,11 @@ import { main } from "../../command/main";
import { getOperation, listOperations } from "../../command/operations";
import {
addUser,
createApiKey,
deleteUser,
listApiKeys,
listUsers,
revokeApiKey,
revokeUserSessions,
setUserDisabled,
updateUser,
@@ -107,6 +110,54 @@ describe("user administration commands", () => {
},
]);
});
test("manages API keys below the user route without leaking token in lists", async () => {
const calls: Call[] = [];
const key = {
id: "key-identifier-123",
username: "alice",
capabilities: ["kubernetes:write"] as const,
expiresAt: "2026-12-01T00:00:00.000Z",
disabled: false,
token: "shown-once-token",
};
expect(
await listApiKeys(
"alice/example",
requestReturning({ items: [{ ...key, token: undefined }] }, calls),
),
).not.toContain(key.token);
await createApiKey(
"alice",
{ capabilities: ["kubernetes:write"] },
requestReturning(key, calls),
);
expect(
await revokeApiKey(
"alice",
key.id,
false,
requestReturning(undefined, calls),
),
).toContain("cancelled");
await revokeApiKey(
"alice",
key.id,
true,
requestReturning(undefined, calls),
);
expect(calls).toEqual([
{ path: "/users/alice%2Fexample/keys", init: undefined },
{
path: "/users/alice/keys",
init: { method: "POST", json: { capabilities: ["kubernetes:write"] } },
},
{
path: "/users/alice/keys/key-identifier-123",
init: { method: "DELETE" },
},
]);
});
});
const operation = {
+19
View File
@@ -0,0 +1,19 @@
import { expect, spyOn, test } from "bun:test";
import { apiKeyRequest } from "../../command/ci";
test("CI requester supplies an API key without session authentication", async () => {
const fetch = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
try {
await apiKeyRequest("ci-secret")("/workspaces/shop/resources/plan", {
headers: { "x-kuber-trust-project": "shop" },
});
const [, init] = fetch.mock.calls[0]!;
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer ci-secret");
expect(headers.get("x-kuber-trust-project")).toBe("shop");
} finally {
fetch.mockRestore();
}
});
+159
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Listr } from "listr2";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -8,6 +9,7 @@ import type { ApiRequester } from "../../lib/build";
import {
ensureWorkspace,
reconcileResources,
runLiveResourceOperation,
runUp,
workspaceAdoptionRoute,
} from "../../command/up";
@@ -226,8 +228,10 @@ describe("up API pipeline", () => {
"/workspaces/shop/resources/plan",
"/workspaces/shop/resources/apply",
"/workspaces/shop/resources/apply",
"/operations/operation-apply/events?after=0",
"/operations/operation-apply",
"/operations/operation-apply",
"/operations/operation-apply/events?after=0",
]);
expect(applyIdempotencyKeys[0]).toBeTruthy();
expect(applyIdempotencyKeys[1]).toBe(applyIdempotencyKeys[0]);
@@ -272,6 +276,161 @@ describe("up API pipeline", () => {
expect(applyRequests[2]?.json).toEqual(applyRequests[0]?.json);
});
test("polls persisted resource progress without duplicating events after reconnect", async () => {
const progress: string[] = [];
let poll = 0;
const request: ApiRequester = async <T>(path: string) => {
if (path.endsWith("/plan")) return { desired: [], stale: [] } as T;
if (path.endsWith("/apply"))
return {
operationId: "operation-apply",
operation: { status: { state: "running" } },
} as T;
if (path.includes("/events")) {
poll++;
return {
items:
poll === 1
? [
{
sequence: 1,
data: null,
},
{
sequence: 2,
data: {
resource: {
apiVersion: "v1",
kind: "Service",
name: "web",
},
phase: "apply",
state: "started",
},
},
]
: [
{
sequence: 2,
data: {
resource: {
apiVersion: "v1",
kind: "Service",
name: "web",
},
phase: "apply",
state: "started",
},
},
{
sequence: 3,
data: {
resource: {
apiVersion: "v1",
kind: "Service",
name: "web",
},
phase: "apply",
state: "succeeded",
},
},
],
} as T;
}
if (path === "/operations/operation-apply") {
return { status: { state: poll > 1 ? "succeeded" : "running" } } as T;
}
throw new Error(`Unexpected request: ${path}`);
};
await reconcileResources("shop", [], 1, undefined, request, {
sleep: async () => {},
onEvent: (event) =>
progress.push(`${event.sequence}:${event.data.state}`),
});
expect(progress).toEqual(["2:started", "3:succeeded"]);
});
test("fails visibly when retained operation progress has a cursor gap", async () => {
let applyAttempts = 0;
let operationPolls = 0;
const request: ApiRequester = async <T>(path: string) => {
if (path.endsWith("/plan")) return { desired: [], stale: [] } as T;
if (path.endsWith("/apply")) {
applyAttempts += 1;
return {
operationId: "operation-apply",
operation: { status: { state: "running" } },
} as T;
}
if (path.includes("/events"))
return {
retainedFirstSequence: 3,
cursorGap: true,
items: [],
} as T;
if (path === "/operations/operation-apply") {
operationPolls += 1;
return { status: { state: "succeeded" } } as T;
}
throw new Error(`Unexpected request: ${path}`);
};
await expect(
reconcileResources("shop", [], 1, undefined, request, {
sleep: async () => {},
}),
).rejects.toThrow("progress history was truncated");
expect(applyAttempts).toBe(1);
expect(operationPolls).toBe(0);
});
test("starts live resource subtasks before the operation and updates them from progress", async () => {
let finish!: () => void;
let child: { title: string; output: string } | undefined;
const completed = new Promise<void>((resolve) => (finish = resolve));
const listr = new Listr([
{
title: "Apply resources",
task: async (_ctx, task) =>
runLiveResourceOperation(
task,
"apply",
[
{
apiVersion: "v1",
kind: "Service",
name: "web",
},
],
async (onEvent) => {
expect(child?.title).toBe("Apply Service/web");
onEvent({
sequence: 1,
data: {
resource: {
apiVersion: "v1",
kind: "Service",
name: "web",
},
phase: "apply",
state: "started",
},
});
expect(child?.output).toBe("started");
await completed;
},
{ onTaskStarted: (_target, activeTask) => (child = activeTask) },
),
},
]);
const run = listr.run();
await Bun.sleep(0);
finish();
await run;
});
test("resubmits with a fresh key after server restart interruption", async () => {
const applyRequests: Array<{ key: string | null; json: unknown }> = [];
let operationPolls = 0;