feat: release 2.7.0-rc2

This commit is contained in:
2026-10-06 15:31:51 +00:00 Unverified
parent cd9fb512cd
commit c8de9ddcba
42 changed files with 6127 additions and 593 deletions
+689 -64
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Writable } from "node:stream";
@@ -14,6 +14,8 @@ import {
MAX_REQUESTS_PER_SECOND,
} from "../../lib/build";
import {
enumerateWorkspace,
serializeWorkspaceManifest,
workspaceManifestDigest,
type WorkspaceSnapshot,
} from "../../lib/workspace";
@@ -22,6 +24,84 @@ import { ApiStreamUnsupportedError } from "../../lib/api";
const directories: string[] = [];
test("auto URI is submitted and grouped by exact package identity while bare auto stays distinct", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-auto-uri-"));
directories.push(root);
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
await writeFile(join(root, "package.json"), "{}\n");
const uri =
"https://github.com/example/bun/releases/download/v1/buildpack.cnb";
const submitted: BuildRequest[] = [];
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path.includes("/events")) return [] as T;
if (path === "/builds") {
const build = init!.json as BuildRequest;
submitted.push(build);
return { state: "succeeded", id: build.id } as T;
}
if (path.endsWith("/result")) {
const build = submitted.find((build) => path.includes(build.id))!;
return {
reference: `image:${build.service}`,
references: Object.fromEntries(
(build.destinations ?? []).map(({ service }) => [
service,
`image:${service}`,
]),
),
} as T;
}
throw new Error(path);
};
const result = await buildServices(
"shop",
{
services: {
one: { build: `auto:${uri}` },
same: { build: `auto:${uri}` },
other: { build: `auto:${uri.replace("/v1/", "/v2/")}` },
bare: { build: "auto" },
},
},
root,
undefined,
{ request, sleep: async () => {}, workspaceRoot: root },
);
expect(result.built).toHaveLength(4);
expect(submitted).toHaveLength(3);
expect(
submitted.every(
({ spec }) =>
spec.builder === "buildpacks" &&
spec.context === "." &&
spec.dockerfile === undefined,
),
).toBe(true);
expect(
submitted.find(({ service }) => service === "one")?.destinations?.[0]
?.service,
).toBe("same");
expect(
submitted.find(({ service }) => service === "one")?.spec.buildpackUri,
).toBe(uri);
expect(
submitted.find(({ service }) => service === "bare")?.spec.buildpackUri,
).toBeUndefined();
await expect(
buildServices(
"shop",
{ services: { bad: { build: "auto:http://localhost/package.cnb" } } },
root,
undefined,
{ request, workspaceRoot: root },
),
).rejects.toThrow(/URI/);
});
function emptySnapshot(): WorkspaceSnapshot {
const manifest = { version: 1 as const, files: [] };
return { manifest, digest: workspaceManifestDigest(manifest), blobs: [] };
@@ -72,6 +152,411 @@ afterEach(async () => {
});
describe("authenticated build API pipeline", () => {
test("reports aggregate unique missing bytes per chunk through renegotiation", async () => {
const snapshot = createSnapshotWithBlobs(2, 1024);
const [first, second] = snapshot.blobs;
const total =
first!.data.byteLength +
second!.data.byteLength +
serializeWorkspaceManifest(snapshot.manifest).byteLength;
const reports: Array<[number, number]> = [];
let release!: () => void;
let patchStarted!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
patchStarted = resolve;
});
let negotiations = 0;
const patches = new Map<string, number>();
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate") {
negotiations++;
return (
negotiations === 1
? {
ready: false,
missing: [
first!.digest,
first!.digest,
second!.digest,
snapshot.digest,
],
}
: negotiations === 2
? { ready: false, missing: [first!.digest, snapshot.digest] }
: { ready: true, missing: [] }
) as T;
}
const digest = decodeURIComponent(path.split("/")[2]!);
if (init?.method === "PATCH") {
if (digest === first!.digest) {
patchStarted();
await held;
}
patches.set(digest, (patches.get(digest) ?? 0) + 1);
return { offset: (init.body as Uint8Array).byteLength } as T;
}
if (init?.method === "POST" && !path.includes("/complete"))
return {
offset: patches.has(digest)
? digest === snapshot.digest
? total - 2048
: 1024
: 0,
complete: patches.has(digest),
} as T;
return { complete: true } as T;
};
const upload = uploadWorkspaceSnapshot(snapshot, request, {
upload: (bytes, size) => reports.push([bytes, size]),
});
await started;
expect(reports.at(-1)?.[1]).toBe(total);
expect(reports.at(-1)?.[0]).toBeLessThan(total);
release();
await upload;
expect(patches.get(first!.digest)).toBe(1);
expect(patches.get(second!.digest)).toBe(1);
expect(patches.get(snapshot.digest)).toBe(1);
expect(reports.at(-1)).toEqual([total, total]);
expect(
reports.every(([bytes, size]) => bytes <= size && size === total),
).toBe(true);
});
test("settles the upload with its original error before starting a build", async () => {
const snapshot = emptySnapshot();
const failure = new Error("upload interrupted");
let builds = 0;
const settled: unknown[] = [];
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate")
return { ready: false, missing: [snapshot.digest] } as T;
if (path === "/builds") {
builds++;
throw new Error("unexpected build");
}
if (init?.method === "PATCH") throw failure;
return { offset: 0, complete: false } as T;
};
await expect(
buildServices(
"shop",
{ services: { web: { build: "." } } },
process.cwd(),
{
uploadSettled: (error) => {
settled.push(error);
},
},
{ request, snapshot },
),
).rejects.toBe(failure);
expect(settled).toEqual([failure]);
expect(builds).toBe(0);
});
test("keeps builder groups separate even when snapshot digests coincide", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-same-digest-"));
directories.push(root);
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
const snapshot = emptySnapshot();
const submissions: BuildRequest[] = [];
const uploads: string[] = [];
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate") {
uploads.push((init!.json as { workspace: string }).workspace);
return { ready: true } as T;
}
if (path === "/builds") {
const build = init!.json as BuildRequest;
submissions.push(build);
return { state: "queued", id: build.id } 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:built" } as T;
throw new Error(path);
};
await buildServices(
"shop",
{
services: {
docker: { build: "." },
auto: { build: "auto" },
},
},
root,
undefined,
{ request, snapshot, sleep: async () => {}, pollIntervalMs: 0 },
);
expect(uploads).toEqual([snapshot.digest, snapshot.digest]);
expect(submissions).toHaveLength(2);
expect(submissions.map(({ spec }) => spec.workspace)).toEqual([
snapshot.digest,
snapshot.digest,
]);
expect(submissions[0]?.spec).not.toHaveProperty("builder");
expect(submissions[1]?.spec).toHaveProperty("builder", "buildpacks");
});
test("auto fails before uploading when no Git repository is available", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-auto-gitless-"));
directories.push(root);
const calls: string[] = [];
const request: ApiRequester = async <T>(path: string) => {
calls.push(path);
return { ready: true } as T;
};
await expect(
buildServices(
"shop",
{ services: { web: { build: "auto" } } },
root,
undefined,
{ request, snapshot: emptySnapshot() },
),
).rejects.toThrow(/requires Git and a Git repository/);
expect(calls).toEqual([]);
});
test("auto uses a contained selected subproject context and Git-filtered snapshot", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-auto-subproject-"));
directories.push(root);
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
await writeFile(join(root, ".gitignore"), "*.secret\n");
await mkdir(join(root, "apps", "web"), { recursive: true });
await writeFile(join(root, "apps", "web", "package.json"), "{}\n");
await writeFile(join(root, "apps", "web", "app.secret"), "private");
const submitted: BuildRequest[] = [];
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;
submitted.push(build);
return { state: "queued", id: build.id } 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:built" } as T;
throw new Error(path);
};
await buildServices(
"shop",
{
services: {
selected: { build: "auto", "x-kuber-build-context": "apps/web" },
root: { build: "auto" },
},
},
root,
undefined,
{ request, sleep: async () => {}, pollIntervalMs: 0 },
);
expect(submitted.map(({ spec }) => spec.context)).toEqual([
"apps/web",
".",
]);
expect(
submitted.every(
({ spec }) =>
(spec as BuildRequest["spec"] & { builder?: string }).builder ===
"buildpacks",
),
).toBe(true);
const autoSnapshot = await enumerateWorkspace(root, "auto");
expect(autoSnapshot.manifest.files.map(({ path }) => path)).toContain(
"apps/web/package.json",
);
expect(autoSnapshot.manifest.files.map(({ path }) => path)).not.toContain(
"apps/web/app.secret",
);
});
test("auto rejects traversal and absolute selected contexts", async () => {
const root = await mkdtemp(
join(tmpdir(), "kuber-auto-context-validation-"),
);
directories.push(root);
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
const request: ApiRequester = async <T>() => ({ ready: true }) as T;
for (const context of [
"../outside",
"apps/../../outside",
"/tmp/outside",
"C:/outside",
]) {
await expect(
buildServices(
"shop",
{
services: {
web: { build: "auto", "x-kuber-build-context": context },
},
},
root,
undefined,
{ request, snapshot: emptySnapshot() },
),
).rejects.toThrow(/x-kuber-build-context/);
}
});
test("uploads distinct auto and Dockerfile snapshots and groups only matching builders", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-mixed-build-"));
directories.push(root);
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
await writeFile(join(root, ".gitignore"), "tracked.secret\n.env*\n");
await writeFile(join(root, "tracked.secret"), "private");
expect(
await Bun.spawn(["git", "-C", root, "add", "-f", "tracked.secret"])
.exited,
).toBe(0);
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
await writeFile(join(root, ".dockerignore"), "app.txt\n");
await writeFile(join(root, "app.txt"), "app");
const regular = await enumerateWorkspace(root);
const auto = await enumerateWorkspace(root, "auto");
const negotiations: string[] = [];
const uploadedManifests = new Map<string, string>();
const submissions: BuildRequest[] = [];
const progress: Array<[number, number]> = [];
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
if (path === "/snapshots/negotiate") {
const workspace = (init!.json as { workspace: string }).workspace;
negotiations.push(workspace);
return {
workspace,
missing:
negotiations.filter((digest) => digest === workspace).length === 1
? [workspace]
: [],
ready:
negotiations.filter((digest) => digest === workspace).length !== 1,
} as T;
}
if (path.includes("/uploads")) {
if (init?.method === "PATCH") {
const digest = decodeURIComponent(path.split("/")[2]!);
uploadedManifests.set(
digest,
Buffer.from(init.body as Uint8Array).toString(),
);
return { offset: (init.body as Uint8Array).byteLength } as T;
}
return { offset: 0, complete: false } as T;
}
if (path === "/builds") {
const build = init?.json as BuildRequest;
submissions.push(build);
return { state: "queued", id: build.id } as T;
}
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
if (path.includes("/events")) return [] as T;
if (path.endsWith("/result")) {
const build = submissions.find(({ id }) => path.includes(id))!;
return {
reference: `image:${build.service}`,
references: Object.fromEntries(
(build.destinations ?? []).map(({ service }) => [
service,
`image:${service}`,
]),
),
} as T;
}
throw new Error(path);
};
const result = await buildServices(
"shop",
{
services: {
docker: { build: "." },
auto: { build: "auto" },
second: { build: "auto" },
},
},
root,
{
upload: (uploaded, total) => {
progress.push([uploaded, total]);
},
},
{ request, sleep: async () => {}, pollIntervalMs: 0 },
);
expect(negotiations).toEqual([
regular.digest,
auto.digest,
regular.digest,
auto.digest,
]);
const totalBytes =
serializeWorkspaceManifest(regular.manifest).byteLength +
serializeWorkspaceManifest(auto.manifest).byteLength;
expect(progress[0]).toEqual([0, totalBytes]);
expect(progress.at(-1)).toEqual([totalBytes, totalBytes]);
expect(
progress.every(
([uploaded, total]) => uploaded <= total && total === totalBytes,
),
).toBe(true);
expect([...uploadedManifests.keys()]).toEqual([
regular.digest,
auto.digest,
]);
expect(
JSON.parse(uploadedManifests.get(regular.digest)!).files.map(
(file: { path: string }) => file.path,
),
).toContain("tracked.secret");
expect(
JSON.parse(uploadedManifests.get(auto.digest)!).files.map(
(file: { path: string }) => file.path,
),
).not.toContain("tracked.secret");
expect(submissions).toHaveLength(2);
expect(submissions[0]?.spec).toMatchObject({
workspace: regular.digest,
context: ".",
});
expect(submissions[0]?.spec).not.toHaveProperty("builder");
expect(submissions[1]?.spec).toMatchObject({
workspace: auto.digest,
context: ".",
builder: "buildpacks",
});
expect(submissions[1]?.spec).not.toHaveProperty("dockerfile");
expect(submissions[1]?.destinations).toEqual([
{ service: "second", image: expect.any(String) },
]);
expect(result.images).toEqual({
docker: "image:docker",
auto: "image:auto",
second: "image:second",
});
});
test("negotiates and uploads the manifest through resumable blob routes", async () => {
const snapshot = emptySnapshot();
const controller = new AbortController();
@@ -615,29 +1100,88 @@ describe("authenticated build API pipeline", () => {
calls.push(path);
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") return { state: "queued" } as T;
if (path.endsWith("/result")) return { reference: "image:web", references: { worker: "image:worker" } } as T;
if (path.endsWith("/result"))
return {
reference: "image:web",
references: { worker: "image:worker" },
} as T;
throw new Error(`Unexpected JSON request ${path}`);
};
const result = await buildServices("shop", {
services: { web: { build: "." }, worker: { build: "." } },
}, process.cwd(), { stream: new Writable({ write(chunk, _, done) {
output.push(String(chunk)); done();
} }) }, { request, snapshot, pollIntervalMs: 0, sleep: async () => {},
streamEvents: async function* (_path, after) {
cursors.push(after);
if (after === 0) {
yield { id: 1, event: { type: "log", sequence: 1, id: "build", message: "once\n" } };
yield { event: { type: "status", status: { version: 1, id: "build", state: "running", createdAt: "now" } } };
} else {
yield { id: 1, event: { type: "log", sequence: 1, id: "build", message: "once\n" } };
yield { event: { type: "status", status: { version: 1, id: "build", state: "succeeded", createdAt: "now" } } };
}
const result = await buildServices(
"shop",
{
services: { web: { build: "." }, worker: { build: "." } },
},
});
process.cwd(),
{
stream: new Writable({
write(chunk, _, done) {
output.push(String(chunk));
done();
},
}),
},
{
request,
snapshot,
pollIntervalMs: 0,
sleep: async () => {},
streamEvents: async function* (_path, after) {
cursors.push(after);
if (after === 0) {
yield {
id: 1,
event: {
type: "log",
sequence: 1,
id: "build",
message: "once\n",
},
};
yield {
event: {
type: "status",
status: {
version: 1,
id: "build",
state: "running",
createdAt: "now",
},
},
};
} else {
yield {
id: 1,
event: {
type: "log",
sequence: 1,
id: "build",
message: "once\n",
},
};
yield {
event: {
type: "status",
status: {
version: 1,
id: "build",
state: "succeeded",
createdAt: "now",
},
},
};
}
},
},
);
expect(result.images).toEqual({ web: "image:web", worker: "image:worker" });
expect(cursors).toEqual([0, 1]);
expect(output).toEqual(["[web] once\n"]);
expect(calls.filter((path) => path.includes("/events") || path.includes("/reconcile"))).toEqual([]);
expect(
calls.filter(
(path) => path.includes("/events") || path.includes("/reconcile"),
),
).toEqual([]);
});
test("falls back to JSON events/reconcile only after SSE is unsupported", async () => {
@@ -646,16 +1190,35 @@ describe("authenticated build API pipeline", () => {
calls.push(path);
if (path === "/snapshots/negotiate") return { ready: true } as T;
if (path === "/builds") return { state: "queued" } as T;
if (path.includes("/events")) return [{ type: "log", sequence: 1, message: "fallback\n" }] as T;
if (path.includes("/events"))
return [{ type: "log", sequence: 1, message: "fallback\n" }] as T;
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
if (path.endsWith("/result")) return { reference: "image:web" } as T;
throw new Error(path);
};
expect((await buildServices("shop", { services: { web: { build: "." } } },
process.cwd(), undefined, { request, snapshot: emptySnapshot(),
streamEvents: async function* () { yield* []; throw new ApiStreamUnsupportedError(); },
})).images).toEqual({ web: "image:web" });
expect(calls.filter((path) => path.includes("/events") || path.includes("/reconcile"))).toEqual([
expect(
(
await buildServices(
"shop",
{ services: { web: { build: "." } } },
process.cwd(),
undefined,
{
request,
snapshot: emptySnapshot(),
streamEvents: async function* () {
yield* [];
throw new ApiStreamUnsupportedError();
},
},
)
).images,
).toEqual({ web: "image:web" });
expect(
calls.filter(
(path) => path.includes("/events") || path.includes("/reconcile"),
),
).toEqual([
expect.stringContaining("/events?after=0"),
expect.stringContaining("/reconcile"),
expect.stringContaining("/events?after=1"),
@@ -666,7 +1229,9 @@ describe("authenticated build API pipeline", () => {
const controller = new AbortController();
const reason = new DOMException("Stopped", "AbortError");
let listening!: () => void;
const ready = new Promise<void>((resolve) => { listening = resolve; });
const ready = new Promise<void>((resolve) => {
listening = resolve;
});
const calls: string[] = [];
const request: ApiRequester = async <T>(path: string) => {
calls.push(path);
@@ -674,14 +1239,26 @@ describe("authenticated build API pipeline", () => {
if (path === "/builds") return { state: "queued" } as T;
throw new Error(path);
};
const result = buildServices("shop", { services: { web: { build: "." } } },
process.cwd(), undefined, { request, snapshot: emptySnapshot(), signal: controller.signal,
const result = buildServices(
"shop",
{ services: { web: { build: "." } } },
process.cwd(),
undefined,
{
request,
snapshot: emptySnapshot(),
signal: controller.signal,
streamEvents: async function* (_path, _after, signal) {
yield* [];
listening();
await new Promise<void>((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal.reason), { once: true }));
await new Promise<void>((_resolve, reject) =>
signal?.addEventListener("abort", () => reject(signal.reason), {
once: true,
}),
);
},
});
},
);
await ready;
controller.abort(reason);
await expect(result).rejects.toBe(reason);
@@ -763,8 +1340,13 @@ describe("authenticated build API pipeline", () => {
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) => {
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;
@@ -776,11 +1358,14 @@ describe("authenticated build API pipeline", () => {
const build = submissions.get(id)!;
if (path.includes("/events"))
return path.includes("after=0")
? [
{ type: "status", status: { state: "running", phase: "running" } },
? ([
{
type: "status",
status: { state: "running", phase: "running" },
},
{ type: "log", sequence: 1, message: `${build.service} log\n` },
] as T
: [] as T;
] as T)
: ([] as T);
if (path.endsWith("/reconcile")) {
await bothStarted;
return { state: "succeeded" } as T;
@@ -794,34 +1379,60 @@ describe("authenticated build API pipeline", () => {
};
const result = await buildServices(
"shop",
{ services: {
web: { build: "." },
worker: { build: "." },
admin: { build: { context: ".", args: { ROLE: "admin" } } },
} },
{
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 () => {} },
{
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" });
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)?.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")?.filter((line) => line === "admin log\n"),
).toHaveLength(1);
expect(output.get("admin")?.join("")).not.toContain("web log");
});
@@ -832,22 +1443,36 @@ describe("authenticated build API pipeline", () => {
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;
? ([
{ 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;
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 },
{
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"]);
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, test } from "bun:test";
import {
listDockerfileTemplates,
renderDockerfileTemplate,
templateIds,
type DockerfileTemplateId,
} from "../../lib/scaffold-templates";
// Parse each Dockerfile's instructions and JSON commands to catch malformed
// templates without requiring a Docker daemon in the test environment.
function parseDockerfile(source: string): {
stages: string[];
commands: string[][];
} {
const stages: string[] = [];
const commands: string[][] = [];
let stageCount = 0;
for (const line of source.split("\n")) {
const text = line.trim();
if (!text || text.startsWith("#")) continue;
const match = /^([A-Z]+)\s+(.+)$/.exec(text);
expect(match, `Invalid Dockerfile instruction: ${text}`).not.toBeNull();
const [, instruction, argument] = match!;
expect([
"FROM",
"WORKDIR",
"COPY",
"RUN",
"ENV",
"USER",
"EXPOSE",
"CMD",
"ENTRYPOINT",
]).toContain(instruction!);
if (instruction === "FROM") {
stageCount++;
const alias = /\s+AS\s+(\w+)$/i.exec(argument!)?.[1];
if (alias) stages.push(alias);
} else {
expect(stageCount, `${instruction} must follow FROM`).toBeGreaterThan(0);
}
if (instruction === "COPY") {
const from = /^--from=(\w+)\s/.exec(argument!)?.[1];
if (from) expect(stages).toContain(from);
}
if (instruction === "CMD" || instruction === "ENTRYPOINT") {
const parsed: unknown = JSON.parse(argument!);
expect(Array.isArray(parsed)).toBe(true);
expect(
(parsed as unknown[]).every((value) => typeof value === "string"),
).toBe(true);
commands.push(parsed as string[]);
}
}
expect(stageCount).toBeGreaterThan(0);
expect(commands).toHaveLength(1);
return { stages, commands };
}
describe("embedded scaffold templates", () => {
test("lists eight selectable, distinct templates in stable order", () => {
const options = listDockerfileTemplates();
expect(options.map(({ id }) => id)).toEqual([...templateIds]);
expect(new Set(templateIds).size).toBe(8);
for (const { id, label, description } of options) {
expect(label.trim()).not.toBe("");
expect(description.trim()).not.toBe("");
expect(description).toMatch(
/src\/|server\.js|dist\/|app:app|binary|port/i,
);
expect(renderDockerfileTemplate(id).dockerfile).toContain("FROM ");
}
expect(listDockerfileTemplates()).toEqual(options);
});
test.each([...templateIds])(
"renders and parses %s with a safe build context",
(id) => {
const { dockerfile, dockerignore } = renderDockerfileTemplate(id);
expect(dockerfile.length).toBeGreaterThan(50);
expect(dockerignore.length).toBeGreaterThan(50);
expect(dockerfile.endsWith("\n")).toBe(true);
expect(dockerignore.endsWith("\n")).toBe(true);
parseDockerfile(dockerfile);
const patterns = dockerignore
.split("\n")
.filter((line) => line && !line.startsWith("#"));
expect(patterns).toContain(".git");
expect(patterns).toContain(".env");
expect(patterns).toContain(".env.*");
expect(patterns).toContain("**/.env");
expect(patterns).toContain("**/.env.*");
expect(dockerfile).not.toMatch(
/COPY\s+.*(?:\.env|credentials|\.pem|\.key)/i,
);
expect(dockerfile).not.toMatch(
/\/home\/dmgnr|\/home\/container|hamsterstore|kuber-server/,
);
},
);
test("static site includes built output while app builders omit it", () => {
expect(renderDockerfileTemplate("static-nginx").dockerignore).not.toMatch(
/^dist\/?$/m,
);
expect(renderDockerfileTemplate("static-nginx").dockerfile).toContain(
"COPY dist/",
);
for (const id of ["bun-compiled", "node-pnpm"] as const) {
expect(renderDockerfileTemplate(id).dockerignore).toMatch(/^dist$/m);
}
});
test("rejects unknown selections", () => {
expect(() =>
renderDockerfileTemplate("missing" as DockerfileTemplateId),
).toThrow("Unknown Dockerfile template: missing");
});
});
+38 -2
View File
@@ -1,6 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import {
getSessionPath,
@@ -69,6 +76,35 @@ describe("API sessions", () => {
expect(await readSession()).toBeUndefined();
});
test("removes malformed runtime sessions without caching them", async () => {
await setupDirectories();
const path = getSessionPath(false);
await mkdir(dirname(path), { recursive: true });
await writeFile(
path,
JSON.stringify({ ...session, expiresAt: "not-a-date" }),
);
expect(await readSession()).toBeUndefined();
await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readSession()).toBeUndefined();
await writeSession(session, false);
expect(await readSession()).toEqual(session);
});
test("removes malformed persistent sessions", async () => {
await setupDirectories();
const path = getSessionPath(true);
await mkdir(dirname(path), { recursive: true });
await writeFile(
path,
JSON.stringify({ ...session, expiresAt: "not-a-date" }),
);
expect(await readSession()).toBeUndefined();
await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" });
});
test("invalidates a memoized missing session after writing", async () => {
await setupDirectories();
expect(await readSession()).toBeUndefined();
+56 -45
View File
@@ -1,51 +1,62 @@
import { expect, test } from "bun:test";
test("a short CLI exits while its version updater remains pending", async () => {
const moduleUrl = new URL("../../lib/version-update.ts", import.meta.url)
.href;
const script = `
const { createVersionObserver } = await import(${JSON.stringify(moduleUrl)});
createVersionObserver({
currentVersion: "2.6.1-rc3",
runner: () => new Promise(() => {}),
})("2.7.0");
`;
const child = Bun.spawn([process.execPath, "-e", script], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const exit = await Promise.race([
child.exited,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("short CLI stayed alive")), 1_000),
),
]);
expect(exit).toBe(0);
expect(await new Response(child.stderr).text()).toBe("");
});
const updaterUrl = new URL("../../lib/version-update.ts", import.meta.url).href;
const entryUrl = new URL("../../index.ts", import.meta.url).href;
const mainUrl = new URL("../../command/main.ts", import.meta.url).href;
const errorUrl = new URL("../../lib/error.ts", import.meta.url).href;
test.each([
["success", "return true", "+ Updated to 2.7.0"],
["failure", "throw new Error('install failed')", "+ New version available: 2.7.0"],
])("reports exactly one %s outcome when it settles", async (_name, outcome, message) => {
const moduleUrl = new URL("../../lib/version-update.ts", import.meta.url).href;
const script = `
const { createVersionObserver } = await import(${JSON.stringify(moduleUrl)});
createVersionObserver({
currentVersion: "2.6.1-rc3",
runner: async () => { ${outcome}; },
})("2.7.0");
await new Promise((resolve) => setTimeout(resolve, 25));
["command error", "return true", "+ Updated to 2.7.0"],
["failure", "throw new Error('offline')", "+ New version available: 2.7.0"],
[
"timeout",
"return await installVersion(version, () => ({ exited: new Promise(() => {}), kill: () => {} }), 1)",
"+ New version available: 2.7.0",
],
])(
"CLI waits for a %s update and preserves its exit status",
async (_name, outcome, message) => {
const script = `
import { mock } from "bun:test";
const { createVersionObserver, installVersion } = await import(${JSON.stringify(updaterUrl)});
const observe = createVersionObserver({ currentVersion: "2.6.1", runner: async (version) => {
if (${JSON.stringify(_name)} !== "timeout") await new Promise(resolve => setTimeout(resolve, 80));
${outcome};
}});
mock.module("citty", () => ({ createMain: () => async () => {
observe("2.7.0");
observe("2.8.0");
if (${JSON.stringify(_name)} === "command error") throw new Error("command failed");
process.exitCode = 7;
}}));
mock.module(${JSON.stringify(mainUrl)}, () => ({ main: {}, initializeCompletion: async () => {} }));
mock.module(${JSON.stringify(errorUrl)}, () => ({ formatUnknownError: String, wrapCommandErrors: () => {} }));
await import(${JSON.stringify(entryUrl)});
`;
const child = Bun.spawn([process.execPath, "-e", script], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
expect(await child.exited).toBe(0);
const stderr = await new Response(child.stderr).text();
expect(stderr.split("\n").filter((line) => line.includes(message))).toHaveLength(1);
});
const started = Date.now();
const child = Bun.spawn([process.execPath, "-e", script, "login"], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const timer = setTimeout(() => child.kill(), 5_000);
try {
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
expect(code).toBe(_name === "command error" ? 1 : 7);
expect(Date.now() - started).toBeGreaterThanOrEqual(
_name === "timeout" ? 900 : 70,
);
expect(stdout).toBe("");
expect(stderr).toBe(
`${_name === "command error" ? "Error: command failed\n" : ""}\x1b[90m${message}\x1b[0m\n`,
);
} finally {
clearTimeout(timer);
}
},
);
+53 -36
View File
@@ -3,6 +3,7 @@ import {
compareVersions,
createVersionObserver,
installVersion,
waitForVersionUpdate,
} from "../../lib/version-update";
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -36,16 +37,15 @@ describe("server version updates", () => {
expect(await installVersion("2.7.0;echo hacked")).toBe(false);
});
test("uses a quiet detached timeout argument vector", async () => {
test("uses a quiet Bun install with explicit inherited environment", async () => {
const calls: string[][] = [];
let unrefs = 0;
const spawn = (
argv: string[],
options: {
stdin: "ignore";
stdout: "ignore";
stderr: "ignore";
detached: true;
env: NodeJS.ProcessEnv;
},
) => {
calls.push(argv);
@@ -53,30 +53,69 @@ describe("server version updates", () => {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
detached: true,
env: process.env,
});
expect(options.env.PATH).toBe(process.env.PATH);
return {
exited: Promise.resolve(0),
unref: () => {
unrefs++;
kill: () => {
throw new Error("unexpected kill");
},
};
};
expect(await installVersion("2.7.0-rc.1+build.2", spawn, 1)).toBe(true);
expect(await installVersion("2.7.0-rc.1+build.2", spawn, 2)).toBe(true);
expect(calls).toEqual([
[
"timeout",
"--signal=TERM",
"--kill-after=2s",
"1s",
"bun",
process.execPath,
"i",
"-g",
"--no-cache",
"@dmgnr/[email protected]+build.2",
],
]);
expect(unrefs).toBe(1);
});
test("kills a stalled installer and treats late success as failure", async () => {
let finish!: (code: number) => void;
const exited = new Promise<number>((resolve) => {
finish = resolve;
});
const signals: (NodeJS.Signals | undefined)[] = [];
const installing = installVersion(
"2.7.0",
() => ({
exited,
kill: (signal) => {
signals.push(signal);
finish(0);
},
}),
1,
);
expect(await installing).toBe(false);
expect(signals).toEqual([undefined]);
});
test("handles spawn errors and bounds an unresponsive child", async () => {
expect(
await installVersion("2.7.0", () => {
throw new Error("spawn failed");
}),
).toBe(false);
const signals: (NodeJS.Signals | undefined)[] = [];
expect(
await installVersion(
"2.7.0",
() => ({
exited: new Promise<number>(() => {}),
kill: (signal) => {
signals.push(signal);
},
}),
1,
),
).toBe(false);
expect(signals).toEqual([undefined, "SIGKILL"]);
});
test("ignores malformed, equal, and older headers without attempting an install", async () => {
@@ -126,7 +165,7 @@ describe("server version updates", () => {
expect(runs).toEqual(["2.6.1"]);
expect(lines).toEqual([]);
finish(true);
await tick();
await waitForVersionUpdate();
expect(lines).toEqual(["\x1b[90m+ Updated to 2.6.1\x1b[0m\n"]);
observe("10.0.0");
await tick();
@@ -155,28 +194,6 @@ describe("server version updates", () => {
}
});
test("a detached pending install does not delay a short CLI or pollute its streams", async () => {
const url = new URL("../../lib/version-update.ts", import.meta.url).href;
const script = `
const { createVersionObserver } = await import(${JSON.stringify(url)});
createVersionObserver({ currentVersion: '2.6.1',
runner: () => new Promise(() => {}) })('2.7.0');
`;
const child = Bun.spawn([process.execPath, "-e", script], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const timeout = setTimeout(() => child.kill(), 1_000);
try {
expect(await child.exited).toBe(0);
expect(await new Response(child.stdout).text()).toBe("");
expect(await new Response(child.stderr).text()).toBe("");
} finally {
clearTimeout(timeout);
}
});
test("a short subprocess emits one outcome to stderr when its runner settles", async () => {
const url = new URL("../../lib/version-update.ts", import.meta.url).href;
for (const result of ["true", "false", "reject"]) {
+206 -2
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
@@ -7,6 +8,7 @@ import {
readlink,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -51,6 +53,105 @@ afterEach(async () => {
});
describe("workspace snapshots", () => {
test("auto uses Git ignore rules even for tracked files, not .dockerignore", async () => {
const root = await repository();
await mkdir(join(root, "nested"));
await writeFile(join(root, ".gitignore"), "*.secret\n.env*\nignored/\n");
await writeFile(join(root, "nested/.gitignore"), "*.log\n!important.log\n");
await writeFile(join(root, ".dockerignore"), "Dockerfile\nvisible.txt\n");
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
await writeFile(join(root, "visible.txt"), "visible");
await writeFile(join(root, "tracked.secret"), "tracked secret");
await writeFile(join(root, ".env.tracked"), "tracked dotenv");
await run("git", [
"-C",
root,
"add",
"-f",
"tracked.secret",
".env.tracked",
]);
await writeFile(join(root, "untracked.secret"), "untracked secret");
await writeFile(join(root, ".env.local"), "untracked dotenv");
await writeFile(join(root, "nested/debug.log"), "ignored");
await run("git", ["-C", root, "add", "-f", "nested/debug.log"]);
await writeFile(join(root, "nested/important.log"), "included");
const regular = await enumerateWorkspace(root);
const auto = await enumerateWorkspace(root, "auto");
expect(regular.manifest.files.map((file) => file.path)).toContain(
"tracked.secret",
);
expect(regular.manifest.files.map((file) => file.path)).toContain(
".env.local",
);
expect(auto.manifest.files.map((file) => file.path)).toEqual([
".dockerignore",
".gitignore",
"Dockerfile",
"nested/.gitignore",
"nested/important.log",
"visible.txt",
]);
expect(auto.digest).not.toBe(regular.digest);
expect(await enumerateWorkspace(root, "auto")).toEqual(auto);
});
test("auto rejects selected dotenv and conventional credential paths", async () => {
for (const [path, tracked] of [
[".env", true],
[".env.local", false],
[".npmrc", false],
["nested/.ssh/id_ed25519", false],
["services/secrets/production.yaml", false],
] as const) {
const root = await repository();
const file = join(root, path);
await mkdir(join(file, ".."), { recursive: true });
await writeFile(file, "credential");
if (tracked) await run("git", ["-C", root, "add", "-f", path]);
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
`refuses to snapshot potential credentials at ${path}`,
);
}
});
test("auto does not reject ignored dotenv or safe sample files", async () => {
const root = await repository();
await writeFile(join(root, ".gitignore"), ".env\nignored/.env.local\n");
await writeFile(join(root, ".env"), "ignored secret");
await mkdir(join(root, "ignored"));
await writeFile(join(root, "ignored/.env.local"), "ignored secret");
await writeFile(join(root, ".env.example"), "TOKEN=replace-me");
await writeFile(join(root, "secretary-notes.txt"), "ordinary source");
const snapshot = await enumerateWorkspace(root, "auto");
expect(snapshot.manifest.files.map((file) => file.path)).toEqual([
".env.example",
".gitignore",
"secretary-notes.txt",
]);
});
test("auto fails closed without a Git repository or executable", async () => {
const root = await temporaryDirectory("kuber-auto-no-git-");
await writeFile(join(root, "app.txt"), "app");
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
/requires Git and a Git repository/,
);
await run("git", ["init", "-q", root]);
const previousPath = process.env.PATH;
try {
process.env.PATH = "";
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
/requires Git and a Git repository/,
);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
test("snapshots Gitless directories deterministically without secrets", async () => {
const root = await temporaryDirectory("kuber-workspace-filesystem-");
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
@@ -69,7 +170,10 @@ describe("workspace snapshots", () => {
test("works without Git and conservatively prunes ignored/generated and credential files", async () => {
const root = await temporaryDirectory("kuber-workspace-no-git-");
await writeFile(join(root, ".gitignore"), "local-only/\n*.generated\nsecrets/\n!secrets/keep.txt\n");
await writeFile(
join(root, ".gitignore"),
"local-only/\n*.generated\nsecrets/\n!secrets/keep.txt\n",
);
await writeFile(join(root, "app.ts"), "source");
await mkdir(join(root, "local-only"));
await writeFile(join(root, "local-only", "hidden"), "secret");
@@ -117,7 +221,8 @@ describe("workspace snapshots", () => {
const second = await enumerateWorkspace(root);
expect(first).toEqual(second);
expect(first.manifest.files.map((file) => file.path)).toEqual([
".gitignore", "source.ts",
".gitignore",
"source.ts",
]);
} finally {
if (previousPath === undefined) delete process.env.PATH;
@@ -212,6 +317,105 @@ describe("workspace snapshots", () => {
await expect(enumerateWorkspace(specialRoot)).rejects.toThrow(
"Special files",
);
const autoRoot = await repository();
await run("ln", ["-s", "../outside", join(autoRoot, "escape")]);
await expect(enumerateWorkspace(autoRoot, "auto")).rejects.toThrow(
"Symlink escapes workspace",
);
});
test("does not read Git-tracked files through replaced parent symlinks in either mode", async () => {
const root = await repository();
const outside = await temporaryDirectory("kuber-workspace-outside-");
await mkdir(join(root, "sub"));
await writeFile(join(root, "sub/visible.txt"), "original");
await run("git", ["-C", root, "add", "sub/visible.txt"]);
await rm(join(root, "sub"), { recursive: true });
await writeFile(join(outside, "visible.txt"), "outside content");
await symlink(outside, join(root, "sub"));
// Git itself may reject a tracked path beneath a symlink before the
// parent guard runs; both outcomes must fail closed.
for (const mode of ["default", "auto"] as const)
await expect(enumerateWorkspace(root, mode)).rejects.toThrow();
});
test("rejects snapshot symlink chains before normalizing target components", async () => {
const root = await repository();
await symlink(".", join(root, "sub"));
await symlink("sub/..", join(root, "chain"));
for (const mode of ["default", "auto"] as const) {
await expect(enumerateWorkspace(root, mode)).rejects.toThrow(
"Symlink traverses snapshot symlink: chain -> sub/..",
);
}
const blobs = new Map(
["sub/..", "."].map((target) => {
const data = Buffer.from(target);
const digest =
`sha256:${createHash("sha256").update(data).digest("hex")}` as const;
return [digest, data] as const;
}),
);
const manifest: WorkspaceManifest = {
version: BUILD_PROTOCOL_VERSION,
files: [
{
path: "chain",
type: "symlink",
digest: [...blobs.keys()][0]!,
size: Buffer.byteLength("sub/.."),
mode: 0o777,
},
{
path: "sub",
type: "symlink",
digest: [...blobs.keys()][1]!,
size: Buffer.byteLength("."),
mode: 0o777,
},
],
};
const destination = join(await temporaryDirectory("kuber-chain-"), "tree");
await expect(
materializeWorkspace(destination, manifest, async (digest) =>
blobs.get(digest)!,
),
).rejects.toThrow("Symlink traverses snapshot symlink: chain -> sub/..");
await expect(stat(destination)).rejects.toMatchObject({ code: "ENOENT" });
});
test("retains safe symlinks in both snapshot modes and materialization", async () => {
const root = await repository();
await writeFile(join(root, "app.txt"), "safe");
await symlink(".", join(root, "sub"));
await symlink("./app.txt", join(root, "alias"));
for (const mode of ["default", "auto"] as const) {
const snapshot = await enumerateWorkspace(root, mode);
expect(snapshot.manifest.files.map((file) => file.path)).toEqual([
"alias",
"app.txt",
"sub",
]);
const blobs = new Map(
snapshot.blobs.map((blob) => [blob.digest, blob.data]),
);
const destination = join(
await temporaryDirectory("kuber-safe-links-"),
"tree",
);
await materializeWorkspace(
destination,
snapshot.manifest,
async (digest) => blobs.get(digest)!,
);
expect(await readlink(join(destination, "sub"))).toBe(".");
expect(await readFile(join(destination, "alias"), "utf8")).toBe("safe");
}
});
test("allows ignored special files and prunes ignored directories", async () => {
+31
View File
@@ -47,6 +47,37 @@ describe("Compose file discovery", () => {
});
describe("Compose parsing", () => {
test("accepts the generated marker and preserves x- extensions", async () => {
const directory = await temporaryDirectory();
const path = join(directory, "compose.yml");
await writeFile(
path,
"managedBy: kuber # See https://npmx.dev/@dmgnr/kuber for documentation.\nname: compose-project\nx-note: retained\nservices:\n app:\n image: nginx:latest\n",
);
expect(await readCompose(path)).toMatchObject({
managedBy: "kuber",
name: "compose-project",
"x-note": "retained",
services: { app: { image: "nginx:latest" } },
});
});
test("rejects incorrect managedBy values", async () => {
const directory = await temporaryDirectory();
const path = join(directory, "compose.yml");
for (const marker of ["other", "42"]) {
await writeFile(path, `managedBy: ${marker}\nservices: {}\n`);
await expect(readCompose(path)).rejects.toThrow();
}
});
test("rejects other unknown top-level properties", async () => {
const directory = await temporaryDirectory();
const path = join(directory, "compose.yml");
await writeFile(path, "managedBy: kuber\nunknown: value\nservices: {}\n");
await expect(readCompose(path)).rejects.toThrow();
});
test("parses and validates a minimal Compose project", async () => {
const directory = await temporaryDirectory();
const path = join(directory, "compose.yml");