feat: improve API keys and build workflows

This commit is contained in:
2026-10-04 20:05:13 +00:00 Unverified
parent 62f2362a2e
commit e4623efe86
27 changed files with 2080 additions and 235 deletions
+131 -48
View File
@@ -179,6 +179,9 @@ type ProgressReporter = (message: string) => void | Promise<void>;
type BuildReporter = {
progress?: ProgressReporter;
stream?: Writable;
/** Per-image output and lifecycle hooks, including for queued images. */
service?: (name: string) => BuildReporter | undefined;
settled?: (name: string, error?: unknown) => void;
};
export type BuildResult = {
@@ -197,6 +200,7 @@ type ImageResult = {
image: string;
digest: Sha256Digest;
reference: string;
references?: Record<string, string>;
};
function posixRelative(root: string, path: string): string {
@@ -405,17 +409,22 @@ export async function uploadWorkspaceSnapshot(
async function reportBuildEvent(
event: BuildEvent,
reporter?: BuildReporter,
reportedStates?: Set<BuildStatus["state"]>,
reportedStates?: Set<string>,
service?: string,
): Promise<number> {
): Promise<void> {
if (event.type === "status") {
if (!reportedStates?.has(event.status.state)) {
reportedStates?.add(event.status.state);
const phase =
event.status.phase ??
(event.status.state === "succeeded" || event.status.state === "failed"
? "done"
: event.status.state);
if (!reportedStates?.has(phase)) {
reportedStates?.add(phase);
await reporter?.progress?.(
`${service ? `${service}: ` : ""}Build ${event.status.state}`,
`${service ? `${service}: ` : ""}Build ${phase}${event.status.state === "failed" ? `: ${event.status.error ?? "unknown error"}` : ""}`,
);
}
return 0;
return;
}
if (reporter?.stream)
reporter.stream.write(
@@ -425,7 +434,6 @@ async function reportBuildEvent(
await reporter?.progress?.(
`${service ? `${service}: ` : ""}${event.message.trimEnd()}`,
);
return event.sequence;
}
function isTransientBuildPollError(error: unknown): boolean {
@@ -482,16 +490,26 @@ async function requestBuildPoll<T>(
async function waitForBuild(
id: string,
request: ApiRequester,
reporter: BuildReporter | undefined,
reporters: Array<{ name: string; reporter?: BuildReporter }>,
pollIntervalMs: number,
sleep: (milliseconds: number) => Promise<void>,
initial: BuildStatus,
signal?: AbortSignal,
service?: string,
prefixService = false,
): Promise<BuildStatus> {
let status = initial;
let sequence = 0;
const reportedStates = new Set<BuildStatus["state"]>();
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>;
}>();
for (const { name, reporter } of reporters)
if (!owners.has(reporter))
owners.set(reporter, { name, reporter, states: new Set<string>() });
for (;;) {
const events = await requestBuildPoll<BuildEvent[]>(
request,
@@ -501,13 +519,32 @@ async function waitForBuild(
sleep,
signal,
);
for (const event of events)
sequence = Math.max(
sequence,
await reportBuildEvent(event, reporter, reportedStates, service),
);
if (status.state === "succeeded" || status.state === "failed")
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;
}
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;
}
status = await requestBuildPoll<BuildStatus>(
request,
`/builds/${encodeURIComponent(id)}/reconcile`,
@@ -545,24 +582,21 @@ export async function resolveBuildImages(
const index = next++;
if (index >= services.length) return;
const service = services[index]!;
options.signal?.throwIfAborted();
try {
references[index] = (
await request<ImageResult>(
"/images/resolve",
{
method: "POST",
json: { project, service },
signal: options.signal,
},
)
).reference;
} catch (error) {
throw new Error(
`Cannot resolve a published image for service ${service}. Run kuber up to build it.`,
{ cause: error },
);
}
options.signal?.throwIfAborted();
try {
references[index] = (
await request<ImageResult>("/images/resolve", {
method: "POST",
json: { project, service },
signal: options.signal,
})
).reference;
} catch (error) {
throw new Error(
`Cannot resolve a published image for service ${service}. Run kuber up to build it.`,
{ cause: error },
);
}
}
},
),
@@ -619,32 +653,80 @@ export async function buildServices(
);
const references: string[] = new Array(plans.length);
const reporters = new Map(
plans.map((plan) => [
plan.name,
reporter?.service?.(plan.name) ?? reporter,
]),
);
const architecture = resolveComposeArch(compose);
const groups = new Map<string, number[]>();
plans.forEach((plan, index) => {
const key = JSON.stringify({
workspace: snapshot.digest,
architecture,
context: plan.context,
dockerfile: plan.dockerfile,
target: plan.target,
buildArgs: plan.buildArgs,
});
const group = groups.get(key) ?? [];
group.push(index);
groups.set(key, group);
});
const batches = [...groups.values()];
let next = 0;
let failure: unknown;
let failed = false;
const worker = async () => {
while (!failed && next < plans.length) {
const index = next++;
const plan = plans[index]!;
while (!failed && next < batches.length) {
const batch = batches[next++]!;
const groupPlans = batch.map((index) => plans[index]!);
try {
options.signal?.throwIfAborted();
await reporter?.progress?.(`Building ${plan.name}`);
references[index] = await buildPlan(plan);
for (const plan of groupPlans) {
await reporters
.get(plan.name)
?.progress?.(
`${reporter?.service ? "" : `${plan.name}: `}Build queued`,
);
}
const result = await buildPlan(groupPlans);
batch.forEach((index) => {
const plan = plans[index]!;
const reference =
index === batch[0]
? result.reference
: result.references?.[plan.name];
if (!reference)
throw new Error(
`Build result missing image for service ${plan.name}`,
);
references[index] = reference;
});
for (const plan of groupPlans) reporter?.settled?.(plan.name);
} catch (error) {
if (!failed) failure = error;
failed = true;
for (const plan of groupPlans) reporter?.settled?.(plan.name, error);
}
}
};
const buildPlan = async (plan: BuildPlan): Promise<string> => {
const buildPlan = async (groupPlans: BuildPlan[]): Promise<ImageResult> => {
const plan = groupPlans[0]!;
const id = randomUUID();
const buildRequest: BuildRequest = {
version: BUILD_PROTOCOL_VERSION,
id,
project,
service: plan.name,
...(groupPlans.length > 1 && {
destinations: groupPlans
.slice(1)
.map(({ name, image }) => ({ service: name, image })),
}),
spec: {
architecture: resolveComposeArch(compose),
architecture,
image: plan.image,
context: plan.context,
dockerfile: plan.dockerfile,
@@ -665,26 +747,27 @@ export async function buildServices(
const status = await waitForBuild(
id,
request,
reporter,
groupPlans.map(({ name }) => ({ name, reporter: reporters.get(name) })),
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
options.sleep ?? ((milliseconds) => Bun.sleep(milliseconds)),
initial,
options.signal,
plan.name,
!reporter?.service,
);
if (status.state !== "succeeded")
throw new Error(
`Build failed for service ${plan.name}: ${status.error ?? "unknown error"}`,
);
return (
await request<ImageResult>(`/builds/${encodeURIComponent(id)}/result`, {
return await request<ImageResult>(
`/builds/${encodeURIComponent(id)}/result`,
{
signal: options.signal,
})
).reference;
},
);
};
await Promise.all(
Array.from({ length: Math.min(concurrency, plans.length) }, worker),
Array.from({ length: Math.min(concurrency, batches.length) }, worker),
);
if (failed) throw failure;
const images = Object.fromEntries(
+70 -42
View File
@@ -4,6 +4,27 @@ import type { ComposeSpecification, Service } from "../schema/docker.d";
import { LABELS } from "../const";
import { deleteResource, applyResource } from "./apply";
export class DatabaseReconciliationError extends Error {
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}`, {
cause: error,
});
this.name = "DatabaseReconciliationError";
}
}
export const DATABASE_NAMESPACE = "database";
export const DATABASE_CLUSTER = "postgres";
export const DATABASE_HOST = `c.${DATABASE_NAMESPACE}.svc.cluster.local`;
@@ -189,33 +210,37 @@ async function readObject<T>(
async function ensureRoleSecret(
claim: PostgresClaim,
): Promise<RoleCredentials> {
const existing = await readObject<V1Secret>({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: claim.secretName,
namespace: DATABASE_NAMESPACE,
},
});
try {
const existing = await readObject<V1Secret>({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: claim.secretName,
namespace: DATABASE_NAMESPACE,
},
});
const username = claim.username;
const password = decodeSecretValue(existing?.data?.password) ?? randomUUID();
const username = claim.username;
const password = decodeSecretValue(existing?.data?.password) ?? randomUUID();
await applyResource({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: claim.secretName,
namespace: DATABASE_NAMESPACE,
},
type: existing?.type ?? "Opaque",
stringData: {
username,
password,
},
} satisfies V1Secret);
await applyResource({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: claim.secretName,
namespace: DATABASE_NAMESPACE,
},
type: existing?.type ?? "Opaque",
stringData: {
username,
password,
},
} satisfies V1Secret);
return { username, password };
return { username, password };
} catch (error) {
throw new DatabaseReconciliationError("role secret setup", error, claim);
}
}
function toManagedRole(claim: PostgresClaim): ManagedRole {
@@ -247,20 +272,27 @@ async function reconcileManagedRoles(
): Promise<void> {
if (claims.length === 0) return;
const cluster = await readObject<
KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } }
>({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Cluster",
metadata: {
name: DATABASE_CLUSTER,
namespace: DATABASE_NAMESPACE,
},
});
let cluster: KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } } | undefined;
try {
cluster = await readObject<
KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } }
>({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Cluster",
metadata: {
name: DATABASE_CLUSTER,
namespace: DATABASE_NAMESPACE,
},
});
} catch (error) {
throw new DatabaseReconciliationError("cluster lookup", error, claims[0]);
}
if (!cluster) {
throw new Error(
`CNPG cluster ${DATABASE_CLUSTER} was not found in namespace ${DATABASE_NAMESPACE}.`,
throw new DatabaseReconciliationError(
`CNPG cluster ${DATABASE_NAMESPACE}/${DATABASE_CLUSTER} lookup`,
new Error("Not found"),
claims[0],
);
}
@@ -287,9 +319,7 @@ async function reconcileManagedRoles(
},
});
} catch (error) {
throw new Error(
`Failed to reconcile managed roles on ${DATABASE_NAMESPACE}/${DATABASE_CLUSTER}: ${error instanceof Error ? error.message : String(error)}`,
);
throw new DatabaseReconciliationError("managed role update", error, claims[0]);
}
}
@@ -329,9 +359,7 @@ async function reconcileDatabases(
},
});
} catch (error) {
throw new Error(
`Failed to reconcile database ${claim.database} owned by ${claim.username}: ${error instanceof Error ? error.message : String(error)}`,
);
throw new DatabaseReconciliationError("database apply", error, claim);
}
}
}