feat: release 2.7.0-rc2

This commit is contained in:
2026-10-06 15:31:51 +00:00 Unverified
parent cd9fb512cd
commit c8de9ddcba
42 changed files with 6127 additions and 593 deletions
+179 -29
View File
@@ -6,6 +6,7 @@ import type { Writable } from "node:stream";
import type { ComposeSpecification, Service } from "../schema/docker.d";
import {
BUILD_PROTOCOL_VERSION,
validateBuildpackUri,
type BuildEvent,
type BuildRequest,
type BuildStatus,
@@ -184,6 +185,8 @@ type BuildPlan = {
name: string;
image: string;
context: string;
builder?: "buildpacks";
buildpackUri?: string;
dockerfile?: string;
target?: string;
buildArgs: string[];
@@ -192,6 +195,8 @@ type BuildPlan = {
type ProgressReporter = (message: string) => void | Promise<void>;
type BuildReporter = {
progress?: ProgressReporter;
upload?: (uploaded: number, total: number) => void;
uploadSettled?: (error?: unknown) => void;
stream?: Writable;
/** Per-image output and lifecycle hooks, including for queued images. */
service?: (name: string) => BuildReporter | undefined;
@@ -210,6 +215,45 @@ type SnapshotNegotiation = {
ready: boolean;
};
class UploadTracker {
private readonly sizes = new Map<Sha256Digest, number>();
private readonly offsets = new Map<Sha256Digest, number>();
constructor(
private readonly report?: (uploaded: number, total: number) => void,
) {}
register(
missing: Sha256Digest[],
blobs: Map<Sha256Digest, Uint8Array>,
emit = true,
): void {
for (const digest of missing) {
const data = blobs.get(digest);
if (!data)
throw new Error(`Server requested unknown workspace blob ${digest}`);
this.sizes.set(digest, data.byteLength);
}
if (emit) this.emit();
}
advance(digest: Sha256Digest, offset: number): void {
this.offsets.set(digest, Math.max(this.offsets.get(digest) ?? 0, offset));
this.emit();
}
complete(): void {
this.emit();
}
private emit(): void {
this.report?.(
[...this.offsets.values()].reduce((sum, bytes) => sum + bytes, 0),
[...this.sizes.values()].reduce((sum, bytes) => sum + bytes, 0),
);
}
}
type ImageResult = {
image: string;
digest: Sha256Digest;
@@ -259,8 +303,35 @@ function resolveBuildPlan(
): BuildPlan | undefined {
if (!service.build) return;
const build = service.build;
const contextInput =
typeof build === "string" ? build : (build.context ?? ".");
const auto =
build === "auto" ||
(typeof build === "string" && build.startsWith("auto:"));
const buildpackUri =
typeof build === "string" && build.startsWith("auto:")
? build.slice(5)
: undefined;
if (buildpackUri !== undefined) validateBuildpackUri(buildpackUri);
const autoContext = service["x-kuber-build-context"];
if (
auto &&
autoContext !== undefined &&
(typeof autoContext !== "string" ||
autoContext.length === 0 ||
autoContext.includes("\\") ||
autoContext.includes("\0") ||
isAbsolute(autoContext) ||
/^[A-Za-z]:/.test(autoContext) ||
autoContext.split("/").some((part) => part === ".." || part === ""))
) {
throw new Error(
`Invalid x-kuber-build-context for service ${name}; expected a relative path inside the workspace`,
);
}
const contextInput = auto
? ((autoContext as string | undefined) ?? ".")
: typeof build === "string"
? build
: (build.context ?? ".");
if (contextInput.includes("://"))
throw new Error(
`Remote build context is not supported for service ${name}`,
@@ -268,7 +339,7 @@ function resolveBuildPlan(
if (typeof build !== "string" && build.dockerfile_inline)
throw new Error(`dockerfile_inline is not supported for service ${name}`);
const contextPath = resolve(cwd, contextInput);
const contextPath = resolve(auto ? repoRoot : cwd, contextInput);
const context = assertInsideRepo(
repoRoot,
contextPath,
@@ -285,6 +356,8 @@ function resolveBuildPlan(
// The server replaces this requested name with its configured imageName.
image: getBuildImageName(project, name, registry),
context,
...(auto && { builder: "buildpacks" as const }),
...(buildpackUri && { buildpackUri }),
dockerfile: dockerfilePath
? assertInsideRepo(repoRoot, dockerfilePath, "Dockerfile", name)
: undefined,
@@ -347,6 +420,7 @@ async function uploadBlob(
scheduler: TaskScheduler,
project?: string,
signal?: AbortSignal,
tracker?: UploadTracker,
): Promise<void> {
const uploadPath = `/blobs/${encodeURIComponent(digest)}/uploads`;
const projectQuery = project ? `?project=${encodeURIComponent(project)}` : "";
@@ -359,6 +433,7 @@ async function uploadBlob(
}),
);
let offset = progress.offset;
tracker?.advance(digest, offset);
while (!progress.complete && offset < data.byteLength) {
const chunk = data.subarray(offset, offset + UPLOAD_CHUNK_BYTES);
const uploaded = await scheduler.run(() =>
@@ -375,6 +450,7 @@ async function uploadBlob(
if (uploaded.offset <= offset)
throw new Error(`Blob upload for ${digest} made no progress`);
offset = uploaded.offset;
tracker?.advance(digest, offset);
}
if (!progress.complete) {
await scheduler.run(() =>
@@ -394,37 +470,46 @@ export async function uploadWorkspaceSnapshot(
scheduler?: TaskScheduler,
project?: string,
signal?: AbortSignal,
tracker = new UploadTracker(reporter?.upload),
initial?: SnapshotNegotiation,
): Promise<void> {
const blobs = new Map(snapshot.blobs.map((blob) => [blob.digest, blob.data]));
blobs.set(snapshot.digest, serializeWorkspaceManifest(snapshot.manifest));
const requestScheduler = scheduler ?? new TaskScheduler();
let first = initial;
for (;;) {
signal?.throwIfAborted();
const negotiation = await requestScheduler.run(() =>
request<SnapshotNegotiation>(
"/snapshots/negotiate",
{
method: "POST",
json: { workspace: snapshot.digest, ...(project && { project }) },
signal,
},
{ timeoutMs: 300_000 },
),
);
if (negotiation.ready) return;
const negotiation =
first ??
(await requestScheduler.run(() =>
request<SnapshotNegotiation>(
"/snapshots/negotiate",
{
method: "POST",
json: { workspace: snapshot.digest, ...(project && { project }) },
signal,
},
{ timeoutMs: 300_000 },
),
));
first = undefined;
if (negotiation.ready) {
tracker.complete();
return;
}
if (negotiation.missing.length === 0)
throw new Error(
"Snapshot negotiation is incomplete but reported no missing blobs",
);
tracker.register(negotiation.missing, blobs);
await runConcurrent(
negotiation.missing,
[...new Set(negotiation.missing)],
requestScheduler.maxConcurrent,
async (digest) => {
const data = blobs.get(digest);
if (!data)
throw new Error(`Server requested unknown workspace blob ${digest}`);
await reporter?.progress?.(`Uploading ${digest}`);
await uploadBlob(
digest,
data,
@@ -432,6 +517,7 @@ export async function uploadWorkspaceSnapshot(
requestScheduler,
project,
signal,
tracker,
);
},
);
@@ -701,8 +787,13 @@ export async function buildServices(
throw new RangeError("buildConcurrency must be a positive integer");
const request = options.request ?? apiRequest;
const hasAuto = Object.values(compose.services ?? {}).some(
(service) =>
service.build === "auto" ||
(typeof service.build === "string" && service.build.startsWith("auto:")),
);
let repoRoot = options.workspaceRoot;
if (!repoRoot && !options.snapshot) {
if (!repoRoot && (!options.snapshot || hasAuto)) {
try {
repoRoot = await getRepoRoot(cwd);
} catch (error) {
@@ -710,7 +801,6 @@ export async function buildServices(
}
}
repoRoot ??= cwd;
const snapshot = options.snapshot ?? (await enumerateWorkspace(repoRoot));
const plans = Object.entries(compose.services ?? {}).flatMap(
([name, service]) => {
const plan = resolveBuildPlan(
@@ -724,14 +814,70 @@ export async function buildServices(
return plan ? [plan] : [];
},
);
await uploadWorkspaceSnapshot(
snapshot,
request,
reporter,
options.scheduler,
project,
options.signal,
const hasDockerfile = plans.some((plan) => !plan.builder);
const snapshot = hasDockerfile
? (options.snapshot ?? (await enumerateWorkspace(repoRoot)))
: undefined;
const autoSnapshot = hasAuto
? await enumerateWorkspace(repoRoot, "auto")
: undefined;
const workspaces = [snapshot, autoSnapshot].filter(
(workspace): workspace is WorkspaceSnapshot => workspace !== undefined,
);
try {
const scheduler = options.scheduler ?? new TaskScheduler();
const tracker = new UploadTracker(reporter?.upload);
// Know both denominators before reporting progress for mixed builders.
const initial =
reporter?.upload && workspaces.length > 1
? await Promise.all(
workspaces.map((workspace) =>
scheduler.run(() =>
request<SnapshotNegotiation>(
"/snapshots/negotiate",
{
method: "POST",
json: { workspace: workspace.digest, project },
signal: options.signal,
},
{ timeoutMs: 300_000 },
),
),
),
)
: undefined;
if (initial) {
initial.forEach((negotiation, index) => {
const workspace = workspaces[index]!;
const blobs = new Map(
workspace.blobs.map((blob) => [blob.digest, blob.data]),
);
blobs.set(
workspace.digest,
serializeWorkspaceManifest(workspace.manifest),
);
tracker.register(negotiation.missing ?? [], blobs, false);
});
tracker.complete();
}
for (const [index, workspace] of workspaces.entries())
await uploadWorkspaceSnapshot(
workspace,
request,
reporter,
scheduler,
project,
options.signal,
tracker,
initial?.[index],
);
reporter?.uploadSettled?.();
} catch (error) {
reporter?.uploadSettled?.(error);
throw error;
}
const snapshotFor = (plan: BuildPlan): WorkspaceSnapshot =>
(plan.builder ? autoSnapshot : snapshot)!;
const references: string[] = new Array(plans.length);
const reporters = new Map(
@@ -744,7 +890,9 @@ export async function buildServices(
const groups = new Map<string, number[]>();
plans.forEach((plan, index) => {
const key = JSON.stringify({
workspace: snapshot.digest,
builder: plan.builder ?? "dockerfile",
buildpackUri: plan.buildpackUri,
workspace: snapshotFor(plan).digest,
architecture,
context: plan.context,
dockerfile: plan.dockerfile,
@@ -810,10 +958,12 @@ export async function buildServices(
architecture,
image: plan.image,
context: plan.context,
dockerfile: plan.dockerfile,
...(plan.builder && { builder: plan.builder }),
...(plan.buildpackUri && { buildpackUri: plan.buildpackUri }),
...(!plan.builder && { dockerfile: plan.dockerfile }),
target: plan.target,
buildArgs: plan.buildArgs,
workspace: snapshot.digest,
workspace: snapshotFor(plan).digest,
},
};
const initial = await request<BuildStatus>(
+262
View File
@@ -0,0 +1,262 @@
/** Embedded templates are bundled with the CLI; no files are read at runtime. */
export const templateIds = [
"bun-service",
"bun-next",
"bun-compiled",
"node-pnpm",
"python-web",
"go-static",
"rust-static",
"static-nginx",
] as const;
export type DockerfileTemplateId = (typeof templateIds)[number];
export type DockerfileTemplate = {
id: DockerfileTemplateId;
label: string;
description: string;
};
export type RenderedDockerfileTemplate = {
dockerfile: string;
dockerignore: string;
};
const commonIgnore = `# Local state and credentials must not enter the build context.
.git
.github
.env
.env.*
**/.env
**/.env.*
*.pem
*.key
*.p12
id_rsa*
credentials.json
*.log
.DS_Store
`;
const templates: Record<
DockerfileTemplateId,
DockerfileTemplate & RenderedDockerfileTemplate
> = {
"bun-service": {
id: "bun-service",
label: "Bun service",
description:
"Bun app with package.json and bun.lock; starts src/index.ts on port 3000.",
dockerfile: `FROM oven/bun:1-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
FROM oven/bun:1-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER bun
EXPOSE 3000
CMD ["bun", "src/index.ts"]
`,
dockerignore: `${commonIgnore}node_modules
dist
.next
coverage
`,
},
"bun-next": {
id: "bun-next",
label: "Bun / Next.js standalone",
description:
"Next.js app with bun.lock and output: 'standalone' in next.config; starts generated server.js on port 3000.",
dockerfile: `FROM oven/bun:1-alpine AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN mkdir -p public && bun run build
FROM oven/bun:1-alpine
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER bun
EXPOSE 3000
CMD ["bun", "server.js"]
`,
dockerignore: `${commonIgnore}node_modules
.next
dist
coverage
`,
},
"bun-compiled": {
id: "bun-compiled",
label: "Compiled Bun executable",
description:
"Bun app with bun.lock and src/index.ts; compiles a Linux executable named app.",
dockerfile: `FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build src/index.ts --compile --outfile /app/app
FROM oven/bun:1-slim
WORKDIR /app
COPY --from=build /app/app ./app
USER bun
EXPOSE 3000
CMD ["./app"]
`,
dockerignore: `${commonIgnore}node_modules
dist
.next
coverage
`,
},
"node-pnpm": {
id: "node-pnpm",
label: "Node / pnpm service",
description:
"Node app with pnpm-lock.yaml, a build script producing dist/index.js, and a packageManager pin in package.json.",
dockerfile: `FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build && pnpm prune --prod
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
`,
dockerignore: `${commonIgnore}node_modules
dist
coverage
.next
`,
},
"python-web": {
id: "python-web",
label: "Python web service",
description:
"Python app with requirements.txt and an ASGI app object at app:app; serves on port 8000 via uvicorn (include it in requirements.txt).",
dockerfile: `FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
ENV PATH=/opt/venv/bin:$PATH PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY --from=build /opt/venv /opt/venv
COPY . .
USER 10001:10001
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
`,
dockerignore: `${commonIgnore}__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
dist/
`,
},
"go-static": {
id: "go-static",
label: "Static Go binary",
description:
"Go module with go.mod and a main package at module root; builds a CGO-free app binary on port 8080.",
dockerfile: `FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /app .
FROM scratch
COPY --from=build /app /app
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]
`,
dockerignore: `${commonIgnore}vendor/
bin/
coverage/
`,
},
"rust-static": {
id: "rust-static",
label: "Static Rust binary",
description:
"Cargo project with Cargo.lock and a binary named app (src/main.rs); no dynamic native-library dependencies; serves on port 8080.",
dockerfile: `FROM rust:1-alpine AS build
WORKDIR /app
RUN apk add --no-cache musl-dev
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --locked --bin app
FROM scratch
COPY --from=build /app/target/release/app /server
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
`,
dockerignore: `${commonIgnore}target/
coverage/
`,
},
"static-nginx": {
id: "static-nginx",
label: "Static site / nginx",
description:
"Static site with ready-to-serve files in dist/; nginx serves them on port 80.",
dockerfile: `FROM nginx:1-alpine
COPY dist/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`,
dockerignore: `${commonIgnore}node_modules
.next
coverage
`,
},
};
export function listDockerfileTemplates(): DockerfileTemplate[] {
return templateIds.map((id) => {
const { label, description } = templates[id];
return { id, label, description };
});
}
export function renderDockerfileTemplate(
id: DockerfileTemplateId,
): RenderedDockerfileTemplate {
const template = templates[id];
if (!template) throw new Error(`Unknown Dockerfile template: ${id}`);
return {
dockerfile: template.dockerfile,
dockerignore: template.dockerignore,
};
}
+327
View File
@@ -0,0 +1,327 @@
import {
open,
readFile,
readdir,
realpath,
rename,
rm,
stat,
} from "node:fs/promises";
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { randomUUID } from "node:crypto";
import { YAML } from "bun";
import type { Service } from "../schema/docker.d";
import { readCompose, resolveComposeFile } from "./yaml";
import {
renderDockerfileTemplate,
templateIds,
type DockerfileTemplateId,
} from "./scaffold-templates";
export const managedHeader =
"managedBy: kuber # See https://npmx.dev/@dmgnr/kuber for documentation.";
const manifests = new Set([
"package.json",
"bun.lock",
"bun.lockb",
"pnpm-lock.yaml",
"pyproject.toml",
"requirements.txt",
"go.mod",
"Cargo.toml",
]);
const excluded = new Set([
".git",
"node_modules",
".next",
"dist",
"build",
"target",
".venv",
"vendor",
]);
const serviceNamePattern = /^[a-z][a-z0-9-]*$/;
export type Source =
| { kind: "image"; image: string }
| { kind: "auto" }
| { kind: "template"; template: DockerfileTemplateId };
export type AppOptions = {
name: string;
path: string;
source: Source;
postgres?: string;
s3?: string;
cpu?: string;
memory?: string;
replicas?: number;
};
export function projectName(name: string): string {
const value = name
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63)
.replace(/-+$/, "");
if (!value) throw new Error("Project name must contain letters or numbers");
return value;
}
export function validateAppPath(path: string): string {
const normalized = path.replaceAll("\\", "/").replace(/\/$/, "") || ".";
if (
isAbsolute(normalized) ||
normalized.split("/").some((part) => part === ".." || part === "")
)
throw new Error("App path must be relative and stay within the project");
return normalized;
}
export async function checkedAppPath(
root: string,
path: string,
): Promise<string> {
const normalized = validateAppPath(path);
const absolute = resolve(root, normalized);
const [realRoot, realTarget, info] = await Promise.all([
realpath(root),
realpath(absolute),
stat(absolute),
]);
if (
!info.isDirectory() ||
(realTarget !== realRoot && !realTarget.startsWith(`${realRoot}${sep}`))
)
throw new Error("App path must be a directory inside the project");
return normalized;
}
export async function detectAppPaths(root: string): Promise<string[]> {
const results: string[] = [];
async function visit(path: string, depth: number): Promise<void> {
const entries = await readdir(join(root, path), { withFileTypes: true });
if (entries.some((entry) => entry.isFile() && manifests.has(entry.name)))
results.push(path);
if (depth >= 3) return;
for (const entry of entries) {
if (
entry.isDirectory() &&
!excluded.has(entry.name) &&
!entry.name.startsWith(".")
)
await visit(
path === "." ? entry.name : `${path}/${entry.name}`,
depth + 1,
);
}
}
await visit(".", 0);
return results;
}
export function serviceForApp(app: AppOptions): Service {
if (!serviceNamePattern.test(app.name) || app.name.length > 63)
throw new Error(
"Service name must be lowercase letters, digits and hyphens, starting with a letter (max 63)",
);
const path = validateAppPath(app.path);
const service: Service = {};
if (app.source.kind === "image") {
if (!app.source.image.trim()) throw new Error("Image must not be empty");
service.image = app.source.image.trim();
} else {
if (
app.source.kind === "template" &&
!templateIds.includes(app.source.template)
)
throw new Error(`Unknown Dockerfile template: ${app.source.template}`);
service.build = app.source.kind === "auto" ? "auto" : path;
if (app.source.kind === "auto" && path !== ".")
service["x-kuber-build-context"] = path;
}
const volumes: string[] = [];
for (const [claim, value] of [
["postgresql", app.postgres],
["s3", app.s3],
] as const) {
if (value) {
if (!/^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)?$/.test(value))
throw new Error(`Invalid ${claim} claim: ${value}`);
volumes.push(`${claim}:${value}`);
}
}
if (volumes.length) service.volumes = volumes;
if (
app.cpu !== undefined ||
app.memory !== undefined ||
app.replicas !== undefined
) {
if (
app.cpu !== undefined &&
(!/^\d+(?:\.\d+)?$/.test(app.cpu) || Number(app.cpu) <= 0)
)
throw new Error("CPU must be a positive number of cores");
if (
app.memory !== undefined &&
(!/^\d+(?:\.\d+)?(?:[kKmMgGtT]|[KMGT]i|[KMGT][bB])?$/.test(app.memory) ||
Number.parseFloat(app.memory) <= 0)
)
throw new Error("Memory must be a positive quantity (e.g. 512m)");
if (
app.replicas !== undefined &&
(!Number.isSafeInteger(app.replicas) || app.replicas < 1)
)
throw new Error("Replicas must be a positive integer");
service.deploy = {
...(app.replicas !== undefined ? { replicas: app.replicas } : {}),
...(app.cpu !== undefined || app.memory !== undefined
? {
resources: {
limits: {
...(app.cpu !== undefined ? { cpus: app.cpu } : {}),
...(app.memory !== undefined ? { memory: app.memory } : {}),
},
},
}
: {}),
};
}
return service;
}
function serviceBlock(name: string, service: Service): string {
const text = YAML.stringify({ [name]: service }, null, 2).trimEnd();
return `${text
.split("\n")
.map((line) => ` ${line}`)
.join("\n")}\n`;
}
async function writeExclusive(path: string, content: string): Promise<void> {
const file = await open(path, "wx");
try {
await file.writeFile(content);
} catch (error) {
await file.close();
await rm(path, { force: true });
throw error;
}
await file.close();
}
/** Existing YAML is kept byte-for-byte; only the new service block is inserted. */
export async function addAppToCompose(
root: string,
app: AppOptions,
composeFile?: string,
): Promise<string> {
const path = composeFile ?? (await resolveComposeFile(root));
if (!path)
throw new Error("Compose file cannot be found. Run kuber init first.");
await checkedAppPath(root, app.path);
const service = serviceForApp(app);
const compose = await readCompose(path);
if (Object.hasOwn(compose.services ?? {}, app.name))
throw new Error(`Service ${app.name} already exists`);
const generated: string[] = [];
const lock = await open(`${path}.kuber.lock`, "wx");
try {
const original = await readFile(path, "utf8");
const source = app.source;
if (source.kind === "template") {
const templates = renderDockerfileTemplate(source.template);
for (const [file, content] of [
["Dockerfile", templates.dockerfile],
[".dockerignore", templates.dockerignore],
] as const) {
const target = join(root, app.path, file);
await writeExclusive(target, content);
generated.push(target);
}
}
const lines = original.split("\n");
const serviceIndex = lines.findIndex((line) =>
/^services:\s*(?:#.*)?$/.test(line),
);
const inlineServicesIndex = lines.findIndex((line) =>
/^services:\s*\{\}\s*(?:#.*)?$/.test(line),
);
if (serviceIndex < 0 && Object.hasOwn(compose, "services"))
if (inlineServicesIndex < 0)
throw new Error(
"Cannot safely insert a service into this Compose file",
);
let updated: string;
if (inlineServicesIndex >= 0) {
const line = lines[inlineServicesIndex]!;
const match = /^(services:\s*)\{\}(\s*(?:#.*)?)$/.exec(line)!;
lines[inlineServicesIndex] =
`${match[1]!.trimEnd()}${match[2]!.trimEnd()}`;
const prefix = lines.slice(0, inlineServicesIndex + 1).join("\n") + "\n";
updated =
prefix +
serviceBlock(app.name, service) +
lines.slice(inlineServicesIndex + 1).join("\n");
} else if (serviceIndex < 0)
updated = `${original.trimEnd()}\nservices:\n${serviceBlock(app.name, service)}`;
else {
let end = serviceIndex + 1;
while (end < lines.length && !/^[^\s#][^:]*:/.test(lines[end]!)) end++;
const prefix = lines.slice(0, end).join("\n").replace(/\n*$/, "\n");
updated =
prefix + serviceBlock(app.name, service) + lines.slice(end).join("\n");
}
if ((await readFile(path, "utf8")) !== original)
throw new Error("Compose file changed while adding the service");
const temporary = join(
dirname(path),
`.${basename(path)}.${randomUUID()}.tmp`,
);
try {
await writeExclusive(temporary, updated);
await rename(temporary, path);
} finally {
await rm(temporary, { force: true });
}
return path;
} catch (error) {
await Promise.all(generated.map((file) => rm(file, { force: true })));
throw error;
} finally {
await lock.close();
await rm(`${path}.kuber.lock`, { force: true });
}
}
export async function createCompose(
root: string,
project: string,
app: AppOptions,
): Promise<string> {
if (await resolveComposeFile(root))
throw new Error("A Compose file already exists; use kuber add app instead");
await checkedAppPath(root, app.path);
const service = serviceForApp(app);
const path = join(root, "compose.yml");
const content = `${managedHeader}\nname: ${JSON.stringify(project)}\nservices:\n${serviceBlock(app.name, service)}`;
const generated: string[] = [];
try {
if (app.source.kind === "template") {
const template = renderDockerfileTemplate(app.source.template);
for (const [file, text] of [
["Dockerfile", template.dockerfile],
[".dockerignore", template.dockerignore],
] as const) {
const target = join(root, app.path, file);
await writeExclusive(target, text);
generated.push(target);
}
}
await writeExclusive(path, content);
return path;
} catch (error) {
await Promise.all(generated.map((file) => rm(file, { force: true })));
throw error;
}
}
+5 -1
View File
@@ -52,6 +52,7 @@ function isSession(value: unknown): value is KuberSession {
return (
typeof session.token === "string" &&
typeof session.expiresAt === "string" &&
Number.isFinite(Date.parse(session.expiresAt)) &&
Boolean(session.user) &&
typeof session.user?.username === "string" &&
Array.isArray(session.user.roles) &&
@@ -64,7 +65,10 @@ async function readSessionFile(
): Promise<KuberSession | undefined> {
try {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isSession(value)) return;
if (!isSession(value)) {
await rm(path, { force: true });
return;
}
if (Date.parse(value.expiresAt) <= Date.now()) {
await rm(path, { force: true });
return;
+73 -118
View File
@@ -1,5 +1,4 @@
import { KUBER_VERSION } from "../shared/version";
import { readlinkSync, statSync } from "node:fs";
type SemVer = {
major: bigint;
@@ -15,66 +14,6 @@ const INSTALL_TIMEOUT_SECONDS = 30;
const gray = (line: string) => `\x1b[90m${line}\x1b[0m\n`;
const reportToStderr = (line: string) => process.stderr.write(line);
// This process owns the install result when the invoking CLI has already exited.
// It opens only the original terminal (never the parent's pipe or stdout), and
// checks its identity before writing in case the pty path has been recycled.
const TTY_HELPER = `
const { openSync, closeSync, fstatSync, writeSync, constants } = require('node:fs');
const [version, seconds, path, dev, ino, rdev, uid] = process.argv.slice(1);
let success = false;
try {
const child = Bun.spawn(['timeout', '--signal=TERM', '--kill-after=2s', seconds + 's',
'bun', 'i', '-g', '--no-cache', '@dmgnr/kuber@' + version],
{ stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
success = (await child.exited) === 0;
} catch {}
try {
const fd = openSync(path, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = fstatSync(fd);
if (stat.isCharacterDevice() && String(stat.dev) === dev &&
String(stat.ino) === ino && String(stat.rdev) === rdev && String(stat.uid) === uid) {
writeSync(fd, '\\x1b[90m+ ' + (success ? 'Updated to ' : 'New version available: ') +
version + '\\x1b[0m\\n');
}
} finally { closeSync(fd); }
} catch {}
`;
function originalTerminal(): string[] | undefined {
if (!process.stderr.isTTY) return;
try {
const path = readlinkSync("/proc/self/fd/2");
if (!/^\/dev\/pts\/[0-9]+$/.test(path)) return;
const stat = statSync(path);
if (!stat.isCharacterDevice()) return;
return [
path,
String(stat.dev),
String(stat.ino),
String(stat.rdev),
String(stat.uid),
];
} catch {
return;
}
}
function installWithTerminalReport(version: string, tty: string[]): void {
const child = Bun.spawn(
[
process.execPath,
"-e",
TTY_HELPER,
version,
String(INSTALL_TIMEOUT_SECONDS),
...tty,
],
{ stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true },
);
child.unref();
}
function parseVersion(value: string | null): SemVer | undefined {
if (!value || value.length > 128) return;
const match = SEMVER.exec(value);
@@ -124,7 +63,7 @@ export type VersionInstallRunner = (version: string) => Promise<boolean>;
type InstallProcess = {
exited: Promise<number>;
unref?(): void;
kill(signal?: NodeJS.Signals): void;
};
type InstallSpawn = (
argv: string[],
@@ -132,7 +71,7 @@ type InstallSpawn = (
stdin: "ignore";
stdout: "ignore";
stderr: "ignore";
detached: true;
env: NodeJS.ProcessEnv;
},
) => InstallProcess;
@@ -149,24 +88,51 @@ export async function installVersion(
timeoutSeconds > 300
)
return false;
const child = spawn(
[
"timeout",
"--signal=TERM",
"--kill-after=2s",
`${timeoutSeconds}s`,
"bun",
"i",
"-g",
"--no-cache",
`@dmgnr/kuber@${version}`,
],
{ stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true },
);
// The detached timeout owns its bounded lifetime; it must not keep a short
// CLI invocation alive. Its result can still be observed while the parent lives.
child.unref?.();
return child.exited.then((code) => code === 0);
try {
const child = spawn(
[process.execPath, "i", "-g", "--no-cache", `@dmgnr/kuber@${version}`],
{ stdin: "ignore", stdout: "ignore", stderr: "ignore", env: process.env },
);
return await new Promise<boolean>((resolve) => {
// Allow a short grace period to reap a child that ignores termination.
let timedOut = false;
const deadline = setTimeout(
() => {
timedOut = true;
try {
child.kill();
} catch {}
const force = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {}
resolve(false);
}, 1_000);
void child.exited.finally(() => clearTimeout(force)).catch(() => {});
},
timeoutSeconds * 1_000 - 1_000,
);
void child.exited.then(
(code) => {
clearTimeout(deadline);
resolve(!timedOut && code === 0);
},
() => {
clearTimeout(deadline);
resolve(false);
},
);
});
} catch {
return false;
}
}
const pendingUpdates = new Set<Promise<void>>();
/** Wait for an update observed during this CLI invocation, including its notice. */
export async function waitForVersionUpdate(): Promise<void> {
await Promise.all(pendingUpdates);
}
export function createVersionObserver({
@@ -184,45 +150,34 @@ export function createVersionObserver({
return;
attempted = true;
// Defer install work beyond the response headers; never block body consumption.
setTimeout(() => {
// The global observer must not install packages in tests or source-tree
// development commands. Explicitly injected runners remain testable.
if (
runner === installVersion &&
(process.env.NODE_ENV === "test" ||
process.env.NODE_ENV === "development" ||
process.argv[1]?.endsWith(".ts"))
)
return;
if (runner === installVersion && report === reportToStderr) {
const tty = originalTerminal();
if (tty) {
try {
installWithTerminalReport(version, tty);
} catch {
try {
report(gray(`+ New version available: ${version}`));
} catch {}
}
return;
}
}
void Promise.resolve()
.then(() => runner(version))
.then(
(success) => {
report(
gray(
`+ ${success ? "Updated to " : "New version available: "}${version}`,
),
);
},
() => report(gray(`+ New version available: ${version}`)),
const pending = new Promise<void>((resolve) => setTimeout(resolve, 0)).then(
async () => {
// The global observer must not install packages in tests or source-tree
// development commands. Explicitly injected runners remain testable.
if (
runner === installVersion &&
(process.env.NODE_ENV === "test" ||
process.env.NODE_ENV === "development" ||
process.argv[1]?.endsWith(".ts"))
)
.catch(() => {
return;
let success = false;
try {
success = await runner(version);
} catch {}
try {
report(
gray(
`+ ${success ? "Updated to " : "New version available: "}${version}`,
),
);
} catch {
// A broken stderr must never affect an API request or command exit status.
});
}, 0);
}
},
);
pendingUpdates.add(pending);
void pending.finally(() => pendingUpdates.delete(pending));
};
}
+263 -42
View File
@@ -14,7 +14,14 @@ import {
readFile,
} from "node:fs/promises";
import type { Stats } from "node:fs";
import { delimiter, dirname, isAbsolute, relative, resolve, sep } from "node:path";
import {
delimiter,
dirname,
isAbsolute,
relative,
resolve,
sep,
} from "node:path";
import { promisify } from "node:util";
import {
BUILD_PROTOCOL_VERSION,
@@ -30,9 +37,14 @@ export async function gitAvailable(): Promise<boolean> {
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
if (!directory) continue;
try {
await access(resolve(directory, process.platform === "win32" ? "git.exe" : "git"), constants.X_OK);
await access(
resolve(directory, process.platform === "win32" ? "git.exe" : "git"),
constants.X_OK,
);
return true;
} catch { /* Continue searching PATH. */ }
} catch {
/* Continue searching PATH. */
}
}
return false;
}
@@ -76,6 +88,36 @@ function isWithin(root: string, candidate: string): boolean {
);
}
function assertSafeSymlinkTarget(
root: string,
path: string,
target: string,
symlinks: Set<string>,
): void {
const source = resolve(root, path);
if (
target.includes("\0") ||
isAbsolute(target) ||
!isWithin(root, resolve(dirname(source), target))
)
throw new Error(`Symlink escapes workspace: ${path} -> ${target}`);
// Inspect raw components before normalization: `sub/..` still traverses
// `sub` when it is a symlink, even though it resolves lexically to `.`.
const components = path.split("/").slice(0, -1);
for (const part of target.split(sep === "\\" ? /[\\/]/ : "/")) {
if (!part || part === ".") continue;
if (part === "..") components.pop();
else {
components.push(part);
if (symlinks.has(components.join("/")))
throw new Error(
`Symlink traverses snapshot symlink: ${path} -> ${target}`,
);
}
}
}
async function gitFiles(root: string, args: string[]): Promise<string[]> {
const { stdout } = await execFileAsync(
"git",
@@ -116,16 +158,127 @@ async function selectedFiles(root: string): Promise<string[]> {
);
}
async function selectedAutoFiles(root: string): Promise<string[]> {
// ls-files includes tracked files even when they now match .gitignore.
// --no-index makes check-ignore apply the same rules to those paths too.
const candidates = await gitFiles(root, [
"--cached",
"--others",
"--exclude-standard",
]);
if (candidates.length === 0) return [];
const child = spawn("git", [
"-C",
root,
"check-ignore",
"--no-index",
"--stdin",
"-z",
]);
const output: Buffer[] = [];
const errors: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => output.push(chunk));
child.stderr.on("data", (chunk: Buffer) => errors.push(chunk));
const completed = new Promise<void>((resolveExit, rejectExit) => {
child.once("error", rejectExit);
child.once("close", (code) => {
if (code === 0 || code === 1) resolveExit();
else
rejectExit(
new Error(
Buffer.concat(errors).toString("utf8") ||
`git check-ignore exited with ${code}`,
),
);
});
});
child.stdin.end(Buffer.from(`${candidates.join("\0")}\0`));
await completed;
const decoder = new TextDecoder("utf-8", { fatal: true });
// Decode strictly, like ls-files, so an invalid path cannot silently alias another.
const ignored = new Set<string>();
const bytes = Buffer.concat(output);
let start = 0;
for (let end = bytes.indexOf(0); end !== -1; end = bytes.indexOf(0, start)) {
if (end > start) ignored.add(decoder.decode(bytes.subarray(start, end)));
start = end + 1;
}
const selected = candidates
.filter((path) => !ignored.has(path))
.sort((a, b) => Buffer.from(a).compare(Buffer.from(b)));
rejectSelectedAutoSecrets(selected);
return selected;
}
function rejectSelectedAutoSecrets(paths: string[]): void {
const unsafe = paths.find((path) => {
const parts = path.split("/");
const name = parts.at(-1)!.toLowerCase();
const directories = parts.slice(0, -1).map((part) => part.toLowerCase());
const dotenv =
/^\.env(?:$|[._-]|rc$)/.test(name) &&
!/^\.env[._-](?:example|sample|template)(?:$|[._-])/.test(name);
const credentialDirectory = directories.some((part) =>
[
".aws",
".azure",
".gnupg",
".ssh",
".kube",
".docker",
"credentials",
"secrets",
].includes(part),
);
const conventionalCredential =
[".npmrc", ".pypirc", ".netrc"].includes(name) ||
/^(?:id_(?:rsa|dsa|ecdsa|ed25519)|identity)(?:$|\.)/.test(name) ||
/^(?:credentials?|.*_credentials?)(?:\.|$)/.test(name) ||
/\.(?:pem|key|p12|pfx|keystore)$/.test(name);
return dotenv || credentialDirectory || conventionalCredential;
});
if (unsafe) {
throw new Error(
`build: auto refuses to snapshot potential credentials at ${unsafe}; add it to .gitignore and retry`,
);
}
}
async function filesystemFiles(root: string): Promise<string[]> {
const files: string[] = [];
const ignoredDirectories = new Set([
".git", ".hg", ".svn", "node_modules", "vendor", "bower_components",
".venv", "venv", "__pycache__", ".tox", ".mypy_cache", ".pytest_cache",
".next", ".nuxt", ".svelte-kit", ".cache", ".turbo", "dist", "build", "coverage",
"target", "out", "tmp", "temp",
".git",
".hg",
".svn",
"node_modules",
"vendor",
"bower_components",
".venv",
"venv",
"__pycache__",
".tox",
".mypy_cache",
".pytest_cache",
".next",
".nuxt",
".svelte-kit",
".cache",
".turbo",
"dist",
"build",
"coverage",
"target",
"out",
"tmp",
"temp",
]);
const sensitiveDirectory = /(?:^|[-_.])(?:secrets?|credentials?|configs?)(?:$|[-_.])/i;
const ignoreRules: Array<{ base: string; pattern: string; directory: boolean }> = [];
const sensitiveDirectory =
/(?:^|[-_.])(?:secrets?|credentials?|configs?)(?:$|[-_.])/i;
const ignoreRules: Array<{
base: string;
pattern: string;
directory: boolean;
}> = [];
const loadIgnore = async (directory: string): Promise<void> => {
try {
const content = await readFile(resolve(directory, ".gitignore"), "utf8");
@@ -136,7 +289,12 @@ async function filesystemFiles(root: string): Promise<string[]> {
// Git's parent-directory semantics, so fallback stays fail-closed.
if (line.startsWith("!")) continue;
const rule = line.replace(/^\//, "");
if (rule) ignoreRules.push({ base: relative(root, directory).split(sep).join("/"), pattern: rule.replace(/\/$/, ""), directory: line.endsWith("/") });
if (rule)
ignoreRules.push({
base: relative(root, directory).split(sep).join("/"),
pattern: rule.replace(/\/$/, ""),
directory: line.endsWith("/"),
});
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
@@ -147,11 +305,22 @@ async function filesystemFiles(root: string): Promise<string[]> {
for (const rule of ignoreRules) {
const prefix = rule.base ? `${rule.base}/` : "";
if (rule.base && path !== rule.base && !path.startsWith(prefix)) continue;
const local = rule.base && path.startsWith(prefix) ? path.slice(prefix.length) : path;
const glob = rule.pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__DOUBLESTAR__").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/__DOUBLESTAR__/g, ".*");
const local =
rule.base && path.startsWith(prefix) ? path.slice(prefix.length) : path;
const glob = rule.pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "__DOUBLESTAR__")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, "[^/]")
.replace(/__DOUBLESTAR__/g, ".*");
const matcher = new RegExp(`^(?:${glob})(?:/.*)?$`);
const basenameMatcher = new RegExp(`^(?:${glob})$`);
if ((matcher.test(local) || local.split("/").some((part) => basenameMatcher.test(part))) && (!rule.directory || isDirectory || local.includes("/"))) ignored = true;
if (
(matcher.test(local) ||
local.split("/").some((part) => basenameMatcher.test(part))) &&
(!rule.directory || isDirectory || local.includes("/"))
)
ignored = true;
}
return ignored;
};
@@ -161,15 +330,30 @@ async function filesystemFiles(root: string): Promise<string[]> {
const source = resolve(directory, entry.name);
const path = relative(root, source).split(sep).join("/");
if (entry.isDirectory()) {
if (ignoredDirectories.has(entry.name) || sensitiveDirectory.test(entry.name) || ignoredByRules(path, true)) continue;
if (
ignoredDirectories.has(entry.name) ||
sensitiveDirectory.test(entry.name) ||
ignoredByRules(path, true)
)
continue;
await visit(source);
}
else if (entry.isFile() || entry.isSymbolicLink()) {
} else if (entry.isFile() || entry.isSymbolicLink()) {
if (ignoredByRules(path, false)) continue;
if (/^\.env/i.test(entry.name) || /(?:secret|credential|password|token|private[-_.]?key)/i.test(entry.name) || /^(?:id_rsa|id_ed25519|known_hosts|config\.json|\.npmrc|\.pypirc|\.netrc)$/i.test(entry.name)) continue;
if (
/^\.env/i.test(entry.name) ||
/(?:secret|credential|password|token|private[-_.]?key)/i.test(
entry.name,
) ||
/^(?:id_rsa|id_ed25519|known_hosts|config\.json|\.npmrc|\.pypirc|\.netrc)$/i.test(
entry.name,
)
)
continue;
files.push(path);
} else {
throw new Error(`Special files are not allowed in workspaces: ${relative(root, source).split(sep).join("/")}`);
throw new Error(
`Special files are not allowed in workspaces: ${relative(root, source).split(sep).join("/")}`,
);
}
}
};
@@ -187,7 +371,12 @@ async function isGitRepository(root: string): Promise<boolean> {
const exitCode = (error as { exitCode?: number }).exitCode;
const message = error instanceof Error ? error.message : String(error);
if (code === "ENOENT") return false;
if (exitCode === 128 || code === 128 || /not a git repository/i.test(message)) return false;
if (
exitCode === 128 ||
code === 128 ||
/not a git repository/i.test(message)
)
return false;
throw error;
}
}
@@ -347,15 +536,23 @@ export function validateWorkspaceManifest(manifest: WorkspaceManifest): void {
export async function enumerateWorkspace(
root: string,
mode: "default" | "auto" = "default",
): Promise<WorkspaceSnapshot> {
const repository = await realpath(root);
const gitBacked = await isGitRepository(repository);
const paths = gitBacked
? await (async () => {
await rejectSelectedSpecialFiles(repository);
return selectedFiles(repository);
})()
: await filesystemFiles(repository);
if (mode === "auto" && !gitBacked)
throw new Error(
"build: auto requires Git and a Git repository to apply .gitignore rules safely",
);
const paths =
mode === "auto"
? await selectedAutoFiles(repository)
: gitBacked
? await (async () => {
await rejectSelectedSpecialFiles(repository);
return selectedFiles(repository);
})()
: await filesystemFiles(repository);
const files: WorkspaceFile[] = [];
const blobs = new Map<Sha256Digest, Uint8Array>();
@@ -364,6 +561,19 @@ export async function enumerateWorkspace(
const source = resolve(repository, path);
if (!isWithin(repository, source))
throw new Error(`Workspace path escapes root: ${path}`);
// Git's index can retain files beneath a directory replaced by a symlink.
// A leaf O_NOFOLLOW does not protect against following that parent.
let parent = repository;
for (const part of path.split("/").slice(0, -1)) {
parent = resolve(parent, part);
try {
if (!(await lstat(parent)).isDirectory())
throw new Error(`Unsafe workspace parent: ${path}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
throw error;
}
}
let stat: Stats;
try {
@@ -377,11 +587,6 @@ export async function enumerateWorkspace(
let entry: WorkspaceFile;
if (stat.isSymbolicLink()) {
const target = await readlink(source);
if (
isAbsolute(target) ||
!isWithin(repository, resolve(dirname(source), target))
)
throw new Error(`Symlink escapes workspace: ${path} -> ${target}`);
data = Buffer.from(target);
entry = {
path,
@@ -422,6 +627,18 @@ export async function enumerateWorkspace(
version: BUILD_PROTOCOL_VERSION,
files,
} satisfies WorkspaceManifest;
const symlinks = new Set(
files.filter((file) => file.type === "symlink").map((file) => file.path),
);
for (const file of files) {
if (file.type === "symlink")
assertSafeSymlinkTarget(
repository,
file.path,
Buffer.from(blobs.get(file.digest)!).toString("utf8"),
symlinks,
);
}
return {
manifest,
digest: workspaceManifestDigest(manifest),
@@ -465,6 +682,21 @@ export async function materializeWorkspace(
reader: BlobReader,
): Promise<void> {
validateWorkspaceManifest(manifest);
const symlinks = new Set(
manifest.files
.filter((file) => file.type === "symlink")
.map((file) => file.path),
);
const linkTargets = new Map<string, string>();
for (const file of manifest.files) {
if (file.type !== "symlink") continue;
const data = await readBlob(reader, file.digest);
if (data.byteLength !== file.size || digest(data) !== file.digest)
throw new Error(`Blob verification failed for ${file.path}`);
const linkTarget = Buffer.from(data).toString("utf8");
assertSafeSymlinkTarget(destination, file.path, linkTarget, symlinks);
linkTargets.set(file.path, linkTarget);
}
await mkdir(destination, { recursive: false, mode: 0o755 });
const root = await realpath(destination);
@@ -492,19 +724,8 @@ export async function materializeWorkspace(
(entry) => entry.type === "symlink",
)) {
await ensureParentDirectories(root, file.path);
const data = await readBlob(reader, file.digest);
if (data.byteLength !== file.size || digest(data) !== file.digest)
throw new Error(`Blob verification failed for ${file.path}`);
const linkTarget = Buffer.from(data).toString("utf8");
const linkTarget = linkTargets.get(file.path)!;
const target = resolve(root, file.path);
if (
linkTarget.includes("\0") ||
isAbsolute(linkTarget) ||
!isWithin(root, resolve(dirname(target), linkTarget))
)
throw new Error(
`Symlink escapes workspace: ${file.path} -> ${linkTarget}`,
);
await symlink(linkTarget, target);
}
}
+22 -4
View File
@@ -16,10 +16,28 @@ let composeSchema: Promise<z.ZodType<ComposeSpecification>> | undefined;
function getComposeSchema(): Promise<z.ZodType<ComposeSpecification>> {
if (!composeSchema) {
composeSchema = import("../schema/docker.ts")
.then(({ default: schema }) =>
z.fromJSONSchema(
schema as unknown as Parameters<typeof z.fromJSONSchema>[0],
) as z.ZodType<ComposeSpecification>,
.then(
({ default: schema }) =>
z
.fromJSONSchema(
schema as unknown as Parameters<typeof z.fromJSONSchema>[0],
)
.superRefine((value, context) => {
if (!value || typeof value !== "object" || Array.isArray(value))
return;
for (const key of Object.keys(value)) {
if (
Object.hasOwn(schema.properties, key) ||
key.startsWith("x-")
)
continue;
context.addIssue({
code: "custom",
path: [key],
message: `Unrecognized Compose property: ${key}`,
});
}
}) as z.ZodType<ComposeSpecification>,
)
.catch((error) => {
composeSchema = undefined;