189 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
}
|