feat: prepare 2.6.1-rc5 shared databases and build SSE
This commit is contained in:
+110
-6
@@ -1,6 +1,8 @@
|
||||
import { KUBER_API_BASE_URL } from "../const";
|
||||
import type { ApiProblemDetails } from "../shared/api";
|
||||
import { KUBER_VERSION_HEADER } from "../shared/version";
|
||||
import { readSession, type KuberSession } from "./session";
|
||||
import { observeServerVersion } from "./version-update";
|
||||
|
||||
export type { ApiProblemDetails } from "../shared/api";
|
||||
|
||||
@@ -19,6 +21,8 @@ export type ApiRequestOptions = {
|
||||
timeoutMs?: number;
|
||||
/** Byte offset for resumable binary upload requests. */
|
||||
uploadOffset?: number;
|
||||
/** Overrides version observation for embedded clients and tests. */
|
||||
onServerVersion?: (version: string | null) => void;
|
||||
};
|
||||
|
||||
export type ApiUploadOptions = ApiRequestOptions & {
|
||||
@@ -181,12 +185,23 @@ async function sendRequest(
|
||||
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,
|
||||
});
|
||||
const response = await fetch(
|
||||
`${options.baseUrl ?? KUBER_API_BASE_URL}${path}`,
|
||||
{
|
||||
...requestInit,
|
||||
body,
|
||||
headers,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
try {
|
||||
(options.onServerVersion ?? observeServerVersion)(
|
||||
response.headers.get(KUBER_VERSION_HEADER),
|
||||
);
|
||||
} catch {
|
||||
// Version discovery must never interrupt an API request.
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
@@ -288,3 +303,92 @@ export async function* apiStreamNdjson<T>(
|
||||
deadline.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised only when a server does not offer the SSE build-events representation. */
|
||||
export class ApiStreamUnsupportedError extends Error {}
|
||||
|
||||
/** One authenticated SSE connection. The caller owns reconnection and its cursor. */
|
||||
export async function* apiStreamEvents<T>(
|
||||
path: string,
|
||||
after: number,
|
||||
signal?: AbortSignal,
|
||||
options: ApiRequestOptions = {},
|
||||
): AsyncGenerator<{ event: T; id?: number; type?: string }, void, void> {
|
||||
const deadline = requestDeadline(signal, 0);
|
||||
const headers = new Headers({ accept: "text/event-stream" });
|
||||
headers.set("last-event-id", String(after));
|
||||
try {
|
||||
const response = await sendRequest(
|
||||
path,
|
||||
{ headers },
|
||||
options,
|
||||
deadline.signal,
|
||||
);
|
||||
if ([404, 405, 406, 415, 501].includes(response.status))
|
||||
throw new ApiStreamUnsupportedError("Build SSE events are unavailable");
|
||||
await assertResponseOk(response);
|
||||
if (
|
||||
!response.headers
|
||||
.get("content-type")
|
||||
?.toLowerCase()
|
||||
.startsWith("text/event-stream")
|
||||
) {
|
||||
await response.body?.cancel();
|
||||
throw new ApiStreamUnsupportedError("Build SSE events are unavailable");
|
||||
}
|
||||
if (!response.body) throw new Error("Empty build SSE response");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let data: string[] = [];
|
||||
let eventType = "";
|
||||
let id: number | undefined;
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
while (buffer.includes("\n")) {
|
||||
const newline = buffer.indexOf("\n");
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line === "") {
|
||||
if (
|
||||
data.length &&
|
||||
(eventType === "log" ||
|
||||
eventType === "status" ||
|
||||
eventType === "gap")
|
||||
)
|
||||
yield {
|
||||
event: JSON.parse(data.join("\n")) as T,
|
||||
id,
|
||||
...(eventType === "gap" && { type: eventType }),
|
||||
};
|
||||
data = [];
|
||||
id = undefined;
|
||||
eventType = "";
|
||||
} else if (line.startsWith("data:"))
|
||||
data.push(line.slice(5).replace(/^ /, ""));
|
||||
else if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||||
else if (line.startsWith("id:")) {
|
||||
const raw = line.slice(3).trim();
|
||||
if (/^(0|[1-9]\d*)$/.test(raw) && Number.isSafeInteger(Number(raw)))
|
||||
id = Number(raw);
|
||||
}
|
||||
}
|
||||
// Bound a malformed or non-SSE response that never terminates a line.
|
||||
if (buffer.length > 2 * 1024 * 1024)
|
||||
throw new Error("Build SSE line too long");
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* Upstream aborted. */
|
||||
}
|
||||
reader.releaseLock();
|
||||
}
|
||||
} finally {
|
||||
deadline.clear();
|
||||
}
|
||||
}
|
||||
|
||||
+93
-30
@@ -12,7 +12,13 @@ import {
|
||||
type Sha256Digest,
|
||||
} from "../shared/build-protocol";
|
||||
import { resolveComposeArch } from "./arch";
|
||||
import { apiRequest, type ApiRequestInit, type ApiRequestOptions } from "./api";
|
||||
import {
|
||||
apiRequest,
|
||||
apiStreamEvents,
|
||||
ApiStreamUnsupportedError,
|
||||
type ApiRequestInit,
|
||||
type ApiRequestOptions,
|
||||
} from "./api";
|
||||
import { DEFAULT_REGISTRY } from "./config";
|
||||
import {
|
||||
enumerateWorkspace,
|
||||
@@ -153,9 +159,17 @@ export type ApiRequester = <T>(
|
||||
options?: ApiRequestOptions,
|
||||
) => Promise<T>;
|
||||
|
||||
export type BuildEventStreamer = (
|
||||
path: string,
|
||||
after: number,
|
||||
signal?: AbortSignal,
|
||||
) => AsyncIterable<{ event: BuildEvent; id?: number; type?: string }>;
|
||||
|
||||
export type BuildOptions = {
|
||||
registry?: string;
|
||||
request?: ApiRequester;
|
||||
/** For embedded clients; defaults to authenticated SSE with the standard API requester. */
|
||||
streamEvents?: BuildEventStreamer;
|
||||
pollIntervalMs?: number;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
snapshot?: WorkspaceSnapshot;
|
||||
@@ -496,20 +510,78 @@ async function waitForBuild(
|
||||
initial: BuildStatus,
|
||||
signal?: AbortSignal,
|
||||
prefixService = false,
|
||||
streamEvents?: BuildEventStreamer,
|
||||
): Promise<BuildStatus> {
|
||||
let status = initial;
|
||||
let sequence = 0;
|
||||
let failureLog = "";
|
||||
// Each physical build belongs only to its destination services. A shared
|
||||
// reporter (the caller's global sink) receives each event just once.
|
||||
const owners = new Map<BuildReporter | undefined, {
|
||||
name: string;
|
||||
reporter?: BuildReporter;
|
||||
states: Set<string>;
|
||||
}>();
|
||||
const owners = new Map<
|
||||
BuildReporter | undefined,
|
||||
{
|
||||
name: string;
|
||||
reporter?: BuildReporter;
|
||||
states: Set<string>;
|
||||
}
|
||||
>();
|
||||
for (const { name, reporter } of reporters)
|
||||
if (!owners.has(reporter))
|
||||
owners.set(reporter, { name, reporter, states: new Set<string>() });
|
||||
const deliver = async (event: BuildEvent, updateStatus = false) => {
|
||||
if (event.type === "log" && event.sequence <= sequence) return;
|
||||
if (event.type === "log")
|
||||
failureLog = (failureLog + event.message).slice(-8_192);
|
||||
for (const { name, reporter, states } of owners.values())
|
||||
await reportBuildEvent(
|
||||
event,
|
||||
reporter,
|
||||
states,
|
||||
prefixService ? name : undefined,
|
||||
);
|
||||
if (event.type === "log") sequence = event.sequence;
|
||||
else if (updateStatus) status = event.status;
|
||||
};
|
||||
if (streamEvents) {
|
||||
const path = `/builds/${encodeURIComponent(id)}/events`;
|
||||
let unsupported = false;
|
||||
while (!unsupported) {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
for await (const { event, id: cursor, type: eventType } of streamEvents(
|
||||
path,
|
||||
sequence,
|
||||
signal,
|
||||
)) {
|
||||
if (eventType === "gap") {
|
||||
const gap = event as unknown as { message?: string };
|
||||
throw new Error(
|
||||
gap.message ??
|
||||
"Build log history was trimmed; some log output is unavailable.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
event.type === "log" &&
|
||||
cursor !== undefined &&
|
||||
cursor !== event.sequence
|
||||
)
|
||||
throw new Error("Build SSE log cursor does not match its sequence");
|
||||
await deliver(event, true);
|
||||
if (status.state === "succeeded" || status.state === "failed") break;
|
||||
}
|
||||
if (status.state === "succeeded" || status.state === "failed") break;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiStreamUnsupportedError) unsupported = true;
|
||||
else if (signal?.aborted) throw signal.reason;
|
||||
else if (!isTransientBuildPollError(error)) throw error;
|
||||
}
|
||||
if (!unsupported) {
|
||||
signal?.throwIfAborted();
|
||||
await sleep(Math.max(100, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
if (!unsupported) return withFailureLog(status, failureLog);
|
||||
}
|
||||
for (;;) {
|
||||
const events = await requestBuildPoll<BuildEvent[]>(
|
||||
request,
|
||||
@@ -519,31 +591,9 @@ async function waitForBuild(
|
||||
sleep,
|
||||
signal,
|
||||
);
|
||||
for (const event of events) {
|
||||
if (event.type === "log" && event.sequence <= sequence) continue;
|
||||
if (event.type === "log")
|
||||
failureLog = (failureLog + event.message).slice(-8_192);
|
||||
for (const { name, reporter, states } of owners.values())
|
||||
await reportBuildEvent(
|
||||
event,
|
||||
reporter,
|
||||
states,
|
||||
prefixService ? name : undefined,
|
||||
);
|
||||
if (event.type === "log") sequence = event.sequence;
|
||||
}
|
||||
for (const event of events) await deliver(event);
|
||||
if (status.state === "succeeded" || status.state === "failed") {
|
||||
const details = failureLog.trim();
|
||||
if (
|
||||
status.state === "failed" &&
|
||||
details &&
|
||||
!status.error?.includes(details)
|
||||
)
|
||||
return {
|
||||
...status,
|
||||
error: `${status.error ?? "BuildKit Job failed"}\n${details}`,
|
||||
};
|
||||
return status;
|
||||
return withFailureLog(status, failureLog);
|
||||
}
|
||||
status = await requestBuildPoll<BuildStatus>(
|
||||
request,
|
||||
@@ -563,6 +613,18 @@ async function waitForBuild(
|
||||
}
|
||||
}
|
||||
|
||||
function withFailureLog(status: BuildStatus, failureLog: string): BuildStatus {
|
||||
const details = failureLog.trim();
|
||||
return status.state === "failed" &&
|
||||
details &&
|
||||
!status.error?.includes(details)
|
||||
? {
|
||||
...status,
|
||||
error: `${status.error ?? "BuildKit Job failed"}\n${details}`,
|
||||
}
|
||||
: status;
|
||||
}
|
||||
|
||||
export async function resolveBuildImages(
|
||||
project: string,
|
||||
compose: ComposeSpecification,
|
||||
@@ -753,6 +815,7 @@ export async function buildServices(
|
||||
initial,
|
||||
options.signal,
|
||||
!reporter?.service,
|
||||
options.streamEvents ?? (options.request ? undefined : apiStreamEvents),
|
||||
);
|
||||
if (status.state !== "succeeded")
|
||||
throw new Error(
|
||||
|
||||
+154
-34
@@ -4,24 +4,65 @@ import type { ComposeSpecification, Service } from "../schema/docker.d";
|
||||
import { LABELS } from "../const";
|
||||
import { deleteResource, applyResource } from "./apply";
|
||||
|
||||
export const DATABASE_RECONCILE_PHASES = [
|
||||
"namespace precheck", "database dependency", "database resource listing",
|
||||
"database resource ownership", "claim discovery", "credential preparation",
|
||||
"role secret lookup", "role secret apply", "cluster lookup",
|
||||
"managed role preparation", "managed role update", "database preparation",
|
||||
"database lookup", "database ownership", "database apply", "credential lookup",
|
||||
"environment assembly", "operation execution",
|
||||
] as const;
|
||||
|
||||
export class DatabaseReconciliationError extends Error {
|
||||
readonly phase: string;
|
||||
|
||||
constructor(phase: string, error: unknown, claim?: PostgresClaim) {
|
||||
const context = claim
|
||||
? ` for database ${claim.database} (service ${claim.service}, role ${claim.username})`
|
||||
: "";
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
// Provider errors can contain credentials or entire request bodies. Only
|
||||
// expose a short, recognisable operational reason, never a raw response.
|
||||
const knownReason = /^(forbidden|not found|conflict|permission denied|connection refused|timed out|timeout|unauthorized|unprocessable entity|service unavailable)\b/i.exec(reason);
|
||||
const status = error && typeof error === "object" && "code" in error &&
|
||||
typeof error.code === "number" && error.code >= 400 && error.code < 600
|
||||
? ` (HTTP ${error.code})`
|
||||
: "";
|
||||
const safeReason = `${knownReason ? knownReason[1] : "Kubernetes request failed"}${status}`;
|
||||
super(`Database reconciliation failed during ${phase}${context}: ${safeReason}`, {
|
||||
const safePhase: string = DATABASE_RECONCILE_PHASES.some((known) => known === phase)
|
||||
? phase
|
||||
: "operation execution";
|
||||
// Claim values are user-controlled and may themselves be credentials.
|
||||
// Keep the actionable phase, but never persist or expose claim identifiers.
|
||||
const context = claim ? " for requested database claim" : "";
|
||||
const provider =
|
||||
error && typeof error === "object"
|
||||
? (error as Record<string, unknown>)
|
||||
: {};
|
||||
const body =
|
||||
provider.body && typeof provider.body === "object"
|
||||
? (provider.body as Record<string, unknown>)
|
||||
: {};
|
||||
const statusCode = [provider.statusCode, provider.code, body.code].find(
|
||||
(value) =>
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 400 &&
|
||||
value < 600,
|
||||
);
|
||||
const status = statusCode === undefined ? "" : ` (HTTP ${statusCode})`;
|
||||
// Provider messages and response bodies can contain secrets, connection
|
||||
// URLs or the full request. Match only a fixed vocabulary, never echo them.
|
||||
const reason = error instanceof Error ? error.message : "";
|
||||
const knownReason =
|
||||
/^(forbidden|not found|conflict|permission denied|connection refused|timed out|timeout|unauthorized|unprocessable entity|service unavailable)\b/i.exec(
|
||||
reason,
|
||||
);
|
||||
const providerReason =
|
||||
typeof body.reason === "string"
|
||||
? /^(Forbidden|NotFound|AlreadyExists|Conflict|Unauthorized|Invalid|ServiceUnavailable)$/.exec(
|
||||
body.reason,
|
||||
)?.[1]
|
||||
: undefined;
|
||||
const fallback = safePhase === "claim discovery"
|
||||
? "Check PostgreSQL claim declarations"
|
||||
: safePhase === "operation execution" && !status
|
||||
? "Unexpected failure; check server logs using the operation ID"
|
||||
: "Kubernetes request failed";
|
||||
const safeReason = `${knownReason?.[1] ?? providerReason ?? fallback}${status}`;
|
||||
super(`Database reconciliation failed during ${safePhase}${context}: ${safeReason}`, {
|
||||
cause: error,
|
||||
});
|
||||
this.name = "DatabaseReconciliationError";
|
||||
this.phase = safePhase;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +251,9 @@ async function readObject<T>(
|
||||
async function ensureRoleSecret(
|
||||
claim: PostgresClaim,
|
||||
): Promise<RoleCredentials> {
|
||||
let existing: V1Secret | undefined;
|
||||
try {
|
||||
const existing = await readObject<V1Secret>({
|
||||
existing = await readObject<V1Secret>({
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
@@ -219,10 +261,12 @@ async function ensureRoleSecret(
|
||||
namespace: DATABASE_NAMESPACE,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("role secret lookup", error, claim);
|
||||
}
|
||||
try {
|
||||
const username = claim.username;
|
||||
const password = decodeSecretValue(existing?.data?.password) ?? randomUUID();
|
||||
|
||||
await applyResource({
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
@@ -239,7 +283,7 @@ async function ensureRoleSecret(
|
||||
|
||||
return { username, password };
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("role secret setup", error, claim);
|
||||
throw new DatabaseReconciliationError("role secret apply", error, claim);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,17 +334,22 @@ async function reconcileManagedRoles(
|
||||
|
||||
if (!cluster) {
|
||||
throw new DatabaseReconciliationError(
|
||||
`CNPG cluster ${DATABASE_NAMESPACE}/${DATABASE_CLUSTER} lookup`,
|
||||
"cluster lookup",
|
||||
new Error("Not found"),
|
||||
claims[0],
|
||||
);
|
||||
}
|
||||
|
||||
const roles = new Map(
|
||||
(cluster.spec?.managed?.roles ?? []).map((role) => [role.name, role]),
|
||||
);
|
||||
for (const claim of claims) {
|
||||
roles.set(claim.username, toManagedRole(claim));
|
||||
let roles: Map<string, ManagedRole>;
|
||||
try {
|
||||
roles = new Map(
|
||||
(cluster.spec?.managed?.roles ?? []).map((role) => [role.name, role]),
|
||||
);
|
||||
for (const claim of claims) {
|
||||
roles.set(claim.username, toManagedRole(claim));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("managed role preparation", error, claims[0]);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -326,17 +375,26 @@ async function reconcileManagedRoles(
|
||||
async function reconcileDatabases(
|
||||
project: string,
|
||||
claims: PostgresClaim[],
|
||||
existingDatabases: Set<string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const uniqueDatabases = new Map<string, PostgresClaim>();
|
||||
for (const claim of claims) {
|
||||
uniqueDatabases.set(`${claim.database}:${claim.username}`, claim);
|
||||
try {
|
||||
for (const claim of claims) {
|
||||
uniqueDatabases.set(`${claim.database}:${claim.username}`, claim);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("database preparation", error);
|
||||
}
|
||||
|
||||
for (const claim of uniqueDatabases.values()) {
|
||||
// An existing Database may be shared with other workspaces. Applying even
|
||||
// an identical object can prune labels owned by the SSA field manager.
|
||||
if (existingDatabases.has(claim.database)) continue;
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
await applyResource({
|
||||
const { objectApi } = await import("./k8s");
|
||||
await objectApi.create({
|
||||
apiVersion: "postgresql.cnpg.io/v1",
|
||||
kind: "Database",
|
||||
metadata: {
|
||||
@@ -357,22 +415,63 @@ async function reconcileDatabases(
|
||||
name: claim.database,
|
||||
owner: claim.username,
|
||||
},
|
||||
});
|
||||
} as KubernetesObject);
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("database apply", error, claim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectDatabases(
|
||||
claims: PostgresClaim[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Set<string>> {
|
||||
const existing = new Set<string>();
|
||||
for (const claim of claims) {
|
||||
if (existing.has(claim.database)) continue;
|
||||
let database: (KubernetesObject & {
|
||||
spec?: { owner?: string; cluster?: { name?: string } };
|
||||
}) | undefined;
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
database = await readObject({
|
||||
apiVersion: "postgresql.cnpg.io/v1",
|
||||
kind: "Database",
|
||||
metadata: { name: claim.database, namespace: DATABASE_NAMESPACE },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("database lookup", error, claim);
|
||||
}
|
||||
if (!database) continue;
|
||||
if (
|
||||
database.spec?.owner !== claim.username ||
|
||||
database.spec?.cluster?.name !== DATABASE_CLUSTER
|
||||
) {
|
||||
throw new DatabaseReconciliationError(
|
||||
"database ownership",
|
||||
new Error("Existing database does not match the requested role and cluster"),
|
||||
claim,
|
||||
);
|
||||
}
|
||||
existing.add(claim.database);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function reconcilePostgresClaim(
|
||||
project: string,
|
||||
claim: PostgresClaim,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RoleCredentials> {
|
||||
throwIfAborted(signal);
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("credential preparation", error, claim);
|
||||
}
|
||||
const existing = await inspectDatabases([claim], signal);
|
||||
const credentials = await ensureRoleSecret(claim);
|
||||
await reconcileManagedRoles([claim], signal);
|
||||
await reconcileDatabases(project, [claim], signal);
|
||||
await reconcileDatabases(project, [claim], existing, signal);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
@@ -381,27 +480,48 @@ export async function reconcilePostgresClaims(
|
||||
compose: ComposeSpecification,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Record<string, Record<string, string>>> {
|
||||
const claims = getComposePostgresClaims(compose);
|
||||
let claims: PostgresClaim[];
|
||||
try {
|
||||
claims = getComposePostgresClaims(compose);
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("claim discovery", error);
|
||||
}
|
||||
if (claims.length === 0) return {};
|
||||
|
||||
// Validate every requested database before touching any role Secret or the
|
||||
// shared Cluster; one conflicting claim must leave all roles untouched.
|
||||
const existing = await inspectDatabases(claims, signal);
|
||||
|
||||
const credentialsBySecret = new Map<string, RoleCredentials>();
|
||||
for (const claim of claims) {
|
||||
if (credentialsBySecret.has(claim.secretName)) continue;
|
||||
throwIfAborted(signal);
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("credential preparation", error, claim);
|
||||
}
|
||||
credentialsBySecret.set(claim.secretName, await ensureRoleSecret(claim));
|
||||
}
|
||||
|
||||
await reconcileManagedRoles(claims, signal);
|
||||
await reconcileDatabases(project, claims, signal);
|
||||
await reconcileDatabases(project, claims, existing, signal);
|
||||
|
||||
return Object.fromEntries(
|
||||
claims.map((claim) => {
|
||||
const credentials = credentialsBySecret.get(claim.secretName);
|
||||
if (!credentials) {
|
||||
throw new Error(`Missing credentials for ${claim.secretName}`);
|
||||
throw new DatabaseReconciliationError(
|
||||
"credential lookup",
|
||||
new Error("Missing credentials"),
|
||||
claim,
|
||||
);
|
||||
}
|
||||
|
||||
return [claim.service, buildPostgresEnvironment(claim, credentials)];
|
||||
try {
|
||||
return [claim.service, buildPostgresEnvironment(claim, credentials)];
|
||||
} catch (error) {
|
||||
throw new DatabaseReconciliationError("environment assembly", error, claim);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { KUBER_VERSION } from "../shared/version";
|
||||
import { readlinkSync, statSync } from "node:fs";
|
||||
|
||||
type SemVer = {
|
||||
major: bigint;
|
||||
minor: bigint;
|
||||
patch: bigint;
|
||||
prerelease: string[];
|
||||
};
|
||||
|
||||
const SEMVER =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
const NUMERIC = /^(0|[1-9]\d*)$/;
|
||||
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);
|
||||
if (!match || match[0] !== value) return;
|
||||
const prerelease = match[4]?.split(".") ?? [];
|
||||
if (prerelease.some((part) => /^\d+$/.test(part) && !NUMERIC.test(part)))
|
||||
return;
|
||||
return {
|
||||
major: BigInt(match[1]!),
|
||||
minor: BigInt(match[2]!),
|
||||
patch: BigInt(match[3]!),
|
||||
prerelease,
|
||||
};
|
||||
}
|
||||
|
||||
/** A positive result means candidate is newer; build metadata has no precedence. */
|
||||
export function compareVersions(
|
||||
candidate: string,
|
||||
current: string,
|
||||
): number | undefined {
|
||||
const a = parseVersion(candidate);
|
||||
const b = parseVersion(current);
|
||||
if (!a || !b) return;
|
||||
for (const field of ["major", "minor", "patch"] as const) {
|
||||
if (a[field] !== b[field]) return a[field] > b[field] ? 1 : -1;
|
||||
}
|
||||
if (!a.prerelease.length || !b.prerelease.length) {
|
||||
return Number(!a.prerelease.length) - Number(!b.prerelease.length);
|
||||
}
|
||||
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
||||
const left = a.prerelease[i];
|
||||
const right = b.prerelease[i];
|
||||
if (left === undefined || right === undefined)
|
||||
return left === undefined ? -1 : 1;
|
||||
if (left === right) continue;
|
||||
const leftNumeric = NUMERIC.test(left);
|
||||
const rightNumeric = NUMERIC.test(right);
|
||||
if (leftNumeric && rightNumeric)
|
||||
return BigInt(left) > BigInt(right) ? 1 : -1;
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export type VersionInstallRunner = (version: string) => Promise<boolean>;
|
||||
|
||||
type InstallProcess = {
|
||||
exited: Promise<number>;
|
||||
unref?(): void;
|
||||
};
|
||||
type InstallSpawn = (
|
||||
argv: string[],
|
||||
options: {
|
||||
stdin: "ignore";
|
||||
stdout: "ignore";
|
||||
stderr: "ignore";
|
||||
detached: true;
|
||||
},
|
||||
) => InstallProcess;
|
||||
|
||||
export async function installVersion(
|
||||
version: string,
|
||||
spawn: InstallSpawn = Bun.spawn,
|
||||
timeoutSeconds = INSTALL_TIMEOUT_SECONDS,
|
||||
): Promise<boolean> {
|
||||
// Defense in depth: never pass an unvalidated header to a subprocess.
|
||||
if (
|
||||
!parseVersion(version) ||
|
||||
!Number.isSafeInteger(timeoutSeconds) ||
|
||||
timeoutSeconds < 1 ||
|
||||
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);
|
||||
}
|
||||
|
||||
export function createVersionObserver({
|
||||
currentVersion = KUBER_VERSION,
|
||||
runner = installVersion,
|
||||
report = reportToStderr,
|
||||
}: {
|
||||
currentVersion?: string;
|
||||
runner?: VersionInstallRunner;
|
||||
report?: (line: string) => void;
|
||||
} = {}): (version: string | null) => void {
|
||||
let attempted = false;
|
||||
return (version) => {
|
||||
if (attempted || !version || compareVersions(version, currentVersion) !== 1)
|
||||
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}`)),
|
||||
)
|
||||
.catch(() => {
|
||||
// A broken stderr must never affect an API request or command exit status.
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
|
||||
export const observeServerVersion = createVersionObserver();
|
||||
Reference in New Issue
Block a user