231 lines
7.7 KiB
TypeScript
231 lines
7.7 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
BUILD_RECORD_API_VERSION,
|
|
BuildStoreConflictError,
|
|
MemoryBuildStore,
|
|
type BuildRecord,
|
|
} from "../../server/build-store";
|
|
import {
|
|
BUILD_PROTOCOL_VERSION,
|
|
type BuildRequest,
|
|
type Sha256Digest,
|
|
} from "../../shared/build-protocol";
|
|
|
|
const workspace = `sha256:${"a".repeat(64)}` as Sha256Digest;
|
|
|
|
function build(
|
|
id: string,
|
|
imageKey = "project\0service\0image",
|
|
createdAt = "2026-09-02T00:00:00.000Z",
|
|
): BuildRecord {
|
|
const request: BuildRequest = {
|
|
version: BUILD_PROTOCOL_VERSION,
|
|
id,
|
|
project: "project",
|
|
service: "service",
|
|
spec: {
|
|
architecture: "amd64",
|
|
image: "registry.test/app:latest",
|
|
context: ".",
|
|
buildArgs: [],
|
|
workspace,
|
|
},
|
|
};
|
|
const status = {
|
|
version: BUILD_PROTOCOL_VERSION,
|
|
id,
|
|
state: "queued" as const,
|
|
createdAt,
|
|
};
|
|
return {
|
|
apiVersion: BUILD_RECORD_API_VERSION,
|
|
kind: "BuildRecord",
|
|
metadata: {
|
|
name: id,
|
|
resourceVersion: "1",
|
|
creationTimestamp: status.createdAt,
|
|
labels: {},
|
|
},
|
|
spec: { request, imageKey, jobName: `job-${id}`, workspaceSubPath: id },
|
|
status: {
|
|
...status,
|
|
logBytes: 0,
|
|
logOffset: 0,
|
|
nextSequence: 1,
|
|
events: [{ type: "status", status }],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("build store", () => {
|
|
test("atomically enforces idempotency and one active image build", async () => {
|
|
const store = new MemoryBuildStore();
|
|
expect((await store.createBuild(build("one"))).created).toBe(true);
|
|
expect((await store.createBuild(build("one"))).created).toBe(false);
|
|
await expect(
|
|
store.createBuild(build("one", "different-key")),
|
|
).rejects.toBeInstanceOf(BuildStoreConflictError);
|
|
const replacement = await store.createBuild(build("two"));
|
|
expect(replacement.created).toBe(true);
|
|
expect(replacement.superseded?.[0]?.metadata.name).toBe("one");
|
|
|
|
const first = (await store.getBuild("one"))!;
|
|
const digest = `sha256:${"b".repeat(64)}` as Sha256Digest;
|
|
first.metadata.resourceVersion = "3";
|
|
Object.assign(first.status, {
|
|
state: "succeeded",
|
|
digest,
|
|
finishedAt: first.status.createdAt,
|
|
});
|
|
await store.replaceBuild(first, "2");
|
|
expect(
|
|
(await store.createBuild(build("three", "2026-09-02T00:00:02.000Z")))
|
|
.created,
|
|
).toBe(true);
|
|
|
|
const changed = (await store.getBuild("one"))!;
|
|
changed.metadata.resourceVersion = "3";
|
|
changed.status.digest = `sha256:${"c".repeat(64)}`;
|
|
await expect(store.replaceBuild(changed, "3")).rejects.toThrow("immutable");
|
|
});
|
|
|
|
test("optimistically updates resumable upload records without leaking mutable data", async () => {
|
|
const store = new MemoryBuildStore();
|
|
const digest = `sha256:${"d".repeat(64)}` as Sha256Digest;
|
|
const upload = {
|
|
apiVersion: BUILD_RECORD_API_VERSION,
|
|
kind: "BuildUpload" as const,
|
|
metadata: {
|
|
name: "upload",
|
|
resourceVersion: "1",
|
|
creationTimestamp: "now",
|
|
},
|
|
spec: { digest, size: 3 },
|
|
status: { offset: 0, data: new Uint8Array() },
|
|
};
|
|
await store.createUpload(upload);
|
|
upload.status.data = Buffer.from("mutated");
|
|
expect((await store.getUpload(digest))?.status.data.byteLength).toBe(0);
|
|
const next = (await store.getUpload(digest))!;
|
|
next.metadata.resourceVersion = "2";
|
|
next.status = { offset: 3, data: Buffer.from("abc") };
|
|
await store.replaceUpload(next, "1");
|
|
await expect(store.replaceUpload(next, "1")).rejects.toBeInstanceOf(
|
|
BuildStoreConflictError,
|
|
);
|
|
});
|
|
|
|
test("supersedes only the current image and leaves terminal history", async () => {
|
|
const store = new MemoryBuildStore();
|
|
await store.createBuild(
|
|
build("old", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
|
);
|
|
const result = await store.createBuild(
|
|
build("new", "project\0service\0image", "2026-09-02T00:00:01.000Z"),
|
|
);
|
|
expect(result.superseded?.[0]?.status.error).toBe(
|
|
"Superseded by newer build",
|
|
);
|
|
expect((await store.getBuild("old"))?.status.state).toBe("failed");
|
|
expect((await store.createBuild(build("new"))).created).toBe(false);
|
|
expect(
|
|
(await store.createBuild(build("other", "project\0service\0other")))
|
|
.created,
|
|
).toBe(true);
|
|
});
|
|
|
|
test("concurrent same-image submissions have one active record", async () => {
|
|
const store = new MemoryBuildStore();
|
|
const results = await Promise.all([
|
|
store.createBuild(build("first")),
|
|
store.createBuild(build("second")),
|
|
store.createBuild(build("third")),
|
|
]);
|
|
expect(results.filter((result) => result.created)).toHaveLength(3);
|
|
const active = (await store.listBuilds()).filter(
|
|
(record) =>
|
|
record.status.state !== "succeeded" && record.status.state !== "failed",
|
|
);
|
|
expect(active).toHaveLength(1);
|
|
expect(active[0]?.metadata.name).toBe("third");
|
|
});
|
|
|
|
test("recovers an active record whose in-memory lock was lost", async () => {
|
|
const store = new MemoryBuildStore();
|
|
await store.createBuild(build("orphan"));
|
|
(store as unknown as { active: Map<string, string> }).active.clear();
|
|
|
|
const replacement = await store.createBuild(build("replacement"));
|
|
expect(replacement.superseded?.[0]?.metadata.name).toBe("orphan");
|
|
expect((await store.getBuild("orphan"))?.status.state).toBe("failed");
|
|
expect(
|
|
await store.ownsBuild("project\0service\0image", "replacement"),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("a delayed older retry cannot reclaim a newer same-image lock", async () => {
|
|
const store = new MemoryBuildStore();
|
|
await store.createBuild(
|
|
build("a", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
|
);
|
|
(store as unknown as { active: Map<string, string> }).active.clear();
|
|
await store.createBuild(
|
|
build("b", "project\0service\0image", "2026-09-02T00:00:01.000Z"),
|
|
);
|
|
|
|
const delayed = await store.createBuild(
|
|
build("a", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
|
);
|
|
expect(delayed.created).toBe(false);
|
|
expect(delayed.record.status.error).toBe("Superseded by newer build");
|
|
expect(await store.ownsBuild("project\0service\0image", "b")).toBe(true);
|
|
});
|
|
|
|
test("returns cleanup metadata when a delayed candidate loses to a newer record", async () => {
|
|
const store = new MemoryBuildStore();
|
|
await store.createBuild(
|
|
build("newer", "project\0service\0image", "2026-09-02T00:00:01.000Z"),
|
|
);
|
|
|
|
const result = await store.createBuild(
|
|
build("delayed", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
|
);
|
|
|
|
expect(result.created).toBe(false);
|
|
expect(result.superseded?.map((record) => record.metadata.name)).toEqual([
|
|
"delayed",
|
|
]);
|
|
expect(
|
|
(await store.createBuild(build("delayed"))).superseded,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("orders and reconciles legacy records with invalid metadata timestamps", async () => {
|
|
const store = new MemoryBuildStore();
|
|
const legacy = build(
|
|
"legacy",
|
|
"project\0service\0image",
|
|
"2026-09-02T00:00:00.000Z",
|
|
);
|
|
legacy.metadata.creationTimestamp = undefined as unknown as string;
|
|
await store.createBuild(legacy);
|
|
|
|
const replacement = await store.createBuild(
|
|
build(
|
|
"replacement",
|
|
"project\0service\0image",
|
|
"2026-09-02T00:00:01.000Z",
|
|
),
|
|
);
|
|
expect(replacement.created).toBe(true);
|
|
expect((await store.getBuild("legacy"))?.status.state).toBe("failed");
|
|
|
|
const malformed = build("a", "project\0service\0other");
|
|
malformed.metadata.creationTimestamp = 0 as unknown as string;
|
|
await store.createBuild(malformed);
|
|
expect(
|
|
(await store.listBuilds()).map((record) => record.metadata.name),
|
|
).toEqual(["a", "legacy", "replacement"]);
|
|
});
|
|
});
|