feat: improve API keys and build workflows
This commit is contained in:
+433
-10
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user