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
+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 });
}