feat(ql3): add profile-aware run log reads

This commit is contained in:
whyour
2026-08-12 01:43:14 +08:00
parent c699c32461
commit 308aa75d89
33 changed files with 2880 additions and 306 deletions
@@ -24,6 +24,7 @@ import { createClusterControlRunReadRoute } from '../run/runReadRoute';
import { createClusterControlRunListRoute } from '../run/runListRoute';
import { createClusterControlRunEventListRoute } from '../run/runEventListRoute';
import { createClusterControlRunStepListRoute } from '../run/runStepListRoute';
import { createClusterControlRunAttemptLogReadRoute } from '../run/runAttemptLogReadRoute';
import { createClusterControlTaskListRoute } from '../task/taskListRoute';
import { createClusterControlTaskReadRoute } from '../task/taskReadRoute';
import { createClusterControlTaskStartRoute } from '../task/taskStartRoute';
@@ -69,6 +70,7 @@ export const PRODUCTION_CLUSTER_CONTROL_ROUTE_OPERATIONS = Object.freeze([
'run.list',
'run.events.list',
'run.steps.list',
'run.log.read',
'run.cancel',
'workflow.read',
'workflow.run.read',
@@ -195,6 +197,10 @@ export function createProductionClusterControlApplicationStack(
input.runs,
input.trustedToolStorage.stepRuns,
),
createClusterControlRunAttemptLogReadRoute(
input.runs,
input.workerRuntime?.runAttemptLogRead,
),
createClusterControlRunCancellationRoute(
input.runCancellation,
createEventId,
@@ -6,12 +6,19 @@ import {
ChecksumMode,
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
MetadataDirective,
PutObjectCommand,
S3Client,
ServerSideEncryption,
} from '@aws-sdk/client-s3';
import {
normalizeRunAttemptLogReadRange,
type RunAttemptLogRangeReadResult,
type RunAttemptLogReadIdentity,
type RunAttemptLogReadRange,
} from '@qinglong/runtime-core/run-attempt-log-read';
import {
MAX_REMOTE_WORKER_ARTIFACT_BYTES,
REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
@@ -31,7 +38,8 @@ const TEMPORARY_METADATA_SCHEMA =
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const BUCKET_PATTERN = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
const PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_=-]{0,254}$/;
const TEMPORARY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const TEMPORARY_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
type S3SendClient = Pick<S3Client, 'send'>;
@@ -70,8 +78,7 @@ export function createS3ClusterRemoteWorkerArtifactClient(
typeof options !== 'object' ||
Array.isArray(options) ||
!/^[a-z0-9][a-z0-9-]{0,62}$/.test(options.region) ||
(options.endpoint !== undefined &&
typeof options.endpoint !== 'string') ||
(options.endpoint !== undefined && typeof options.endpoint !== 'string') ||
(options.forcePathStyle !== undefined &&
typeof options.forcePathStyle !== 'boolean')
) {
@@ -79,9 +86,7 @@ export function createS3ClusterRemoteWorkerArtifactClient(
}
return new S3Client({
region: options.region,
...(options.endpoint === undefined
? {}
: { endpoint: options.endpoint }),
...(options.endpoint === undefined ? {} : { endpoint: options.endpoint }),
forcePathStyle: options.forcePathStyle ?? false,
});
}
@@ -116,6 +121,11 @@ type ArtifactAuthority = Readonly<{
logArtifactId: string;
}>;
type StoredArtifactHead = Readonly<{
receipt: Readonly<RemoteWorkerArtifactReceipt>;
eTag?: string;
}>;
type NormalizedStorageCommand = ArtifactAuthority &
Readonly<{
byteLength: number;
@@ -127,7 +137,9 @@ const DIAGNOSTIC_CONTEXT = Object.freeze({
});
function configurationError(message: string): TypeError {
return new TypeError(`S3 Remote Worker Artifact store is invalid: ${message}`);
return new TypeError(
`S3 Remote Worker Artifact store is invalid: ${message}`,
);
}
function prepareOptions(
@@ -175,14 +187,15 @@ function prepareOptions(
throw configurationError('expected bucket owner is invalid');
}
const encryption = options.encryption;
if (!encryption || typeof encryption !== 'object' || Array.isArray(encryption)) {
if (
!encryption ||
typeof encryption !== 'object' ||
Array.isArray(encryption)
) {
throw configurationError('encryption is required');
}
let preparedEncryption: PreparedOptions['encryption'];
if (
encryption.mode === 's3' &&
Object.keys(encryption).length === 1
) {
if (encryption.mode === 's3' && Object.keys(encryption).length === 1) {
preparedEncryption = Object.freeze({
ServerSideEncryption: ServerSideEncryption.AES256,
});
@@ -383,9 +396,8 @@ function finalMetadata(
),
'ql3-byte-length': String(command.byteLength),
'ql3-content-sha256': sha256,
'ql3-truncated': command.truncated === undefined
? 'omitted'
: String(command.truncated),
'ql3-truncated':
command.truncated === undefined ? 'omitted' : String(command.truncated),
});
}
@@ -460,6 +472,99 @@ function parseStoredReceipt(
);
}
function canonicalETag(value: unknown): string {
if (
typeof value !== 'string' ||
value.length < 3 ||
value.length > 256 ||
!/^"[^"\u0000-\u001f\u007f]+"$/.test(value)
) {
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
return value;
}
function assertRangeMetadata(
authority: ArtifactAuthority,
receipt: Readonly<RemoteWorkerArtifactReceipt>,
output: Readonly<{
ContentLength?: number | undefined;
ContentRange?: string | undefined;
ContentType?: string | undefined;
ETag?: string | undefined;
Metadata?: Readonly<Record<string, string | undefined>> | undefined;
}>,
eTag: string,
start: number,
endExclusive: number,
): void {
const metadata = output.Metadata;
const truncated =
receipt.truncated === undefined ? 'omitted' : String(receipt.truncated);
if (
output.ContentLength !== endExclusive - start ||
output.ContentRange !==
`bytes ${start}-${endExclusive - 1}/${receipt.byteLength}` ||
output.ContentType !== REMOTE_WORKER_ARTIFACT_CONTENT_TYPE ||
canonicalETag(output.ETag) !== eTag ||
metadataValue(metadata, 'ql3-schema') !== METADATA_SCHEMA ||
metadataValue(metadata, 'ql3-project-sha256') !==
fieldDigest('project', authority.projectId) ||
metadataValue(metadata, 'ql3-run-sha256') !==
fieldDigest('run', authority.runId) ||
metadataValue(metadata, 'ql3-attempt-sha256') !==
fieldDigest('attempt', authority.attemptId) ||
metadataValue(metadata, 'ql3-log-artifact-sha256') !==
fieldDigest('log-artifact', authority.logArtifactId) ||
metadataValue(metadata, 'ql3-byte-length') !== String(receipt.byteLength) ||
metadataValue(metadata, 'ql3-content-sha256') !== receipt.sha256 ||
metadataValue(metadata, 'ql3-truncated') !== truncated
) {
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
}
async function readBoundedRangeBody(
body: unknown,
expectedBytes: number,
signal?: AbortSignal,
): Promise<Buffer> {
if (
!body ||
typeof body !== 'object' ||
!(Symbol.asyncIterator in body) ||
typeof (body as AsyncIterable<Uint8Array>)[Symbol.asyncIterator] !==
'function'
) {
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
const content = Buffer.allocUnsafe(expectedBytes);
let received = 0;
try {
for await (const chunk of body as AsyncIterable<Uint8Array>) {
if (signal?.aborted) throw signal.reason;
if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) {
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
if (received + chunk.byteLength > expectedBytes) {
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).copy(
content,
received,
);
received += chunk.byteLength;
}
if (received !== expectedBytes) {
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
return content;
} catch (error) {
content.fill(0);
throw error;
}
}
function isNotFound(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const value = error as {
@@ -467,13 +572,17 @@ function isNotFound(error: unknown): boolean {
Code?: unknown;
$metadata?: { httpStatusCode?: unknown };
};
return value.name === 'NotFound' ||
return (
value.name === 'NotFound' ||
value.name === 'NoSuchKey' ||
value.Code === 'NoSuchKey' ||
value.$metadata?.httpStatusCode === 404;
value.$metadata?.httpStatusCode === 404
);
}
function requestOptions(signal?: AbortSignal): { abortSignal: AbortSignal } | undefined {
function requestOptions(
signal?: AbortSignal,
): { abortSignal: AbortSignal } | undefined {
return signal === undefined ? undefined : { abortSignal: signal };
}
@@ -549,7 +658,8 @@ class ArtifactContentDigest {
* objects are never overwritten or deleted by this adapter.
*/
export class S3ClusterRemoteWorkerArtifactStore
implements ClusterRemoteWorkerArtifactStore {
implements ClusterRemoteWorkerArtifactStore
{
private readonly options: PreparedOptions;
constructor(options: S3ClusterRemoteWorkerArtifactStoreOptions) {
@@ -561,6 +671,90 @@ export class S3ClusterRemoteWorkerArtifactStore
signal?: AbortSignal,
): Promise<Readonly<RemoteWorkerArtifactReceipt> | undefined> {
const authority = normalizeLookup(lookup);
return (await this.head(authority, signal))?.receipt;
}
async readLogRange(
rawIdentity: Readonly<RunAttemptLogReadIdentity>,
rawRange: Readonly<RunAttemptLogReadRange>,
signal?: AbortSignal,
): Promise<RunAttemptLogRangeReadResult> {
const authority = normalizeLookup(rawIdentity);
const range = normalizeRunAttemptLogReadRange(rawRange);
const stored = await this.head(authority, signal);
if (!stored) return Object.freeze({ status: 'missing' as const });
const start = Math.min(range.offset, stored.receipt.byteLength);
const endExclusive = Math.min(
start + range.length,
stored.receipt.byteLength,
);
const truncation = Object.freeze({
truncated: stored.receipt.truncated ?? ('unknown' as const),
});
if (start === endExclusive) {
return Object.freeze({
status: 'available' as const,
content: Buffer.alloc(0),
start,
endExclusive,
totalBytes: stored.receipt.byteLength,
truncation,
});
}
const eTag = canonicalETag(stored.eTag);
if (signal?.aborted) throw signal.reason;
try {
const output = await this.options.client.send(
new GetObjectCommand({
Bucket: this.options.bucket,
Key: finalObjectKey(this.options.prefix, authority),
IfMatch: eTag,
Range: `bytes=${start}-${endExclusive - 1}`,
...(this.options.expectedBucketOwner === undefined
? {}
: { ExpectedBucketOwner: this.options.expectedBucketOwner }),
}),
requestOptions(signal),
);
assertRangeMetadata(
authority,
stored.receipt,
output,
eTag,
start,
endExclusive,
);
const bytes = await readBoundedRangeBody(
output.Body,
endExclusive - start,
signal,
);
return Object.freeze({
status: 'available' as const,
content: bytes,
start,
endExclusive,
totalBytes: stored.receipt.byteLength,
...(endExclusive < stored.receipt.byteLength
? { nextOffset: endExclusive }
: {}),
truncation,
});
} catch (error) {
if (isNotFound(error)) {
return Object.freeze({ status: 'missing' as const });
}
if (error instanceof S3ClusterRemoteWorkerArtifactStoreError) throw error;
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable', {
cause: error,
});
}
}
private async head(
authority: ArtifactAuthority,
signal?: AbortSignal,
): Promise<StoredArtifactHead | undefined> {
if (signal?.aborted) throw signal.reason;
try {
const output = await this.options.client.send(
@@ -574,7 +768,10 @@ export class S3ClusterRemoteWorkerArtifactStore
}),
requestOptions(signal),
);
return parseStoredReceipt(authority, output);
return Object.freeze({
receipt: parseStoredReceipt(authority, output),
...(output.ETag === undefined ? {} : { eTag: output.ETag }),
});
} catch (error) {
if (isNotFound(error)) return undefined;
if (error instanceof S3ClusterRemoteWorkerArtifactStoreError) throw error;
@@ -604,9 +801,7 @@ export class S3ClusterRemoteWorkerArtifactStore
existing.truncated !== command.truncated ||
existing.sha256 !== incomingSha256
) {
throw new S3ClusterRemoteWorkerArtifactStoreError(
'integrity_mismatch',
);
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
return existing;
}
@@ -693,13 +888,11 @@ export class S3ClusterRemoteWorkerArtifactStore
stored.truncated !== command.truncated ||
stored.sha256 !== sha256
) {
throw new S3ClusterRemoteWorkerArtifactStoreError(
'integrity_mismatch',
);
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
result = Object.freeze({
...stored,
status: copied ? 'stored' as const : 'already_stored' as const,
status: copied ? ('stored' as const) : ('already_stored' as const),
});
} catch (error) {
primaryError = error;
@@ -770,9 +963,7 @@ export class S3ClusterRemoteWorkerArtifactStore
output.Metadata?.['ql3-owner-sha256'] !== ownerSha256 ||
canonicalChecksum(output.ChecksumSHA256) !== sha256
) {
throw new S3ClusterRemoteWorkerArtifactStoreError(
'integrity_mismatch',
);
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
}
}
}
@@ -16,6 +16,11 @@ import {
type RemoteWorkerCompletionRepository,
type RemoteWorkerCompletionResult,
} from '@qinglong/runtime-core/remote-worker-completion';
import type {
RunAttemptLogRangeReadResult,
RunAttemptLogReadIdentity,
RunAttemptLogReadRange,
} from '@qinglong/runtime-core/run-attempt-log-read';
export interface ClusterRemoteWorkerArtifactStorageCommand {
readonly projectId: string;
@@ -47,6 +52,12 @@ export interface ClusterRemoteWorkerArtifactStore {
lookup: Readonly<ClusterRemoteWorkerArtifactLookup>,
signal?: AbortSignal,
): Promise<Readonly<RemoteWorkerArtifactReceipt> | undefined>;
/** Optional during Alpha so upload-only test and alternate stores remain compatible. */
readLogRange?(
identity: Readonly<RunAttemptLogReadIdentity>,
range: Readonly<RunAttemptLogReadRange>,
signal?: AbortSignal,
): Promise<RunAttemptLogRangeReadResult>;
}
export interface ClusterRemoteWorkerArtifactUploadInput {
@@ -353,11 +364,13 @@ export class ClusterRemoteWorkerCompletionService {
}
try {
const result = normalizeRemoteWorkerCompletionResult(
await this.repository.complete(Object.freeze({
...command,
attemptEventId: eventId(this.createEventId),
runEventId: eventId(this.createEventId),
})),
await this.repository.complete(
Object.freeze({
...command,
attemptEventId: eventId(this.createEventId),
runEventId: eventId(this.createEventId),
}),
),
);
if (
result.runId !== command.runId ||
@@ -1,6 +1,7 @@
// Remote execution owns the least-privilege assembly of Worker-facing runtime capabilities.
import type { PostgresPool } from '@qinglong/runtime-core';
import type { RemoteWorkerSecretValueProvider } from '@qinglong/runtime-core/remote-secret-delivery';
import type { RunAttemptLogRangeReader } from '@qinglong/runtime-core/run-attempt-log-read';
import {
PostgresClusterDispatchSource,
PostgresRemoteRunActivationRepository,
@@ -11,23 +12,15 @@ import {
PostgresTaskExecutionRevisionSource,
PostgresWorkerSessionRepository,
} from '@qinglong/cluster-postgres/runtime';
import {
ClusterRemoteWorkerOfferClaimService,
} from './remoteWorkerDispatcher';
import {
ClusterRemoteRunActivationService,
} from './remoteRunActivationService';
import {
ClusterRemoteWorkerSecretDeliveryService,
} from './remoteWorkerSecretDeliveryService';
import { ClusterRemoteWorkerOfferClaimService } from './remoteWorkerDispatcher';
import { ClusterRemoteRunActivationService } from './remoteRunActivationService';
import { ClusterRemoteWorkerSecretDeliveryService } from './remoteWorkerSecretDeliveryService';
import {
ClusterRemoteWorkerArtifactService,
ClusterRemoteWorkerCompletionService,
type ClusterRemoteWorkerArtifactStore,
} from './remoteWorkerCompletionService';
import {
ClusterRemoteWorkerLeaseControlService,
} from './remoteWorkerLeaseControlService';
import { ClusterRemoteWorkerLeaseControlService } from './remoteWorkerLeaseControlService';
import type { WorkerIngressPipelineOptions } from '../worker-ingress/workerIngressPipeline';
export interface ClusterWorkerRuntimeDependencies {
@@ -49,6 +42,7 @@ export interface ClusterWorkerRuntimePort {
readonly leaseControl: NonNullable<
WorkerIngressPipelineOptions['leaseControl']
>;
readonly runAttemptLogRead?: RunAttemptLogRangeReader;
}
export function createClusterWorkerRuntimePort(
@@ -67,9 +61,11 @@ export function createClusterWorkerRuntimePort(
}
const workerSessions = new PostgresWorkerSessionRepository(pool);
const completionRepository =
new PostgresRemoteWorkerCompletionRepository(pool);
const completionRepository = new PostgresRemoteWorkerCompletionRepository(
pool,
);
const secretProvider = dependencies.secretProvider;
const readLogRange = dependencies.artifactStore.readLogRange;
return Object.freeze({
offers: new ClusterRemoteWorkerOfferClaimService(
new PostgresClusterDispatchSource(pool),
@@ -99,5 +95,12 @@ export function createClusterWorkerRuntimePort(
leaseControl: new ClusterRemoteWorkerLeaseControlService(
new PostgresRemoteWorkerLeaseControlRepository(pool),
),
...(readLogRange === undefined
? {}
: {
runAttemptLogRead: Object.freeze({
read: readLogRange.bind(dependencies.artifactStore),
}),
}),
});
}
@@ -0,0 +1,176 @@
import {
InvalidRunAttemptLogReadError,
RunAttemptLogReadService,
RunAttemptLogReadUnavailableError,
type RunAttemptLogRangeReader,
type RunAttemptLogReadResult,
} from '@qinglong/runtime-core/run-attempt-log-read';
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
import type {
ClusterControlAuthorizedOperationRequest,
ClusterControlRouteDefinition,
ClusterControlRouteParameters,
} from '../transport/routeRegistry';
export const CLUSTER_CONTROL_RUN_ATTEMPT_LOG_READ_ROUTE = Object.freeze({
method: 'GET' as const,
path: '/api/v3/projects/{projectId}/runs/{runId}/attempts/{attemptId}/log',
operationId: 'run.log.read',
permission: 'artifact.read',
projectParameter: 'projectId',
allowedQuery: Object.freeze(['length', 'offset']),
});
const DEFAULT_READ_BYTES = 64 * 1024;
const MAXIMUM_READ_BYTES = 256 * 1024;
function response(
statusCode: number,
body: Readonly<Record<string, unknown>>,
): ClusterControlAdmissionResponse {
return Object.freeze({ statusCode, body: Object.freeze(body) });
}
function parseQuery(
query: Readonly<Record<string, readonly string[]>>,
): Readonly<{ offset: number; length: number }> {
const offsetValues = query.offset;
const lengthValues = query.length;
if (
(offsetValues !== undefined && offsetValues.length !== 1) ||
(lengthValues !== undefined && lengthValues.length !== 1)
) {
throw new TypeError();
}
const rawOffset = offsetValues?.[0];
const offset = rawOffset === undefined ? 0 : Number(rawOffset);
const rawLength = lengthValues?.[0];
const length =
rawLength === undefined ? DEFAULT_READ_BYTES : Number(rawLength);
if (
!Number.isSafeInteger(offset) ||
offset < 0 ||
(rawOffset !== undefined && String(offset) !== rawOffset) ||
!Number.isSafeInteger(length) ||
length < 1 ||
length > MAXIMUM_READ_BYTES ||
(rawLength !== undefined && String(length) !== rawLength)
) {
throw new TypeError();
}
return Object.freeze({ offset, length });
}
function validateQuery(
query: Readonly<Record<string, readonly string[]>>,
): void {
parseQuery(query);
}
function projection(
result: Extract<RunAttemptLogReadResult, { readonly status: 'available' }>,
): Readonly<Record<string, unknown>> {
return Object.freeze({
schema: 'qinglong/run-attempt-log-read-result@v1',
status: 'available',
projectId: result.projectId,
runId: result.runId,
attemptId: result.attemptId,
range: Object.freeze({
start: result.start,
endExclusive: result.endExclusive,
totalBytes: result.totalBytes,
...(result.nextOffset === undefined
? {}
: { nextOffset: result.nextOffset }),
}),
encoding: 'base64',
content: Buffer.from(
result.content.buffer,
result.content.byteOffset,
result.content.byteLength,
).toString('base64'),
truncation: result.truncation,
});
}
export function createClusterControlRunAttemptLogReadRoute(
runs: Pick<RunRepositoryReader, 'findRunById' | 'findAttemptById'>,
reader?: RunAttemptLogRangeReader,
): Readonly<ClusterControlRouteDefinition> {
if (
!runs ||
typeof runs.findRunById !== 'function' ||
typeof runs.findAttemptById !== 'function' ||
(reader !== undefined && typeof reader.read !== 'function')
) {
throw new TypeError(
'Cluster-control Run Attempt log read dependencies are invalid',
);
}
const service =
reader === undefined
? undefined
: new RunAttemptLogReadService(runs, reader, {
executorType: 'remote_worker',
artifactIdPattern: /^wlog-[a-f0-9]{30}$/,
maximumReadBytes: MAXIMUM_READ_BYTES,
activeMissingIsPending: true,
});
return Object.freeze({
...CLUSTER_CONTROL_RUN_ATTEMPT_LOG_READ_ROUTE,
validateQuery,
async handle(
authorized: ClusterControlAuthorizedOperationRequest,
parameters: ClusterControlRouteParameters,
) {
if (authorized.request.body !== null) {
return response(400, { code: 'invalid_request_body' });
}
if (authorized.projectId === null || service === undefined) {
return response(503, { code: 'artifact_unavailable' });
}
let range;
try {
range = parseQuery(authorized.request.query);
} catch {
return response(400, { code: 'invalid_run_log_read_query' });
}
try {
const result = await service.read({
projectId: authorized.projectId,
runId: parameters.runId!,
attemptId: parameters.attemptId!,
range,
signal: authorized.request.signal,
});
if (result.status === 'not_found') {
return response(404, { code: 'artifact_not_found' });
}
if (result.status === 'pending') {
return response(202, {
schema: 'qinglong/run-attempt-log-read-result@v1',
status: 'pending',
projectId: result.projectId,
runId: result.runId,
attemptId: result.attemptId,
});
}
if (result.status === 'missing') {
return response(503, { code: 'artifact_unavailable' });
}
return response(200, projection(result));
} catch (error) {
if (error instanceof InvalidRunAttemptLogReadError) {
return response(400, { code: 'invalid_run_log_read_request' });
}
if (error instanceof RunAttemptLogReadUnavailableError) {
return response(503, { code: 'artifact_unavailable' });
}
return response(503, { code: 'artifact_unavailable' });
}
},
});
}
@@ -308,6 +308,13 @@ export function createClusterControlAdmissionPipeline(
now,
);
if (decision.effect === 'deny') {
if (route.permission === 'artifact.read') {
throw securityError(
404,
'artifact_not_found',
'Cluster-control Artifact is not available',
);
}
throw securityError(
403,
'forbidden',
@@ -315,6 +322,13 @@ export function createClusterControlAdmissionPipeline(
);
}
if (decision.effect === 'require_approval') {
if (route.permission === 'artifact.read') {
throw securityError(
404,
'artifact_not_found',
'Cluster-control Artifact is not available',
);
}
throw securityError(
403,
'approval_required',