feat: v2
This commit is contained in:
+288
@@ -0,0 +1,288 @@
|
||||
import { KUBER_API_BASE_URL } from "../const";
|
||||
import type { ApiProblemDetails } from "../shared/api";
|
||||
import { readSession, type KuberSession } from "./session";
|
||||
|
||||
export type { ApiProblemDetails } from "../shared/api";
|
||||
|
||||
export const DEFAULT_API_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type ApiRequestInit = RequestInit & {
|
||||
/** JSON is serialized here so callers do not need to manage content headers. */
|
||||
json?: unknown;
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = {
|
||||
authenticated?: boolean;
|
||||
baseUrl?: string;
|
||||
session?: KuberSession;
|
||||
/** Set to 0 to disable the deadline, primarily for long-lived streams. */
|
||||
timeoutMs?: number;
|
||||
/** Byte offset for resumable binary upload requests. */
|
||||
uploadOffset?: number;
|
||||
};
|
||||
|
||||
export type ApiUploadOptions = ApiRequestOptions & {
|
||||
offset: number;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export class KuberApiError extends Error {
|
||||
readonly code: string;
|
||||
readonly requestId?: string;
|
||||
readonly operationId?: string;
|
||||
readonly problem: ApiProblemDetails;
|
||||
|
||||
constructor(message: string, status: number, problem?: ApiProblemDetails) {
|
||||
super(message);
|
||||
this.name = "KuberApiError";
|
||||
this.status = status;
|
||||
this.code = problem?.code ?? `HTTP_${status}`;
|
||||
this.requestId = problem?.requestId;
|
||||
this.operationId = problem?.operationId;
|
||||
this.problem = {
|
||||
...problem,
|
||||
status,
|
||||
title: problem?.title ?? message,
|
||||
code: this.code,
|
||||
};
|
||||
}
|
||||
|
||||
readonly status: number;
|
||||
}
|
||||
|
||||
type RequestDeadline = {
|
||||
signal: AbortSignal;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
function requestDeadline(
|
||||
signal: AbortSignal | null | undefined,
|
||||
timeoutMs = DEFAULT_API_TIMEOUT_MS,
|
||||
): RequestDeadline {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
||||
throw new RangeError("timeoutMs must be a finite non-negative number");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const abortFromCaller = () => controller.abort(signal?.reason);
|
||||
if (signal?.aborted) abortFromCaller();
|
||||
else signal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
|
||||
const timer =
|
||||
timeoutMs === 0
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
controller.abort(
|
||||
new DOMException(
|
||||
`API request timed out after ${timeoutMs}ms`,
|
||||
"TimeoutError",
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
clear: () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abortFromCaller);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type FetchBody = NonNullable<RequestInit["body"]>;
|
||||
|
||||
function isBinaryBody(body: FetchBody): boolean {
|
||||
return (
|
||||
body instanceof ArrayBuffer ||
|
||||
ArrayBuffer.isView(body) ||
|
||||
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
||||
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream)
|
||||
);
|
||||
}
|
||||
|
||||
async function requestHeaders(
|
||||
init: ApiRequestInit,
|
||||
options: ApiRequestOptions,
|
||||
): Promise<Headers> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
|
||||
if (Object.hasOwn(init, "json")) {
|
||||
headers.set("content-type", "application/json");
|
||||
} else if (
|
||||
init.body !== undefined &&
|
||||
init.body !== null &&
|
||||
!headers.has("content-type") &&
|
||||
!isBinaryBody(init.body)
|
||||
) {
|
||||
// Preserve the original apiRequest convention: string bodies are JSON.
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
if (options.uploadOffset !== undefined) {
|
||||
if (
|
||||
!Number.isSafeInteger(options.uploadOffset) ||
|
||||
options.uploadOffset < 0
|
||||
) {
|
||||
throw new RangeError("uploadOffset must be a non-negative safe integer");
|
||||
}
|
||||
headers.set("upload-offset", String(options.uploadOffset));
|
||||
}
|
||||
|
||||
if (options.authenticated !== false) {
|
||||
const session = options.session ?? (await readSession());
|
||||
if (!session) throw new Error("Not logged in. Run kuber login first.");
|
||||
headers.set("authorization", `Bearer ${session.token}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function responseProblem(response: Response): Promise<ApiProblemDetails> {
|
||||
let problem: ApiProblemDetails | undefined;
|
||||
try {
|
||||
const value: unknown = JSON.parse(await response.text());
|
||||
if (value && typeof value === "object") {
|
||||
problem = value as ApiProblemDetails;
|
||||
}
|
||||
} catch {
|
||||
// The status and response headers still provide a stable error shape.
|
||||
}
|
||||
|
||||
return {
|
||||
...problem,
|
||||
status: response.status,
|
||||
title: problem?.title || response.statusText || `HTTP ${response.status}`,
|
||||
code: problem?.code || `HTTP_${response.status}`,
|
||||
requestId:
|
||||
problem?.requestId ?? response.headers.get("x-request-id") ?? undefined,
|
||||
operationId:
|
||||
problem?.operationId ??
|
||||
response.headers.get("x-operation-id") ??
|
||||
undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertResponseOk(response: Response): Promise<void> {
|
||||
if (response.ok) return;
|
||||
const problem = await responseProblem(response);
|
||||
throw new KuberApiError(
|
||||
problem.detail || problem.message || problem.title,
|
||||
response.status,
|
||||
problem,
|
||||
);
|
||||
}
|
||||
|
||||
async function sendRequest(
|
||||
path: string,
|
||||
init: ApiRequestInit,
|
||||
options: ApiRequestOptions,
|
||||
signal: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const headers = await requestHeaders(init, options);
|
||||
const { json, ...requestInit } = init;
|
||||
const body = Object.hasOwn(init, "json") ? JSON.stringify(json) : init.body;
|
||||
return fetch(`${options.baseUrl ?? KUBER_API_BASE_URL}${path}`, {
|
||||
...requestInit,
|
||||
body,
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
init: ApiRequestInit = {},
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const deadline = requestDeadline(init.signal, options.timeoutMs);
|
||||
try {
|
||||
const response = await sendRequest(path, init, options, deadline.signal);
|
||||
await assertResponseOk(response);
|
||||
|
||||
if (
|
||||
init.method?.toUpperCase() === "HEAD" ||
|
||||
response.status === 204 ||
|
||||
response.status === 205 ||
|
||||
response.body === null
|
||||
) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
} finally {
|
||||
deadline.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function apiUpload<T = void>(
|
||||
path: string,
|
||||
body: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>,
|
||||
options: ApiUploadOptions,
|
||||
): Promise<T> {
|
||||
const {
|
||||
contentType = "application/octet-stream",
|
||||
offset,
|
||||
...requestOptions
|
||||
} = options;
|
||||
return apiRequest<T>(
|
||||
path,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": contentType },
|
||||
body: body as FetchBody,
|
||||
},
|
||||
{ ...requestOptions, uploadOffset: offset },
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses records as they arrive instead of buffering the complete response. */
|
||||
export async function* apiStreamNdjson<T>(
|
||||
path: string,
|
||||
init: ApiRequestInit = {},
|
||||
options: ApiRequestOptions = {},
|
||||
): AsyncGenerator<T, void, void> {
|
||||
const deadline = requestDeadline(init.signal, options.timeoutMs);
|
||||
try {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("accept", "application/x-ndjson");
|
||||
const response = await sendRequest(
|
||||
path,
|
||||
{ ...init, headers },
|
||||
options,
|
||||
deadline.signal,
|
||||
);
|
||||
await assertResponseOk(response);
|
||||
if (!response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, "").trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) yield JSON.parse(line) as T;
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
const finalLine = buffer.replace(/\r$/, "").trim();
|
||||
if (finalLine) yield JSON.parse(finalLine) as T;
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// Cancellation can race with an upstream abort.
|
||||
}
|
||||
reader.releaseLock();
|
||||
}
|
||||
} finally {
|
||||
deadline.clear();
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -9,14 +9,19 @@ const ResourceOrder = {
|
||||
Namespace: 0,
|
||||
GarageBucket: 1,
|
||||
GarageKey: 2,
|
||||
StorageClass: 3,
|
||||
PersistentVolumeClaim: 4,
|
||||
Secret: 5,
|
||||
ConfigMap: 6,
|
||||
Service: 7,
|
||||
Deployment: 8,
|
||||
Ingress: 9,
|
||||
IngressRoute: 10,
|
||||
ServiceAccount: 3,
|
||||
ClusterRole: 4,
|
||||
Role: 5,
|
||||
ClusterRoleBinding: 6,
|
||||
RoleBinding: 7,
|
||||
StorageClass: 8,
|
||||
PersistentVolumeClaim: 9,
|
||||
Secret: 10,
|
||||
ConfigMap: 11,
|
||||
Service: 12,
|
||||
Deployment: 13,
|
||||
Ingress: 14,
|
||||
IngressRoute: 15,
|
||||
} as const;
|
||||
|
||||
const ManagedResources = [
|
||||
|
||||
+278
-585
@@ -1,41 +1,42 @@
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { hostname, tmpdir } from "node:os";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Writable } from "node:stream";
|
||||
import type { ComposeSpecification, Service } from "../schema/docker.d";
|
||||
import { getComposeArch, withComposeArch } from "./arch";
|
||||
import { DEFAULT_BUILDERS, DEFAULT_REGISTRY } from "./config";
|
||||
import {
|
||||
BUILD_PROTOCOL_VERSION,
|
||||
type BuildEvent,
|
||||
type BuildRequest,
|
||||
type BuildStatus,
|
||||
type Sha256Digest,
|
||||
} from "../shared/build-protocol";
|
||||
import { resolveComposeArch } from "./arch";
|
||||
import { apiRequest, type ApiRequestInit } from "./api";
|
||||
import { DEFAULT_REGISTRY } from "./config";
|
||||
import {
|
||||
enumerateWorkspace,
|
||||
serializeWorkspaceManifest,
|
||||
type WorkspaceSnapshot,
|
||||
} from "./workspace";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
export type ApiRequester = <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => Promise<T>;
|
||||
|
||||
export type BuildOptions = {
|
||||
registry?: string;
|
||||
builders?: {
|
||||
amd64?: string;
|
||||
arm64?: string;
|
||||
remoteRoot?: string;
|
||||
};
|
||||
request?: ApiRequester;
|
||||
pollIntervalMs?: number;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
snapshot?: WorkspaceSnapshot;
|
||||
};
|
||||
|
||||
type BuildRuntime = {
|
||||
registry: string;
|
||||
builders: {
|
||||
amd64: string;
|
||||
arm64: string;
|
||||
remoteRoot: string;
|
||||
};
|
||||
};
|
||||
|
||||
const buildRuntime = new AsyncLocalStorage<BuildRuntime>();
|
||||
|
||||
function getBuildRuntime(): BuildRuntime {
|
||||
return (
|
||||
buildRuntime.getStore() ?? {
|
||||
registry: DEFAULT_REGISTRY,
|
||||
builders: DEFAULT_BUILDERS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type BuildPlan = {
|
||||
name: string;
|
||||
image: string;
|
||||
@@ -57,36 +58,35 @@ export type BuildResult = {
|
||||
images: Record<string, string>;
|
||||
};
|
||||
|
||||
type SpawnResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
type SnapshotNegotiation = {
|
||||
workspace: Sha256Digest;
|
||||
missing: Sha256Digest[];
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
function summarizeCommandFailure(
|
||||
exitCode: number,
|
||||
stderrLines: string[],
|
||||
stdoutLines: string[],
|
||||
type ImageResult = {
|
||||
image: string;
|
||||
digest: Sha256Digest;
|
||||
reference: string;
|
||||
};
|
||||
|
||||
function posixRelative(root: string, path: string): string {
|
||||
return relative(root, path).split(sep).join("/") || ".";
|
||||
}
|
||||
|
||||
function assertInsideRepo(
|
||||
repoRoot: string,
|
||||
path: string,
|
||||
description: string,
|
||||
service: string,
|
||||
): string {
|
||||
const lines = (stderrLines.length > 0 ? stderrLines : stdoutLines)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return `Command failed with exit code ${exitCode}`;
|
||||
|
||||
const preview = lines.slice(0, 8).join("\n");
|
||||
return lines.length > 8
|
||||
? `${preview}\n... (${lines.length - 8} more lines)`
|
||||
: preview;
|
||||
}
|
||||
|
||||
function toReadableStream(
|
||||
stream: number | ReadableStream<Uint8Array> | undefined,
|
||||
): ReadableStream<Uint8Array> | undefined {
|
||||
return typeof stream === "number" ? undefined : stream;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
const value = relative(repoRoot, path);
|
||||
if (value.startsWith(`..${sep}`) || value === ".." || isAbsolute(value)) {
|
||||
throw new Error(
|
||||
`${description} must stay inside the git repo for service ${service}`,
|
||||
);
|
||||
}
|
||||
return posixRelative(repoRoot, path);
|
||||
}
|
||||
|
||||
function resolveBuildArgs(service: Service): string[] {
|
||||
@@ -94,12 +94,9 @@ function resolveBuildArgs(service: Service): string[] {
|
||||
!service.build ||
|
||||
typeof service.build === "string" ||
|
||||
!service.build.args
|
||||
) {
|
||||
)
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(service.build.args)) return [...service.build.args];
|
||||
|
||||
return Object.entries(service.build.args)
|
||||
.filter(([, value]) => value !== null)
|
||||
.map(([key, value]) => `${key}=${String(value)}`);
|
||||
@@ -111,238 +108,66 @@ function resolveBuildPlan(
|
||||
service: Service,
|
||||
cwd: string,
|
||||
repoRoot: string,
|
||||
buildRoot: string,
|
||||
registry: string,
|
||||
): BuildPlan | undefined {
|
||||
if (!service.build) return;
|
||||
|
||||
const build = service.build;
|
||||
const contextInput =
|
||||
typeof build === "string" ? build : (build.context ?? ".");
|
||||
|
||||
if (contextInput.includes("://")) {
|
||||
if (contextInput.includes("://"))
|
||||
throw new Error(
|
||||
`Remote build context is not supported for service ${name}`,
|
||||
);
|
||||
}
|
||||
if (typeof build !== "string" && build.dockerfile_inline)
|
||||
throw new Error(`dockerfile_inline is not supported for service ${name}`);
|
||||
|
||||
const contextPath = resolve(cwd, contextInput);
|
||||
const contextRelative = relative(repoRoot, contextPath);
|
||||
if (contextRelative.startsWith("..") || isAbsolute(contextRelative)) {
|
||||
throw new Error(
|
||||
`Build context must stay inside the git repo for service ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof build !== "string" && build.dockerfile_inline) {
|
||||
throw new Error(`dockerfile_inline is not supported for service ${name}`);
|
||||
}
|
||||
|
||||
const context = assertInsideRepo(
|
||||
repoRoot,
|
||||
contextPath,
|
||||
"Build context",
|
||||
name,
|
||||
);
|
||||
const dockerfilePath =
|
||||
typeof build === "string" || !build.dockerfile
|
||||
? undefined
|
||||
: resolve(contextPath, build.dockerfile);
|
||||
const dockerfileRelative = dockerfilePath
|
||||
? relative(repoRoot, dockerfilePath)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
dockerfileRelative?.startsWith("..") ||
|
||||
isAbsolute(dockerfileRelative ?? "")
|
||||
) {
|
||||
throw new Error(
|
||||
`Dockerfile must stay inside the git repo for service ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
image: getBuildImageName(project, name),
|
||||
context: `${buildRoot}/${contextRelative === "" ? "." : contextRelative}`,
|
||||
dockerfile: dockerfileRelative
|
||||
? `${buildRoot}/${dockerfileRelative}`
|
||||
// The server replaces this requested name with its configured imageName.
|
||||
image: getBuildImageName(project, name, registry),
|
||||
context,
|
||||
dockerfile: dockerfilePath
|
||||
? assertInsideRepo(repoRoot, dockerfilePath, "Dockerfile", name)
|
||||
: undefined,
|
||||
target: typeof build === "string" ? undefined : build.target,
|
||||
buildArgs: resolveBuildArgs(service),
|
||||
};
|
||||
}
|
||||
|
||||
function getRemoteBuilder(): string {
|
||||
const builders = getBuildRuntime().builders;
|
||||
return getComposeArch() === "amd64" ? builders.amd64 : builders.arm64;
|
||||
}
|
||||
|
||||
function isOnRemoteBuilder(): boolean {
|
||||
return hostname() === getRemoteBuilder().split("@").at(-1);
|
||||
}
|
||||
|
||||
async function pumpStream(
|
||||
stream: ReadableStream<Uint8Array> | null | undefined,
|
||||
onLine: (line: string) => void | Promise<void>,
|
||||
) {
|
||||
if (!stream) return;
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) await onLine(line);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
const line = buffer.replace(/\r$/, "");
|
||||
if (line) await onLine(line);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithOutput(
|
||||
command: Bun.Subprocess,
|
||||
reporter?: BuildReporter,
|
||||
) {
|
||||
const stdoutLines: string[] = [];
|
||||
const stderrLines: string[] = [];
|
||||
let streamBuffer = "";
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function flushStreamBuffer() {
|
||||
if (!reporter?.stream || streamBuffer.length === 0) return;
|
||||
reporter.stream.write(streamBuffer);
|
||||
streamBuffer = "";
|
||||
}
|
||||
|
||||
function queueStreamLine(line: string) {
|
||||
streamBuffer += `${line}\n`;
|
||||
if (streamBuffer.length >= 8192) {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = undefined;
|
||||
}
|
||||
flushStreamBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (flushTimer) return;
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = undefined;
|
||||
flushStreamBuffer();
|
||||
}, 33);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
pumpStream(toReadableStream(command.stdout), async (line) => {
|
||||
stdoutLines.push(line);
|
||||
if (reporter?.stream) queueStreamLine(line);
|
||||
else if (reporter?.progress) await reporter.progress(line);
|
||||
}),
|
||||
pumpStream(toReadableStream(command.stderr), async (line) => {
|
||||
stderrLines.push(line);
|
||||
if (reporter?.stream) queueStreamLine(line);
|
||||
else if (reporter?.progress) await reporter.progress(line);
|
||||
}),
|
||||
]);
|
||||
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = undefined;
|
||||
}
|
||||
flushStreamBuffer();
|
||||
|
||||
const exitCode = await command.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
summarizeCommandFailure(exitCode, stderrLines, stdoutLines),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdoutLines.join("\n"),
|
||||
stderr: stderrLines.join("\n"),
|
||||
} satisfies SpawnResult;
|
||||
}
|
||||
|
||||
function ssh(script: string) {
|
||||
const remoteCommand = `bash -lc ${shellQuote(script)}`;
|
||||
return Bun.spawn(["ssh", getRemoteBuilder(), remoteCommand], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
function scp(localPath: string, remotePath: string) {
|
||||
return Bun.spawn(["scp", localPath, `${getRemoteBuilder()}:${remotePath}`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
export function parseImageManifestDigest(output: string): string {
|
||||
const manifest = JSON.parse(output) as { digest?: unknown };
|
||||
if (
|
||||
typeof manifest.digest !== "string" ||
|
||||
!/^sha256:[a-f0-9]{64}$/.test(manifest.digest)
|
||||
) {
|
||||
)
|
||||
throw new Error("Registry response did not contain a valid image digest");
|
||||
}
|
||||
return manifest.digest;
|
||||
}
|
||||
|
||||
export function toPinnedImage(image: string, digest: string): string {
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(digest)) {
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(digest))
|
||||
throw new Error(`Invalid image digest ${digest}`);
|
||||
}
|
||||
return `${image}@${digest}`;
|
||||
}
|
||||
|
||||
export function getBuildImageName(
|
||||
project: string,
|
||||
service: string,
|
||||
registry = getBuildRuntime().registry,
|
||||
registry = DEFAULT_REGISTRY,
|
||||
): string {
|
||||
return `${registry}/kuber/${project}-${service}:latest`;
|
||||
}
|
||||
|
||||
async function inspectRemoteImageDigest(image: string): Promise<string> {
|
||||
const inspectCommand = [
|
||||
"docker",
|
||||
"buildx",
|
||||
"imagetools",
|
||||
"inspect",
|
||||
image,
|
||||
"--format",
|
||||
"{{json .Manifest}}",
|
||||
]
|
||||
.map(shellQuote)
|
||||
.join(" ");
|
||||
|
||||
const result = await runWithOutput(
|
||||
ssh(`set -euo pipefail; ${inspectCommand}`),
|
||||
);
|
||||
return parseImageManifestDigest(result.stdout);
|
||||
}
|
||||
|
||||
async function tryInspectRemoteImageDigest(
|
||||
image: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await inspectRemoteImageDigest(image);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return `${registry.replace(/\/+$/, "")}/kuber/${project}-${service}:latest`;
|
||||
}
|
||||
|
||||
export function imageDigestChanged(
|
||||
@@ -352,15 +177,126 @@ export function imageDigestChanged(
|
||||
return !before || !after || before !== after;
|
||||
}
|
||||
|
||||
function resolveBuildRuntime(options: BuildOptions): BuildRuntime {
|
||||
return {
|
||||
registry: options.registry ?? DEFAULT_REGISTRY,
|
||||
builders: {
|
||||
amd64: options.builders?.amd64 ?? DEFAULT_BUILDERS.amd64,
|
||||
arm64: options.builders?.arm64 ?? DEFAULT_BUILDERS.arm64,
|
||||
remoteRoot: options.builders?.remoteRoot ?? DEFAULT_BUILDERS.remoteRoot,
|
||||
},
|
||||
};
|
||||
export async function getRepoRoot(cwd: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"rev-parse",
|
||||
"--show-toplevel",
|
||||
]);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function uploadBlob(
|
||||
digest: Sha256Digest,
|
||||
data: Uint8Array,
|
||||
request: ApiRequester,
|
||||
): Promise<void> {
|
||||
const path = `/blobs/${encodeURIComponent(digest)}/uploads`;
|
||||
const progress = await request<{ offset: number; complete: boolean }>(path, {
|
||||
method: "POST",
|
||||
json: { size: data.byteLength },
|
||||
});
|
||||
let offset = progress.offset;
|
||||
while (!progress.complete && offset < data.byteLength) {
|
||||
const chunk = data.subarray(offset, offset + UPLOAD_CHUNK_BYTES);
|
||||
const uploaded = await request<{ offset: number }>(path, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"upload-offset": String(offset),
|
||||
},
|
||||
body: chunk,
|
||||
});
|
||||
if (uploaded.offset <= offset)
|
||||
throw new Error(`Blob upload for ${digest} made no progress`);
|
||||
offset = uploaded.offset;
|
||||
}
|
||||
if (!progress.complete) {
|
||||
await request(`${path}/complete`, { method: "POST", json: {} });
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadWorkspaceSnapshot(
|
||||
snapshot: WorkspaceSnapshot,
|
||||
request: ApiRequester = apiRequest,
|
||||
reporter?: BuildReporter,
|
||||
): Promise<void> {
|
||||
const blobs = new Map(snapshot.blobs.map((blob) => [blob.digest, blob.data]));
|
||||
blobs.set(snapshot.digest, serializeWorkspaceManifest(snapshot.manifest));
|
||||
|
||||
for (;;) {
|
||||
const negotiation = await request<SnapshotNegotiation>(
|
||||
"/snapshots/negotiate",
|
||||
{
|
||||
method: "POST",
|
||||
json: { workspace: snapshot.digest },
|
||||
},
|
||||
);
|
||||
if (negotiation.ready) return;
|
||||
if (negotiation.missing.length === 0)
|
||||
throw new Error(
|
||||
"Snapshot negotiation is incomplete but reported no missing blobs",
|
||||
);
|
||||
for (const digest of negotiation.missing) {
|
||||
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, request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reportBuildEvent(
|
||||
event: BuildEvent,
|
||||
reporter?: BuildReporter,
|
||||
reportedStates?: Set<BuildStatus["state"]>,
|
||||
): Promise<number> {
|
||||
if (event.type === "status") {
|
||||
if (!reportedStates?.has(event.status.state)) {
|
||||
reportedStates?.add(event.status.state);
|
||||
await reporter?.progress?.(`Build ${event.status.state}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (reporter?.stream) reporter.stream.write(event.message);
|
||||
else await reporter?.progress?.(event.message.trimEnd());
|
||||
return event.sequence;
|
||||
}
|
||||
|
||||
async function waitForBuild(
|
||||
id: string,
|
||||
request: ApiRequester,
|
||||
reporter: BuildReporter | undefined,
|
||||
pollIntervalMs: number,
|
||||
sleep: (milliseconds: number) => Promise<void>,
|
||||
initial: BuildStatus,
|
||||
): Promise<BuildStatus> {
|
||||
let status = initial;
|
||||
let sequence = 0;
|
||||
const reportedStates = new Set<BuildStatus["state"]>();
|
||||
for (;;) {
|
||||
const events = await request<BuildEvent[]>(
|
||||
`/builds/${encodeURIComponent(id)}/events?after=${sequence}`,
|
||||
);
|
||||
for (const event of events)
|
||||
sequence = Math.max(
|
||||
sequence,
|
||||
await reportBuildEvent(event, reporter, reportedStates),
|
||||
);
|
||||
if (status.state === "succeeded" || status.state === "failed")
|
||||
return status;
|
||||
status = await request<BuildStatus>(
|
||||
`/builds/${encodeURIComponent(id)}/reconcile`,
|
||||
{
|
||||
method: "POST",
|
||||
json: {},
|
||||
},
|
||||
);
|
||||
if (status.state !== "succeeded" && status.state !== "failed")
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBuildImages(
|
||||
@@ -368,293 +304,25 @@ export async function resolveBuildImages(
|
||||
compose: ComposeSpecification,
|
||||
options: BuildOptions = {},
|
||||
): Promise<Record<string, string>> {
|
||||
return buildRuntime.run(resolveBuildRuntime(options), () =>
|
||||
withComposeArch(compose, async () => {
|
||||
const images: Record<string, string> = {};
|
||||
for (const [name, service] of Object.entries(compose.services ?? {})) {
|
||||
if (!service.build) continue;
|
||||
const image = getBuildImageName(project, name);
|
||||
try {
|
||||
images[name] = toPinnedImage(
|
||||
image,
|
||||
await inspectRemoteImageDigest(image),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot resolve a published image for service ${name}. Run kuber up to build it.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getRepoRoot(cwd: string): Promise<string> {
|
||||
return Bun.$.cwd(cwd)`git rev-parse --show-toplevel`
|
||||
.text()
|
||||
.then((e) => e.trim());
|
||||
}
|
||||
|
||||
async function getHeadSha(repoRoot: string): Promise<string> {
|
||||
return Bun.$.cwd(repoRoot)`git rev-parse HEAD`.text().then((e) => e.trim());
|
||||
}
|
||||
|
||||
async function getTrackedDiff(repoRoot: string): Promise<string> {
|
||||
return Bun.$.cwd(repoRoot)`git diff --binary HEAD`.text();
|
||||
}
|
||||
|
||||
async function getUntrackedFiles(repoRoot: string): Promise<string[]> {
|
||||
const result = Bun.spawn(
|
||||
["git", "ls-files", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
const output = await runWithOutput(result);
|
||||
|
||||
return output.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function getIgnoredDotenvFiles(repoRoot: string): Promise<string[]> {
|
||||
const result = Bun.spawn(
|
||||
[
|
||||
"git",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--ignored",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
".env*",
|
||||
"**/.env*",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
const output = await runWithOutput(result);
|
||||
|
||||
return output.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function getRemoteHead(remoteRepo: string): Promise<string | undefined> {
|
||||
const result = ssh(
|
||||
`if [ -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} rev-parse HEAD; fi`,
|
||||
);
|
||||
|
||||
const output = await runWithOutput(result);
|
||||
const head = output.stdout.trim();
|
||||
return head || undefined;
|
||||
}
|
||||
|
||||
async function syncRemoteRepo(
|
||||
repoRoot: string,
|
||||
remoteRepo: string,
|
||||
reporter?: BuildReporter,
|
||||
) {
|
||||
const headSha = await getHeadSha(repoRoot);
|
||||
const remoteHead = await getRemoteHead(remoteRepo);
|
||||
const tempDir = await mkdtemp(`${tmpdir()}/kuber-build-`);
|
||||
|
||||
try {
|
||||
if (remoteHead !== headSha) {
|
||||
await reporter?.progress?.("Transferring latest git bundle");
|
||||
const bundlePath = `${tempDir}/repo.bundle`;
|
||||
const remoteBundle = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}.bundle`;
|
||||
|
||||
await Bun.$.cwd(repoRoot)`git bundle create ${bundlePath} HEAD`;
|
||||
await runWithOutput(
|
||||
ssh(`mkdir -p ${shellQuote(getBuildRuntime().builders.remoteRoot)}`),
|
||||
reporter,
|
||||
);
|
||||
await runWithOutput(scp(bundlePath, remoteBundle), reporter);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`mkdir -p ${shellQuote(remoteRepo)}`,
|
||||
`if [ ! -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} init; fi`,
|
||||
`git -C ${shellQuote(remoteRepo)} fetch --force "$PWD/${remoteBundle}" HEAD`,
|
||||
`git -C ${shellQuote(remoteRepo)} reset --hard FETCH_HEAD`,
|
||||
`git -C ${shellQuote(remoteRepo)} clean -fd`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
} else {
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`git -C ${shellQuote(remoteRepo)} reset --hard HEAD`,
|
||||
`git -C ${shellQuote(remoteRepo)} clean -fd`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
const request = options.request ?? apiRequest;
|
||||
const images: Record<string, string> = {};
|
||||
for (const [service, definition] of Object.entries(compose.services ?? {})) {
|
||||
if (!definition.build) continue;
|
||||
try {
|
||||
images[service] = (
|
||||
await request<ImageResult>("/images/resolve", {
|
||||
method: "POST",
|
||||
json: { project, service },
|
||||
})
|
||||
).reference;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot resolve a published image for service ${service}. Run kuber up to build it.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
const diff = await getTrackedDiff(repoRoot);
|
||||
if (diff.trim().length > 0) {
|
||||
await reporter?.progress?.("Applying local git diff on remote builder");
|
||||
const diffPath = `${tempDir}/repo.diff`;
|
||||
const remoteDiff = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}.diff`;
|
||||
await Bun.write(diffPath, diff);
|
||||
await runWithOutput(scp(diffPath, remoteDiff), reporter);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`git -C ${shellQuote(remoteRepo)} apply --allow-binary-replacement "$PWD/${remoteDiff}"`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
}
|
||||
|
||||
const untrackedFiles = await getUntrackedFiles(repoRoot);
|
||||
if (untrackedFiles.length > 0) {
|
||||
await reporter?.progress?.("Syncing untracked files to remote builder");
|
||||
const untrackedDir = `${tempDir}/untracked`;
|
||||
|
||||
for (const file of untrackedFiles) {
|
||||
const source = resolve(repoRoot, file);
|
||||
const target = resolve(untrackedDir, file);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await cp(source, target, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
const untrackedArchive = `${tempDir}/untracked.tar`;
|
||||
const remoteUntrackedArchive = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}-untracked.tar`;
|
||||
await Bun.$`tar -C ${untrackedDir} -cf ${untrackedArchive} .`;
|
||||
await runWithOutput(
|
||||
scp(untrackedArchive, remoteUntrackedArchive),
|
||||
reporter,
|
||||
);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`tar -C ${shellQuote(remoteRepo)} -xf "$PWD/${remoteUntrackedArchive}"`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
}
|
||||
|
||||
const ignoredDotenvFiles = await getIgnoredDotenvFiles(repoRoot);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`git -C ${shellQuote(remoteRepo)} clean -fdX -- .env* '**/.env*'`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
|
||||
if (ignoredDotenvFiles.length === 0) return;
|
||||
|
||||
await reporter?.progress?.("Syncing ignored .env* files to remote builder");
|
||||
const dotenvDir = `${tempDir}/dotenv`;
|
||||
|
||||
for (const file of ignoredDotenvFiles) {
|
||||
const source = resolve(repoRoot, file);
|
||||
const target = resolve(dotenvDir, file);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await cp(source, target, { force: true });
|
||||
}
|
||||
|
||||
const dotenvArchive = `${tempDir}/dotenv.tar`;
|
||||
const remoteDotenvArchive = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}-dotenv.tar`;
|
||||
await Bun.$`tar -C ${dotenvDir} -cf ${dotenvArchive} .`;
|
||||
await runWithOutput(scp(dotenvArchive, remoteDotenvArchive), reporter);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`tar -C ${shellQuote(remoteRepo)} -xf "$PWD/${remoteDotenvArchive}"`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function getBuildPlans(
|
||||
project: string,
|
||||
compose: ComposeSpecification,
|
||||
cwd: string,
|
||||
repoRoot: string,
|
||||
remoteRepo: string,
|
||||
): BuildPlan[] {
|
||||
return Object.entries(compose.services ?? {}).flatMap(([name, service]) => {
|
||||
const plan = resolveBuildPlan(
|
||||
project,
|
||||
name,
|
||||
service,
|
||||
cwd,
|
||||
repoRoot,
|
||||
remoteRepo,
|
||||
);
|
||||
return plan ? [plan] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function buildRemote(plan: BuildPlan, reporter?: BuildReporter) {
|
||||
await reporter?.progress?.(`Building ${plan.name}`);
|
||||
const args = [
|
||||
"docker",
|
||||
"build",
|
||||
"--push",
|
||||
"--progress=plain",
|
||||
"-t",
|
||||
plan.image,
|
||||
...(plan.dockerfile ? ["-f", plan.dockerfile] : []),
|
||||
...(plan.target ? ["--target", plan.target] : []),
|
||||
...plan.buildArgs.flatMap((arg) => ["--build-arg", arg]),
|
||||
plan.context,
|
||||
];
|
||||
|
||||
const command = args.map(shellQuote).join(" ");
|
||||
await runWithOutput(ssh(`set -euo pipefail; ${command}`), reporter);
|
||||
}
|
||||
|
||||
async function buildLocal(plan: BuildPlan, reporter?: BuildReporter) {
|
||||
await reporter?.progress?.(`Building ${plan.name}`);
|
||||
const command = Bun.spawn(
|
||||
[
|
||||
"docker",
|
||||
"build",
|
||||
"--push",
|
||||
"--progress=plain",
|
||||
"-t",
|
||||
plan.image,
|
||||
...(plan.dockerfile ? ["-f", plan.dockerfile] : []),
|
||||
...(plan.target ? ["--target", plan.target] : []),
|
||||
...plan.buildArgs.flatMap((arg) => ["--build-arg", arg]),
|
||||
plan.context,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
await runWithOutput(command, reporter);
|
||||
return images;
|
||||
}
|
||||
|
||||
export async function buildServices(
|
||||
@@ -664,45 +332,70 @@ export async function buildServices(
|
||||
reporter?: BuildReporter,
|
||||
options: BuildOptions = {},
|
||||
): Promise<BuildResult> {
|
||||
const runtime = resolveBuildRuntime(options);
|
||||
if (!Object.values(compose.services ?? {}).some((service) => service.build))
|
||||
return { built: [], changed: [], images: {} };
|
||||
|
||||
return buildRuntime.run(runtime, () =>
|
||||
withComposeArch(compose, async () => {
|
||||
if (
|
||||
!Object.values(compose.services ?? {}).some((service) => service.build)
|
||||
) {
|
||||
return { built: [], changed: [], images: {} };
|
||||
}
|
||||
|
||||
const repoRoot = await getRepoRoot(cwd);
|
||||
const localBuilder = isOnRemoteBuilder();
|
||||
const buildRoot = localBuilder
|
||||
? repoRoot
|
||||
: `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}`;
|
||||
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
|
||||
|
||||
if (plans.length === 0) return { built: [], changed: [], images: {} };
|
||||
|
||||
if (!localBuilder) {
|
||||
await syncRemoteRepo(repoRoot, buildRoot, reporter);
|
||||
}
|
||||
|
||||
const changed: string[] = [];
|
||||
const images: Record<string, string> = {};
|
||||
for (const plan of plans) {
|
||||
const before = await tryInspectRemoteImageDigest(plan.image);
|
||||
if (localBuilder) await buildLocal(plan, reporter);
|
||||
else await buildRemote(plan, reporter);
|
||||
const after = await inspectRemoteImageDigest(plan.image);
|
||||
images[plan.name] = toPinnedImage(plan.image, after);
|
||||
if (imageDigestChanged(before, after)) changed.push(plan.name);
|
||||
}
|
||||
|
||||
return {
|
||||
built: plans.map((plan) => plan.name),
|
||||
changed,
|
||||
images,
|
||||
};
|
||||
}),
|
||||
const request = options.request ?? apiRequest;
|
||||
const repoRoot = await getRepoRoot(cwd);
|
||||
const snapshot = options.snapshot ?? (await enumerateWorkspace(repoRoot));
|
||||
const plans = Object.entries(compose.services ?? {}).flatMap(
|
||||
([name, service]) => {
|
||||
const plan = resolveBuildPlan(
|
||||
project,
|
||||
name,
|
||||
service,
|
||||
cwd,
|
||||
repoRoot,
|
||||
options.registry ?? DEFAULT_REGISTRY,
|
||||
);
|
||||
return plan ? [plan] : [];
|
||||
},
|
||||
);
|
||||
await uploadWorkspaceSnapshot(snapshot, request, reporter);
|
||||
|
||||
const images: Record<string, string> = {};
|
||||
for (const plan of plans) {
|
||||
await reporter?.progress?.(`Building ${plan.name}`);
|
||||
const id = randomUUID();
|
||||
const buildRequest: BuildRequest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
id,
|
||||
project,
|
||||
service: plan.name,
|
||||
spec: {
|
||||
architecture: resolveComposeArch(compose),
|
||||
image: plan.image,
|
||||
context: plan.context,
|
||||
dockerfile: plan.dockerfile,
|
||||
target: plan.target,
|
||||
buildArgs: plan.buildArgs,
|
||||
workspace: snapshot.digest,
|
||||
},
|
||||
};
|
||||
const initial = await request<BuildStatus>("/builds", {
|
||||
method: "POST",
|
||||
json: buildRequest,
|
||||
});
|
||||
const status = await waitForBuild(
|
||||
id,
|
||||
request,
|
||||
reporter,
|
||||
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
||||
options.sleep ?? ((milliseconds) => Bun.sleep(milliseconds)),
|
||||
initial,
|
||||
);
|
||||
if (status.state !== "succeeded")
|
||||
throw new Error(
|
||||
`Build failed for service ${plan.name}: ${status.error ?? "unknown error"}`,
|
||||
);
|
||||
images[plan.name] = (
|
||||
await request<ImageResult>(`/builds/${encodeURIComponent(id)}/result`)
|
||||
).reference;
|
||||
}
|
||||
|
||||
return {
|
||||
built: plans.map((plan) => plan.name),
|
||||
changed: plans.map((plan) => plan.name),
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ import { IMAGE_REGISTRY } from "../const";
|
||||
const DEFAULT_CONFIG_FILE = ".kuberrc.ts";
|
||||
|
||||
export const DEFAULT_REGISTRY = IMAGE_REGISTRY;
|
||||
export const DEFAULT_BUILDERS = {
|
||||
amd64: "kuber@astral-th",
|
||||
arm64: "kuber@astral",
|
||||
remoteRoot: "kuber-build",
|
||||
} as const;
|
||||
export const DEFAULT_ROLLOUT_TIMEOUT_MS = 300_000;
|
||||
|
||||
type Hooks = Pick<
|
||||
@@ -23,11 +18,6 @@ export type ResolvedKuberConfig = Hooks & {
|
||||
projectConfigured: boolean;
|
||||
composeFile?: string;
|
||||
registry: string;
|
||||
builders: {
|
||||
amd64: string;
|
||||
arm64: string;
|
||||
remoteRoot: string;
|
||||
};
|
||||
rolloutTimeoutMs: number;
|
||||
configFile?: string;
|
||||
};
|
||||
@@ -64,22 +54,6 @@ function validateConfig(config: unknown, file: string): KuberConfig {
|
||||
if (value.registry !== undefined)
|
||||
requireNonEmptyString(value.registry, "registry");
|
||||
|
||||
if (value.builders !== undefined) {
|
||||
if (
|
||||
!value.builders ||
|
||||
typeof value.builders !== "object" ||
|
||||
Array.isArray(value.builders)
|
||||
) {
|
||||
throw new Error(`builders in ${file} must be an object`);
|
||||
}
|
||||
const builders = value.builders as Record<string, unknown>;
|
||||
for (const field of ["amd64", "arm64", "remoteRoot"]) {
|
||||
if (builders[field] !== undefined) {
|
||||
requireNonEmptyString(builders[field], `builders.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
value.rolloutTimeoutMs !== undefined &&
|
||||
(typeof value.rolloutTimeoutMs !== "number" ||
|
||||
@@ -148,7 +122,6 @@ export async function loadConfig(
|
||||
configFile,
|
||||
)
|
||||
: {};
|
||||
const builders = raw.builders ?? {};
|
||||
const composeFile = raw.composeFile?.trim();
|
||||
const registry = (raw.registry?.trim() ?? DEFAULT_REGISTRY).replace(
|
||||
/\/+$/,
|
||||
@@ -165,11 +138,6 @@ export async function loadConfig(
|
||||
: resolve(cwd, composeFile)
|
||||
: undefined,
|
||||
registry,
|
||||
builders: {
|
||||
amd64: builders.amd64?.trim() ?? DEFAULT_BUILDERS.amd64,
|
||||
arm64: builders.arm64?.trim() ?? DEFAULT_BUILDERS.arm64,
|
||||
remoteRoot: builders.remoteRoot?.trim() ?? DEFAULT_BUILDERS.remoteRoot,
|
||||
},
|
||||
rolloutTimeoutMs: raw.rolloutTimeoutMs ?? DEFAULT_ROLLOUT_TIMEOUT_MS,
|
||||
configFile: exists ? configFile : undefined,
|
||||
compose: raw.compose,
|
||||
|
||||
+45
-31
@@ -14,9 +14,7 @@ import type {
|
||||
V1VolumeMount,
|
||||
} from "@kubernetes/client-node";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { basename } from "node:path";
|
||||
import type { ComposeSpecification } from "../schema/docker.d";
|
||||
import type { Service } from "../schema/docker.d";
|
||||
import { LABELS } from "../const";
|
||||
@@ -25,6 +23,10 @@ import { isPostgresVolumeEntry } from "./database";
|
||||
import { toEnvVars } from "./format";
|
||||
import { deepMerge } from "./shared";
|
||||
import { isS3VolumeEntry } from "./storage";
|
||||
import {
|
||||
LocalArtifactProvider,
|
||||
type ArtifactProvider,
|
||||
} from "../shared/artifacts";
|
||||
|
||||
type NormalizedMount = {
|
||||
name: string;
|
||||
@@ -303,21 +305,17 @@ function isBindSource(source: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSourcePath(source: string, cwd: string): string {
|
||||
return source.startsWith("~")
|
||||
? resolve(process.env.HOME ?? "", source.slice(1))
|
||||
: resolve(cwd, source);
|
||||
}
|
||||
|
||||
function isConfigFileSource(source: string, cwd: string): boolean {
|
||||
const path = resolveSourcePath(source, cwd);
|
||||
return existsSync(path) && statSync(path).isFile();
|
||||
function isConfigFileSource(
|
||||
source: string,
|
||||
artifacts: ArtifactProvider,
|
||||
): boolean {
|
||||
return artifacts.isFile(source, { expandHome: true });
|
||||
}
|
||||
|
||||
function parseStringMount(
|
||||
entry: string,
|
||||
index: number,
|
||||
cwd: string,
|
||||
artifacts: ArtifactProvider,
|
||||
): NormalizedMount | undefined {
|
||||
if (isPostgresVolumeEntry(entry) || isS3VolumeEntry(entry)) return;
|
||||
|
||||
@@ -348,7 +346,7 @@ function parseStringMount(
|
||||
: `volume-${index}`,
|
||||
kind:
|
||||
source && isBindSource(source)
|
||||
? isConfigFileSource(source, cwd)
|
||||
? isConfigFileSource(source, artifacts)
|
||||
? "config-file"
|
||||
: "bind"
|
||||
: "volume",
|
||||
@@ -366,9 +364,10 @@ function parseStringMount(
|
||||
function toMount(
|
||||
entry: ServiceVolume,
|
||||
index: number,
|
||||
cwd: string,
|
||||
artifacts: ArtifactProvider,
|
||||
): NormalizedMount | undefined {
|
||||
if (typeof entry === "string") return parseStringMount(entry, index, cwd);
|
||||
if (typeof entry === "string")
|
||||
return parseStringMount(entry, index, artifacts);
|
||||
|
||||
if (!entry.target) return;
|
||||
if (
|
||||
@@ -389,7 +388,7 @@ function toMount(
|
||||
: `${entry.type}-${toKubeName(source) || index}`
|
||||
: `${entry.type}-${index}`,
|
||||
kind:
|
||||
entry.type === "bind" && source && isConfigFileSource(source, cwd)
|
||||
entry.type === "bind" && source && isConfigFileSource(source, artifacts)
|
||||
? "config-file"
|
||||
: entry.type,
|
||||
source,
|
||||
@@ -450,12 +449,12 @@ function getTopLevelVolumeDataLocality(
|
||||
|
||||
function toMounts(
|
||||
service: Service,
|
||||
cwd = process.cwd(),
|
||||
artifacts: ArtifactProvider,
|
||||
volumes: ComposeVolumes = {},
|
||||
): NormalizedMount[] {
|
||||
const mounts =
|
||||
service.volumes?.flatMap((entry, index) => {
|
||||
const mount = toMount(entry, index, cwd);
|
||||
const mount = toMount(entry, index, artifacts);
|
||||
if (!mount) return [];
|
||||
|
||||
return [
|
||||
@@ -855,15 +854,13 @@ function parseEnvFile(text: string): Record<string, string> {
|
||||
|
||||
async function readEnvFiles(
|
||||
envFile: Service["env_file"],
|
||||
cwd: string,
|
||||
artifacts: ArtifactProvider,
|
||||
): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const entry of toEnvFilePaths(envFile)) {
|
||||
const path = resolve(cwd, entry.path);
|
||||
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const text = artifacts.readText(entry.path);
|
||||
Object.assign(result, parseEnvFile(text));
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -892,8 +889,9 @@ export function serviceToDeployment(
|
||||
extraEnv: Record<string, string> = {},
|
||||
volumes: ComposeVolumes = {},
|
||||
buildImages: Record<string, string> = {},
|
||||
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
||||
): V1Deployment {
|
||||
const mounts = toMounts(service, cwd, volumes);
|
||||
const mounts = toMounts(service, artifacts, volumes);
|
||||
const ports = toPorts(service);
|
||||
const hasEnvSecret =
|
||||
Boolean(service.env_file) || Object.keys(extraEnv).length > 0;
|
||||
@@ -1007,10 +1005,11 @@ export function volumesToPvc(
|
||||
service: Service,
|
||||
cwd = process.cwd(),
|
||||
volumes: ComposeVolumes = {},
|
||||
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
||||
): V1PersistentVolumeClaim[] {
|
||||
const claims = new Map<string, V1PersistentVolumeClaim>();
|
||||
|
||||
for (const mount of toMounts(service, cwd, volumes)) {
|
||||
for (const mount of toMounts(service, artifacts, volumes)) {
|
||||
const claimName =
|
||||
mount.kind === "bind"
|
||||
? mount.source
|
||||
@@ -1052,10 +1051,11 @@ export function volumesToStorageClasses(
|
||||
service: Service,
|
||||
cwd = process.cwd(),
|
||||
volumes: ComposeVolumes = {},
|
||||
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
||||
): LonghornStorageClass[] {
|
||||
const classes = new Map<string, LonghornStorageClass>();
|
||||
|
||||
for (const mount of toMounts(service, cwd, volumes)) {
|
||||
for (const mount of toMounts(service, artifacts, volumes)) {
|
||||
const policy = getLonghornStoragePolicy(mount);
|
||||
if (!policy) continue;
|
||||
|
||||
@@ -1089,14 +1089,14 @@ export function volumesToConfigMaps(
|
||||
service: Service,
|
||||
cwd = process.cwd(),
|
||||
volumes: ComposeVolumes = {},
|
||||
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
||||
): V1ConfigMap[] {
|
||||
const configMaps = new Map<string, V1ConfigMap>();
|
||||
|
||||
for (const mount of toMounts(service, cwd, volumes)) {
|
||||
for (const mount of toMounts(service, artifacts, volumes)) {
|
||||
if (mount.kind !== "config-file" || !mount.source || !mount.configKey)
|
||||
continue;
|
||||
|
||||
const sourcePath = resolveSourcePath(mount.source, cwd);
|
||||
configMaps.set(mount.name, {
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
@@ -1106,7 +1106,9 @@ export function volumesToConfigMaps(
|
||||
labels: LABELS,
|
||||
},
|
||||
data: {
|
||||
[mount.configKey]: readFileSync(sourcePath, "utf8"),
|
||||
[mount.configKey]: artifacts.readText(mount.source, {
|
||||
expandHome: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1120,9 +1122,10 @@ export async function envFromToSecrets(
|
||||
service: Service,
|
||||
cwd = process.cwd(),
|
||||
extraEnv: Record<string, string> = {},
|
||||
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
||||
): Promise<V1Secret[]> {
|
||||
const stringData = {
|
||||
...(await readEnvFiles(service.env_file, cwd)),
|
||||
...(await readEnvFiles(service.env_file, artifacts)),
|
||||
...extraEnv,
|
||||
};
|
||||
if (Object.keys(stringData).length === 0) return [];
|
||||
@@ -1180,6 +1183,7 @@ export async function composeToKubernetes(
|
||||
cwd = process.cwd(),
|
||||
serviceEnv: Record<string, Record<string, string>> = {},
|
||||
buildImages: Record<string, string> = {},
|
||||
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
||||
): Promise<KubernetesResource[]> {
|
||||
const resources = new Map<string, KubernetesResource>();
|
||||
|
||||
@@ -1191,11 +1195,18 @@ export async function composeToKubernetes(
|
||||
service,
|
||||
cwd,
|
||||
compose.volumes,
|
||||
artifacts,
|
||||
)) {
|
||||
resources.set(getResourceKey(storageClass), storageClass);
|
||||
}
|
||||
|
||||
for (const pvc of volumesToPvc(project, service, cwd, compose.volumes)) {
|
||||
for (const pvc of volumesToPvc(
|
||||
project,
|
||||
service,
|
||||
cwd,
|
||||
compose.volumes,
|
||||
artifacts,
|
||||
)) {
|
||||
resources.set(getResourceKey(pvc), pvc);
|
||||
}
|
||||
|
||||
@@ -1204,6 +1215,7 @@ export async function composeToKubernetes(
|
||||
service,
|
||||
cwd,
|
||||
compose.volumes,
|
||||
artifacts,
|
||||
)) {
|
||||
resources.set(getResourceKey(configMap), configMap);
|
||||
}
|
||||
@@ -1214,6 +1226,7 @@ export async function composeToKubernetes(
|
||||
service,
|
||||
cwd,
|
||||
serviceEnv[name] ?? {},
|
||||
artifacts,
|
||||
);
|
||||
for (const secret of envSecrets) {
|
||||
resources.set(getResourceKey(secret), secret);
|
||||
@@ -1231,6 +1244,7 @@ export async function composeToKubernetes(
|
||||
serviceEnv[name] ?? {},
|
||||
compose.volumes,
|
||||
buildImages,
|
||||
artifacts,
|
||||
);
|
||||
const envSecret = envSecrets[0];
|
||||
if (envSecret) {
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
import { KUBER_API_BASE_URL } from "../const";
|
||||
import { readSession, type KuberSession } from "./session";
|
||||
|
||||
export type ExecStartFrame = {
|
||||
type: "start";
|
||||
version: 1;
|
||||
deployment: string;
|
||||
command: string[];
|
||||
tty: boolean;
|
||||
container?: string;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
export type ExecClientWireFrame =
|
||||
| ExecStartFrame
|
||||
| { type: "stdin"; data: string; encoding: "base64"; eof?: boolean }
|
||||
| { type: "resize"; columns: number; rows: number }
|
||||
| { type: "close" };
|
||||
|
||||
export type ExecServerWireFrame =
|
||||
| { type: "stdout" | "stderr"; data: string; encoding: "base64" }
|
||||
| { type: "exit"; exitCode: number; reason?: string; message?: string }
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
export type ExecOutputFrame =
|
||||
| { type: "stdout"; data: Uint8Array }
|
||||
| { type: "stderr"; data: Uint8Array }
|
||||
| Exclude<ExecServerWireFrame, { type: "stdout" | "stderr" }>;
|
||||
|
||||
export interface ExecWebSocket {
|
||||
binaryType: "arraybuffer" | "blob";
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
addEventListener(type: string, listener: (event: any) => void): void;
|
||||
removeEventListener(type: string, listener: (event: any) => void): void;
|
||||
}
|
||||
|
||||
export type ExecWebSocketFactory = (
|
||||
url: string,
|
||||
headers: Readonly<Record<string, string>>,
|
||||
) => ExecWebSocket;
|
||||
|
||||
export type OpenExecOptions = {
|
||||
baseUrl?: string;
|
||||
session?: KuberSession;
|
||||
socketFactory?: ExecWebSocketFactory;
|
||||
};
|
||||
|
||||
export interface ExecApiSession extends AsyncIterable<ExecOutputFrame> {
|
||||
sendStdin(data: Uint8Array, eof?: boolean): void;
|
||||
resize(columns: number, rows: number): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function websocketUrl(baseUrl: string, path: string): string {
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const defaultSocketFactory: ExecWebSocketFactory = (url, headers) =>
|
||||
new WebSocket(url, {
|
||||
headers,
|
||||
} as unknown as string[]) as unknown as ExecWebSocket;
|
||||
|
||||
function parseFrame(value: unknown): ExecOutputFrame {
|
||||
if (typeof value !== "string")
|
||||
throw new Error("Exec frame must be JSON text");
|
||||
const frame: unknown = JSON.parse(value);
|
||||
if (!frame || typeof frame !== "object" || !("type" in frame))
|
||||
throw new Error("Invalid exec frame");
|
||||
const wire = frame as ExecServerWireFrame;
|
||||
if (wire.type === "stdout" || wire.type === "stderr") {
|
||||
if (wire.encoding !== "base64" || typeof wire.data !== "string")
|
||||
throw new Error("Invalid exec output frame");
|
||||
return {
|
||||
type: wire.type,
|
||||
data: Uint8Array.from(Buffer.from(wire.data, "base64")),
|
||||
};
|
||||
}
|
||||
if (wire.type === "exit" && Number.isSafeInteger(wire.exitCode)) return wire;
|
||||
if (wire.type === "error" && typeof wire.message === "string") return wire;
|
||||
throw new Error("Invalid exec frame");
|
||||
}
|
||||
|
||||
export async function openExecSession(
|
||||
project: string,
|
||||
start: Omit<ExecStartFrame, "type" | "version">,
|
||||
signal: AbortSignal,
|
||||
options: OpenExecOptions = {},
|
||||
): Promise<ExecApiSession> {
|
||||
if (signal.aborted)
|
||||
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
||||
const session = options.session ?? (await readSession());
|
||||
if (!session) throw new Error("Not logged in. Run kuber login first.");
|
||||
const path = `/workspaces/${encodeURIComponent(project)}/exec`;
|
||||
const socket = (options.socketFactory ?? defaultSocketFactory)(
|
||||
websocketUrl(options.baseUrl ?? KUBER_API_BASE_URL, path),
|
||||
{
|
||||
authorization: `Bearer ${session.token}`,
|
||||
},
|
||||
);
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
socket.send(JSON.stringify({ type: "start", version: 1, ...start }));
|
||||
resolve();
|
||||
};
|
||||
const onError = (event: { error?: unknown }) => {
|
||||
cleanup();
|
||||
reject(event.error ?? new Error("Exec WebSocket connection failed"));
|
||||
};
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
reject(new Error("Exec WebSocket closed before opening"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
socket.close(1000, "aborted");
|
||||
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanup = () => {
|
||||
socket.removeEventListener("open", onOpen);
|
||||
socket.removeEventListener("error", onError);
|
||||
socket.removeEventListener("close", onClose);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
socket.addEventListener("open", onOpen);
|
||||
socket.addEventListener("error", onError);
|
||||
socket.addEventListener("close", onClose);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
|
||||
const frames: ExecOutputFrame[] = [];
|
||||
const readers: Array<{
|
||||
resolve: (result: IteratorResult<ExecOutputFrame>) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
let closed = false;
|
||||
let failure: unknown;
|
||||
let live = false;
|
||||
const pending: ExecClientWireFrame[] = [];
|
||||
const finish = (error?: unknown) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
failure = error;
|
||||
socket.removeEventListener("message", onMessage);
|
||||
socket.removeEventListener("error", onError);
|
||||
socket.removeEventListener("close", onClose);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
for (const reader of readers.splice(0)) {
|
||||
if (error) reader.reject(error);
|
||||
else reader.resolve({ done: true, value: undefined });
|
||||
}
|
||||
};
|
||||
const onMessage = (event: { data: unknown }) => {
|
||||
try {
|
||||
if (!live) {
|
||||
live = true;
|
||||
for (const frame of pending.splice(0)) socket.send(JSON.stringify(frame));
|
||||
}
|
||||
const frame = parseFrame(event.data);
|
||||
const reader = readers.shift();
|
||||
if (reader) reader.resolve({ done: false, value: frame });
|
||||
else frames.push(frame);
|
||||
} catch (error) {
|
||||
socket.close(1002, "invalid frame");
|
||||
finish(error);
|
||||
}
|
||||
};
|
||||
const onError = (event: { error?: unknown }) =>
|
||||
finish(event.error ?? new Error("Exec WebSocket failed"));
|
||||
const onClose = () => finish();
|
||||
const onAbort = () => {
|
||||
socket.close(1000, "aborted");
|
||||
finish();
|
||||
};
|
||||
socket.addEventListener("message", onMessage);
|
||||
socket.addEventListener("error", onError);
|
||||
socket.addEventListener("close", onClose);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const send = (frame: ExecClientWireFrame) => {
|
||||
if (closed) throw failure ?? new Error("Exec session is closed");
|
||||
if (!live) {
|
||||
pending.push(frame);
|
||||
return;
|
||||
}
|
||||
socket.send(JSON.stringify(frame));
|
||||
};
|
||||
return {
|
||||
sendStdin(data, eof) {
|
||||
send({
|
||||
type: "stdin",
|
||||
data: Buffer.from(data).toString("base64"),
|
||||
encoding: "base64",
|
||||
...(eof ? { eof: true } : {}),
|
||||
});
|
||||
},
|
||||
resize(columns, rows) {
|
||||
send({ type: "resize", columns, rows });
|
||||
},
|
||||
close() {
|
||||
if (!closed && socket.readyState === 1) send({ type: "close" });
|
||||
socket.close(1000, "client closed");
|
||||
finish();
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next(): Promise<IteratorResult<ExecOutputFrame>> {
|
||||
const frame = frames.shift();
|
||||
if (frame) return Promise.resolve({ done: false, value: frame });
|
||||
if (failure) return Promise.reject(failure);
|
||||
if (closed) return Promise.resolve({ done: true, value: undefined });
|
||||
return new Promise((resolve, reject) =>
|
||||
readers.push({ resolve, reject }),
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+11
-8
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "../schema/docker.d";
|
||||
import { composeToKubernetes, type KubernetesResource } from "./convert";
|
||||
import { reconcilePostgresClaims } from "./database";
|
||||
import { reconcileS3Claims } from "./storage";
|
||||
import { getComposePostgresClaims } from "./database";
|
||||
import { getComposeS3Claims } from "./storage";
|
||||
import { resolveBuildImages, type BuildOptions } from "./build";
|
||||
|
||||
export async function renderResources(
|
||||
@@ -10,12 +10,15 @@ export async function renderResources(
|
||||
cwd = process.cwd(),
|
||||
options: BuildOptions = {},
|
||||
): Promise<KubernetesResource[]> {
|
||||
const postgresEnv = await reconcilePostgresClaims(project, compose);
|
||||
const s3Env = await reconcileS3Claims(project, compose);
|
||||
const serviceEnv = { ...postgresEnv };
|
||||
for (const [service, environment] of Object.entries(s3Env)) {
|
||||
serviceEnv[service] = { ...serviceEnv[service], ...environment };
|
||||
const providers = [
|
||||
...(getComposePostgresClaims(compose).length ? ["Postgres"] : []),
|
||||
...(getComposeS3Claims(compose).length ? ["S3"] : []),
|
||||
];
|
||||
if (providers.length > 0) {
|
||||
throw new Error(
|
||||
`Cannot export without generated ${providers.join(" and ")} credentials. Export is side-effect-free; run kuber up or remove managed provider claims.`,
|
||||
);
|
||||
}
|
||||
const buildImages = await resolveBuildImages(project, compose, options);
|
||||
return composeToKubernetes(project, compose, cwd, serviceEnv, buildImages);
|
||||
return composeToKubernetes(project, compose, cwd, {}, buildImages);
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export type KuberSession = {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
user: {
|
||||
username: string;
|
||||
roles: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function runtimeSessionPath(): string {
|
||||
const directory =
|
||||
process.env.XDG_RUNTIME_DIR ??
|
||||
join(tmpdir(), `kuber-${process.getuid?.() ?? "user"}`);
|
||||
return join(directory, "kuber", "session.json");
|
||||
}
|
||||
|
||||
function persistentSessionPath(): string {
|
||||
const directory =
|
||||
process.env.XDG_CONFIG_HOME ??
|
||||
join(process.env.HOME ?? tmpdir(), ".config");
|
||||
return join(directory, "kuber", "session.json");
|
||||
}
|
||||
|
||||
export function getSessionPath(persistent: boolean): string {
|
||||
return persistent ? persistentSessionPath() : runtimeSessionPath();
|
||||
}
|
||||
|
||||
function isSession(value: unknown): value is KuberSession {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const session = value as Partial<KuberSession>;
|
||||
return (
|
||||
typeof session.token === "string" &&
|
||||
typeof session.expiresAt === "string" &&
|
||||
Boolean(session.user) &&
|
||||
typeof session.user?.username === "string" &&
|
||||
Array.isArray(session.user.roles) &&
|
||||
session.user.roles.every((role) => typeof role === "string")
|
||||
);
|
||||
}
|
||||
|
||||
async function readSessionFile(
|
||||
path: string,
|
||||
): Promise<KuberSession | undefined> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isSession(value)) return;
|
||||
if (Date.parse(value.expiresAt) <= Date.now()) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof SyntaxError) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSession(): Promise<KuberSession | undefined> {
|
||||
return (
|
||||
(await readSessionFile(runtimeSessionPath())) ??
|
||||
(await readSessionFile(persistentSessionPath()))
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeSession(
|
||||
session: KuberSession,
|
||||
persistent: boolean,
|
||||
): Promise<string> {
|
||||
const path = getSessionPath(persistent);
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
await chmod(dirname(path), 0o700);
|
||||
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, `${JSON.stringify(session, null, 2)}\n`, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
});
|
||||
await rename(temporaryPath, path);
|
||||
await chmod(path, 0o600);
|
||||
await rm(getSessionPath(!persistent), { force: true });
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function removeSessions(): Promise<void> {
|
||||
await Promise.all(
|
||||
[runtimeSessionPath(), persistentSessionPath()].map((path) =>
|
||||
rm(path, { force: true }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
readlink,
|
||||
realpath,
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
BUILD_PROTOCOL_VERSION,
|
||||
assertSha256Digest,
|
||||
type Sha256Digest,
|
||||
type WorkspaceFile,
|
||||
type WorkspaceManifest,
|
||||
} from "../shared/build-protocol";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type WorkspaceBlob = {
|
||||
digest: Sha256Digest;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
export type WorkspaceSnapshot = {
|
||||
manifest: WorkspaceManifest;
|
||||
digest: Sha256Digest;
|
||||
blobs: WorkspaceBlob[];
|
||||
};
|
||||
|
||||
export type BlobReader =
|
||||
| ((digest: Sha256Digest) => Promise<Uint8Array>)
|
||||
| { get(digest: Sha256Digest): Promise<Uint8Array> };
|
||||
|
||||
function digest(data: Uint8Array | string): Sha256Digest {
|
||||
return `sha256:${createHash("sha256").update(data).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function validateWorkspacePath(path: string): void {
|
||||
if (
|
||||
!path ||
|
||||
path.includes("\0") ||
|
||||
path.includes("\\") ||
|
||||
isAbsolute(path) ||
|
||||
path.split("/").some((part) => !part || part === "." || part === "..")
|
||||
) {
|
||||
throw new Error(`Unsafe workspace path: ${JSON.stringify(path)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
const path = relative(root, candidate);
|
||||
return (
|
||||
path === "" ||
|
||||
(!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path))
|
||||
);
|
||||
}
|
||||
|
||||
async function gitFiles(root: string, args: string[]): Promise<string[]> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-C", root, "ls-files", "-z", ...args],
|
||||
{
|
||||
encoding: "buffer",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const files: string[] = [];
|
||||
let start = 0;
|
||||
for (
|
||||
let end = stdout.indexOf(0);
|
||||
end !== -1;
|
||||
end = stdout.indexOf(0, start)
|
||||
) {
|
||||
if (end > start) files.push(decoder.decode(stdout.subarray(start, end)));
|
||||
start = end + 1;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function selectedFiles(root: string): Promise<string[]> {
|
||||
const [normal, dotenv] = await Promise.all([
|
||||
gitFiles(root, ["--cached", "--others", "--exclude-standard"]),
|
||||
gitFiles(root, [
|
||||
"--others",
|
||||
"--ignored",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
".env*",
|
||||
"**/.env*",
|
||||
]),
|
||||
]);
|
||||
return [...new Set([...normal, ...dotenv])].sort((a, b) =>
|
||||
Buffer.from(a).compare(Buffer.from(b)),
|
||||
);
|
||||
}
|
||||
|
||||
async function isIgnored(root: string, path: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("git", ["-C", root, "check-ignore", "-q", "--", path]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code?: number }).code === 1) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectSelectedSpecialFiles(
|
||||
root: string,
|
||||
directory = root,
|
||||
): Promise<void> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (directory === root && entry.name === ".git") continue;
|
||||
const source = resolve(directory, entry.name);
|
||||
const path = relative(root, source).split(sep).join("/");
|
||||
if (entry.isDirectory()) {
|
||||
if (!(await isIgnored(root, path)))
|
||||
await rejectSelectedSpecialFiles(root, source);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() || entry.isSymbolicLink()) continue;
|
||||
if (!(await isIgnored(root, path)) || entry.name.startsWith(".env"))
|
||||
throw new Error(`Special files are not allowed in workspaces: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalManifest(manifest: WorkspaceManifest): string {
|
||||
return JSON.stringify({
|
||||
version: manifest.version,
|
||||
files: manifest.files.map((file) => ({
|
||||
path: file.path,
|
||||
type: file.type,
|
||||
digest: file.digest,
|
||||
size: file.size,
|
||||
mode: file.mode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeWorkspaceManifest(
|
||||
manifest: WorkspaceManifest,
|
||||
): Uint8Array {
|
||||
validateWorkspaceManifest(manifest);
|
||||
return Buffer.from(canonicalManifest(manifest));
|
||||
}
|
||||
|
||||
export function workspaceManifestDigest(
|
||||
manifest: WorkspaceManifest,
|
||||
): Sha256Digest {
|
||||
return digest(serializeWorkspaceManifest(manifest));
|
||||
}
|
||||
|
||||
export function validateWorkspaceManifest(manifest: WorkspaceManifest): void {
|
||||
if (
|
||||
manifest.version !== BUILD_PROTOCOL_VERSION ||
|
||||
!Array.isArray(manifest.files)
|
||||
) {
|
||||
throw new Error("Unsupported workspace manifest");
|
||||
}
|
||||
|
||||
let previous = "";
|
||||
const seen = new Set<string>();
|
||||
for (const file of manifest.files) {
|
||||
validateWorkspacePath(file.path);
|
||||
assertSha256Digest(file.digest);
|
||||
if (!Number.isSafeInteger(file.size) || file.size < 0)
|
||||
throw new Error(`Invalid size for ${file.path}`);
|
||||
if (
|
||||
(file.type === "file" && file.mode !== 0o644 && file.mode !== 0o755) ||
|
||||
(file.type === "symlink" && file.mode !== 0o777)
|
||||
) {
|
||||
throw new Error(`Invalid mode for ${file.path}`);
|
||||
}
|
||||
if (file.type !== "file" && file.type !== "symlink")
|
||||
throw new Error(`Invalid entry type for ${file.path}`);
|
||||
if (seen.has(file.path))
|
||||
throw new Error(`Duplicate workspace path: ${file.path}`);
|
||||
for (const parent of file.path
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.map((_, index, parts) => parts.slice(0, index + 1).join("/"))) {
|
||||
if (seen.has(parent))
|
||||
throw new Error(`Workspace entry is used as a directory: ${parent}`);
|
||||
}
|
||||
if (previous && Buffer.from(previous).compare(Buffer.from(file.path)) >= 0)
|
||||
throw new Error("Workspace files must be bytewise sorted");
|
||||
seen.add(file.path);
|
||||
previous = file.path;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enumerateWorkspace(
|
||||
root: string,
|
||||
): Promise<WorkspaceSnapshot> {
|
||||
const repository = await realpath(root);
|
||||
await rejectSelectedSpecialFiles(repository);
|
||||
const paths = await selectedFiles(repository);
|
||||
const files: WorkspaceFile[] = [];
|
||||
const blobs = new Map<Sha256Digest, Uint8Array>();
|
||||
|
||||
for (const path of paths) {
|
||||
validateWorkspacePath(path);
|
||||
const source = resolve(repository, path);
|
||||
if (!isWithin(repository, source))
|
||||
throw new Error(`Workspace path escapes root: ${path}`);
|
||||
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(source);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let data: Uint8Array;
|
||||
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,
|
||||
type: "symlink",
|
||||
digest: digest(data),
|
||||
size: data.byteLength,
|
||||
mode: 0o777,
|
||||
};
|
||||
} else if (stat.isFile()) {
|
||||
let mode: 0o644 | 0o755;
|
||||
const handle = await open(
|
||||
source,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
if (!opened.isFile()) throw new Error(`Not a regular file: ${path}`);
|
||||
mode = opened.mode & 0o111 ? 0o755 : 0o644;
|
||||
data = await handle.readFile();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
entry = {
|
||||
path,
|
||||
type: "file",
|
||||
digest: digest(data),
|
||||
size: data.byteLength,
|
||||
mode,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Special files are not allowed in workspaces: ${path}`);
|
||||
}
|
||||
files.push(entry);
|
||||
blobs.set(entry.digest, data);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
files,
|
||||
} satisfies WorkspaceManifest;
|
||||
return {
|
||||
manifest,
|
||||
digest: workspaceManifestDigest(manifest),
|
||||
blobs: [...blobs].map(([blobDigest, data]) => ({
|
||||
digest: blobDigest,
|
||||
data,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureParentDirectories(
|
||||
root: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
let current = root;
|
||||
for (const part of path.split("/").slice(0, -1)) {
|
||||
current = resolve(current, part);
|
||||
try {
|
||||
const stat = await lstat(current);
|
||||
if (!stat.isDirectory())
|
||||
throw new Error(`Workspace parent is not a directory: ${path}`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
await mkdir(current, { mode: 0o755 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBlob(
|
||||
reader: BlobReader,
|
||||
blobDigest: Sha256Digest,
|
||||
): Promise<Uint8Array> {
|
||||
return typeof reader === "function"
|
||||
? reader(blobDigest)
|
||||
: reader.get(blobDigest);
|
||||
}
|
||||
|
||||
export async function materializeWorkspace(
|
||||
destination: string,
|
||||
manifest: WorkspaceManifest,
|
||||
reader: BlobReader,
|
||||
): Promise<void> {
|
||||
validateWorkspaceManifest(manifest);
|
||||
await mkdir(destination, { recursive: false, mode: 0o755 });
|
||||
const root = await realpath(destination);
|
||||
|
||||
for (const file of manifest.files.filter((entry) => entry.type === "file")) {
|
||||
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 target = resolve(root, file.path);
|
||||
const handle = await open(
|
||||
target,
|
||||
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
|
||||
file.mode,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(data);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await chmod(target, file.mode);
|
||||
}
|
||||
|
||||
for (const file of manifest.files.filter(
|
||||
(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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user