Files
kuber/server/build-store.ts
T
2026-09-03 11:28:30 +07:00

189 lines
5.3 KiB
TypeScript

import type {
BuildEvent,
BuildRequest,
BuildStatus,
Sha256Digest,
} from "../shared/build-protocol";
export const BUILD_RECORD_API_VERSION = "kuber.astrxl.dev/v1" as const;
export interface BuildRecord {
apiVersion: typeof BUILD_RECORD_API_VERSION;
kind: "BuildRecord";
metadata: {
name: string;
resourceVersion: string;
creationTimestamp: string;
labels: Record<string, string>;
};
spec: {
request: BuildRequest;
imageKey: string;
jobName: string;
workspaceSubPath: string;
};
status: BuildStatus & {
cancelled?: boolean;
jobCreated?: boolean;
logBytes: number;
logOffset: number;
nextSequence: number;
events: BuildEvent[];
};
}
export interface UploadRecord {
apiVersion: typeof BUILD_RECORD_API_VERSION;
kind: "BuildUpload";
metadata: {
name: string;
resourceVersion: string;
creationTimestamp: string;
};
spec: { digest: Sha256Digest; size: number };
status: { offset: number; data: Uint8Array };
}
export type CreateBuildResult = { record: BuildRecord; created: boolean };
/** Implementations must make createBuild and replace operations atomic. */
export interface BuildStore {
createBuild(record: BuildRecord): Promise<CreateBuildResult>;
getBuild(id: string): Promise<BuildRecord | undefined>;
listBuilds(): Promise<BuildRecord[]>;
replaceBuild(
record: BuildRecord,
expectedResourceVersion: string,
): Promise<void>;
getUpload(digest: Sha256Digest): Promise<UploadRecord | undefined>;
createUpload(record: UploadRecord): Promise<UploadRecord>;
replaceUpload(
record: UploadRecord,
expectedResourceVersion: string,
): Promise<void>;
deleteUpload(digest: Sha256Digest): Promise<void>;
}
export class BuildStoreConflictError extends Error {
readonly code = "BUILD_STORE_CONFLICT";
}
function clone<T>(value: T): T {
return structuredClone(value);
}
function terminal(record: BuildRecord): boolean {
return (
record.status.state === "succeeded" || record.status.state === "failed"
);
}
function sameSpec(left: BuildRecord, right: BuildRecord): boolean {
return JSON.stringify(left.spec) === JSON.stringify(right.spec);
}
export class MemoryBuildStore implements BuildStore {
private readonly builds = new Map<string, BuildRecord>();
private readonly uploads = new Map<Sha256Digest, UploadRecord>();
private readonly active = new Map<string, string>();
async createBuild(record: BuildRecord): Promise<CreateBuildResult> {
const existing = this.builds.get(record.metadata.name);
if (existing) {
if (!sameSpec(existing, record)) {
throw new BuildStoreConflictError(
"Build ID was already used for a different request",
);
}
return { record: clone(existing), created: false };
}
const activeId = this.active.get(record.spec.imageKey);
if (activeId) {
const active = this.builds.get(activeId);
if (active && !terminal(active)) {
throw new BuildStoreConflictError(
`Build '${activeId}' is already active for ${record.spec.imageKey}`,
);
}
}
this.builds.set(record.metadata.name, clone(record));
this.active.set(record.spec.imageKey, record.metadata.name);
return { record: clone(record), created: true };
}
async getBuild(id: string) {
const value = this.builds.get(id);
return value && clone(value);
}
async listBuilds() {
return [...this.builds.values()]
.sort((a, b) =>
a.metadata.creationTimestamp.localeCompare(
b.metadata.creationTimestamp,
),
)
.map(clone);
}
async replaceBuild(record: BuildRecord, expectedResourceVersion: string) {
const current = this.builds.get(record.metadata.name);
if (
!current ||
current.metadata.resourceVersion !== expectedResourceVersion
) {
throw new BuildStoreConflictError(
"Build record was concurrently modified",
);
}
if (!sameSpec(current, record)) {
throw new BuildStoreConflictError("Build specification is immutable");
}
if (
current.status.state === "succeeded" &&
(record.status.state !== "succeeded" ||
record.status.digest !== current.status.digest)
) {
throw new BuildStoreConflictError(
"A successful image digest is immutable",
);
}
this.builds.set(record.metadata.name, clone(record));
if (
terminal(record) &&
this.active.get(record.spec.imageKey) === record.metadata.name
) {
this.active.delete(record.spec.imageKey);
}
}
async getUpload(digest: Sha256Digest) {
const value = this.uploads.get(digest);
return value && clone(value);
}
async createUpload(record: UploadRecord) {
const current = this.uploads.get(record.spec.digest);
if (current) return clone(current);
this.uploads.set(record.spec.digest, clone(record));
return clone(record);
}
async replaceUpload(record: UploadRecord, expectedResourceVersion: string) {
const current = this.uploads.get(record.spec.digest);
if (
!current ||
current.metadata.resourceVersion !== expectedResourceVersion
) {
throw new BuildStoreConflictError(
"Upload record was concurrently modified",
);
}
this.uploads.set(record.spec.digest, clone(record));
}
async deleteUpload(digest: Sha256Digest) {
this.uploads.delete(digest);
}
}