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
+433 -10
View File
@@ -582,6 +582,301 @@ describe("authenticated build API pipeline", () => {
expect(calls.some(({ path }) => path.endsWith("/result"))).toBe(true);
});
test("builds equivalent services once and attributes events and image results to each", async () => {
const snapshot = emptySnapshot();
const submitted: BuildRequest[] = [];
const messages = new Map<string, string[]>();
const settled: string[] = [];
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") {
submitted.push(init!.json as BuildRequest);
return { state: "queued" } as T;
}
if (path.includes("/events"))
return [
{ type: "status", status: { state: "running", phase: "running" } },
{ type: "log", sequence: 1, message: "shared log\n" },
] as T;
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
if (path.endsWith("/result"))
return {
reference: "registry/kuber/shop-web@sha256:abc",
references: { worker: "registry/kuber/shop-worker@sha256:abc" },
} as T;
throw new Error(path);
};
const result = await buildServices(
"shop",
{
services: { web: { build: "." }, worker: { build: { context: "." } } },
},
process.cwd(),
{
service: (name) => {
const output: string[] = [];
messages.set(name, output);
return {
progress: (message) => {
output.push(message);
},
};
},
settled: (name) => {
settled.push(name);
},
},
{ request, snapshot, sleep: async () => {}, pollIntervalMs: 0 },
);
expect(submitted).toHaveLength(1);
expect(submitted[0]?.destinations).toEqual([
{
service: "worker",
image: "registry.neko-piranha.ts.net/kuber/shop-worker:latest",
},
]);
expect(result).toEqual({
built: ["web", "worker"],
changed: ["web", "worker"],
images: {
web: "registry/kuber/shop-web@sha256:abc",
worker: "registry/kuber/shop-worker@sha256:abc",
},
});
expect(settled).toEqual(["web", "worker"]);
for (const name of ["web", "worker"])
expect(messages.get(name)?.join(" ")).toContain("shared log");
});
test("routes overlapping build logs only to their destination children", async () => {
const snapshot = emptySnapshot();
const submissions = new Map<string, BuildRequest>();
const output = new Map<string, string[]>();
let started = 0;
let release!: () => void;
const bothStarted = new Promise<void>((resolve) => { release = resolve; });
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 BuildRequest;
submissions.set(build.id, build);
if (++started === 2) release();
return { state: "queued" } as T;
}
const id = path.split("/")[2]!;
const build = submissions.get(id)!;
if (path.includes("/events"))
return path.includes("after=0")
? [
{ type: "status", status: { state: "running", phase: "running" } },
{ type: "log", sequence: 1, message: `${build.service} log\n` },
] as T
: [] as T;
if (path.endsWith("/reconcile")) {
await bothStarted;
return { state: "succeeded" } as T;
}
if (path.endsWith("/result"))
return {
reference: `image:${build.service}`,
references: { worker: "image:worker" },
} as T;
throw new Error(path);
};
const result = await buildServices(
"shop",
{ services: {
web: { build: "." },
worker: { build: "." },
admin: { build: { context: ".", args: { ROLE: "admin" } } },
} },
process.cwd(),
{ service: (name) => {
const lines: string[] = [];
output.set(name, lines);
return {
progress: (message) => { lines.push(message); },
stream: new Writable({ write(chunk, _encoding, done) {
lines.push(String(chunk));
done();
} }),
};
} },
{ request, snapshot, buildConcurrency: 2, pollIntervalMs: 0, sleep: async () => {} },
);
expect(submissions.size).toBe(2);
expect([...submissions.values()].find(({ service }) => service === "web")?.destinations?.map(({ service }) => service)).toEqual(["worker"]);
expect(result.images).toEqual({ web: "image:web", worker: "image:worker", admin: "image:admin" });
for (const name of ["web", "worker"]) {
expect(output.get(name)?.filter((line) => line === "web log\n")).toHaveLength(1);
expect(output.get(name)?.join("")).not.toContain("admin log");
expect(output.get(name)).toContain("Build running");
}
expect(output.get("admin")?.filter((line) => line === "admin log\n")).toHaveLength(1);
expect(output.get("admin")?.join("")).not.toContain("web log");
});
test("reports a grouped build once to a shared reporter", async () => {
const output: string[] = [];
const request: ApiRequester = async <T>(path: string) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") return { state: "queued" } as T;
if (path.includes("/events"))
return path.includes("after=0")
? [{ type: "log", sequence: 1, message: "one physical build\n" }] as T
: [] as T;
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
if (path.endsWith("/result"))
return { reference: "image:web", references: { worker: "image:worker" } } as T;
throw new Error(path);
};
await buildServices(
"shop",
{ services: { web: { build: "." }, worker: { build: "." } } },
process.cwd(),
{ stream: new Writable({ write(chunk, _encoding, done) {
output.push(String(chunk));
done();
} }) },
{ request, snapshot: emptySnapshot(), sleep: async () => {}, pollIntervalMs: 0 },
);
expect(output).toEqual(["[web] one physical build\n"]);
});
test("does not merge builds differing in context, Dockerfile, target, or build arguments", async () => {
const snapshot = emptySnapshot();
const submitted: BuildRequest[] = [];
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") {
submitted.push(init!.json as BuildRequest);
return { state: "queued" } as T;
}
if (path.includes("/events")) return [] as T;
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
if (path.endsWith("/result"))
return { reference: "image@sha256:abc" } as T;
throw new Error(path);
};
await buildServices(
"shop",
{
services: {
base: { build: "." },
context: { build: { context: "nested" } },
dockerfile: { build: { context: ".", dockerfile: "Otherfile" } },
target: { build: { context: ".", target: "test" } },
args: { build: { context: ".", args: { MODE: "test" } } },
},
},
process.cwd(),
undefined,
{ request, snapshot },
);
expect(submitted).toHaveLength(5);
expect(submitted.every((build) => !build.destinations)).toBe(true);
});
test("marks every service failed when a shared BuildKit job fails", async () => {
const snapshot = emptySnapshot();
const settled: Array<[string, unknown]> = [];
let submissions = 0;
const request: ApiRequester = async <T>(path: string) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") {
submissions++;
return { state: "queued" } as T;
}
if (path.includes("/events"))
return [{ type: "log", sequence: 1, message: "build failed\n" }] as T;
if (path.endsWith("/reconcile"))
return { state: "failed", error: "export failed" } as T;
throw new Error(path);
};
await expect(
buildServices(
"shop",
{
services: {
web: { build: "." },
worker: { build: "." },
},
},
process.cwd(),
{
settled: (name, error) => {
settled.push([name, error]);
},
},
{ request, snapshot, sleep: async () => {}, pollIntervalMs: 0 },
),
).rejects.toThrow("export failed\nbuild failed");
expect(submissions).toBe(1);
expect(settled.map(([name]) => name)).toEqual(["web", "worker"]);
expect(settled.every(([, error]) => error instanceof Error)).toBe(true);
});
test("cancels a shared build once and does not start queued groups", async () => {
const snapshot = emptySnapshot();
const controller = new AbortController();
const reason = new DOMException("Cancelled", "AbortError");
const started: BuildRequest[] = [];
const settled: string[] = [];
let ready!: () => void;
const submitted = new Promise<void>((resolve) => {
ready = resolve;
});
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") {
started.push(init!.json as BuildRequest);
ready();
return await new Promise<T>((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => reject(init.signal!.reason),
{ once: true },
);
});
}
throw new Error(path);
};
const result = buildServices(
"shop",
{
services: {
web: { build: "." },
worker: { build: "." },
different: { build: { context: ".", args: { MODE: "different" } } },
},
},
process.cwd(),
{
settled: (name) => {
settled.push(name);
},
},
{ request, snapshot, buildConcurrency: 1, signal: controller.signal },
);
await submitted;
controller.abort(reason);
await expect(result).rejects.toBe(reason);
expect(started).toHaveLength(1);
expect(started[0]!.destinations?.map(({ service }) => service)).toEqual([
"worker",
]);
expect(settled).toEqual(["web", "worker"]);
});
test("bounds overlapping builds, attributes logs, and returns images in compose order", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-build-api-"));
directories.push(root);
@@ -631,7 +926,8 @@ describe("authenticated build API pipeline", () => {
await gates.get(service);
return { id, state: "succeeded" } as T;
}
if (path.endsWith("/result")) return { reference: `image:${service}` } as T;
if (path.endsWith("/result"))
return { reference: `image:${service}` } as T;
throw new Error(`Unexpected request ${path}`);
};
const run = buildServices(
@@ -640,7 +936,7 @@ describe("authenticated build API pipeline", () => {
services: Object.fromEntries(
["one", "two", "three", "four"].map((name) => [
name,
{ build: "." },
{ build: { context: ".", args: { SERVICE: name } } },
]),
),
},
@@ -683,6 +979,126 @@ describe("authenticated build API pipeline", () => {
}
});
test("keeps per-image logs and lifecycle updates separate, including a failed build", async () => {
const snapshot = emptySnapshot();
const outputs = new Map<string, string[]>();
const ids = new Map<string, string>();
const settled: Array<[string, unknown]> = [];
const statuses = ["creating", "starting", "running", "done"] as const;
let polls = 0;
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 BuildRequest;
ids.set(build.id, build.service);
return { state: "queued" } as T;
}
const id = path.split("/")[2]!;
const service = ids.get(id)!;
if (path.includes("/events")) {
const phase = statuses[Math.min(polls, 3)]!;
return [
{
type: "status",
status: {
state:
phase === "done"
? service === "server"
? "failed"
: "succeeded"
: phase === "running"
? "running"
: "queued",
phase,
error:
service === "server" && phase === "done"
? "stack trace"
: undefined,
},
},
{ type: "log", sequence: polls + 1, message: `${service} log\n` },
] as T;
}
if (path.endsWith("/reconcile")) {
const phase = statuses[Math.min(polls++, 3)]!;
return {
state:
phase === "done"
? service === "server"
? "failed"
: "succeeded"
: phase === "running"
? "running"
: "queued",
phase,
error: "stack trace",
} as T;
}
if (path.endsWith("/result"))
return { reference: `image:${service}` } as T;
throw new Error(path);
};
await expect(
buildServices(
"shop",
{
services: {
client: { build: "." },
server: { build: { context: ".", args: { SERVICE: "server" } } },
},
},
process.cwd(),
{
service: (name) => {
const output: string[] = [];
outputs.set(name, output);
return {
progress: (message) => {
output.push(message);
},
stream: new Writable({
write(chunk, _encoding, done) {
output.push(String(chunk));
done();
},
}),
};
},
settled: (name, error) => {
settled.push([name, error]);
},
},
{
request,
snapshot,
sleep: async () => {},
pollIntervalMs: 0,
buildConcurrency: 1,
},
),
).rejects.toThrow(
"Build failed for service server: stack trace\nserver log",
);
expect(outputs.get("client")).toEqual(
expect.arrayContaining([
"Build queued",
"Build creating",
"Build starting",
"Build running",
"Build done",
"client log\n",
]),
);
expect(outputs.get("client")?.join("")).not.toContain("server log");
expect(outputs.get("server")?.join("")).toContain("server log");
expect(settled.map(([name]) => name)).toEqual(["client", "server"]);
expect(settled[1]?.[1]).toBeInstanceOf(Error);
expect(ids.size).toBe(2); // Different build arguments require separate jobs.
});
test("drains active builds and does not start queued services after failure", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-build-api-"));
directories.push(root);
@@ -722,16 +1138,19 @@ describe("authenticated build API pipeline", () => {
"shop",
{
services: {
one: { build: "." },
two: { build: "." },
three: { build: "." },
one: { build: { context: ".", args: { SERVICE: "one" } } },
two: { build: { context: ".", args: { SERVICE: "two" } } },
three: { build: { context: ".", args: { SERVICE: "three" } } },
},
},
root,
undefined,
{ request, snapshot, buildConcurrency: 2 },
);
const outcome = run.then(() => undefined, (error: unknown) => error);
const outcome = run.then(
() => undefined,
(error: unknown) => error,
);
await bothStarted;
fail(new Error("first build failed"));
await Promise.resolve();
@@ -776,9 +1195,9 @@ describe("authenticated build API pipeline", () => {
"shop",
{
services: {
one: { build: "." },
two: { build: "." },
three: { build: "." },
one: { build: { context: ".", args: { SERVICE: "one" } } },
two: { build: { context: ".", args: { SERVICE: "two" } } },
three: { build: { context: ".", args: { SERVICE: "three" } } },
},
},
root,
@@ -927,7 +1346,11 @@ describe("authenticated build API pipeline", () => {
}
const images = await run;
expect(Object.keys(images)).toEqual(services);
expect(images).toEqual(Object.fromEntries(services.map((service) => [service, `image:${service}`])));
expect(images).toEqual(
Object.fromEntries(
services.map((service) => [service, `image:${service}`]),
),
);
expect(started).toEqual(services);
} finally {
for (const release of pending) release();
+33
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import type { ComposeSpecification, Service } from "../../schema/docker.d";
import {
DatabaseReconciliationError,
buildDatabaseUrl,
buildPostgresEnvironment,
getComposePostgresClaims,
@@ -152,6 +153,38 @@ describe("managed PostgreSQL claims", () => {
]);
});
test("reports database apply phase and claim without exposing provider credentials", async () => {
const claim = {
service: "app",
username: "app_role",
database: "app_db",
secretName: "postgres-app_role",
};
spyOn(objectApi, "read").mockImplementation(async (resource) => {
if (resource.kind === "Secret") {
return { ...resource, data: { password: Buffer.from("private-value").toString("base64") } } as never;
}
return { ...resource, spec: { managed: { roles: [] } } } as never;
});
spyOn(objectApi, "patch").mockImplementation(async (resource) => {
if (resource.kind === "Database") {
throw Object.assign(new Error("Forbidden: password=private-value"), { code: 403 });
}
return resource as never;
});
let failure: unknown;
try {
await reconcilePostgresClaim("project", claim);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
expect((failure as Error).message).toBe(
"Database reconciliation failed during database apply for database app_db (service app, role app_role): Forbidden (HTTP 403)",
);
expect((failure as Error).message).not.toContain("private-value");
});
test("reconciles a CNPG cluster returned with managed fields without sending them back", async () => {
const claim = {
service: "app",