feat: v2
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user