mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
createLocalExecutionContextRecipe,
|
||||
normalizeLocalDispatchCommand,
|
||||
type LocalDispatchCommand,
|
||||
type LocalExecutionEnvironmentBinding,
|
||||
} from '../local-runtime/localDispatch';
|
||||
import { parseSecretRef } from '../secret/secretReference';
|
||||
import {
|
||||
compileCommandTaskDefinition,
|
||||
createTaskDefinitionRevisionRef,
|
||||
type CommandTaskExecutionPlan,
|
||||
} from './taskDefinitionExecutionCompiler';
|
||||
import type { TaskDefinitionRecord } from './taskDefinition';
|
||||
import type { TaskSpecSemanticRegistry } from './taskSpecSemantic';
|
||||
import {
|
||||
effectiveRemoteWorkerPlacement,
|
||||
type RemoteWorkerPlacementSpec,
|
||||
} from '../remote-execution/remoteWorkerPlacement';
|
||||
|
||||
export const CLUSTER_EXECUTOR_TYPE = 'remote_worker';
|
||||
export const CLUSTER_EXECUTION_PLAN_SCHEMA = 'qinglong/command-execution@v1';
|
||||
export const MAX_CLUSTER_EXECUTION_PLAN_BYTES = 96 * 1024;
|
||||
|
||||
export interface ClusterTaskExecutionRevisionContent {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: string;
|
||||
readonly sourceRevision: number;
|
||||
readonly sourceContentDigest: string;
|
||||
readonly executorType: typeof CLUSTER_EXECUTOR_TYPE;
|
||||
readonly planSchema: typeof CLUSTER_EXECUTION_PLAN_SCHEMA;
|
||||
readonly command: LocalDispatchCommand;
|
||||
readonly environment: readonly LocalExecutionEnvironmentBinding[];
|
||||
readonly workingDirectory?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly placement?: RemoteWorkerPlacementSpec;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface ClusterTaskExecutionRevision
|
||||
extends ClusterTaskExecutionRevisionContent {
|
||||
readonly contentDigest: string;
|
||||
}
|
||||
|
||||
export interface ClusterTaskExecutionRevisionSource {
|
||||
resolveClusterTaskExecutionRevision(identity: {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly sourceRevision: number;
|
||||
}): Promise<ClusterTaskExecutionRevision | null>;
|
||||
}
|
||||
|
||||
export class InvalidClusterExecutionRevisionError extends TypeError {
|
||||
readonly code = 'CLUSTER_EXECUTION_REVISION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Cluster execution revision is invalid: ${message}`);
|
||||
this.name = 'InvalidClusterExecutionRevisionError';
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\0') ||
|
||||
/[\u0001-\u001f\u007f]/.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > 128
|
||||
) {
|
||||
throw new InvalidClusterExecutionRevisionError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function revision(value: unknown): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
throw new InvalidClusterExecutionRevisionError(
|
||||
'sourceRevision is invalid',
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new InvalidClusterExecutionRevisionError('createdAtMs is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function normalizeContent(
|
||||
value: ClusterTaskExecutionRevisionContent,
|
||||
): ClusterTaskExecutionRevisionContent {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidClusterExecutionRevisionError('shape is invalid');
|
||||
}
|
||||
const allowed = new Set([
|
||||
'command',
|
||||
'createdAtMs',
|
||||
'environment',
|
||||
'executorType',
|
||||
'planSchema',
|
||||
'placement',
|
||||
'projectId',
|
||||
'sourceContentDigest',
|
||||
'sourceRevision',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
'timeoutMs',
|
||||
'workingDirectory',
|
||||
]);
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
keys.some((key) => !allowed.has(key)) ||
|
||||
[
|
||||
'command',
|
||||
'createdAtMs',
|
||||
'environment',
|
||||
'executorType',
|
||||
'planSchema',
|
||||
'projectId',
|
||||
'sourceContentDigest',
|
||||
'sourceRevision',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
].some((key) => !keys.includes(key))
|
||||
) {
|
||||
throw new InvalidClusterExecutionRevisionError('shape is invalid');
|
||||
}
|
||||
const projectId = identifier(value.projectId, 'projectId');
|
||||
const taskId = identifier(value.taskId, 'taskId');
|
||||
const sourceRevision = revision(value.sourceRevision);
|
||||
if (!/^[0-9a-f]{64}$/.test(value.sourceContentDigest)) {
|
||||
throw new InvalidClusterExecutionRevisionError(
|
||||
'sourceContentDigest is invalid',
|
||||
);
|
||||
}
|
||||
const taskRevision = createTaskDefinitionRevisionRef({
|
||||
revision: sourceRevision,
|
||||
contentDigest: value.sourceContentDigest,
|
||||
});
|
||||
if (
|
||||
value.taskRevision !== taskRevision ||
|
||||
value.executorType !== CLUSTER_EXECUTOR_TYPE ||
|
||||
value.planSchema !== CLUSTER_EXECUTION_PLAN_SCHEMA
|
||||
) {
|
||||
throw new InvalidClusterExecutionRevisionError(
|
||||
'source or executor fence is invalid',
|
||||
);
|
||||
}
|
||||
let command: LocalDispatchCommand;
|
||||
let environment: readonly LocalExecutionEnvironmentBinding[];
|
||||
try {
|
||||
command = normalizeLocalDispatchCommand(value.command);
|
||||
environment = createLocalExecutionContextRecipe({
|
||||
environment: value.environment,
|
||||
createdAtMs: value.createdAtMs,
|
||||
}).environment;
|
||||
for (const binding of environment) {
|
||||
if (
|
||||
binding.kind === 'secret' &&
|
||||
parseSecretRef(binding.secretRef).projectId !== projectId
|
||||
) {
|
||||
throw new Error('cross-project Secret reference');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw new InvalidClusterExecutionRevisionError(
|
||||
'command or environment is invalid',
|
||||
);
|
||||
}
|
||||
let workingDirectory: string | undefined;
|
||||
if (value.workingDirectory !== undefined) {
|
||||
if (
|
||||
typeof value.workingDirectory !== 'string' ||
|
||||
!value.workingDirectory.startsWith('/') ||
|
||||
value.workingDirectory.includes('\0') ||
|
||||
Buffer.byteLength(value.workingDirectory, 'utf8') > 4096
|
||||
) {
|
||||
throw new InvalidClusterExecutionRevisionError(
|
||||
'workingDirectory is invalid',
|
||||
);
|
||||
}
|
||||
workingDirectory = value.workingDirectory;
|
||||
}
|
||||
let timeoutMs: number | undefined;
|
||||
if (value.timeoutMs !== undefined) {
|
||||
if (
|
||||
!Number.isSafeInteger(value.timeoutMs) ||
|
||||
value.timeoutMs < 1 ||
|
||||
value.timeoutMs > 365 * 24 * 60 * 60_000
|
||||
) {
|
||||
throw new InvalidClusterExecutionRevisionError('timeoutMs is invalid');
|
||||
}
|
||||
timeoutMs = value.timeoutMs;
|
||||
}
|
||||
const createdAtMs = timestamp(value.createdAtMs);
|
||||
const placement = value.placement === undefined
|
||||
? undefined
|
||||
: effectiveRemoteWorkerPlacement(value.placement);
|
||||
const normalized = Object.freeze({
|
||||
projectId,
|
||||
taskId,
|
||||
taskRevision,
|
||||
sourceRevision,
|
||||
sourceContentDigest: value.sourceContentDigest,
|
||||
executorType: CLUSTER_EXECUTOR_TYPE,
|
||||
planSchema: CLUSTER_EXECUTION_PLAN_SCHEMA,
|
||||
command,
|
||||
environment,
|
||||
...(workingDirectory === undefined ? {} : { workingDirectory }),
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
...(placement === undefined ? {} : { placement }),
|
||||
createdAtMs,
|
||||
});
|
||||
if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > MAX_CLUSTER_EXECUTION_PLAN_BYTES) {
|
||||
throw new InvalidClusterExecutionRevisionError('plan byte budget exceeded');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function digest(
|
||||
content: ClusterTaskExecutionRevisionContent,
|
||||
): string {
|
||||
const { createdAtMs: _createdAtMs, ...immutable } = content;
|
||||
return createHash('sha256')
|
||||
.update('qinglong.cluster-task-execution-revision.v1\0', 'utf8')
|
||||
.update(JSON.stringify(immutable), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function createClusterTaskExecutionRevision(
|
||||
value: ClusterTaskExecutionRevisionContent,
|
||||
): ClusterTaskExecutionRevision {
|
||||
const content = normalizeContent(value);
|
||||
return Object.freeze({ ...content, contentDigest: digest(content) });
|
||||
}
|
||||
|
||||
export function normalizeClusterTaskExecutionRevision(
|
||||
value: ClusterTaskExecutionRevision,
|
||||
): ClusterTaskExecutionRevision {
|
||||
const { contentDigest, ...candidate } = value;
|
||||
const content = normalizeContent(candidate);
|
||||
const expected = digest(content);
|
||||
if (contentDigest !== expected) {
|
||||
throw new InvalidClusterExecutionRevisionError(
|
||||
'contentDigest does not match content',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...content, contentDigest: expected });
|
||||
}
|
||||
|
||||
export function compileClusterCommandTaskDefinition(
|
||||
definition: TaskDefinitionRecord,
|
||||
semanticRegistry: TaskSpecSemanticRegistry,
|
||||
): ClusterTaskExecutionRevision {
|
||||
const plan: CommandTaskExecutionPlan = compileCommandTaskDefinition(
|
||||
definition,
|
||||
semanticRegistry,
|
||||
);
|
||||
return createClusterTaskExecutionRevision({
|
||||
projectId: plan.projectId,
|
||||
taskId: plan.taskId,
|
||||
taskRevision: plan.taskRevision,
|
||||
sourceRevision: plan.sourceRevision,
|
||||
sourceContentDigest: plan.sourceContentDigest,
|
||||
executorType: CLUSTER_EXECUTOR_TYPE,
|
||||
planSchema: CLUSTER_EXECUTION_PLAN_SCHEMA,
|
||||
command: plan.command,
|
||||
environment: plan.environment,
|
||||
...(plan.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: plan.workingDirectory }),
|
||||
...(plan.timeoutMs === undefined ? {} : { timeoutMs: plan.timeoutMs }),
|
||||
placement: effectiveRemoteWorkerPlacement(plan.placement ?? {}),
|
||||
createdAtMs: plan.createdAtMs,
|
||||
});
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
TASK_DEFINITION_KINDS,
|
||||
normalizeTaskDefinitionCursor,
|
||||
type TaskDefinitionCursor,
|
||||
type TaskDefinitionRecord,
|
||||
type TaskDefinitionSource,
|
||||
} from '../taskDefinition';
|
||||
|
||||
export const DEFAULT_BOUNDED_TASK_LIST_LIMIT = 32;
|
||||
export const MAX_BOUNDED_TASK_LIST_LIMIT = 64;
|
||||
|
||||
const MAX_REVISION = 2_147_483_647;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export interface BoundedTaskListInput {
|
||||
readonly limit?: number;
|
||||
readonly after?: Readonly<TaskDefinitionCursor>;
|
||||
}
|
||||
|
||||
export interface BoundedTaskListItem {
|
||||
readonly taskId: string;
|
||||
readonly revision: number;
|
||||
readonly name: string;
|
||||
readonly kind: TaskDefinitionRecord['kind'];
|
||||
readonly specSchema: string;
|
||||
readonly enabled: boolean;
|
||||
readonly updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface BoundedTaskListProjection {
|
||||
readonly tasks: readonly Readonly<BoundedTaskListItem>[];
|
||||
readonly hasMore: boolean;
|
||||
readonly next?: Readonly<TaskDefinitionCursor>;
|
||||
}
|
||||
|
||||
export class InvalidBoundedTaskListProjectionError extends TypeError {
|
||||
readonly code = 'BOUNDED_TASK_LIST_PROJECTION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Bounded Task list projection input is invalid');
|
||||
this.name = 'InvalidBoundedTaskListProjectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundedTaskListProjectionUnavailableError extends Error {
|
||||
readonly code = 'BOUNDED_TASK_LIST_PROJECTION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Bounded Task list projection is unavailable');
|
||||
this.name = 'BoundedTaskListProjectionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximumBytes: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
Number(value) >= minimum &&
|
||||
Number(value) <= maximum
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInput(
|
||||
value: Readonly<BoundedTaskListInput>,
|
||||
): Readonly<{ limit: number; after?: Readonly<TaskDefinitionCursor> }> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidBoundedTaskListProjectionError();
|
||||
}
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (
|
||||
keys.length > 2 ||
|
||||
keys.some((key) => key !== 'limit' && key !== 'after') ||
|
||||
(value.limit !== undefined &&
|
||||
!integer(value.limit, 1, MAX_BOUNDED_TASK_LIST_LIMIT))
|
||||
) {
|
||||
throw new InvalidBoundedTaskListProjectionError();
|
||||
}
|
||||
let after: Readonly<TaskDefinitionCursor> | undefined;
|
||||
try {
|
||||
after =
|
||||
value.after === undefined
|
||||
? undefined
|
||||
: normalizeTaskDefinitionCursor(value.after);
|
||||
} catch {
|
||||
throw new InvalidBoundedTaskListProjectionError();
|
||||
}
|
||||
return Object.freeze({
|
||||
limit: value.limit ?? DEFAULT_BOUNDED_TASK_LIST_LIMIT,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
}
|
||||
|
||||
function projectTask(
|
||||
value: TaskDefinitionRecord,
|
||||
projectId: string,
|
||||
after?: string,
|
||||
): Readonly<BoundedTaskListItem> | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
value.projectId !== projectId ||
|
||||
(after !== undefined && value.taskId <= after) ||
|
||||
!boundedText(value.taskId, 128) ||
|
||||
!integer(value.revision, 1, MAX_REVISION) ||
|
||||
!boundedText(value.name, 255) ||
|
||||
!TASK_DEFINITION_KINDS.includes(value.kind) ||
|
||||
!boundedText(value.spec?.schema, 137) ||
|
||||
typeof value.enabled !== 'boolean' ||
|
||||
!integer(value.updatedAtMs, 0, Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
taskId: value.taskId,
|
||||
revision: value.revision,
|
||||
name: value.name,
|
||||
kind: value.kind,
|
||||
specSchema: value.spec.schema,
|
||||
enabled: value.enabled,
|
||||
updatedAtMs: value.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeBoundedTaskListProjection(
|
||||
source: Pick<TaskDefinitionSource, 'listTaskDefinitions'>,
|
||||
projectId: string,
|
||||
input: Readonly<BoundedTaskListInput>,
|
||||
): Promise<Readonly<BoundedTaskListProjection>> {
|
||||
if (
|
||||
!source ||
|
||||
typeof source.listTaskDefinitions !== 'function' ||
|
||||
!boundedText(projectId, 128)
|
||||
) {
|
||||
throw new InvalidBoundedTaskListProjectionError();
|
||||
}
|
||||
const normalized = normalizeInput(input);
|
||||
let page;
|
||||
try {
|
||||
page = await source.listTaskDefinitions({
|
||||
projectId,
|
||||
limit: normalized.limit,
|
||||
...(normalized.after === undefined ? {} : { after: normalized.after }),
|
||||
});
|
||||
} catch {
|
||||
throw new BoundedTaskListProjectionUnavailableError();
|
||||
}
|
||||
if (
|
||||
!page ||
|
||||
!Array.isArray(page.definitions) ||
|
||||
page.definitions.length > normalized.limit ||
|
||||
typeof page.truncated !== 'boolean' ||
|
||||
page.truncated !== Boolean(page.next)
|
||||
) {
|
||||
throw new BoundedTaskListProjectionUnavailableError();
|
||||
}
|
||||
|
||||
const tasks: Readonly<BoundedTaskListItem>[] = [];
|
||||
let boundary = normalized.after?.taskId;
|
||||
for (const definition of page.definitions) {
|
||||
const projected = projectTask(definition, projectId, boundary);
|
||||
if (!projected) throw new BoundedTaskListProjectionUnavailableError();
|
||||
tasks.push(projected);
|
||||
boundary = definition.taskId;
|
||||
}
|
||||
if (
|
||||
page.truncated &&
|
||||
(!page.next || page.next.taskId !== boundary || tasks.length === 0)
|
||||
) {
|
||||
throw new BoundedTaskListProjectionUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
tasks: Object.freeze(tasks),
|
||||
hasMore: page.truncated,
|
||||
...(page.truncated
|
||||
? { next: Object.freeze({ taskId: boundary! }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionRecord,
|
||||
type TaskDefinitionSource,
|
||||
} from '../taskDefinition';
|
||||
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export type BoundedTaskReadProjection =
|
||||
| Readonly<{ found: false }>
|
||||
| Readonly<{
|
||||
found: true;
|
||||
taskId: string;
|
||||
revision: number;
|
||||
name: string;
|
||||
kind: TaskDefinitionRecord['kind'];
|
||||
specSchema: string;
|
||||
enabled: boolean;
|
||||
contentDigest: string;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}>;
|
||||
|
||||
export class InvalidBoundedTaskReadProjectionError extends TypeError {
|
||||
readonly code = 'BOUNDED_TASK_READ_PROJECTION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Bounded Task read projection input is invalid');
|
||||
this.name = 'InvalidBoundedTaskReadProjectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundedTaskReadProjectionUnavailableError extends Error {
|
||||
readonly code = 'BOUNDED_TASK_READ_PROJECTION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Bounded Task read projection is unavailable');
|
||||
this.name = 'BoundedTaskReadProjectionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
Buffer.byteLength(value, 'utf8') <= 128 &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeBoundedTaskReadProjection(
|
||||
source: Pick<TaskDefinitionSource, 'findCurrentTaskDefinition'>,
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
): Promise<Readonly<BoundedTaskReadProjection>> {
|
||||
if (
|
||||
!source ||
|
||||
typeof source.findCurrentTaskDefinition !== 'function' ||
|
||||
!identifier(projectId) ||
|
||||
!identifier(taskId)
|
||||
) {
|
||||
throw new InvalidBoundedTaskReadProjectionError();
|
||||
}
|
||||
|
||||
let definition: TaskDefinitionRecord | null;
|
||||
try {
|
||||
definition = await source.findCurrentTaskDefinition(projectId, taskId);
|
||||
} catch {
|
||||
throw new BoundedTaskReadProjectionUnavailableError();
|
||||
}
|
||||
if (!definition) return Object.freeze({ found: false });
|
||||
|
||||
let current: TaskDefinitionRecord;
|
||||
try {
|
||||
current = normalizeTaskDefinitionRecord(definition);
|
||||
} catch {
|
||||
throw new BoundedTaskReadProjectionUnavailableError();
|
||||
}
|
||||
if (current.projectId !== projectId || current.taskId !== taskId) {
|
||||
return Object.freeze({ found: false });
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
found: true,
|
||||
taskId: current.taskId,
|
||||
revision: current.revision,
|
||||
name: current.name,
|
||||
kind: current.kind,
|
||||
specSchema: current.spec.schema,
|
||||
enabled: current.enabled,
|
||||
contentDigest: current.contentDigest,
|
||||
createdAtMs: current.createdAtMs,
|
||||
updatedAtMs: current.updatedAtMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const TASK_DEFINITION_KINDS = Object.freeze([
|
||||
'script',
|
||||
'command',
|
||||
'workflow',
|
||||
'agent',
|
||||
'tool',
|
||||
] as const);
|
||||
export const MAX_TASK_DEFINITION_PAGE_SIZE = 256;
|
||||
export const MAX_TASK_DEFINITION_SPEC_BYTES = 64 * 1024;
|
||||
export const MAX_TASK_DEFINITION_LABELS = 32;
|
||||
|
||||
const TASK_SPEC_SCHEMA_PATTERN =
|
||||
/^[a-z][a-z0-9.-]{0,63}\/[a-z][a-z0-9.-]{0,63}@v[1-9][0-9]{0,5}$/;
|
||||
const LABEL_KEY_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?(?:\/[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?)?$/;
|
||||
const MUTATION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
export type TaskDefinitionKind = (typeof TASK_DEFINITION_KINDS)[number];
|
||||
export type TaskDefinitionJson =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| readonly TaskDefinitionJson[]
|
||||
| Readonly<{ [key: string]: TaskDefinitionJson }>;
|
||||
|
||||
export interface TaskDefinitionSpec {
|
||||
readonly schema: string;
|
||||
readonly config: Readonly<{ [key: string]: TaskDefinitionJson }>;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionRecord {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly revision: number;
|
||||
readonly mutationId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly kind: TaskDefinitionKind;
|
||||
readonly spec: TaskDefinitionSpec;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly enabled: boolean;
|
||||
readonly contentDigest: string;
|
||||
readonly createdAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendTaskDefinitionRevisionCommand {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly expectedRevision: number | null;
|
||||
readonly mutationId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly kind: TaskDefinitionKind;
|
||||
readonly spec: TaskDefinitionSpec;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly enabled: boolean;
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionCursor {
|
||||
readonly taskId: string;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionPage {
|
||||
readonly definitions: readonly TaskDefinitionRecord[];
|
||||
readonly truncated: boolean;
|
||||
readonly next?: TaskDefinitionCursor;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionSource {
|
||||
findCurrentTaskDefinition(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
): Promise<TaskDefinitionRecord | null>;
|
||||
findTaskDefinitionRevision(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
revision: number,
|
||||
): Promise<TaskDefinitionRecord | null>;
|
||||
listTaskDefinitions(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: TaskDefinitionCursor;
|
||||
}): Promise<TaskDefinitionPage>;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionRepository extends TaskDefinitionSource {
|
||||
appendTaskDefinitionRevision(
|
||||
command: AppendTaskDefinitionRevisionCommand,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
definition: TaskDefinitionRecord;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidTaskDefinitionError extends TypeError {
|
||||
readonly code = 'TASK_DEFINITION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`TaskDefinition is invalid: ${message}`);
|
||||
this.name = 'InvalidTaskDefinitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskDefinitionConflictError extends Error {
|
||||
readonly code = 'TASK_DEFINITION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('TaskDefinition mutation conflicts with durable state');
|
||||
this.name = 'TaskDefinitionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskDefinitionUnavailableError extends Error {
|
||||
readonly code = 'TASK_DEFINITION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('TaskDefinition storage is unavailable');
|
||||
this.name = 'TaskDefinitionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError(`${label} has an invalid shape`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximumBytes: number,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.includes('\0') ||
|
||||
/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
return boundedText(value, label, 128);
|
||||
}
|
||||
|
||||
export function assertTaskDefinitionIdentifier(
|
||||
value: unknown,
|
||||
label = 'identifier',
|
||||
): asserts value is string {
|
||||
identifier(value, label);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new InvalidTaskDefinitionError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function revision(value: unknown, label: string): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function assertTaskDefinitionRevision(
|
||||
value: unknown,
|
||||
label = 'revision',
|
||||
): asserts value is number {
|
||||
revision(value, label);
|
||||
}
|
||||
|
||||
function normalizeJson(
|
||||
value: unknown,
|
||||
budget: { nodes: number },
|
||||
depth: number,
|
||||
): TaskDefinitionJson {
|
||||
budget.nodes += 1;
|
||||
if (budget.nodes > 1024 || depth > 12) {
|
||||
throw new InvalidTaskDefinitionError('spec exceeds its structure budget');
|
||||
}
|
||||
if (value === null || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new InvalidTaskDefinitionError('spec contains an invalid number');
|
||||
}
|
||||
return Object.is(value, -0) ? 0 : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value.includes('\0') || Buffer.byteLength(value, 'utf8') > 16 * 1024) {
|
||||
throw new InvalidTaskDefinitionError('spec contains invalid text');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 256) {
|
||||
throw new InvalidTaskDefinitionError('spec array is too large');
|
||||
}
|
||||
return Object.freeze(
|
||||
value.map((entry) => normalizeJson(entry, budget, depth + 1)),
|
||||
);
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new InvalidTaskDefinitionError('spec contains a non-JSON value');
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new InvalidTaskDefinitionError('spec object prototype is invalid');
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
if (keys.length > 256) {
|
||||
throw new InvalidTaskDefinitionError('spec object is too large');
|
||||
}
|
||||
const normalized = Object.create(null) as Record<string, TaskDefinitionJson>;
|
||||
for (const key of keys) {
|
||||
boundedText(key, 'spec key', 128);
|
||||
normalized[key] = normalizeJson(
|
||||
(value as Record<string, unknown>)[key],
|
||||
budget,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
export function normalizeTaskDefinitionSpec(
|
||||
value: TaskDefinitionSpec,
|
||||
): TaskDefinitionSpec {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidTaskDefinitionError('spec must be an object');
|
||||
}
|
||||
exactKeys(value, ['config', 'schema'], [], 'spec');
|
||||
if (
|
||||
typeof value.schema !== 'string' ||
|
||||
!TASK_SPEC_SCHEMA_PATTERN.test(value.schema)
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError('spec schema is invalid');
|
||||
}
|
||||
const config = normalizeJson(value.config, { nodes: 0 }, 0);
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
throw new InvalidTaskDefinitionError('spec config must be an object');
|
||||
}
|
||||
const normalized: TaskDefinitionSpec = Object.freeze({
|
||||
schema: value.schema,
|
||||
config: config as Readonly<Record<string, TaskDefinitionJson>>,
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_TASK_DEFINITION_SPEC_BYTES
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError('spec exceeds its byte budget');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeTaskDefinitionLabels(
|
||||
value: Readonly<Record<string, string>>,
|
||||
): Readonly<Record<string, string>> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidTaskDefinitionError('labels must be an object');
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new InvalidTaskDefinitionError('labels prototype is invalid');
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
if (keys.length > MAX_TASK_DEFINITION_LABELS) {
|
||||
throw new InvalidTaskDefinitionError('labels exceed their count budget');
|
||||
}
|
||||
const normalized = Object.create(null) as Record<string, string>;
|
||||
for (const key of keys) {
|
||||
if (!LABEL_KEY_PATTERN.test(key)) {
|
||||
throw new InvalidTaskDefinitionError('label key is invalid');
|
||||
}
|
||||
normalized[key] = boundedText(value[key], 'label value', 256);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function semanticDefinition(value: {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly revision: number;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly kind: TaskDefinitionKind;
|
||||
readonly spec: TaskDefinitionSpec;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly enabled: boolean;
|
||||
}): object {
|
||||
return {
|
||||
projectId: value.projectId,
|
||||
taskId: value.taskId,
|
||||
revision: value.revision,
|
||||
name: value.name,
|
||||
...(value.description === undefined
|
||||
? {}
|
||||
: { description: value.description }),
|
||||
kind: value.kind,
|
||||
spec: value.spec,
|
||||
labels: value.labels,
|
||||
enabled: value.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function taskDefinitionContentDigest(
|
||||
value: Parameters<typeof semanticDefinition>[0],
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify(semanticDefinition(value)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function normalizeDefinitionFields(value: {
|
||||
readonly projectId: unknown;
|
||||
readonly taskId: unknown;
|
||||
readonly revision: unknown;
|
||||
readonly name: unknown;
|
||||
readonly description?: unknown;
|
||||
readonly kind: unknown;
|
||||
readonly spec: TaskDefinitionSpec;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly enabled: unknown;
|
||||
}): Omit<
|
||||
TaskDefinitionRecord,
|
||||
'mutationId' | 'contentDigest' | 'createdAtMs' | 'updatedAtMs'
|
||||
> {
|
||||
const projectId = identifier(value.projectId, 'projectId');
|
||||
const taskId = identifier(value.taskId, 'taskId');
|
||||
const normalizedRevision = revision(value.revision, 'revision');
|
||||
const name = boundedText(value.name, 'name', 255);
|
||||
const description =
|
||||
value.description === undefined
|
||||
? undefined
|
||||
: boundedText(value.description, 'description', 4096);
|
||||
if (!TASK_DEFINITION_KINDS.includes(value.kind as TaskDefinitionKind)) {
|
||||
throw new InvalidTaskDefinitionError('kind is invalid');
|
||||
}
|
||||
if (typeof value.enabled !== 'boolean') {
|
||||
throw new InvalidTaskDefinitionError('enabled is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId,
|
||||
taskId,
|
||||
revision: normalizedRevision,
|
||||
name,
|
||||
...(description === undefined ? {} : { description }),
|
||||
kind: value.kind as TaskDefinitionKind,
|
||||
spec: normalizeTaskDefinitionSpec(value.spec),
|
||||
labels: normalizeTaskDefinitionLabels(value.labels),
|
||||
enabled: value.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAppendTaskDefinitionRevisionCommand(
|
||||
value: AppendTaskDefinitionRevisionCommand,
|
||||
): Readonly<AppendTaskDefinitionRevisionCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidTaskDefinitionError('command must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'enabled',
|
||||
'expectedRevision',
|
||||
'kind',
|
||||
'labels',
|
||||
'mutationId',
|
||||
'name',
|
||||
'occurredAtMs',
|
||||
'projectId',
|
||||
'spec',
|
||||
'taskId',
|
||||
],
|
||||
['description'],
|
||||
'command',
|
||||
);
|
||||
const expectedRevision =
|
||||
value.expectedRevision === null
|
||||
? null
|
||||
: revision(value.expectedRevision, 'expectedRevision');
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId)
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError('mutationId is invalid');
|
||||
}
|
||||
const fields = normalizeDefinitionFields({
|
||||
...value,
|
||||
revision: expectedRevision === null ? 1 : expectedRevision + 1,
|
||||
});
|
||||
return Object.freeze({
|
||||
projectId: fields.projectId,
|
||||
taskId: fields.taskId,
|
||||
expectedRevision,
|
||||
mutationId: value.mutationId,
|
||||
name: fields.name,
|
||||
...('description' in fields
|
||||
? { description: fields.description as string }
|
||||
: {}),
|
||||
kind: fields.kind,
|
||||
spec: fields.spec,
|
||||
labels: fields.labels,
|
||||
enabled: fields.enabled,
|
||||
occurredAtMs: timestamp(value.occurredAtMs, 'occurredAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTaskDefinitionRecord(
|
||||
value: TaskDefinitionRecord,
|
||||
): TaskDefinitionRecord {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidTaskDefinitionError('record must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'contentDigest',
|
||||
'createdAtMs',
|
||||
'enabled',
|
||||
'kind',
|
||||
'labels',
|
||||
'mutationId',
|
||||
'name',
|
||||
'projectId',
|
||||
'revision',
|
||||
'spec',
|
||||
'taskId',
|
||||
'updatedAtMs',
|
||||
],
|
||||
['description'],
|
||||
'record',
|
||||
);
|
||||
const fields = normalizeDefinitionFields(value);
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
|
||||
typeof value.contentDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(value.contentDigest)
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError('record identity is invalid');
|
||||
}
|
||||
const createdAtMs = timestamp(value.createdAtMs, 'createdAtMs');
|
||||
const updatedAtMs = timestamp(value.updatedAtMs, 'updatedAtMs');
|
||||
if (updatedAtMs < createdAtMs) {
|
||||
throw new InvalidTaskDefinitionError('record time order is invalid');
|
||||
}
|
||||
const expectedDigest = taskDefinitionContentDigest(fields);
|
||||
if (value.contentDigest !== expectedDigest) {
|
||||
throw new InvalidTaskDefinitionError('content digest did not match');
|
||||
}
|
||||
return Object.freeze({
|
||||
...fields,
|
||||
mutationId: value.mutationId,
|
||||
contentDigest: value.contentDigest,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTaskDefinitionRecord(
|
||||
command: AppendTaskDefinitionRevisionCommand,
|
||||
createdAtMs: number,
|
||||
): TaskDefinitionRecord {
|
||||
const normalized = normalizeAppendTaskDefinitionRevisionCommand(command);
|
||||
const fields = normalizeDefinitionFields({
|
||||
...normalized,
|
||||
revision:
|
||||
normalized.expectedRevision === null
|
||||
? 1
|
||||
: normalized.expectedRevision + 1,
|
||||
});
|
||||
const record = {
|
||||
...fields,
|
||||
mutationId: normalized.mutationId,
|
||||
contentDigest: taskDefinitionContentDigest(fields),
|
||||
createdAtMs: timestamp(createdAtMs, 'createdAtMs'),
|
||||
updatedAtMs: normalized.occurredAtMs,
|
||||
};
|
||||
return normalizeTaskDefinitionRecord(record);
|
||||
}
|
||||
|
||||
export function assertTaskDefinitionPageSize(limit: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_TASK_DEFINITION_PAGE_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
`TaskDefinition page size must be between 1 and ${MAX_TASK_DEFINITION_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTaskDefinitionCursor(
|
||||
cursor: TaskDefinitionCursor,
|
||||
): TaskDefinitionCursor {
|
||||
if (
|
||||
!cursor ||
|
||||
typeof cursor !== 'object' ||
|
||||
Array.isArray(cursor) ||
|
||||
Object.keys(cursor).length !== 1
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError('cursor is invalid');
|
||||
}
|
||||
return Object.freeze({ taskId: identifier(cursor.taskId, 'cursor.taskId') });
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
assertTaskDefinitionIdentifier,
|
||||
assertTaskDefinitionPageSize,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
normalizeTaskDefinitionCursor,
|
||||
type AppendTaskDefinitionRevisionCommand,
|
||||
type TaskDefinitionCursor,
|
||||
type TaskDefinitionPage,
|
||||
type TaskDefinitionRecord,
|
||||
} from './taskDefinition';
|
||||
import { normalizeProjectPolicySubject } from '../security/project-policy/projectPolicy';
|
||||
import type { SecurityPolicyFence, SecuritySubject } from '../security/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '../security/audit/securityAudit';
|
||||
|
||||
export interface AuthorizedTaskDefinitionRevisionMutation {
|
||||
readonly command: AppendTaskDefinitionRevisionCommand;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionAdministrationRepository {
|
||||
appendAuthorizedTaskDefinitionRevision(
|
||||
mutation: AuthorizedTaskDefinitionRevisionMutation,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
definition: TaskDefinitionRecord;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface AuthorizedTaskDefinitionInspection {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AuthorizedTaskDefinitionList {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: TaskDefinitionCursor;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionAdministrationSource {
|
||||
findAuthorizedCurrentTaskDefinition(
|
||||
inspection: AuthorizedTaskDefinitionInspection,
|
||||
): Promise<TaskDefinitionRecord | null>;
|
||||
listAuthorizedTaskDefinitions(
|
||||
query: AuthorizedTaskDefinitionList,
|
||||
): Promise<TaskDefinitionPage>;
|
||||
}
|
||||
|
||||
export class InvalidTaskDefinitionAdministrationMutationError extends TypeError {
|
||||
readonly code = 'TASK_DEFINITION_ADMINISTRATION_MUTATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`TaskDefinition administration mutation is invalid: ${message}`);
|
||||
this.name = 'InvalidTaskDefinitionAdministrationMutationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskDefinitionAdministrationAuthorizationFenceConflictError extends Error {
|
||||
readonly code = 'TASK_DEFINITION_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('TaskDefinition administration authorization fence changed');
|
||||
this.name =
|
||||
'TaskDefinitionAdministrationAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskDefinitionAdministrationMutationConflictError extends Error {
|
||||
readonly code = 'TASK_DEFINITION_ADMINISTRATION_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('TaskDefinition administration mutation conflicts with durable state');
|
||||
this.name = 'TaskDefinitionAdministrationMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTaskDefinitionAdministrationReadError extends TypeError {
|
||||
readonly code = 'TASK_DEFINITION_ADMINISTRATION_READ_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`TaskDefinition administration read is invalid: ${message}`);
|
||||
this.name = 'InvalidTaskDefinitionAdministrationReadError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskDefinitionAdministrationReadConflictError extends Error {
|
||||
readonly code = 'TASK_DEFINITION_ADMINISTRATION_READ_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('TaskDefinition administration read conflicts with durable state');
|
||||
this.name = 'TaskDefinitionAdministrationReadConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function normalizeFence(value: SecurityPolicyFence): SecurityPolicyFence {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['bindingVersion', 'projectVersion']) ||
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
!Number.isSafeInteger(value.bindingVersion) ||
|
||||
(value.bindingVersion as number) < 1
|
||||
) {
|
||||
throw new InvalidTaskDefinitionAdministrationMutationError(
|
||||
'authorization fence is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectVersion: value.projectVersion,
|
||||
bindingVersion: value.bindingVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedTaskDefinitionRevisionMutation(
|
||||
value: AuthorizedTaskDefinitionRevisionMutation,
|
||||
): Readonly<AuthorizedTaskDefinitionRevisionMutation> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['actor', 'audit', 'command', 'fence'])
|
||||
) {
|
||||
throw new InvalidTaskDefinitionAdministrationMutationError(
|
||||
'mutation shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const command = normalizeAppendTaskDefinitionRevisionCommand(
|
||||
value.command,
|
||||
);
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
const fence = normalizeFence(value.fence);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
const operationId =
|
||||
command.expectedRevision === null ? 'task.create' : 'task.update';
|
||||
if (
|
||||
audit.eventId !== command.mutationId ||
|
||||
audit.operationId !== operationId ||
|
||||
audit.projectId !== command.projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, actor) ||
|
||||
audit.authenticationId === null ||
|
||||
!audit.fence ||
|
||||
audit.fence.projectVersion !== fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidTaskDefinitionAdministrationMutationError(
|
||||
'audit binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ command, actor, fence, audit });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTaskDefinitionAdministrationMutationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidTaskDefinitionAdministrationMutationError(
|
||||
'mutation value is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTaskDefinitionReadAuthority(
|
||||
value: Readonly<{
|
||||
projectId: string;
|
||||
actor: SecuritySubject;
|
||||
fence: SecurityPolicyFence;
|
||||
audit: SecurityAuditRecord;
|
||||
}>,
|
||||
): Readonly<{
|
||||
projectId: string;
|
||||
actor: SecuritySubject;
|
||||
fence: SecurityPolicyFence;
|
||||
audit: SecurityAuditRecord;
|
||||
}> {
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
const fence = normalizeFence(value.fence);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
audit.operationId !== 'task.read' ||
|
||||
audit.projectId !== value.projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, actor) ||
|
||||
audit.authenticationId === null ||
|
||||
!audit.fence ||
|
||||
audit.fence.projectVersion !== fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidTaskDefinitionAdministrationReadError(
|
||||
'audit binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
actor,
|
||||
fence,
|
||||
audit,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedTaskDefinitionInspection(
|
||||
value: AuthorizedTaskDefinitionInspection,
|
||||
): Readonly<AuthorizedTaskDefinitionInspection> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['actor', 'audit', 'fence', 'projectId', 'taskId'])
|
||||
) {
|
||||
throw new InvalidTaskDefinitionAdministrationReadError(
|
||||
'inspection shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(value.taskId, 'taskId');
|
||||
return Object.freeze({
|
||||
...normalizeTaskDefinitionReadAuthority(value),
|
||||
taskId: value.taskId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTaskDefinitionAdministrationReadError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidTaskDefinitionAdministrationReadError(
|
||||
'inspection value is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedTaskDefinitionList(
|
||||
value: AuthorizedTaskDefinitionList,
|
||||
): Readonly<AuthorizedTaskDefinitionList> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidTaskDefinitionAdministrationReadError(
|
||||
'list shape is invalid',
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
!['actor', 'audit', 'fence', 'limit', 'projectId'].every((key) =>
|
||||
keys.includes(key),
|
||||
) ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
!['actor', 'after', 'audit', 'fence', 'limit', 'projectId'].includes(
|
||||
key,
|
||||
),
|
||||
)
|
||||
) {
|
||||
throw new InvalidTaskDefinitionAdministrationReadError(
|
||||
'list shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
|
||||
assertTaskDefinitionPageSize(value.limit);
|
||||
const authority = normalizeTaskDefinitionReadAuthority(value);
|
||||
const after = Object.hasOwn(value, 'after')
|
||||
? normalizeTaskDefinitionCursor(value.after as TaskDefinitionCursor)
|
||||
: undefined;
|
||||
return Object.freeze({
|
||||
...authority,
|
||||
limit: value.limit,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTaskDefinitionAdministrationReadError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidTaskDefinitionAdministrationReadError(
|
||||
'list value is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
createLocalExecutionContextRecipe,
|
||||
createLocalTaskExecutionRevision,
|
||||
type LocalDispatchCommand,
|
||||
type LocalExecutionContextRecipe,
|
||||
type LocalExecutionEnvironmentBinding,
|
||||
type LocalTaskExecutionRevision,
|
||||
} from '../local-runtime/localDispatch';
|
||||
import {
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionRecord,
|
||||
} from './taskDefinition';
|
||||
import {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
InvalidTaskSpecSemanticError,
|
||||
TaskSpecSemanticRegistry,
|
||||
UnsupportedTaskSpecError,
|
||||
} from './taskSpecSemantic';
|
||||
import type { RemoteWorkerPlacementSpec } from '../remote-execution/remoteWorkerPlacement';
|
||||
|
||||
export const TASK_DEFINITION_REVISION_REF_PREFIX = 'qltd:v1:';
|
||||
|
||||
const TASK_DEFINITION_REVISION_REF_PATTERN =
|
||||
/^qltd:v1:([1-9][0-9]{0,9}):([a-f0-9]{64})$/;
|
||||
|
||||
export interface TaskDefinitionRevisionRef {
|
||||
readonly revision: number;
|
||||
readonly contentDigest: string;
|
||||
}
|
||||
|
||||
export interface CommandTaskExecutionPlan {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: string;
|
||||
readonly sourceRevision: number;
|
||||
readonly sourceContentDigest: string;
|
||||
readonly command: LocalDispatchCommand;
|
||||
readonly environment: readonly LocalExecutionEnvironmentBinding[];
|
||||
readonly workingDirectory?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly placement?: RemoteWorkerPlacementSpec;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface LocalCommandTaskExecutionPlan {
|
||||
readonly source: CommandTaskExecutionPlan;
|
||||
readonly contextRecipe: LocalExecutionContextRecipe;
|
||||
readonly executionRevision: LocalTaskExecutionRevision;
|
||||
}
|
||||
|
||||
export class UnsupportedTaskDefinitionCompilationError extends Error {
|
||||
readonly code = 'TASK_DEFINITION_COMPILATION_UNSUPPORTED';
|
||||
|
||||
constructor() {
|
||||
super('TaskDefinition cannot be compiled by this execution compiler');
|
||||
this.name = 'UnsupportedTaskDefinitionCompilationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTaskDefinitionCompilationError extends TypeError {
|
||||
readonly code = 'TASK_DEFINITION_COMPILATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`TaskDefinition compilation is invalid: ${message}`);
|
||||
this.name = 'InvalidTaskDefinitionCompilationError';
|
||||
}
|
||||
}
|
||||
|
||||
export function createTaskDefinitionRevisionRef(
|
||||
value: TaskDefinitionRevisionRef,
|
||||
): string {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !== 'contentDigest,revision' ||
|
||||
!Number.isSafeInteger(value.revision) ||
|
||||
value.revision < 1 ||
|
||||
value.revision > 2_147_483_647 ||
|
||||
typeof value.contentDigest !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(value.contentDigest)
|
||||
) {
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'source revision identity is invalid',
|
||||
);
|
||||
}
|
||||
return `${TASK_DEFINITION_REVISION_REF_PREFIX}${value.revision}:${value.contentDigest}`;
|
||||
}
|
||||
|
||||
export function parseTaskDefinitionRevisionRef(
|
||||
value: unknown,
|
||||
): TaskDefinitionRevisionRef {
|
||||
if (typeof value !== 'string') {
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'source revision reference is invalid',
|
||||
);
|
||||
}
|
||||
const match = TASK_DEFINITION_REVISION_REF_PATTERN.exec(value);
|
||||
const revision = match ? Number(match[1]) : Number.NaN;
|
||||
const contentDigest = match?.[2];
|
||||
if (
|
||||
!Number.isSafeInteger(revision) ||
|
||||
revision < 1 ||
|
||||
revision > 2_147_483_647 ||
|
||||
!contentDigest ||
|
||||
createTaskDefinitionRevisionRef({ revision, contentDigest }) !== value
|
||||
) {
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'source revision reference is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ revision, contentDigest });
|
||||
}
|
||||
|
||||
function canonicalRecord(definition: TaskDefinitionRecord): TaskDefinitionRecord {
|
||||
try {
|
||||
return normalizeTaskDefinitionRecord(definition);
|
||||
} catch {
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'source record is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function compileCommandTaskDefinition(
|
||||
definition: TaskDefinitionRecord,
|
||||
semanticRegistry: TaskSpecSemanticRegistry,
|
||||
): CommandTaskExecutionPlan {
|
||||
if (!(semanticRegistry instanceof TaskSpecSemanticRegistry)) {
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'semantic registry is invalid',
|
||||
);
|
||||
}
|
||||
const source = canonicalRecord(definition);
|
||||
if (
|
||||
source.kind !== 'command' ||
|
||||
source.spec.schema !== BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
|
||||
) {
|
||||
throw new UnsupportedTaskDefinitionCompilationError();
|
||||
}
|
||||
if (!source.enabled) {
|
||||
throw new InvalidTaskDefinitionCompilationError('source is disabled');
|
||||
}
|
||||
|
||||
let semanticSpec;
|
||||
try {
|
||||
semanticSpec = semanticRegistry.normalize({
|
||||
projectId: source.projectId,
|
||||
taskId: source.taskId,
|
||||
kind: source.kind,
|
||||
spec: source.spec,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedTaskSpecError) {
|
||||
throw new UnsupportedTaskDefinitionCompilationError();
|
||||
}
|
||||
if (error instanceof InvalidTaskSpecSemanticError) {
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'source spec semantics are invalid',
|
||||
);
|
||||
}
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'semantic validation failed',
|
||||
);
|
||||
}
|
||||
if (JSON.stringify(semanticSpec) !== JSON.stringify(source.spec)) {
|
||||
throw new InvalidTaskDefinitionCompilationError(
|
||||
'source spec is not semantically canonical',
|
||||
);
|
||||
}
|
||||
|
||||
const config = semanticSpec.config as unknown as Readonly<{
|
||||
command: LocalDispatchCommand;
|
||||
environment: readonly LocalExecutionEnvironmentBinding[];
|
||||
workingDirectory?: string;
|
||||
timeoutMs?: number;
|
||||
placement?: RemoteWorkerPlacementSpec;
|
||||
}>;
|
||||
const taskRevision = createTaskDefinitionRevisionRef({
|
||||
revision: source.revision,
|
||||
contentDigest: source.contentDigest,
|
||||
});
|
||||
return Object.freeze({
|
||||
projectId: source.projectId,
|
||||
taskId: source.taskId,
|
||||
taskRevision,
|
||||
sourceRevision: source.revision,
|
||||
sourceContentDigest: source.contentDigest,
|
||||
command: config.command,
|
||||
environment: config.environment,
|
||||
...(config.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: config.workingDirectory }),
|
||||
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
|
||||
...(config.placement === undefined ? {} : { placement: config.placement }),
|
||||
createdAtMs: source.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function compileLocalCommandTaskDefinition(
|
||||
definition: TaskDefinitionRecord,
|
||||
semanticRegistry: TaskSpecSemanticRegistry,
|
||||
): LocalCommandTaskExecutionPlan {
|
||||
const source = compileCommandTaskDefinition(definition, semanticRegistry);
|
||||
const contextRecipe = createLocalExecutionContextRecipe({
|
||||
environment: source.environment,
|
||||
createdAtMs: source.createdAtMs,
|
||||
});
|
||||
const executionRevision = createLocalTaskExecutionRevision({
|
||||
projectId: source.projectId,
|
||||
taskId: source.taskId,
|
||||
taskRevision: source.taskRevision,
|
||||
executorType: 'local_process',
|
||||
command: source.command,
|
||||
...(source.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: source.workingDirectory }),
|
||||
...(source.timeoutMs === undefined ? {} : { timeoutMs: source.timeoutMs }),
|
||||
contextRef: contextRecipe.contextRef,
|
||||
createdAtMs: source.createdAtMs,
|
||||
});
|
||||
return Object.freeze({ source, contextRecipe, executionRevision });
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import {
|
||||
TASK_DEFINITION_KINDS,
|
||||
assertTaskDefinitionIdentifier,
|
||||
normalizeTaskDefinitionSpec,
|
||||
type TaskDefinitionJson,
|
||||
type TaskDefinitionKind,
|
||||
type TaskDefinitionSpec,
|
||||
} from './taskDefinition';
|
||||
import { parseSecretRef } from '../secret/secretReference';
|
||||
import { normalizeRemoteWorkerPlacement } from '../remote-execution/remoteWorkerPlacement';
|
||||
|
||||
export const MAX_TASK_SPEC_SEMANTIC_SCHEMAS = 32;
|
||||
export const BUILT_IN_COMMAND_TASK_SPEC_SCHEMA = 'qinglong/command@v1';
|
||||
export const MAX_COMMAND_TASK_ENVIRONMENT_ENTRIES = 256;
|
||||
export const MAX_COMMAND_TASK_ENVIRONMENT_BYTES = 64 * 1024;
|
||||
export const MAX_COMMAND_TASK_TIMEOUT_MS = 365 * 24 * 60 * 60_000;
|
||||
|
||||
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
||||
|
||||
export interface TaskSpecSemanticContext {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly kind: TaskDefinitionKind;
|
||||
readonly spec: TaskDefinitionSpec;
|
||||
}
|
||||
|
||||
export interface TaskSpecSemanticDescriptor {
|
||||
readonly schema: string;
|
||||
readonly kind: TaskDefinitionKind;
|
||||
normalizeConfig(
|
||||
config: Readonly<Record<string, TaskDefinitionJson>>,
|
||||
context: Readonly<Omit<TaskSpecSemanticContext, 'spec'>>,
|
||||
): Readonly<Record<string, TaskDefinitionJson>>;
|
||||
}
|
||||
|
||||
export interface TaskSpecSemanticMetadata {
|
||||
readonly schema: string;
|
||||
readonly kind: TaskDefinitionKind;
|
||||
}
|
||||
|
||||
export class UnsupportedTaskSpecError extends Error {
|
||||
readonly code = 'TASK_SPEC_UNSUPPORTED';
|
||||
|
||||
constructor() {
|
||||
super('TaskDefinition spec schema is unsupported');
|
||||
this.name = 'UnsupportedTaskSpecError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTaskSpecSemanticError extends TypeError {
|
||||
readonly code = 'TASK_SPEC_SEMANTIC_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`TaskDefinition spec semantics are invalid: ${message}`);
|
||||
this.name = 'InvalidTaskSpecSemanticError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys<T>(
|
||||
value: T,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): asserts value is T & Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidTaskSpecSemanticError(`${label} must be an object`);
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximumBytes: number,
|
||||
allowEmpty = false,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
(!allowEmpty && value.length === 0) ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): TaskDefinitionJson {
|
||||
exactKeys(value, ['kind'], ['args', 'command', 'file', 'shell'], 'command');
|
||||
if (value.kind === 'argv') {
|
||||
exactKeys(value, ['args', 'file', 'kind'], [], 'argv command');
|
||||
const file = boundedText(value.file, 'command file', 4096);
|
||||
if (!file.startsWith('/')) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'command file must be an absolute path',
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(value.args) || value.args.length > 256) {
|
||||
throw new InvalidTaskSpecSemanticError('command arguments are invalid');
|
||||
}
|
||||
let bytes = Buffer.byteLength(file, 'utf8');
|
||||
const args = value.args.map((argument) => {
|
||||
const normalized = boundedText(
|
||||
argument,
|
||||
'command argument',
|
||||
16 * 1024,
|
||||
true,
|
||||
);
|
||||
bytes += Buffer.byteLength(normalized, 'utf8');
|
||||
return normalized;
|
||||
});
|
||||
if (bytes > 64 * 1024) {
|
||||
throw new InvalidTaskSpecSemanticError('command byte budget exceeded');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: 'argv',
|
||||
file,
|
||||
args: Object.freeze(args),
|
||||
});
|
||||
}
|
||||
if (value.kind === 'shell') {
|
||||
exactKeys(value, ['command', 'kind'], ['shell'], 'shell command');
|
||||
const command = boundedText(value.command, 'shell command', 64 * 1024);
|
||||
const shell = value.shell ?? '/bin/sh';
|
||||
if (shell !== '/bin/sh' && shell !== '/bin/bash') {
|
||||
throw new InvalidTaskSpecSemanticError('shell is not allowlisted');
|
||||
}
|
||||
return Object.freeze({ kind: 'shell', command, shell });
|
||||
}
|
||||
throw new InvalidTaskSpecSemanticError('command kind is invalid');
|
||||
}
|
||||
|
||||
function normalizeEnvironment(
|
||||
value: unknown,
|
||||
projectId: string,
|
||||
): readonly TaskDefinitionJson[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > MAX_COMMAND_TASK_ENVIRONMENT_ENTRIES
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError('environment is invalid');
|
||||
}
|
||||
const names = new Set<string>();
|
||||
let bytes = 0;
|
||||
const environment = value.map((entry) => {
|
||||
exactKeys(entry, ['kind', 'name'], ['secretRef', 'value'], 'environment');
|
||||
const name = boundedText(entry.name, 'environment name', 128);
|
||||
if (
|
||||
!ENVIRONMENT_NAME_PATTERN.test(name) ||
|
||||
name.startsWith('QL3_') ||
|
||||
names.has(name)
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'environment name is invalid or duplicated',
|
||||
);
|
||||
}
|
||||
names.add(name);
|
||||
bytes += Buffer.byteLength(name, 'utf8');
|
||||
if (entry.kind === 'public') {
|
||||
exactKeys(entry, ['kind', 'name', 'value'], [], 'public environment');
|
||||
const publicValue = boundedText(
|
||||
entry.value,
|
||||
'environment value',
|
||||
16 * 1024,
|
||||
true,
|
||||
);
|
||||
bytes += Buffer.byteLength(publicValue, 'utf8');
|
||||
return Object.freeze({ name, kind: 'public', value: publicValue });
|
||||
}
|
||||
if (entry.kind === 'secret') {
|
||||
exactKeys(entry, ['kind', 'name', 'secretRef'], [], 'secret environment');
|
||||
const secretRef = boundedText(entry.secretRef, 'secretRef', 512);
|
||||
let reference;
|
||||
try {
|
||||
reference = parseSecretRef(secretRef);
|
||||
} catch {
|
||||
throw new InvalidTaskSpecSemanticError('secretRef is invalid');
|
||||
}
|
||||
if (reference.projectId !== projectId) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'secretRef belongs to another Project',
|
||||
);
|
||||
}
|
||||
bytes += Buffer.byteLength(secretRef, 'utf8');
|
||||
return Object.freeze({ name, kind: 'secret', secretRef });
|
||||
}
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'environment binding kind is invalid',
|
||||
);
|
||||
});
|
||||
if (bytes > MAX_COMMAND_TASK_ENVIRONMENT_BYTES) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'environment byte budget exceeded',
|
||||
);
|
||||
}
|
||||
environment.sort((left, right) =>
|
||||
(left as { name: string }).name.localeCompare(
|
||||
(right as { name: string }).name,
|
||||
),
|
||||
);
|
||||
return Object.freeze(environment);
|
||||
}
|
||||
|
||||
function normalizeCommandConfig(
|
||||
config: Readonly<Record<string, TaskDefinitionJson>>,
|
||||
context: Readonly<Omit<TaskSpecSemanticContext, 'spec'>>,
|
||||
): Readonly<Record<string, TaskDefinitionJson>> {
|
||||
exactKeys(
|
||||
config,
|
||||
['command'],
|
||||
['environment', 'placement', 'timeoutMs', 'workingDirectory'],
|
||||
'command config',
|
||||
);
|
||||
const command = normalizeCommand(config.command);
|
||||
const environment = normalizeEnvironment(config.environment ?? [], context.projectId);
|
||||
const placement = config.placement === undefined
|
||||
? undefined
|
||||
: normalizeRemoteWorkerPlacement(config.placement);
|
||||
let workingDirectory: string | undefined;
|
||||
if (config.workingDirectory !== undefined) {
|
||||
workingDirectory = boundedText(
|
||||
config.workingDirectory,
|
||||
'workingDirectory',
|
||||
4096,
|
||||
);
|
||||
if (!workingDirectory.startsWith('/')) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'workingDirectory must be an absolute path',
|
||||
);
|
||||
}
|
||||
}
|
||||
let timeoutMs: number | undefined;
|
||||
if (config.timeoutMs !== undefined) {
|
||||
if (
|
||||
!Number.isSafeInteger(config.timeoutMs) ||
|
||||
(config.timeoutMs as number) < 1 ||
|
||||
(config.timeoutMs as number) > MAX_COMMAND_TASK_TIMEOUT_MS
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError('timeoutMs is invalid');
|
||||
}
|
||||
timeoutMs = config.timeoutMs as number;
|
||||
}
|
||||
return Object.freeze({
|
||||
command,
|
||||
environment,
|
||||
...(placement === undefined
|
||||
? {}
|
||||
: { placement: placement as unknown as TaskDefinitionJson }),
|
||||
...(workingDirectory === undefined ? {} : { workingDirectory }),
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
});
|
||||
}
|
||||
|
||||
const BUILT_IN_DESCRIPTORS: readonly TaskSpecSemanticDescriptor[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
kind: 'command' as const,
|
||||
normalizeConfig: normalizeCommandConfig,
|
||||
}),
|
||||
]);
|
||||
|
||||
export class TaskSpecSemanticRegistry {
|
||||
readonly #descriptors: ReadonlyMap<
|
||||
string,
|
||||
TaskSpecSemanticDescriptor
|
||||
>;
|
||||
readonly #metadata: readonly TaskSpecSemanticMetadata[];
|
||||
|
||||
constructor(descriptors: readonly TaskSpecSemanticDescriptor[]) {
|
||||
if (
|
||||
!Array.isArray(descriptors) ||
|
||||
descriptors.length < 1 ||
|
||||
descriptors.length > MAX_TASK_SPEC_SEMANTIC_SCHEMAS
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'registry descriptor count is invalid',
|
||||
);
|
||||
}
|
||||
const bySchema = new Map<string, TaskSpecSemanticDescriptor>();
|
||||
for (const descriptor of descriptors) {
|
||||
exactKeys(
|
||||
descriptor,
|
||||
['kind', 'normalizeConfig', 'schema'],
|
||||
[],
|
||||
'registry descriptor',
|
||||
);
|
||||
const schema = normalizeTaskDefinitionSpec({
|
||||
schema: descriptor.schema,
|
||||
config: {},
|
||||
}).schema;
|
||||
if (
|
||||
!TASK_DEFINITION_KINDS.includes(descriptor.kind) ||
|
||||
typeof descriptor.normalizeConfig !== 'function' ||
|
||||
bySchema.has(schema)
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'registry descriptor is invalid or duplicated',
|
||||
);
|
||||
}
|
||||
bySchema.set(
|
||||
schema,
|
||||
Object.freeze({
|
||||
schema,
|
||||
kind: descriptor.kind,
|
||||
normalizeConfig: descriptor.normalizeConfig,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.#descriptors = bySchema;
|
||||
this.#metadata = Object.freeze(
|
||||
[...bySchema.values()]
|
||||
.map(({ schema, kind }) => Object.freeze({ schema, kind }))
|
||||
.sort((left, right) => left.schema.localeCompare(right.schema)),
|
||||
);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
list(): readonly TaskSpecSemanticMetadata[] {
|
||||
return this.#metadata;
|
||||
}
|
||||
|
||||
supports(kind: TaskDefinitionKind, schema: string): boolean {
|
||||
return this.#descriptors.get(schema)?.kind === kind;
|
||||
}
|
||||
|
||||
normalize(context: TaskSpecSemanticContext): TaskDefinitionSpec {
|
||||
if (!context || typeof context !== 'object' || Array.isArray(context)) {
|
||||
throw new InvalidTaskSpecSemanticError('context is invalid');
|
||||
}
|
||||
exactKeys(
|
||||
context,
|
||||
['kind', 'projectId', 'spec', 'taskId'],
|
||||
[],
|
||||
'semantic context',
|
||||
);
|
||||
assertTaskDefinitionIdentifier(context.projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(context.taskId, 'taskId');
|
||||
if (!TASK_DEFINITION_KINDS.includes(context.kind)) {
|
||||
throw new InvalidTaskSpecSemanticError('TaskDefinition kind is invalid');
|
||||
}
|
||||
const spec = normalizeTaskDefinitionSpec(context.spec);
|
||||
const descriptor = this.#descriptors.get(spec.schema);
|
||||
if (!descriptor) throw new UnsupportedTaskSpecError();
|
||||
if (descriptor.kind !== context.kind) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'schema does not match TaskDefinition kind',
|
||||
);
|
||||
}
|
||||
let config: Readonly<Record<string, TaskDefinitionJson>>;
|
||||
try {
|
||||
config = descriptor.normalizeConfig(
|
||||
spec.config,
|
||||
Object.freeze({
|
||||
projectId: context.projectId,
|
||||
taskId: context.taskId,
|
||||
kind: context.kind,
|
||||
}),
|
||||
);
|
||||
return normalizeTaskDefinitionSpec({ schema: spec.schema, config });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTaskSpecSemanticError) throw error;
|
||||
throw new InvalidTaskSpecSemanticError('validator rejected the config');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBuiltInTaskSpecSemanticRegistry(): TaskSpecSemanticRegistry {
|
||||
return new TaskSpecSemanticRegistry(BUILT_IN_DESCRIPTORS);
|
||||
}
|
||||
|
||||
export function createTaskSpecSemanticRegistry(
|
||||
extensions: readonly TaskSpecSemanticDescriptor[] = [],
|
||||
): TaskSpecSemanticRegistry {
|
||||
if (
|
||||
!Array.isArray(extensions) ||
|
||||
extensions.some(
|
||||
(descriptor) =>
|
||||
!descriptor ||
|
||||
typeof descriptor !== 'object' ||
|
||||
typeof descriptor.schema !== 'string' ||
|
||||
descriptor.schema.startsWith('qinglong/'),
|
||||
)
|
||||
) {
|
||||
throw new InvalidTaskSpecSemanticError(
|
||||
'extension descriptor uses the reserved qinglong namespace',
|
||||
);
|
||||
}
|
||||
return new TaskSpecSemanticRegistry([
|
||||
...BUILT_IN_DESCRIPTORS,
|
||||
...extensions,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user