feat: v2
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BuildConflictError,
|
||||
BuildController,
|
||||
BuildValidationError,
|
||||
type BuildJobObservation,
|
||||
type BuildKubernetesOperations,
|
||||
} from "../../server/build-controller";
|
||||
import { MemoryBuildStore } from "../../server/build-store";
|
||||
import { FilesystemCas } from "../../server/cas";
|
||||
import type { KubernetesJob } from "../../server/build-job";
|
||||
import {
|
||||
BUILD_PROTOCOL_VERSION,
|
||||
type BuildRequest,
|
||||
type Sha256Digest,
|
||||
type WorkspaceManifest,
|
||||
} from "../../shared/build-protocol";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
function digest(value: Uint8Array | string): Sha256Digest {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
class FakeKubernetes implements BuildKubernetesOperations {
|
||||
jobs: KubernetesJob[] = [];
|
||||
deleted: string[] = [];
|
||||
observation: BuildJobObservation | undefined = { phase: "queued" };
|
||||
logs = "";
|
||||
async createJob(job: KubernetesJob) {
|
||||
this.jobs.push(job);
|
||||
}
|
||||
async getJob() {
|
||||
return this.observation;
|
||||
}
|
||||
async getJobLogs() {
|
||||
return this.logs;
|
||||
}
|
||||
async deleteJob(_namespace: string, name: string) {
|
||||
this.deleted.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(maxLogBytes = 1024) {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-controller-"));
|
||||
roots.push(root);
|
||||
const cas = new FilesystemCas(join(root, "cas"));
|
||||
const store = new MemoryBuildStore();
|
||||
const kubernetes = new FakeKubernetes();
|
||||
const source = Buffer.from("FROM scratch\n");
|
||||
const sourceDigest = await cas.put(source);
|
||||
const manifest: WorkspaceManifest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
files: [
|
||||
{
|
||||
path: "Dockerfile",
|
||||
type: "file",
|
||||
digest: sourceDigest,
|
||||
size: source.byteLength,
|
||||
mode: 0o644,
|
||||
},
|
||||
],
|
||||
};
|
||||
const workspace = await cas.put(Buffer.from(JSON.stringify(manifest)));
|
||||
let now = 0;
|
||||
const controller = new BuildController({
|
||||
cas,
|
||||
store,
|
||||
kubernetes,
|
||||
namespace: "builds",
|
||||
workspaceRoot: join(root, "workspaces"),
|
||||
workspaceClaimName: "workspaces",
|
||||
cacheImage: "registry.test/cache/app",
|
||||
maxLogBytes,
|
||||
now: () => new Date(Date.UTC(2026, 8, 2, 0, 0, now++)),
|
||||
resolveDigest: async () => `sha256:${"f".repeat(64)}`,
|
||||
});
|
||||
const request: BuildRequest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
id: "request-one",
|
||||
project: "demo",
|
||||
service: "web",
|
||||
spec: {
|
||||
architecture: "amd64",
|
||||
image: "registry.test/demo/web:latest",
|
||||
context: ".",
|
||||
buildArgs: [],
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
return {
|
||||
root,
|
||||
cas,
|
||||
store,
|
||||
kubernetes,
|
||||
controller,
|
||||
request,
|
||||
workspace,
|
||||
sourceDigest,
|
||||
};
|
||||
}
|
||||
|
||||
async function internalFixture(maxLogBytes = 1024) {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-controller-internal-"));
|
||||
roots.push(root);
|
||||
const cas = new FilesystemCas(join(root, "cas"));
|
||||
const store = new MemoryBuildStore();
|
||||
const kubernetes = new FakeKubernetes();
|
||||
const source = Buffer.from("FROM scratch\n");
|
||||
const sourceDigest = await cas.put(source);
|
||||
const manifest: WorkspaceManifest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
files: [
|
||||
{
|
||||
path: "Dockerfile",
|
||||
type: "file",
|
||||
digest: sourceDigest,
|
||||
size: source.byteLength,
|
||||
mode: 0o644,
|
||||
},
|
||||
],
|
||||
};
|
||||
const workspace = await cas.put(Buffer.from(JSON.stringify(manifest)));
|
||||
let now = 0;
|
||||
const controller = new BuildController({
|
||||
cas,
|
||||
store,
|
||||
kubernetes,
|
||||
namespace: "builds",
|
||||
workspaceRoot: join(root, "workspaces"),
|
||||
workspaceClaimName: "workspaces",
|
||||
cacheImage: (request) =>
|
||||
`cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/cache-${request.project}-${request.service}`,
|
||||
imageName: (request) =>
|
||||
`registry.neko-piranha.ts.net/kuber/${request.project}-${request.service}:latest`,
|
||||
pushImage: (request) =>
|
||||
`cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/${request.project}-${request.service}:latest`,
|
||||
pushRegistryInsecure: true,
|
||||
maxLogBytes,
|
||||
now: () => new Date(Date.UTC(2026, 8, 2, 0, 0, now++)),
|
||||
resolveDigest: async () => `sha256:${"f".repeat(64)}`,
|
||||
});
|
||||
const request: BuildRequest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
id: "request-internal",
|
||||
project: "demo",
|
||||
service: "web",
|
||||
spec: {
|
||||
architecture: "amd64",
|
||||
image: "registry.neko-piranha.ts.net/kuber/demo-web:latest",
|
||||
context: ".",
|
||||
buildArgs: [],
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
return {
|
||||
root,
|
||||
cas,
|
||||
store,
|
||||
kubernetes,
|
||||
controller,
|
||||
request,
|
||||
workspace,
|
||||
sourceDigest,
|
||||
};
|
||||
}
|
||||
|
||||
describe("build controller", () => {
|
||||
test("negotiates snapshots and resumes verified blob uploads", async () => {
|
||||
const { controller, cas } = await fixture();
|
||||
const content = Buffer.from("resumable");
|
||||
const expected = digest(content);
|
||||
expect(
|
||||
await controller.beginBlobUpload(expected, content.byteLength),
|
||||
).toMatchObject({ offset: 0, complete: false });
|
||||
expect(
|
||||
await controller.uploadBlobChunk(expected, 0, content.subarray(0, 3)),
|
||||
).toMatchObject({ offset: 3 });
|
||||
expect(
|
||||
await controller.beginBlobUpload(expected, content.byteLength),
|
||||
).toMatchObject({ offset: 3 });
|
||||
await expect(
|
||||
controller.uploadBlobChunk(expected, 0, content),
|
||||
).rejects.toBeInstanceOf(BuildConflictError);
|
||||
await controller.uploadBlobChunk(expected, 3, content.subarray(3));
|
||||
expect(await controller.completeBlobUpload(expected)).toMatchObject({
|
||||
complete: true,
|
||||
offset: content.byteLength,
|
||||
});
|
||||
expect(
|
||||
await controller.uploadBlobChunk(expected, 0, Buffer.from("retry")),
|
||||
).toMatchObject({
|
||||
complete: true,
|
||||
size: content.byteLength,
|
||||
offset: content.byteLength,
|
||||
});
|
||||
expect(Buffer.from(await cas.get(expected))).toEqual(content);
|
||||
|
||||
const bad = `sha256:${"0".repeat(64)}` as Sha256Digest;
|
||||
await controller.beginBlobUpload(bad, 1);
|
||||
await controller.uploadBlobChunk(bad, 0, Buffer.from("x"));
|
||||
await expect(controller.completeBlobUpload(bad)).rejects.toBeInstanceOf(
|
||||
BuildValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("reports the manifest and source blobs missing during negotiation", async () => {
|
||||
const { controller, cas } = await fixture();
|
||||
const absent = `sha256:${"1".repeat(64)}` as Sha256Digest;
|
||||
expect(await controller.negotiateSnapshot(absent)).toEqual({
|
||||
workspace: absent,
|
||||
missing: [absent],
|
||||
ready: false,
|
||||
});
|
||||
const manifest = Buffer.from(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
files: [
|
||||
{ path: "x", type: "file", digest: absent, size: 1, mode: 420 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const workspace = await cas.put(manifest);
|
||||
expect(await controller.negotiateSnapshot(workspace)).toEqual({
|
||||
workspace,
|
||||
missing: [absent],
|
||||
ready: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("submits once, blocks competing image builds, captures bounded logs, and resolves immutable results", async () => {
|
||||
const { controller, kubernetes, request } = await fixture(8);
|
||||
expect(await controller.submitBuild(request)).toMatchObject({
|
||||
state: "queued",
|
||||
});
|
||||
expect(
|
||||
await controller.submitBuild(structuredClone(request)),
|
||||
).toMatchObject({ state: "queued" });
|
||||
expect(kubernetes.jobs).toHaveLength(1);
|
||||
const jobSpec = kubernetes.jobs[0]!.spec as any;
|
||||
expect(
|
||||
jobSpec.template.spec.containers[0].volumeMounts,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "workspace",
|
||||
mountPath: "/workspace",
|
||||
subPath: `workspaces/${kubernetes.jobs[0]!.metadata.name}`,
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
controller.submitBuild({ ...request, id: "request-two" }),
|
||||
).rejects.toBeInstanceOf(BuildConflictError);
|
||||
await expect(
|
||||
controller.submitBuild({ ...request, project: "changed" }),
|
||||
).rejects.toBeInstanceOf(BuildConflictError);
|
||||
|
||||
kubernetes.logs = "old-line\nnew-line\n";
|
||||
kubernetes.observation = {
|
||||
phase: "running",
|
||||
startedAt: "2026-09-02T00:00:03.000Z",
|
||||
};
|
||||
expect(await controller.reconcileBuild(request.id)).toMatchObject({
|
||||
state: "running",
|
||||
});
|
||||
const logs = (await controller.getBuildEvents(request.id)).filter(
|
||||
(event) => event.type === "log",
|
||||
);
|
||||
expect(
|
||||
logs.reduce(
|
||||
(bytes, event) => bytes + Buffer.byteLength(event.message),
|
||||
0,
|
||||
),
|
||||
).toBeLessThanOrEqual(8);
|
||||
|
||||
kubernetes.logs += "done\n";
|
||||
kubernetes.observation = {
|
||||
phase: "succeeded",
|
||||
finishedAt: "2026-09-02T00:00:04.000Z",
|
||||
};
|
||||
const status = await controller.reconcileBuild(request.id);
|
||||
expect(status).toMatchObject({
|
||||
state: "succeeded",
|
||||
digest: `sha256:${"f".repeat(64)}`,
|
||||
});
|
||||
expect(await controller.getBuildResult(request.id)).toEqual({
|
||||
image: "registry.test/demo/web",
|
||||
digest: `sha256:${"f".repeat(64)}`,
|
||||
reference: `registry.test/demo/web@sha256:${"f".repeat(64)}`,
|
||||
});
|
||||
expect(await controller.reconcileBuild(request.id)).toEqual(status);
|
||||
});
|
||||
|
||||
test("cancels idempotently and cleans up only terminal build resources", async () => {
|
||||
const { controller, kubernetes, request, root } = await fixture();
|
||||
await controller.submitBuild(request);
|
||||
await expect(controller.cleanupBuild(request.id)).rejects.toBeInstanceOf(
|
||||
BuildConflictError,
|
||||
);
|
||||
expect(await controller.cancelBuild(request.id)).toMatchObject({
|
||||
state: "failed",
|
||||
error: "Build cancelled",
|
||||
});
|
||||
const deletes = kubernetes.deleted.length;
|
||||
expect(await controller.cancelBuild(request.id)).toMatchObject({
|
||||
error: "Build cancelled",
|
||||
});
|
||||
expect(kubernetes.deleted).toHaveLength(deletes);
|
||||
await controller.cleanupBuild(request.id);
|
||||
expect(kubernetes.deleted.length).toBe(deletes + 1);
|
||||
await expect(
|
||||
lstat(join(root, "workspaces", kubernetes.jobs[0]!.metadata.name)),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("pushes to internal registry, records canonical result, and uses insecure flags", async () => {
|
||||
const { controller, kubernetes, request } = await internalFixture();
|
||||
await controller.submitBuild(request);
|
||||
expect(kubernetes.jobs).toHaveLength(1);
|
||||
const container = (kubernetes.jobs[0]!.spec as any).template.spec
|
||||
.containers[0];
|
||||
expect(container.args).toContain(
|
||||
"--output=type=image,name=cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/demo-web:latest,push=true,registry.insecure=true",
|
||||
);
|
||||
expect(container.args).toContain(
|
||||
"--import-cache=type=registry,ref=cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/cache-demo-web,registry.insecure=true",
|
||||
);
|
||||
expect(container.args).toContain(
|
||||
"--export-cache=type=registry,ref=cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/cache-demo-web,mode=max,registry.insecure=true",
|
||||
);
|
||||
|
||||
kubernetes.observation = {
|
||||
phase: "succeeded",
|
||||
finishedAt: "2026-09-02T00:00:04.000Z",
|
||||
};
|
||||
const status = await controller.reconcileBuild(request.id);
|
||||
expect(status).toMatchObject({ state: "succeeded" });
|
||||
const result = await controller.getBuildResult(request.id);
|
||||
expect(result.image).toBe("registry.neko-piranha.ts.net/kuber/demo-web");
|
||||
expect(result.reference).toBe(
|
||||
`registry.neko-piranha.ts.net/kuber/demo-web@sha256:${"f".repeat(64)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not add insecure flags when pushRegistryInsecure is false", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-controller-secure-"));
|
||||
roots.push(root);
|
||||
const cas = new FilesystemCas(join(root, "cas"));
|
||||
const store = new MemoryBuildStore();
|
||||
const kubernetes = new FakeKubernetes();
|
||||
const source = Buffer.from("FROM scratch\n");
|
||||
const sourceDigest = await cas.put(source);
|
||||
const manifest: WorkspaceManifest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
files: [
|
||||
{
|
||||
path: "Dockerfile",
|
||||
type: "file",
|
||||
digest: sourceDigest,
|
||||
size: source.byteLength,
|
||||
mode: 0o644,
|
||||
},
|
||||
],
|
||||
};
|
||||
const workspace = await cas.put(Buffer.from(JSON.stringify(manifest)));
|
||||
let now = 0;
|
||||
const controller = new BuildController({
|
||||
cas,
|
||||
store,
|
||||
kubernetes,
|
||||
namespace: "builds",
|
||||
workspaceRoot: join(root, "workspaces"),
|
||||
workspaceClaimName: "workspaces",
|
||||
cacheImage: "registry.test/cache/app",
|
||||
imageName: (request) =>
|
||||
`registry.test/${request.project}-${request.service}:latest`,
|
||||
pushImage: (request) =>
|
||||
`internal.registry:5000/${request.project}-${request.service}:latest`,
|
||||
maxLogBytes: 1024,
|
||||
now: () => new Date(Date.UTC(2026, 8, 2, 0, 0, now++)),
|
||||
resolveDigest: async () => `sha256:${"f".repeat(64)}`,
|
||||
});
|
||||
const request: BuildRequest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
id: "request-secure",
|
||||
project: "demo",
|
||||
service: "web",
|
||||
spec: {
|
||||
architecture: "amd64",
|
||||
image: "registry.test/demo-web:latest",
|
||||
context: ".",
|
||||
buildArgs: [],
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
await controller.submitBuild(request);
|
||||
const container = (kubernetes.jobs[0]!.spec as any).template.spec
|
||||
.containers[0];
|
||||
for (const arg of container.args) {
|
||||
expect(arg).not.toContain("registry.insecure");
|
||||
}
|
||||
});
|
||||
|
||||
test("pushImage from request cannot redirect to a different image in results", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-controller-redirect-"));
|
||||
roots.push(root);
|
||||
const cas = new FilesystemCas(join(root, "cas"));
|
||||
const store = new MemoryBuildStore();
|
||||
const kubernetes = new FakeKubernetes();
|
||||
const source = Buffer.from("FROM scratch\n");
|
||||
const sourceDigest = await cas.put(source);
|
||||
const manifest: WorkspaceManifest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
files: [
|
||||
{
|
||||
path: "Dockerfile",
|
||||
type: "file",
|
||||
digest: sourceDigest,
|
||||
size: source.byteLength,
|
||||
mode: 0o644,
|
||||
},
|
||||
],
|
||||
};
|
||||
const workspace = await cas.put(Buffer.from(JSON.stringify(manifest)));
|
||||
let now = 0;
|
||||
const controller = new BuildController({
|
||||
cas,
|
||||
store,
|
||||
kubernetes,
|
||||
namespace: "builds",
|
||||
workspaceRoot: join(root, "workspaces"),
|
||||
workspaceClaimName: "workspaces",
|
||||
cacheImage: "canonical.test/cache/app",
|
||||
imageName: (request) =>
|
||||
`canonical.test/${request.project}-${request.service}:latest`,
|
||||
pushImage: (request) =>
|
||||
`internal.test/${request.project}-${request.service}:latest`,
|
||||
maxLogBytes: 1024,
|
||||
now: () => new Date(Date.UTC(2026, 8, 2, 0, 0, now++)),
|
||||
resolveDigest: async () => `sha256:${"f".repeat(64)}`,
|
||||
});
|
||||
const request: BuildRequest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
id: "request-redirect",
|
||||
project: "demo",
|
||||
service: "web",
|
||||
spec: {
|
||||
architecture: "amd64",
|
||||
image: "canonical.test/demo-web:latest",
|
||||
context: ".",
|
||||
buildArgs: [],
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
await controller.submitBuild(request);
|
||||
const container = (kubernetes.jobs[0]!.spec as any).template.spec
|
||||
.containers[0];
|
||||
expect(
|
||||
container.args.some((arg: string) =>
|
||||
arg.includes("name=internal.test/demo-web:latest"),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
kubernetes.observation = {
|
||||
phase: "succeeded",
|
||||
finishedAt: "2026-09-02T00:00:04.000Z",
|
||||
};
|
||||
await controller.reconcileBuild(request.id);
|
||||
const result = await controller.getBuildResult(request.id);
|
||||
expect(result.image).toBe("canonical.test/demo-web");
|
||||
expect(result.reference).toBe(
|
||||
`canonical.test/demo-web@sha256:${"f".repeat(64)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user