feat: improve API keys and build workflows
This commit is contained in:
@@ -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