feat: release 2.7.0-rc2
This commit is contained in:
+179
-29
@@ -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>(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user