378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
export const KUBER_API_VERSION = "kuber.astrxl.dev/v2" as const;
|
|
export const MAX_WORKSPACE_CONFIG_BYTES = 768 * 1024;
|
|
export const MAX_SOURCE_REFERENCE_BYTES = 64 * 1024;
|
|
export const MAX_REVISION_PAYLOAD_BYTES = 900 * 1024;
|
|
|
|
export const WORKSPACE_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
export const RESERVED_WORKSPACE_IDS: ReadonlySet<string> = new Set([
|
|
"kuber-system",
|
|
"database",
|
|
"garage-system",
|
|
"routing",
|
|
]);
|
|
|
|
export interface ObjectMeta {
|
|
name: string;
|
|
uid: string;
|
|
resourceVersion: string;
|
|
creationTimestamp: string;
|
|
labels?: Record<string, string>;
|
|
annotations?: Record<string, string>;
|
|
}
|
|
|
|
export interface WorkspaceSourceReference {
|
|
uri: string;
|
|
digest: string;
|
|
revision?: string;
|
|
}
|
|
|
|
export interface WorkspaceSpec {
|
|
source: WorkspaceSourceReference;
|
|
config?: unknown;
|
|
}
|
|
|
|
export interface WorkspaceStatus {
|
|
latestRevision: number;
|
|
}
|
|
|
|
export interface Workspace {
|
|
apiVersion: typeof KUBER_API_VERSION;
|
|
kind: "Workspace";
|
|
metadata: ObjectMeta;
|
|
spec: WorkspaceSpec;
|
|
status: WorkspaceStatus;
|
|
}
|
|
|
|
export interface WorkspaceRevision {
|
|
apiVersion: typeof KUBER_API_VERSION;
|
|
kind: "WorkspaceRevision";
|
|
metadata: ObjectMeta & {
|
|
workspaceUid: string;
|
|
};
|
|
spec: Readonly<WorkspaceSpec> & {
|
|
workspaceId: string;
|
|
revision: number;
|
|
};
|
|
}
|
|
|
|
export interface CreateWorkspaceInput {
|
|
id: string;
|
|
source: WorkspaceSourceReference;
|
|
config?: unknown;
|
|
labels?: Record<string, string>;
|
|
annotations?: Record<string, string>;
|
|
}
|
|
|
|
export interface UpdateWorkspaceInput {
|
|
source: WorkspaceSourceReference;
|
|
config?: unknown;
|
|
labels?: Record<string, string>;
|
|
annotations?: Record<string, string>;
|
|
}
|
|
|
|
export class WorkspaceValidationError extends Error {
|
|
readonly code = "WORKSPACE_INVALID";
|
|
}
|
|
|
|
export class WorkspaceConflictError extends Error {
|
|
readonly code = "WORKSPACE_CONFLICT";
|
|
}
|
|
|
|
export class WorkspaceNotFoundError extends Error {
|
|
readonly code = "WORKSPACE_NOT_FOUND";
|
|
}
|
|
|
|
/** Persistence must atomically compare resourceVersion and append the revision. */
|
|
export interface WorkspacePersistence {
|
|
get(id: string): Promise<Workspace | undefined>;
|
|
list(): Promise<Workspace[]>;
|
|
create(workspace: Workspace, revision: WorkspaceRevision): Promise<void>;
|
|
replace(
|
|
workspace: Workspace,
|
|
revision: WorkspaceRevision,
|
|
expectedResourceVersion: string,
|
|
): Promise<void>;
|
|
getRevision(
|
|
workspaceId: string,
|
|
revision: number,
|
|
): Promise<WorkspaceRevision | undefined>;
|
|
listRevisions(workspaceId: string): Promise<WorkspaceRevision[]>;
|
|
}
|
|
|
|
export interface WorkspaceStore {
|
|
create(input: CreateWorkspaceInput): Promise<Workspace>;
|
|
get(id: string): Promise<Workspace | undefined>;
|
|
list(): Promise<Workspace[]>;
|
|
update(
|
|
id: string,
|
|
input: UpdateWorkspaceInput,
|
|
ifMatch: string,
|
|
): Promise<Workspace>;
|
|
getRevision(
|
|
id: string,
|
|
revision: number,
|
|
): Promise<WorkspaceRevision | undefined>;
|
|
listRevisions(id: string): Promise<WorkspaceRevision[]>;
|
|
}
|
|
|
|
export interface WorkspaceStoreOptions {
|
|
now?: () => Date;
|
|
uid?: () => string;
|
|
}
|
|
|
|
export function validateWorkspaceId(id: string): void {
|
|
if (!WORKSPACE_ID_PATTERN.test(id)) {
|
|
throw new WorkspaceValidationError(
|
|
"Workspace ID must be a lowercase DNS label of at most 63 characters",
|
|
);
|
|
}
|
|
if (id.startsWith("kube-") || RESERVED_WORKSPACE_IDS.has(id)) {
|
|
throw new WorkspaceValidationError(`Workspace ID '${id}' is reserved`);
|
|
}
|
|
}
|
|
|
|
export function workspaceEtag(workspace: Pick<Workspace, "metadata">): string {
|
|
return `"${workspace.metadata.resourceVersion}"`;
|
|
}
|
|
|
|
export function resourceVersionFromEtag(etag: string): string {
|
|
const value = etag.trim();
|
|
const match = /^(?:W\/)?"([^"\\]+)"$/.exec(value);
|
|
if (!match?.[1]) {
|
|
throw new WorkspaceValidationError("If-Match must contain a valid ETag");
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
function encodedSize(value: unknown): number {
|
|
let serialized: string;
|
|
try {
|
|
serialized = JSON.stringify(value);
|
|
} catch {
|
|
throw new WorkspaceValidationError(
|
|
"Workspace payload must be JSON serializable",
|
|
);
|
|
}
|
|
if (serialized === undefined) {
|
|
throw new WorkspaceValidationError(
|
|
"Workspace payload must be JSON serializable",
|
|
);
|
|
}
|
|
return Buffer.byteLength(serialized);
|
|
}
|
|
|
|
function validateSpec(spec: WorkspaceSpec): void {
|
|
if (!spec.source || typeof spec.source !== "object") {
|
|
throw new WorkspaceValidationError("A source reference is required");
|
|
}
|
|
if (!spec.source.uri?.trim() || !spec.source.digest?.trim()) {
|
|
throw new WorkspaceValidationError("Source uri and digest are required");
|
|
}
|
|
const source = spec.source as unknown as Record<string, unknown>;
|
|
for (const field of ["blob", "content", "data", "archive", "files"]) {
|
|
if (field in source) {
|
|
throw new WorkspaceValidationError(
|
|
"Inline source blobs are not supported; provide a source reference",
|
|
);
|
|
}
|
|
}
|
|
if (encodedSize(spec.source) > MAX_SOURCE_REFERENCE_BYTES) {
|
|
throw new WorkspaceValidationError("Source reference is too large");
|
|
}
|
|
if (encodedSize(spec.config ?? null) > MAX_WORKSPACE_CONFIG_BYTES) {
|
|
throw new WorkspaceValidationError("Workspace config is too large");
|
|
}
|
|
if (encodedSize(spec) > MAX_REVISION_PAYLOAD_BYTES) {
|
|
throw new WorkspaceValidationError(
|
|
"Workspace revision payload is too large",
|
|
);
|
|
}
|
|
}
|
|
|
|
function clone<T>(value: T): T {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
function revisionFor(
|
|
workspace: Workspace,
|
|
uid: () => string,
|
|
creationTimestamp: string,
|
|
): WorkspaceRevision {
|
|
const revision = workspace.status.latestRevision;
|
|
return {
|
|
apiVersion: KUBER_API_VERSION,
|
|
kind: "WorkspaceRevision",
|
|
metadata: {
|
|
name: `${workspace.metadata.name}-r${revision}`,
|
|
uid: uid(),
|
|
workspaceUid: workspace.metadata.uid,
|
|
resourceVersion: workspace.metadata.resourceVersion,
|
|
creationTimestamp,
|
|
},
|
|
spec: clone({
|
|
...workspace.spec,
|
|
workspaceId: workspace.metadata.name,
|
|
revision,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export class PersistentWorkspaceStore implements WorkspaceStore {
|
|
private readonly now: () => Date;
|
|
private readonly uid: () => string;
|
|
|
|
constructor(
|
|
private readonly persistence: WorkspacePersistence,
|
|
options: WorkspaceStoreOptions = {},
|
|
) {
|
|
this.now = options.now ?? (() => new Date());
|
|
this.uid = options.uid ?? randomUUID;
|
|
}
|
|
|
|
async create(input: CreateWorkspaceInput): Promise<Workspace> {
|
|
validateWorkspaceId(input.id);
|
|
const spec = clone({ source: input.source, config: input.config });
|
|
validateSpec(spec);
|
|
const workspace: Workspace = {
|
|
apiVersion: KUBER_API_VERSION,
|
|
kind: "Workspace",
|
|
metadata: {
|
|
name: input.id,
|
|
uid: this.uid(),
|
|
resourceVersion: "1",
|
|
creationTimestamp: this.now().toISOString(),
|
|
...(input.labels && { labels: clone(input.labels) }),
|
|
...(input.annotations && { annotations: clone(input.annotations) }),
|
|
},
|
|
spec,
|
|
status: { latestRevision: 1 },
|
|
};
|
|
await this.persistence.create(
|
|
workspace,
|
|
revisionFor(workspace, this.uid, workspace.metadata.creationTimestamp),
|
|
);
|
|
return clone(workspace);
|
|
}
|
|
|
|
async get(id: string): Promise<Workspace | undefined> {
|
|
const workspace = await this.persistence.get(id);
|
|
return workspace && clone(workspace);
|
|
}
|
|
|
|
async list(): Promise<Workspace[]> {
|
|
return clone(await this.persistence.list());
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
input: UpdateWorkspaceInput,
|
|
ifMatch: string,
|
|
): Promise<Workspace> {
|
|
validateWorkspaceId(id);
|
|
const current = await this.persistence.get(id);
|
|
if (!current)
|
|
throw new WorkspaceNotFoundError(`Workspace '${id}' not found`);
|
|
const expected = resourceVersionFromEtag(ifMatch);
|
|
if (expected !== current.metadata.resourceVersion) {
|
|
throw new WorkspaceConflictError("Workspace ETag does not match");
|
|
}
|
|
const spec = clone({ source: input.source, config: input.config });
|
|
validateSpec(spec);
|
|
const workspace: Workspace = {
|
|
...clone(current),
|
|
metadata: {
|
|
...clone(current.metadata),
|
|
resourceVersion: String(Number(current.metadata.resourceVersion) + 1),
|
|
...(input.labels !== undefined && { labels: clone(input.labels) }),
|
|
...(input.annotations !== undefined && {
|
|
annotations: clone(input.annotations),
|
|
}),
|
|
},
|
|
spec,
|
|
status: { latestRevision: current.status.latestRevision + 1 },
|
|
};
|
|
await this.persistence.replace(
|
|
workspace,
|
|
revisionFor(workspace, this.uid, this.now().toISOString()),
|
|
expected,
|
|
);
|
|
return clone(workspace);
|
|
}
|
|
|
|
async getRevision(id: string, revision: number) {
|
|
const value = await this.persistence.getRevision(id, revision);
|
|
return value && clone(value);
|
|
}
|
|
|
|
async listRevisions(id: string) {
|
|
return clone(await this.persistence.listRevisions(id));
|
|
}
|
|
}
|
|
|
|
export class MemoryWorkspacePersistence implements WorkspacePersistence {
|
|
private readonly workspaces = new Map<string, Workspace>();
|
|
private readonly revisions = new Map<
|
|
string,
|
|
Map<number, WorkspaceRevision>
|
|
>();
|
|
|
|
async get(id: string) {
|
|
const value = this.workspaces.get(id);
|
|
return value && clone(value);
|
|
}
|
|
|
|
async list() {
|
|
return [...this.workspaces.values()]
|
|
.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name))
|
|
.map(clone);
|
|
}
|
|
|
|
async create(workspace: Workspace, revision: WorkspaceRevision) {
|
|
if (this.workspaces.has(workspace.metadata.name)) {
|
|
throw new WorkspaceConflictError("Workspace already exists");
|
|
}
|
|
this.workspaces.set(workspace.metadata.name, clone(workspace));
|
|
this.revisions.set(
|
|
workspace.metadata.name,
|
|
new Map([[revision.spec.revision, clone(revision)]]),
|
|
);
|
|
}
|
|
|
|
async replace(
|
|
workspace: Workspace,
|
|
revision: WorkspaceRevision,
|
|
expectedResourceVersion: string,
|
|
) {
|
|
const current = this.workspaces.get(workspace.metadata.name);
|
|
if (!current) throw new WorkspaceNotFoundError("Workspace not found");
|
|
if (current.metadata.resourceVersion !== expectedResourceVersion) {
|
|
throw new WorkspaceConflictError("Workspace was concurrently modified");
|
|
}
|
|
const revisions = this.revisions.get(workspace.metadata.name);
|
|
if (!revisions || revisions.has(revision.spec.revision)) {
|
|
throw new WorkspaceConflictError("Workspace revision already exists");
|
|
}
|
|
this.workspaces.set(workspace.metadata.name, clone(workspace));
|
|
revisions.set(revision.spec.revision, clone(revision));
|
|
}
|
|
|
|
async getRevision(id: string, revision: number) {
|
|
const value = this.revisions.get(id)?.get(revision);
|
|
return value && clone(value);
|
|
}
|
|
|
|
async listRevisions(id: string) {
|
|
return [...(this.revisions.get(id)?.values() ?? [])]
|
|
.sort((a, b) => a.spec.revision - b.spec.revision)
|
|
.map(clone);
|
|
}
|
|
}
|
|
|
|
export class MemoryWorkspaceStore extends PersistentWorkspaceStore {
|
|
constructor(options: WorkspaceStoreOptions = {}) {
|
|
super(new MemoryWorkspacePersistence(), options);
|
|
}
|
|
}
|