feat: improve API keys and build workflows

This commit is contained in:
2026-10-04 20:05:13 +00:00 Unverified
parent 62f2362a2e
commit e4623efe86
27 changed files with 2080 additions and 235 deletions
+157 -1
View File
@@ -12,6 +12,7 @@ import {
revokeUserSessions,
setUserDisabled,
updateUser,
users,
} from "../../command/users";
import type { ApiRequestInit } from "../../lib/api";
@@ -130,7 +131,10 @@ describe("user administration commands", () => {
await createApiKey(
"alice",
{ capabilities: ["kubernetes:write"] },
requestReturning(key, calls),
async <T>(path: string, init?: ApiRequestInit): Promise<T> => {
calls.push({ path, init });
return (init ? key : user) as T;
},
);
expect(
await revokeApiKey(
@@ -148,6 +152,7 @@ describe("user administration commands", () => {
);
expect(calls).toEqual([
{ path: "/users/alice%2Fexample/keys", init: undefined },
{ path: "/users/alice", init: undefined },
{
path: "/users/alice/keys",
init: { method: "POST", json: { capabilities: ["kubernetes:write"] } },
@@ -158,6 +163,157 @@ describe("user administration commands", () => {
},
]);
});
test("lists all users' keys without a username and filters with one", async () => {
const calls: Call[] = [];
const request = async <T>(
path: string,
init?: ApiRequestInit,
): Promise<T> => {
calls.push({ path, init });
if (path === "/users")
return { items: [user, { ...user, username: "bob" }] } as T;
return {
items: [
{
id: `${path.includes("bob") ? "bob" : "alice"}-key`,
username: path.includes("bob") ? "bob" : "alice",
capabilities: ["kubernetes:read"],
disabled: false,
},
],
} as T;
};
const all = await listApiKeys(undefined, request);
expect(all).toContain("alice-key");
expect(all).toContain("bob-key");
expect(all).toContain("never");
expect(calls.map((call) => call.path)).toEqual([
"/users",
"/users/alice/keys",
"/users/bob/keys",
]);
calls.length = 0;
expect(await listApiKeys("alice", request)).not.toContain("bob-key");
expect(calls.map((call) => call.path)).toEqual(["/users/alice/keys"]);
});
test("key creation requires an existing active user", async () => {
const calls: Call[] = [];
const body = { capabilities: ["kubernetes:read" as const] };
await expect(
createApiKey("missing", body, async (path) => {
calls.push({ path });
throw new Error("User 'missing' not found");
}),
).rejects.toThrow("User 'missing' not found");
expect(calls).toEqual([{ path: "/users/missing" }]);
await expect(
createApiKey(
"alice",
body,
requestReturning({ ...user, disabled: true }, calls),
),
).rejects.toThrow("user 'alice' is inactive");
expect(calls.at(-1)?.path).toBe("/users/alice");
});
test("creates non-expiring and finite API keys with the requested POST bodies", async () => {
const calls: Call[] = [];
const key = {
id: "key-identifier-123",
username: "alice",
capabilities: ["kubernetes:read"] as const,
disabled: false,
token: "shown-once-token",
};
const request = async <T>(
path: string,
init?: ApiRequestInit,
): Promise<T> => {
calls.push({ path, init });
return (init ? key : user) as T;
};
await createApiKey(
"alice",
{ capabilities: ["kubernetes:read"], workspace: "team/shop" },
request,
);
const expiresAt = "2026-12-01T00:00:00.000Z";
await createApiKey(
"alice",
{
capabilities: ["kubernetes:read"],
workspace: "team/shop",
expiresAt,
},
request,
);
expect(calls).toEqual([
{ path: "/users/alice", init: undefined },
{
path: "/users/alice/keys",
init: {
method: "POST",
json: { capabilities: ["kubernetes:read"], workspace: "team/shop" },
},
},
{ path: "/users/alice", init: undefined },
{
path: "/users/alice/keys",
init: {
method: "POST",
json: {
capabilities: ["kubernetes:read"],
workspace: "team/shop",
expiresAt,
},
},
},
]);
expect(calls[1]?.init?.json).not.toHaveProperty("expiresAt");
});
test("key command help declares username, capability examples, and non-expiry", async () => {
const commands = (users.subCommands as Record<string, any>)?.keys;
if (!commands || typeof commands === "function")
throw new Error("Missing keys command");
const subCommands = await Promise.resolve(commands.subCommands);
const create = subCommands?.create;
const ls = subCommands?.ls;
if (
!create ||
typeof create === "function" ||
!ls ||
typeof ls === "function"
)
throw new Error("Missing key subcommands");
expect(create.args?.username).toMatchObject({
type: "positional",
required: true,
});
expect(create.args?.capabilities?.description).toContain(
"kubernetes:read,kubernetes:write",
);
expect(create.args?.["expires-days"]?.description).toContain("none");
expect(ls.meta?.description).toContain("optional positional username");
await expect(
create.run?.({
args: { username: "alice", capabilities: "invalid", "expires-days": "90" },
} as never),
).rejects.toThrow("kubernetes:read,kubernetes:write");
await expect(
create.run?.({
args: {
username: "alice",
capabilities: "kubernetes:read",
"expires-days": "NaN",
},
} as never),
).rejects.toThrow("1 to 365, or none");
});
});
const operation = {
+161 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, 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";
import { Writable } from "node:stream";
import type { ApiRequestInit, ApiRequestOptions } from "../../lib/api";
import { KuberApiError } from "../../lib/api";
import type { ApiRequester } from "../../lib/build";
@@ -25,6 +26,104 @@ const snapshot = {
};
describe("up API pipeline", () => {
test("attaches each build log to its started image child", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
const previousCwd = process.cwd();
const originalRun = Listr.prototype.run;
const lines = new Map<string, string[]>();
const acquired: string[] = [];
const runSpy = spyOn(Listr.prototype, "run").mockImplementation(function (this: Listr) {
if (this.tasks[0]?.title === "Build web") {
for (const entry of this.tasks) {
const name = entry.title!.replace(/^Build /, "");
const executable = entry as unknown as { taskFn: typeof entry.task.task };
const originalTask = executable.taskFn;
executable.taskFn = async (ctx, child) => {
const output: string[] = [];
lines.set(name, output);
child.stdout = () => {
acquired.push(name);
return new Writable({ write(chunk, _encoding, done) {
output.push(String(chunk));
done();
} });
};
return originalTask(ctx, child);
};
}
}
return originalRun.call(this);
});
try {
await writeFile(join(root, "compose.yml"),
"services:\n web:\n build: .\n worker:\n build: .\n admin:\n build:\n context: .\n args:\n ROLE: admin\n");
await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n');
process.chdir(root);
const trust = await resolveTrustIdentity("shop", root);
const builds = new Map<string, string>();
const request: ApiRequester = async <T>(path: string, init?: ApiRequestInit) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") {
const build = init!.json as { id: string; service: string };
builds.set(build.id, build.service);
return { state: "queued" } as T;
}
const service = builds.get(path.split("/")[2]!)!;
if (path.includes("/events"))
return path.includes("after=0")
? [{ type: "log", sequence: 1, message: `${service} log\n` }] as T
: [] as T;
if (path.endsWith("/reconcile"))
return { state: service === "admin" ? "failed" : "succeeded", error: "admin failed" } as T;
if (path.endsWith("/result"))
return { reference: "image:web", references: { worker: "image:worker" } } as T;
throw new Error(path);
};
await expect(provideContext(() => runUp(true, request, { trust }))).rejects.toThrow("admin failed");
expect(builds.size).toBe(2);
expect(acquired.sort()).toEqual(["admin", "web", "worker"]);
expect(lines.get("web")).toEqual(["web log\n"]);
expect(lines.get("worker")).toEqual(["web log\n"]);
expect(lines.get("admin")).toEqual(["admin log\n"]);
} finally {
runSpy.mockRestore();
process.chdir(previousCwd);
await rm(root, { recursive: true, force: true });
}
});
test("renders a failing image under its child and sends the stack to the parent bottom bar", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
const previousCwd = process.cwd();
const rendered: string[] = [];
const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
rendered.push(String(chunk));
return true;
}) as typeof process.stdout.write);
try {
await writeFile(join(root, "compose.yml"), "services:\n client:\n build: .\n server:\n build: .\n");
await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n');
process.chdir(root);
const trust = await resolveTrustIdentity("shop", root);
const request: ApiRequester = async <T>(path: string, init?: ApiRequestInit) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") return { id: (init!.json as { id: string }).id, state: "failed", error: "buildkit failed\nstack detail" } as T;
if (path.includes("/events")) return [] as T;
throw new Error(path);
};
await expect(provideContext(() => runUp(true, request, { trust }))).rejects.toThrow();
const output = rendered.join("");
expect(output).toContain("Build client");
expect(output).toContain("Build server");
expect(output).toContain("buildkit failed");
expect(output).toContain("stack detail");
} finally {
writes.mockRestore();
process.chdir(previousCwd);
await rm(root, { recursive: true, force: true });
}
});
test("forwards the build timeout through the trusted requester", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
const previousCwd = process.cwd();
@@ -108,6 +207,14 @@ describe("up API pipeline", () => {
const previousCwd = process.cwd();
const order: string[] = [];
const started: string[] = [];
let rootTasks: Listr["tasks"] | undefined;
let backingTasks: Listr["tasks"] | undefined;
const originalRun = Listr.prototype.run;
const runSpy = spyOn(Listr.prototype, "run").mockImplementation(function (this: Listr) {
if (this.tasks[0]?.title === "Read compose") rootTasks = this.tasks;
if (this.tasks[0]?.title === "Reconcile databases") backingTasks = this.tasks;
return originalRun.call(this);
});
let bothReady!: () => void;
const bothStarted = new Promise<void>((resolve) => {
bothReady = resolve;
@@ -158,6 +265,18 @@ describe("up API pipeline", () => {
const run = provideContext(() => runUp(false, request, { trust }));
try {
await bothStarted;
const titles = rootTasks!.map(({ title }) => title);
expect(titles.indexOf("Reconcile backing services")).toBeLessThan(
titles.indexOf("Render manifests"),
);
expect(titles.indexOf("Render manifests")).toBeLessThan(
titles.indexOf("Reconcile resources"),
);
expect(backingTasks!.map(({ title }) => title)).toEqual([
"Reconcile databases",
"Reconcile S3 storage",
]);
expect(rootTasks!.filter(({ title }) => title === "Reconcile databases")).toEqual([]);
expect(started).toEqual(["database", "storage"]);
expect(order.indexOf("/workspaces/shop/adopt")).toBeLessThan(
order.indexOf("/workspaces/shop/databases"),
@@ -184,6 +303,47 @@ describe("up API pipeline", () => {
await run.catch(() => {});
}
} finally {
runSpy.mockRestore();
process.chdir(previousCwd);
await rm(root, { recursive: true, force: true });
}
});
test("shows database API problem details on the database task without starting resources", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
const previousCwd = process.cwd();
const rendered: string[] = [];
const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
rendered.push(String(chunk));
return true;
}) as typeof process.stdout.write);
const errors = spyOn(process.stderr, "write").mockImplementation(((chunk: string | Uint8Array) => {
rendered.push(String(chunk));
return true;
}) as typeof process.stderr.write);
const calls: string[] = [];
try {
await writeFile(join(root, "compose.yml"), "services:\n web:\n image: nginx\n volumes:\n - postgresql:web_db\n");
await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n');
process.chdir(root);
const trust = await resolveTrustIdentity("shop", root);
const detail = "Database reconciliation failed during database apply for database web_db (service web, role web): Forbidden";
const request: ApiRequester = async <T>(path: string) => {
calls.push(path);
if (path === "/workspaces/shop") throw new KuberApiError("missing", 404);
if (path === "/workspaces") return { metadata: { name: "shop", uid: "workspace", resourceVersion: "1" } } as T;
if (path.endsWith("/adopt")) return { resourcesAdopted: 0 } as T;
if (path.endsWith("/databases")) throw new KuberApiError(detail, 500, {
title: "Operation failed", status: 500, code: "DATABASE_RECONCILE_FAILED", detail,
});
throw new Error(`Unexpected request: ${path}`);
};
await expect(provideContext(() => runUp(false, request, { trust }))).rejects.toThrow(detail);
expect(rendered.join("")).toContain("database apply for database web_db");
expect(calls).not.toContain("/workspaces/shop/resources/plan");
} finally {
errors.mockRestore();
writes.mockRestore();
process.chdir(previousCwd);
await rm(root, { recursive: true, force: true });
}