feat: v2
This commit is contained in:
+245
@@ -0,0 +1,245 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue =
|
||||
| JsonPrimitive
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type ApiProblemDetails = {
|
||||
type?: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
message?: string;
|
||||
/** Stable machine-readable identifier, unlike title or detail. */
|
||||
code: string;
|
||||
requestId?: string;
|
||||
operationId?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type Page<T> = {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
export const KUBER_API_VERSION = "kuber.astrxl.dev/v2" as const;
|
||||
|
||||
export type ObjectMeta = {
|
||||
name: string;
|
||||
uid: string;
|
||||
resourceVersion: string;
|
||||
creationTimestamp: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type WorkspaceSourceReference = {
|
||||
uri: string;
|
||||
digest: string;
|
||||
revision?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceSpec = {
|
||||
source: WorkspaceSourceReference;
|
||||
config?: unknown;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
apiVersion: typeof KUBER_API_VERSION;
|
||||
kind: "Workspace";
|
||||
metadata: ObjectMeta;
|
||||
spec: WorkspaceSpec;
|
||||
status: { latestRevision: number };
|
||||
};
|
||||
|
||||
export type CreateWorkspaceRequest = {
|
||||
id: string;
|
||||
source: WorkspaceSourceReference;
|
||||
config?: unknown;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
export type UpdateWorkspaceRequest = Omit<CreateWorkspaceRequest, "id">;
|
||||
export type CreateWorkspaceResponse = Workspace;
|
||||
export type ListWorkspacesResponse = Page<Workspace>;
|
||||
export type GetWorkspaceResponse = Workspace;
|
||||
export type DeleteWorkspaceResponse = OperationAcceptedResponse;
|
||||
export type AdoptWorkspaceResponse = {
|
||||
workspaceId: string;
|
||||
workspaceUid: string;
|
||||
resourcesAdopted: number;
|
||||
};
|
||||
export type AdoptPlatformRequest = { workspaceUid: string };
|
||||
export type AdoptPlatformResponse = AdoptWorkspaceResponse;
|
||||
|
||||
export type WorkspaceState =
|
||||
| "pending"
|
||||
| "ready"
|
||||
| "degraded"
|
||||
| "stopped"
|
||||
| "deleting"
|
||||
| "failed";
|
||||
|
||||
export type ServiceStatus = {
|
||||
name: string;
|
||||
state: "pending" | "running" | "stopped" | "failed" | "unknown";
|
||||
readyReplicas: number;
|
||||
desiredReplicas: number;
|
||||
image?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceStatusResponse = {
|
||||
workspaceId: string;
|
||||
state: WorkspaceState;
|
||||
services: ServiceStatus[];
|
||||
observedAt: string;
|
||||
};
|
||||
|
||||
export type LifecycleAction = "start" | "stop" | "restart" | "delete";
|
||||
export type LifecycleRequest = {
|
||||
action: LifecycleAction;
|
||||
services?: string[];
|
||||
};
|
||||
export type LifecycleResponse = OperationAcceptedResponse;
|
||||
|
||||
export type ResourceObject = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export type ResourceChange = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
action: "create" | "update" | "delete" | "unchanged";
|
||||
};
|
||||
export type ReconcileResourcesRequest = {
|
||||
resources: ResourceObject[];
|
||||
prune?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
export type ReconcileResourcesResponse = {
|
||||
operationId?: string;
|
||||
changes: ResourceChange[];
|
||||
};
|
||||
|
||||
export type DatabaseEngine = "postgres";
|
||||
export type DatabaseClaim = {
|
||||
name: string;
|
||||
engine: DatabaseEngine;
|
||||
database?: string;
|
||||
username?: string;
|
||||
};
|
||||
export type ReconcileDatabasesRequest = { claims: DatabaseClaim[] };
|
||||
export type DatabaseBinding = {
|
||||
claim: string;
|
||||
service: string;
|
||||
environment: Record<string, string>;
|
||||
};
|
||||
export type ReconcileDatabasesResponse = {
|
||||
bindings: DatabaseBinding[];
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type StorageKind = "s3";
|
||||
export type StorageClaim = {
|
||||
name: string;
|
||||
kind: StorageKind;
|
||||
bucket?: string;
|
||||
region?: string;
|
||||
};
|
||||
export type ReconcileStorageRequest = { claims: StorageClaim[] };
|
||||
export type StorageBinding = {
|
||||
claim: string;
|
||||
service: string;
|
||||
environment: Record<string, string>;
|
||||
};
|
||||
export type ReconcileStorageResponse = {
|
||||
bindings: StorageBinding[];
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type OperationState =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
export type OperationError = { code: string; message: string };
|
||||
export type Operation = {
|
||||
apiVersion: typeof KUBER_API_VERSION;
|
||||
kind: "Operation";
|
||||
metadata: ObjectMeta & { workspaceUid?: string };
|
||||
spec: { workspaceId: string; action: string };
|
||||
status: {
|
||||
state: OperationState;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
result?: unknown;
|
||||
error?: OperationError;
|
||||
};
|
||||
};
|
||||
export type OperationAcceptedResponse = {
|
||||
operationId: string;
|
||||
operation?: Operation;
|
||||
};
|
||||
export type GetOperationResponse = Operation;
|
||||
export type CancelOperationResponse = Operation;
|
||||
export type OperationEvent = {
|
||||
operationId: string;
|
||||
sequence: number;
|
||||
timestamp: string;
|
||||
type: "status" | "progress" | "log" | "result" | "error";
|
||||
data: JsonValue;
|
||||
};
|
||||
|
||||
export type UserRole = "admin" | "operator" | "viewer" | (string & {});
|
||||
export type User = {
|
||||
username: string;
|
||||
roles: UserRole[];
|
||||
disabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
export type CreateUserRequest = {
|
||||
username: string;
|
||||
password: string;
|
||||
roles: UserRole[];
|
||||
};
|
||||
export type UpdateUserRequest = {
|
||||
password?: string;
|
||||
roles?: UserRole[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
export type UserResponse = User;
|
||||
export type ListUsersResponse = Page<User>;
|
||||
|
||||
export type AuditEvent = {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
workspaceId?: string;
|
||||
requestId?: string;
|
||||
operationId?: string;
|
||||
outcome: "success" | "failure";
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
export type ListAuditEventsRequest = {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
actor?: string;
|
||||
workspaceId?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
};
|
||||
export type ListAuditEventsResponse = Page<AuditEvent>;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import {
|
||||
dirname,
|
||||
isAbsolute,
|
||||
normalize,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
|
||||
export interface ArtifactProvider {
|
||||
isFile(path: string, options?: ArtifactReadOptions): boolean;
|
||||
readText(path: string, options?: ArtifactReadOptions): string;
|
||||
}
|
||||
|
||||
export type ArtifactReadOptions = {
|
||||
expandHome?: boolean;
|
||||
};
|
||||
|
||||
export type ArtifactBundle = {
|
||||
version: 1;
|
||||
files: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ArtifactLimits = {
|
||||
maxArtifactCount?: number;
|
||||
maxArtifactBytes?: number;
|
||||
maxTotalBytes?: number;
|
||||
};
|
||||
|
||||
export type LocalArtifactProviderOptions = ArtifactLimits & {
|
||||
workspace: string;
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
export type BundleArtifactProviderOptions = ArtifactLimits;
|
||||
|
||||
export const DEFAULT_ARTIFACT_LIMITS = {
|
||||
maxArtifactCount: 100,
|
||||
maxArtifactBytes: 1024 * 1024,
|
||||
maxTotalBytes: 4 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
function artifactError(
|
||||
message: string,
|
||||
code?: string,
|
||||
): Error & { code?: string } {
|
||||
return Object.assign(new Error(message), code ? { code } : {});
|
||||
}
|
||||
|
||||
function toWorkspacePath(path: string): string {
|
||||
if (
|
||||
!path ||
|
||||
path.includes("\0") ||
|
||||
path.includes("\\") ||
|
||||
path.startsWith("~") ||
|
||||
isAbsolute(path)
|
||||
) {
|
||||
throw artifactError(`Artifact path must be workspace-relative: ${path}`);
|
||||
}
|
||||
|
||||
const normalized = normalize(path);
|
||||
if (normalized === ".." || normalized.startsWith(`..${sep}`)) {
|
||||
throw artifactError(`Artifact path escapes the workspace: ${path}`);
|
||||
}
|
||||
|
||||
return normalized.replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function assertWithinWorkspace(workspace: string, path: string): void {
|
||||
const relation = relative(workspace, path);
|
||||
if (
|
||||
relation === ".." ||
|
||||
relation.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relation)
|
||||
) {
|
||||
throw artifactError(`Artifact path escapes the workspace: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRealPathWithinWorkspace(workspace: string, path: string): void {
|
||||
let existingPath = path;
|
||||
while (!existsSync(existingPath)) {
|
||||
const parent = dirname(existingPath);
|
||||
if (parent === existingPath) break;
|
||||
existingPath = parent;
|
||||
}
|
||||
assertWithinWorkspace(workspace, realpathSync(existingPath));
|
||||
}
|
||||
|
||||
function assertPositiveLimit(name: string, value: number | undefined): void {
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
||||
throw new Error(`${name} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
class ArtifactBudget {
|
||||
readonly #limits: ArtifactLimits;
|
||||
#count = 0;
|
||||
#bytes = 0;
|
||||
|
||||
constructor(limits: ArtifactLimits) {
|
||||
assertPositiveLimit("maxArtifactCount", limits.maxArtifactCount);
|
||||
assertPositiveLimit("maxArtifactBytes", limits.maxArtifactBytes);
|
||||
assertPositiveLimit("maxTotalBytes", limits.maxTotalBytes);
|
||||
this.#limits = limits;
|
||||
}
|
||||
|
||||
add(path: string, content: string): void {
|
||||
const bytes = Buffer.byteLength(content);
|
||||
if (
|
||||
this.#limits.maxArtifactBytes !== undefined &&
|
||||
bytes > this.#limits.maxArtifactBytes
|
||||
) {
|
||||
throw artifactError(
|
||||
`Artifact ${path} exceeds the ${this.#limits.maxArtifactBytes} byte limit`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.#limits.maxArtifactCount !== undefined &&
|
||||
this.#count + 1 > this.#limits.maxArtifactCount
|
||||
) {
|
||||
throw artifactError(
|
||||
`Artifact count exceeds the ${this.#limits.maxArtifactCount} limit`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.#limits.maxTotalBytes !== undefined &&
|
||||
this.#bytes + bytes > this.#limits.maxTotalBytes
|
||||
) {
|
||||
throw artifactError(
|
||||
`Artifact bytes exceed the ${this.#limits.maxTotalBytes} byte limit`,
|
||||
);
|
||||
}
|
||||
|
||||
this.#count += 1;
|
||||
this.#bytes += bytes;
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalArtifactProvider implements ArtifactProvider {
|
||||
readonly #workspace: string;
|
||||
readonly #strict: boolean;
|
||||
readonly #budget: ArtifactBudget;
|
||||
|
||||
constructor(options: LocalArtifactProviderOptions) {
|
||||
this.#workspace = options.strict
|
||||
? realpathSync(options.workspace)
|
||||
: resolve(options.workspace);
|
||||
this.#strict = options.strict ?? false;
|
||||
this.#budget = new ArtifactBudget(options);
|
||||
}
|
||||
|
||||
#resolve(path: string, options: ArtifactReadOptions): string {
|
||||
if (!this.#strict) {
|
||||
return options.expandHome && path.startsWith("~")
|
||||
? resolve(process.env.HOME ?? "", path.slice(1))
|
||||
: resolve(this.#workspace, path);
|
||||
}
|
||||
|
||||
const candidate = resolve(this.#workspace, toWorkspacePath(path));
|
||||
assertWithinWorkspace(this.#workspace, candidate);
|
||||
assertRealPathWithinWorkspace(this.#workspace, candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
isFile(path: string, options: ArtifactReadOptions = {}): boolean {
|
||||
const resolved = this.#resolve(path, options);
|
||||
return existsSync(resolved) && statSync(resolved).isFile();
|
||||
}
|
||||
|
||||
readText(path: string, options: ArtifactReadOptions = {}): string {
|
||||
const resolved = this.#resolve(path, options);
|
||||
if (this.#strict) {
|
||||
// Resolve again at read time so a symlink swap cannot bypass confinement.
|
||||
assertWithinWorkspace(this.#workspace, realpathSync(resolved));
|
||||
}
|
||||
const content = readFileSync(resolved, "utf8");
|
||||
this.#budget.add(path, content);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export class BundleArtifactProvider implements ArtifactProvider {
|
||||
readonly #files = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
bundle: ArtifactBundle,
|
||||
options: BundleArtifactProviderOptions = {},
|
||||
) {
|
||||
if (bundle.version !== 1)
|
||||
throw new Error("Unsupported artifact bundle version");
|
||||
|
||||
const budget = new ArtifactBudget({
|
||||
...DEFAULT_ARTIFACT_LIMITS,
|
||||
...options,
|
||||
});
|
||||
for (const [path, content] of Object.entries(bundle.files)) {
|
||||
const normalized = toWorkspacePath(path);
|
||||
if (this.#files.has(normalized)) {
|
||||
throw artifactError(`Duplicate artifact path: ${path}`);
|
||||
}
|
||||
budget.add(normalized, content);
|
||||
this.#files.set(normalized, content);
|
||||
}
|
||||
}
|
||||
|
||||
isFile(path: string): boolean {
|
||||
return this.#files.has(toWorkspacePath(path));
|
||||
}
|
||||
|
||||
readText(path: string): string {
|
||||
const normalized = toWorkspacePath(path);
|
||||
const content = this.#files.get(normalized);
|
||||
if (content === undefined) {
|
||||
throw artifactError(`Artifact not found: ${path}`, "ENOENT");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export function createArtifactBundle(
|
||||
files: Record<string, string>,
|
||||
): ArtifactBundle {
|
||||
return { version: 1, files: { ...files } };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export const BUILD_PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
export type Sha256Digest = `sha256:${string}`;
|
||||
export type BuildArchitecture = "amd64" | "arm64";
|
||||
|
||||
export type WorkspaceFile = {
|
||||
path: string;
|
||||
type: "file" | "symlink";
|
||||
digest: Sha256Digest;
|
||||
size: number;
|
||||
mode: 0o644 | 0o755 | 0o777;
|
||||
};
|
||||
|
||||
export type WorkspaceManifest = {
|
||||
version: typeof BUILD_PROTOCOL_VERSION;
|
||||
files: WorkspaceFile[];
|
||||
};
|
||||
|
||||
export type BuildSpec = {
|
||||
architecture: BuildArchitecture;
|
||||
image: string;
|
||||
context: string;
|
||||
dockerfile?: string;
|
||||
target?: string;
|
||||
buildArgs: string[];
|
||||
workspace: Sha256Digest;
|
||||
};
|
||||
|
||||
export type BuildRequest = {
|
||||
version: typeof BUILD_PROTOCOL_VERSION;
|
||||
id: string;
|
||||
project: string;
|
||||
service: string;
|
||||
spec: BuildSpec;
|
||||
};
|
||||
|
||||
export type BuildState = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
export type BuildStatus = {
|
||||
version: typeof BUILD_PROTOCOL_VERSION;
|
||||
id: string;
|
||||
state: BuildState;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
digest?: Sha256Digest;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type BuildEvent =
|
||||
| { type: "status"; status: BuildStatus }
|
||||
| { type: "log"; id: string; sequence: number; message: string };
|
||||
|
||||
export function isSha256Digest(value: unknown): value is Sha256Digest {
|
||||
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export function assertSha256Digest(
|
||||
value: unknown,
|
||||
): asserts value is Sha256Digest {
|
||||
if (!isSha256Digest(value))
|
||||
throw new Error(`Invalid SHA-256 digest: ${value}`);
|
||||
}
|
||||
Reference in New Issue
Block a user