feat: add CI deployment and live progress
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user