feat: improve API keys and build workflows
This commit is contained in:
+125
-10
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user