feat: improve API keys and build workflows
This commit is contained in:
+43
-22
@@ -1,6 +1,7 @@
|
||||
import type { KubernetesObject } from "@kubernetes/client-node";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ComposeSpecification } from "../schema/docker.d";
|
||||
import { DatabaseReconciliationError } from "../lib/database";
|
||||
import type { Operation as PublicOperation } from "../shared/api";
|
||||
import type { BuildRequest, Sha256Digest } from "../shared/build-protocol";
|
||||
import {
|
||||
@@ -87,7 +88,6 @@ const MAX_LOGIN_FAILURES = 5;
|
||||
const DEFAULT_JSON_LIMIT = 1024 * 1024;
|
||||
const WORKSPACE_LEASE_TTL_MS = 30_000;
|
||||
const WORKSPACE_LEASE_RENEW_INTERVAL_MS = WORKSPACE_LEASE_TTL_MS / 3;
|
||||
const DEFAULT_API_KEY_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
const MAX_API_KEY_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface ApiWorkspaceStore extends WorkspaceStore {
|
||||
@@ -248,7 +248,7 @@ export async function authenticateRequest(
|
||||
if (
|
||||
!apiKey ||
|
||||
!tokenHashesEqual(tokenHash, apiKey.tokenHash) ||
|
||||
Date.parse(apiKey.expiresAt) <= now()
|
||||
(apiKey.expiresAt !== undefined && Date.parse(apiKey.expiresAt) <= now())
|
||||
)
|
||||
return;
|
||||
const user = await options.store.getUser(apiKey.username);
|
||||
@@ -725,11 +725,17 @@ export function createApp(
|
||||
username: string,
|
||||
capabilities: readonly Capability[],
|
||||
workspace: string | undefined,
|
||||
expiresAt: string | undefined,
|
||||
): Promise<void> {
|
||||
const parent = identity.apiKey;
|
||||
if (!parent) return;
|
||||
|
||||
let reason: "target_user" | "capabilities" | "workspace" | undefined;
|
||||
let reason:
|
||||
| "target_user"
|
||||
| "capabilities"
|
||||
| "workspace"
|
||||
| "expiry"
|
||||
| undefined;
|
||||
if (username !== parent.username) reason = "target_user";
|
||||
else if (
|
||||
!capabilities.every((capability) =>
|
||||
@@ -739,6 +745,12 @@ export function createApp(
|
||||
reason = "capabilities";
|
||||
else if (parent.workspace !== undefined && workspace !== parent.workspace)
|
||||
reason = "workspace";
|
||||
else if (
|
||||
parent.expiresAt !== undefined &&
|
||||
(expiresAt === undefined ||
|
||||
Date.parse(expiresAt) > Date.parse(parent.expiresAt))
|
||||
)
|
||||
reason = "expiry";
|
||||
if (!reason) return;
|
||||
|
||||
await audit(identity, request, "api_key.create", "denied", {
|
||||
@@ -746,12 +758,13 @@ export function createApp(
|
||||
username,
|
||||
capabilities,
|
||||
...(workspace && { workspace }),
|
||||
...(expiresAt && { expiresAt }),
|
||||
});
|
||||
throw new HttpError(
|
||||
403,
|
||||
"Forbidden",
|
||||
"API_KEY_DELEGATION_FORBIDDEN",
|
||||
"API key children must use the caller's user, capabilities, and workspace scope",
|
||||
"API key children must use the caller's user, capabilities, workspace, and expiry scope",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1144,10 +1157,18 @@ export function createApp(
|
||||
const failed = await transitionOperationToFailure(
|
||||
operation.metadata.name,
|
||||
{
|
||||
code: leaseLost ? "WORKSPACE_LEASE_LOST" : "OPERATION_FAILED",
|
||||
code: leaseLost
|
||||
? "WORKSPACE_LEASE_LOST"
|
||||
: action === "databases.reconcile" &&
|
||||
error instanceof DatabaseReconciliationError
|
||||
? "DATABASE_RECONCILE_FAILED"
|
||||
: "OPERATION_FAILED",
|
||||
message: leaseLost
|
||||
? "Workspace operation lease ownership was lost"
|
||||
: message,
|
||||
: action === "databases.reconcile" &&
|
||||
!(error instanceof DatabaseReconciliationError)
|
||||
? "Database reconciliation failed"
|
||||
: message,
|
||||
},
|
||||
);
|
||||
const failure = failed.status.error;
|
||||
@@ -1572,7 +1593,7 @@ export function createApp(
|
||||
username: key.username,
|
||||
capabilities: key.capabilities,
|
||||
...(key.workspace && { workspace: key.workspace }),
|
||||
expiresAt: key.expiresAt,
|
||||
...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }),
|
||||
disabled: Boolean(key.disabled),
|
||||
})),
|
||||
});
|
||||
@@ -1580,13 +1601,9 @@ export function createApp(
|
||||
if (!keyId && request.method === "POST") {
|
||||
await requireCapability(identity, request, "users:write");
|
||||
const body = await readJson(request);
|
||||
const expiresAt =
|
||||
body.expiresAt === undefined
|
||||
? new Date(now() + DEFAULT_API_KEY_MS).toISOString()
|
||||
: typeof body.expiresAt === "string"
|
||||
? body.expiresAt
|
||||
: "";
|
||||
const expires = new Date(expiresAt);
|
||||
const expiresAt = body.expiresAt;
|
||||
const expires =
|
||||
typeof expiresAt === "string" ? new Date(expiresAt) : undefined;
|
||||
if (
|
||||
!Array.isArray(body.capabilities) ||
|
||||
body.capabilities.length === 0 ||
|
||||
@@ -1596,16 +1613,19 @@ export function createApp(
|
||||
(typeof body.workspace !== "string" ||
|
||||
!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(body.workspace) ||
|
||||
body.workspace.length > 63)) ||
|
||||
!Number.isFinite(expires.getTime()) ||
|
||||
expires.toISOString() !== expiresAt ||
|
||||
expires.getTime() <= now() ||
|
||||
expires.getTime() > now() + MAX_API_KEY_MS
|
||||
(expiresAt !== undefined &&
|
||||
(typeof expiresAt !== "string" ||
|
||||
!expires ||
|
||||
!Number.isFinite(expires.getTime()) ||
|
||||
expires.toISOString() !== expiresAt ||
|
||||
expires.getTime() <= now() ||
|
||||
expires.getTime() > now() + MAX_API_KEY_MS))
|
||||
)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Invalid API key",
|
||||
"API_KEY_INVALID",
|
||||
"Capabilities and an expiry no more than 365 days away are required",
|
||||
"Valid capabilities and an optional expiry no more than 365 days away are required",
|
||||
);
|
||||
await requireApiKeyDelegation(
|
||||
identity,
|
||||
@@ -1615,6 +1635,7 @@ export function createApp(
|
||||
typeof body.workspace === "string" && body.workspace
|
||||
? body.workspace
|
||||
: undefined,
|
||||
expiresAt as string | undefined,
|
||||
);
|
||||
const token = createToken();
|
||||
const key: ApiKeyRecord = {
|
||||
@@ -1624,7 +1645,7 @@ export function createApp(
|
||||
capabilities: body.capabilities,
|
||||
...(typeof body.workspace === "string" &&
|
||||
body.workspace && { workspace: body.workspace }),
|
||||
expiresAt,
|
||||
...(typeof expiresAt === "string" && { expiresAt }),
|
||||
};
|
||||
if (!(await options.store.getUser(username)))
|
||||
throw new HttpError(
|
||||
@@ -1639,7 +1660,7 @@ export function createApp(
|
||||
keyId: key.id,
|
||||
capabilities: key.capabilities,
|
||||
...(key.workspace && { workspace: key.workspace }),
|
||||
expiresAt: key.expiresAt,
|
||||
...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }),
|
||||
});
|
||||
return response(
|
||||
{
|
||||
@@ -1647,7 +1668,7 @@ export function createApp(
|
||||
username,
|
||||
capabilities: key.capabilities,
|
||||
...(key.workspace && { workspace: key.workspace }),
|
||||
expiresAt: key.expiresAt,
|
||||
...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }),
|
||||
disabled: false,
|
||||
token,
|
||||
},
|
||||
|
||||
+16
-9
@@ -36,7 +36,7 @@ export type ApiKeyRecord = {
|
||||
username: string;
|
||||
capabilities: Capability[];
|
||||
workspace?: string;
|
||||
expiresAt: string;
|
||||
expiresAt?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -129,12 +129,14 @@ export function normalizeApiKey(key: NewApiKey): ApiKeyRecord {
|
||||
) {
|
||||
throw new Error("API key workspace scope is invalid");
|
||||
}
|
||||
const expiresAt = new Date(key.expiresAt);
|
||||
if (
|
||||
!Number.isFinite(expiresAt.getTime()) ||
|
||||
expiresAt.toISOString() !== key.expiresAt
|
||||
) {
|
||||
throw new Error("API key expiration must be an ISO timestamp");
|
||||
if (key.expiresAt !== undefined) {
|
||||
const expiresAt = new Date(key.expiresAt);
|
||||
if (
|
||||
!Number.isFinite(expiresAt.getTime()) ||
|
||||
expiresAt.toISOString() !== key.expiresAt
|
||||
) {
|
||||
throw new Error("API key expiration must be an ISO timestamp");
|
||||
}
|
||||
}
|
||||
return {
|
||||
...key,
|
||||
@@ -279,7 +281,12 @@ export class MemoryAuthStore implements AuthStore {
|
||||
|
||||
async getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined> {
|
||||
const key = this.apiKeysByTokenHash.get(tokenHash);
|
||||
if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return;
|
||||
if (
|
||||
!key ||
|
||||
key.disabled ||
|
||||
(key.expiresAt !== undefined && Date.parse(key.expiresAt) <= Date.now())
|
||||
)
|
||||
return;
|
||||
const user = this.users.get(key.username);
|
||||
if (!user || user.disabled) return;
|
||||
return key;
|
||||
@@ -309,7 +316,7 @@ export class MemoryAuthStore implements AuthStore {
|
||||
|
||||
async deleteExpiredApiKeys(now = Date.now()): Promise<number> {
|
||||
const expired = [...this.apiKeys.values()].filter(
|
||||
(key) => Date.parse(key.expiresAt) <= now,
|
||||
(key) => key.expiresAt !== undefined && Date.parse(key.expiresAt) <= now,
|
||||
);
|
||||
for (const key of expired) this.deleteApiKey(key.id);
|
||||
return expired.length;
|
||||
|
||||
+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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -8,6 +8,7 @@ export type BuildJobOptions = {
|
||||
workspaceSubPath?: string;
|
||||
cacheImage: string;
|
||||
pushImage?: string;
|
||||
pushImages?: string[];
|
||||
pushRegistryInsecure?: boolean;
|
||||
cacheRegistryInsecure?: boolean;
|
||||
buildkitImage?: string;
|
||||
@@ -72,6 +73,9 @@ export function createBuildJob(options: BuildJobOptions): KubernetesJob {
|
||||
? `${workspace}/${dockerfilePath}`
|
||||
: `${context}/Dockerfile`;
|
||||
const outputImage = options.pushImage ?? options.spec.image;
|
||||
const outputImages = [outputImage, ...(options.pushImages ?? [])];
|
||||
if (outputImages.some((image) => !image || /[,"\r\n]/.test(image)))
|
||||
throw new Error("Invalid BuildKit output image name");
|
||||
const importCacheInsecure = options.cacheRegistryInsecure
|
||||
? ",registry.insecure=true"
|
||||
: "";
|
||||
@@ -92,7 +96,7 @@ export function createBuildJob(options: BuildJobOptions): KubernetesJob {
|
||||
...options.spec.buildArgs.map((arg) => `--opt=build-arg:${arg}`),
|
||||
`--import-cache=type=registry,ref=${options.cacheImage}${importCacheInsecure}`,
|
||||
`--export-cache=type=registry,ref=${options.cacheImage},mode=max${exportCacheInsecure}`,
|
||||
`--output=type=image,name=${outputImage},push=true${outputInsecure}`,
|
||||
`--output=type=image,${outputImages.length === 1 ? `name=${outputImage}` : `"name=${outputImages.join(",")}"`},push=true${outputInsecure}`,
|
||||
];
|
||||
const labels = {
|
||||
"app.kubernetes.io/name": "kuber-buildkit",
|
||||
|
||||
@@ -887,18 +887,42 @@ export class KubernetesBuildOperations implements BuildKubernetesOperations {
|
||||
(condition) =>
|
||||
condition.type === "Complete" && condition.status === "True",
|
||||
);
|
||||
const phase = failed
|
||||
? "failed"
|
||||
: complete
|
||||
? "succeeded"
|
||||
: (job.status?.active ?? 0) > 0
|
||||
let phase: BuildJobObservation["phase"] = "queued";
|
||||
let podError: string | undefined;
|
||||
let containerStartedAt: string | undefined;
|
||||
if (!failed && !complete) {
|
||||
const pods = await this.core.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `job-name=${name}`,
|
||||
});
|
||||
const pod = pods.items
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.metadata?.creationTimestamp?.getTime() ?? 0) -
|
||||
(b.metadata?.creationTimestamp?.getTime() ?? 0),
|
||||
)
|
||||
.at(-1);
|
||||
const container = pod?.status?.containerStatuses?.find(
|
||||
(entry) => entry.name === "buildkit",
|
||||
);
|
||||
phase =
|
||||
pod?.status?.phase === "Running" && container?.state?.running
|
||||
? "running"
|
||||
: "queued";
|
||||
: !pod || !pod.spec?.nodeName
|
||||
? "creating"
|
||||
: "starting";
|
||||
podError =
|
||||
container?.state?.terminated?.message ??
|
||||
container?.state?.waiting?.message;
|
||||
containerStartedAt = container?.state?.running?.startedAt?.toISOString();
|
||||
} else phase = failed ? "failed" : "succeeded";
|
||||
return {
|
||||
phase,
|
||||
startedAt: job.status?.startTime?.toISOString(),
|
||||
startedAt: phase === "running" ? containerStartedAt : undefined,
|
||||
finishedAt: job.status?.completionTime?.toISOString(),
|
||||
...(failed?.message && { error: failed.message }),
|
||||
...((failed?.message || podError) && {
|
||||
error: failed?.message ?? podError,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -191,15 +191,22 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
|
||||
"username",
|
||||
"capabilities",
|
||||
"workspace",
|
||||
"expiresAt",
|
||||
"disabled",
|
||||
];
|
||||
if (!secret.data || !hasOnlyKeys(secret.data, keys)) return;
|
||||
if (
|
||||
!secret.data ||
|
||||
!hasOnlyKeys(
|
||||
secret.data,
|
||||
secret.data.expiresAt === undefined ? keys : [...keys, "expiresAt"],
|
||||
)
|
||||
)
|
||||
return;
|
||||
const id = decode(secret.data.id);
|
||||
const tokenHash = decode(secret.data.tokenHash);
|
||||
const username = decode(secret.data.username);
|
||||
const capabilities = parseCapabilities(secret.data.capabilities);
|
||||
const workspace = decode(secret.data.workspace);
|
||||
const workspace =
|
||||
secret.data.workspace === "" ? "" : decode(secret.data.workspace);
|
||||
const expiresAt = decode(secret.data.expiresAt);
|
||||
const disabled = decode(secret.data.disabled);
|
||||
if (
|
||||
@@ -207,7 +214,7 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
|
||||
!tokenHash ||
|
||||
!username ||
|
||||
!capabilities ||
|
||||
!expiresAt ||
|
||||
(secret.data.expiresAt !== undefined && !expiresAt) ||
|
||||
(workspace !== "" && workspace === undefined) ||
|
||||
(disabled !== "true" && disabled !== "false") ||
|
||||
secret.metadata?.name !== objectName("api-key", tokenHash)
|
||||
@@ -220,7 +227,7 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
|
||||
username,
|
||||
capabilities,
|
||||
...(workspace && { workspace }),
|
||||
expiresAt,
|
||||
...(expiresAt !== undefined && { expiresAt }),
|
||||
disabled: disabled === "true",
|
||||
});
|
||||
} catch {
|
||||
@@ -459,7 +466,12 @@ export class KubernetesAuthStore implements AuthStore {
|
||||
const key = (await this.listSecrets("api-key"))
|
||||
.map(parseApiKey)
|
||||
.find((item): item is ApiKeyRecord => item?.tokenHash === tokenHash);
|
||||
if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return;
|
||||
if (
|
||||
!key ||
|
||||
key.disabled ||
|
||||
(key.expiresAt !== undefined && Date.parse(key.expiresAt) <= Date.now())
|
||||
)
|
||||
return;
|
||||
const user = await this.getUser(key.username);
|
||||
if (!user || user.disabled) return;
|
||||
return key;
|
||||
@@ -478,7 +490,9 @@ export class KubernetesAuthStore implements AuthStore {
|
||||
username: normalized.username,
|
||||
capabilities: JSON.stringify(normalized.capabilities),
|
||||
workspace: normalized.workspace ?? "",
|
||||
expiresAt: normalized.expiresAt,
|
||||
...(normalized.expiresAt !== undefined && {
|
||||
expiresAt: normalized.expiresAt,
|
||||
}),
|
||||
disabled: String(Boolean(normalized.disabled)),
|
||||
},
|
||||
);
|
||||
@@ -505,7 +519,9 @@ export class KubernetesAuthStore implements AuthStore {
|
||||
.map(parseApiKey)
|
||||
.filter(
|
||||
(key): key is ApiKeyRecord =>
|
||||
key !== undefined && Date.parse(key.expiresAt) <= now,
|
||||
key !== undefined &&
|
||||
key.expiresAt !== undefined &&
|
||||
Date.parse(key.expiresAt) <= now,
|
||||
);
|
||||
for (const key of expired)
|
||||
await this.deleteSecret(objectName("api-key", key.tokenHash));
|
||||
|
||||
@@ -237,7 +237,9 @@ export function sanitizeOperationError(
|
||||
): OperationError {
|
||||
const message =
|
||||
action === "databases.reconcile"
|
||||
? "Database reconciliation failed"
|
||||
? error.code === "DATABASE_RECONCILE_FAILED"
|
||||
? redactString(error.message)
|
||||
: "Database reconciliation failed"
|
||||
: action === "storage.reconcile"
|
||||
? "Storage reconciliation failed"
|
||||
: redactString(error.message);
|
||||
|
||||
+12
-13
@@ -43,7 +43,9 @@ export type ParsedImageReference = {
|
||||
function validateRepository(repository: string, image: string): void {
|
||||
if (
|
||||
!repository ||
|
||||
repository.split("/").some((part) => !part || part === "." || part === "..")
|
||||
repository
|
||||
.split("/")
|
||||
.some((part) => !/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/.test(part))
|
||||
)
|
||||
throw new Error(`Invalid image reference: ${image}`);
|
||||
}
|
||||
@@ -63,29 +65,26 @@ export function parseImageReference(image: string): ParsedImageReference {
|
||||
throw new Error(`Invalid image reference: ${image}`);
|
||||
|
||||
const at = repositoryAndReference.lastIndexOf("@");
|
||||
let digest: Sha256Digest | undefined;
|
||||
if (at !== -1) {
|
||||
const value = repositoryAndReference.slice(at + 1);
|
||||
assertSha256Digest(value);
|
||||
digest = value;
|
||||
repositoryAndReference = repositoryAndReference.slice(0, at);
|
||||
validateRepository(repositoryAndReference, image);
|
||||
return {
|
||||
registry,
|
||||
repository: repositoryAndReference,
|
||||
reference: value,
|
||||
digest: value,
|
||||
};
|
||||
}
|
||||
const lastSlash = repositoryAndReference.lastIndexOf("/");
|
||||
const colon = repositoryAndReference.lastIndexOf(":");
|
||||
const reference =
|
||||
colon > lastSlash ? repositoryAndReference.slice(colon + 1) : "latest";
|
||||
const tag =
|
||||
colon > lastSlash ? repositoryAndReference.slice(colon + 1) : undefined;
|
||||
const repository =
|
||||
colon > lastSlash
|
||||
tag !== undefined
|
||||
? repositoryAndReference.slice(0, colon)
|
||||
: repositoryAndReference;
|
||||
validateRepository(repository, image);
|
||||
if (!reference) throw new Error(`Invalid image reference: ${image}`);
|
||||
return { registry, repository, reference };
|
||||
if (tag !== undefined && !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag))
|
||||
throw new Error(`Invalid image reference: ${image}`);
|
||||
if (digest) return { registry, repository, reference: digest, digest };
|
||||
return { registry, repository, reference: tag ?? "latest" };
|
||||
}
|
||||
|
||||
function bearerParameters(
|
||||
|
||||
Reference in New Issue
Block a user