feat: improve API keys and build workflows
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user