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
+125 -10
View File
@@ -38,7 +38,13 @@ export interface BuildCas extends MaterializeCas {
put(data: Uint8Array, expected?: Sha256Digest): Promise<Sha256Digest>;
}
export type JobPhase = "queued" | "running" | "succeeded" | "failed";
export type JobPhase =
| "queued"
| "creating"
| "starting"
| "running"
| "succeeded"
| "failed";
export interface BuildJobObservation {
phase: JobPhase;
@@ -187,7 +193,41 @@ function validateRequest(request: BuildRequest): void {
throw new BuildValidationError("Build arguments must be strings");
}
assertSha256Digest(request.spec.workspace);
if (
[
request.spec.image,
...(Array.isArray(request.destinations)
? request.destinations.map((destination) => destination?.image)
: []),
].some((image) => typeof image === "string" && /[,"\r\n]/.test(image))
)
throw new BuildValidationError(
"Build image cannot contain BuildKit output separators",
);
parseImageReference(request.spec.image);
if (request.destinations !== undefined) {
if (!Array.isArray(request.destinations))
throw new BuildValidationError("Build destinations must be an array");
const services = new Set([request.service]);
const images = new Set([request.spec.image]);
for (const destination of request.destinations) {
if (
!destination ||
typeof destination.service !== "string" ||
!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(destination.service) ||
destination.service.length > 63 ||
services.has(destination.service) ||
typeof destination.image !== "string" ||
images.has(destination.image)
)
throw new BuildValidationError(
"Build destinations must have unique services and images",
);
parseImageReference(destination.image);
services.add(destination.service);
images.add(destination.image);
}
}
}
function recordStatus(record: BuildRecord): BuildStatus {
@@ -195,6 +235,7 @@ function recordStatus(record: BuildRecord): BuildStatus {
version,
id,
state,
phase,
createdAt,
startedAt,
finishedAt,
@@ -205,6 +246,7 @@ function recordStatus(record: BuildRecord): BuildStatus {
version,
id,
state,
...(phase && { phase }),
createdAt,
...(startedAt && { startedAt }),
...(finishedAt && { finishedAt }),
@@ -381,8 +423,32 @@ export class BuildController {
async submitBuild(request: BuildRequest): Promise<BuildStatus> {
request = clone(request);
if (
request.destinations !== undefined &&
!Array.isArray(request.destinations)
)
throw new BuildValidationError("Build destinations must be an array");
if (
request.destinations?.some(
(destination) =>
!destination ||
typeof destination.service !== "string" ||
typeof destination.image !== "string",
)
)
throw new BuildValidationError("Invalid build destination");
if (this.options.imageName)
request.spec.image = this.options.imageName(request);
if (this.options.imageName && request.destinations)
request.destinations = request.destinations.map((destination) => ({
service: destination.service,
image: this.options.imageName!({
...request,
service: destination.service,
spec: { ...request.spec, image: destination.image },
destinations: undefined,
}),
}));
validateRequest(request);
const imageKey = `${request.project}\0${request.service}\0${request.spec.image}`;
const previous =
@@ -417,6 +483,7 @@ export class BuildController {
version: BUILD_PROTOCOL_VERSION,
id: request.id,
state: "queued",
phase: "queued",
createdAt,
};
const record: BuildRecord = {
@@ -492,6 +559,16 @@ export class BuildController {
const pushImage = this.options.pushImage
? this.options.pushImage(request)
: request.spec.image;
const pushImages = request.destinations?.map((destination) =>
this.options.pushImage
? this.options.pushImage({
...request,
service: destination.service,
spec: { ...request.spec, image: destination.image },
destinations: undefined,
})
: destination.image,
);
const job = createBuildJob({
name: jobName,
namespace: this.options.namespace,
@@ -503,6 +580,7 @@ export class BuildController {
),
cacheImage,
pushImage,
pushImages,
pushRegistryInsecure: this.options.pushRegistryInsecure,
cacheRegistryInsecure:
this.options.cacheRegistryInsecure ??
@@ -683,14 +761,23 @@ export class BuildController {
record.spec.jobName,
);
if (!observation) return recordStatus(record);
if (observation.phase === "running" && record.status.state === "queued") {
if (
(observation.phase === "queued" ||
observation.phase === "creating" ||
observation.phase === "starting" ||
observation.phase === "running") &&
(record.status.state === "queued" || observation.phase === "running")
) {
await requireLeaseOwnership();
record = await this.setState(
record,
"running",
{
startedAt: observation.startedAt ?? this.now().toISOString(),
},
observation.phase === "running" ? "running" : "queued",
observation.phase === "running"
? {
phase: "running",
startedAt: observation.startedAt ?? this.now().toISOString(),
}
: { phase: observation.phase },
reconcileLease,
);
} else if (observation.phase === "failed") {
@@ -702,6 +789,7 @@ export class BuildController {
startedAt: record.status.startedAt ?? observation.startedAt,
finishedAt: observation.finishedAt ?? this.now().toISOString(),
error: observation.error ?? "BuildKit Job failed",
phase: "done",
},
reconcileLease,
);
@@ -710,6 +798,14 @@ export class BuildController {
try {
digest = await this.digestResolver(record.spec.request.spec.image);
assertSha256Digest(digest);
for (const destination of record.spec.request.destinations ?? []) {
const aliasDigest = await this.digestResolver(destination.image);
assertSha256Digest(aliasDigest);
if (aliasDigest !== digest)
throw new Error(
`Pushed image ${destination.image} has a different digest`,
);
}
} catch (error) {
await requireLeaseOwnership();
record = await this.setState(
@@ -718,6 +814,7 @@ export class BuildController {
{
finishedAt: this.now().toISOString(),
error: `Unable to resolve pushed image digest: ${error instanceof Error ? error.message : String(error)}`,
phase: "done",
},
reconcileLease,
);
@@ -731,6 +828,7 @@ export class BuildController {
startedAt: record.status.startedAt ?? observation.startedAt,
finishedAt: observation.finishedAt ?? this.now().toISOString(),
digest,
phase: "done",
},
reconcileLease,
);
@@ -783,6 +881,7 @@ export class BuildController {
const next = await this.setState(record, "failed", {
finishedAt: this.now().toISOString(),
error: "Build cancelled",
phase: "done",
cancelled: true,
});
return recordStatus(next);
@@ -799,9 +898,12 @@ export class BuildController {
});
}
async getBuildResult(
id: string,
): Promise<{ image: string; digest: Sha256Digest; reference: string }> {
async getBuildResult(id: string): Promise<{
image: string;
digest: Sha256Digest;
reference: string;
references?: Record<string, string>;
}> {
const record = await this.requireBuild(id);
if (record.status.state !== "succeeded" || !record.status.digest)
throw new BuildConflictError("Build has no immutable image result");
@@ -811,6 +913,17 @@ export class BuildController {
image,
digest: record.status.digest,
reference: `${image}@${record.status.digest}`,
...(record.spec.request.destinations?.length && {
references: Object.fromEntries(
record.spec.request.destinations.map((destination) => {
const alias = parseImageReference(destination.image);
return [
destination.service,
`${alias.registry}/${alias.repository}@${record.status.digest}`,
];
}),
),
}),
};
}
@@ -857,7 +970,8 @@ export class BuildController {
values: Partial<BuildRecord["status"]>,
reconcileLease?: BuildReconciliationLease,
): Promise<BuildRecord> {
if (record.status.state === state && state === "running") return record;
if (record.status.state === state && record.status.phase === values.phase)
return record;
return this.updateBuild(
record,
(next) => {
@@ -878,6 +992,7 @@ export class BuildController {
await this.setState(current, "failed", {
finishedAt: this.now().toISOString(),
error: message,
phase: "done",
});
}