mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import {
|
|
normalizeProjectPolicySubject,
|
|
type ProjectRole,
|
|
} from '../security/project-policy/projectPolicy';
|
|
import {
|
|
normalizeSecurityPolicyDecision,
|
|
type SecurityPolicyFence,
|
|
type SecuritySubject,
|
|
} from '../security/security';
|
|
import { RUN_STATUSES, type RunStatus } from './run';
|
|
|
|
export const RUN_CANCELLATION_SCHEMA = 'qinglong/run-cancellation@v1' as const;
|
|
/** @deprecated Use RUN_CANCELLATION_SCHEMA from the profile-neutral export. */
|
|
export const CLUSTER_RUN_CANCELLATION_SCHEMA = RUN_CANCELLATION_SCHEMA;
|
|
export const CLUSTER_RUN_CANCELLATION_STATUSES = [
|
|
'accepted',
|
|
'already_requested',
|
|
'already_terminal',
|
|
] as const;
|
|
|
|
export type ClusterRunCancellationStatus =
|
|
(typeof CLUSTER_RUN_CANCELLATION_STATUSES)[number];
|
|
|
|
export interface ClusterRunCancellationRequestBody {
|
|
readonly schema: typeof CLUSTER_RUN_CANCELLATION_SCHEMA;
|
|
readonly mutationId: string;
|
|
}
|
|
|
|
export interface ClusterRunCancellationWorkflowTarget {
|
|
readonly packageName: string;
|
|
readonly workflowId: string;
|
|
}
|
|
|
|
export interface ClusterRunCancellationCommand {
|
|
readonly projectId: string;
|
|
readonly runId: string;
|
|
readonly mutationId: string;
|
|
readonly eventId: string;
|
|
readonly subject: Readonly<SecuritySubject>;
|
|
readonly policyFence: Readonly<SecurityPolicyFence>;
|
|
readonly workflowTarget?: Readonly<ClusterRunCancellationWorkflowTarget>;
|
|
}
|
|
|
|
export interface ClusterRunCancellationResult {
|
|
readonly status: ClusterRunCancellationStatus;
|
|
readonly projectId: string;
|
|
readonly runId: string;
|
|
readonly runStatus: RunStatus;
|
|
readonly runVersion: number;
|
|
readonly eventSequence: number;
|
|
readonly cancelRequestedAtMs?: number;
|
|
readonly cancelReason?: 'user' | 'policy' | 'shutdown' | 'reconcile' | 'timeout';
|
|
}
|
|
|
|
export interface ClusterRunCancellationResponseBody
|
|
extends ClusterRunCancellationResult {
|
|
readonly schema: typeof CLUSTER_RUN_CANCELLATION_SCHEMA;
|
|
}
|
|
|
|
export interface ClusterRunCancellationRepository {
|
|
requestUserCancellation(
|
|
command: Readonly<ClusterRunCancellationCommand>,
|
|
): Promise<Readonly<ClusterRunCancellationResult>>;
|
|
}
|
|
|
|
export type ClusterRunCancellationFenceReason =
|
|
| 'authorization_changed'
|
|
| 'project_mismatch'
|
|
| 'state_mismatch';
|
|
|
|
export class InvalidClusterRunCancellationError extends TypeError {
|
|
constructor(message: string) {
|
|
super(`Cluster Run cancellation is invalid: ${message}`);
|
|
this.name = 'InvalidClusterRunCancellationError';
|
|
}
|
|
}
|
|
|
|
export class ClusterRunCancellationNotFoundError extends Error {
|
|
readonly code = 'CLUSTER_RUN_CANCELLATION_NOT_FOUND';
|
|
constructor() {
|
|
super('Cluster Run cancellation target does not exist');
|
|
this.name = 'ClusterRunCancellationNotFoundError';
|
|
}
|
|
}
|
|
|
|
export class ClusterRunCancellationFenceRejectedError extends Error {
|
|
readonly code = 'CLUSTER_RUN_CANCELLATION_FENCE_REJECTED';
|
|
constructor(readonly reason: ClusterRunCancellationFenceReason) {
|
|
super(`Cluster Run cancellation fence rejected: ${reason}`);
|
|
this.name = 'ClusterRunCancellationFenceRejectedError';
|
|
}
|
|
}
|
|
|
|
export class ClusterRunCancellationUnavailableError extends Error {
|
|
readonly code = 'CLUSTER_RUN_CANCELLATION_UNAVAILABLE';
|
|
constructor(options?: ErrorOptions) {
|
|
super('Cluster Run cancellation is unavailable', options);
|
|
this.name = 'ClusterRunCancellationUnavailableError';
|
|
}
|
|
}
|
|
|
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
const EVENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
|
|
const PACKAGE_NAME_PATTERN =
|
|
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
const WORKFLOW_ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
|
|
const TERMINAL = new Set<RunStatus>([
|
|
'succeeded',
|
|
'failed',
|
|
'cancelled',
|
|
'timed_out',
|
|
]);
|
|
const CANCEL_REASONS = new Set([
|
|
'user',
|
|
'policy',
|
|
'shutdown',
|
|
'reconcile',
|
|
'timeout',
|
|
]);
|
|
|
|
function exactKeys(value: object, expected: readonly string[]): void {
|
|
const actual = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
if (
|
|
actual.length !== canonical.length ||
|
|
actual.some((key, index) => key !== canonical[index])
|
|
) {
|
|
throw new InvalidClusterRunCancellationError('shape is invalid');
|
|
}
|
|
}
|
|
|
|
function identifier(value: unknown, name: string): string {
|
|
if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
|
|
throw new InvalidClusterRunCancellationError(`${name} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function counter(value: unknown, name: string, minimum: number): number {
|
|
if (
|
|
typeof value !== 'number' ||
|
|
!Number.isSafeInteger(value) ||
|
|
value < minimum ||
|
|
value > 2_147_483_647
|
|
) {
|
|
throw new InvalidClusterRunCancellationError(`${name} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function timestamp(value: unknown, name: string): number {
|
|
if (
|
|
typeof value !== 'number' ||
|
|
!Number.isSafeInteger(value) ||
|
|
value < 0
|
|
) {
|
|
throw new InvalidClusterRunCancellationError(`${name} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parseClusterRunCancellationRequestBody(
|
|
value: unknown,
|
|
): Readonly<ClusterRunCancellationRequestBody> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidClusterRunCancellationError('request body is invalid');
|
|
}
|
|
exactKeys(value, ['schema', 'mutationId']);
|
|
const body = value as Record<string, unknown>;
|
|
if (body.schema !== CLUSTER_RUN_CANCELLATION_SCHEMA) {
|
|
throw new InvalidClusterRunCancellationError('schema is invalid');
|
|
}
|
|
return Object.freeze({
|
|
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
|
mutationId: identifier(body.mutationId, 'mutationId'),
|
|
});
|
|
}
|
|
|
|
export function normalizeClusterRunCancellationCommand(
|
|
value: ClusterRunCancellationCommand,
|
|
): Readonly<ClusterRunCancellationCommand> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidClusterRunCancellationError('command is invalid');
|
|
}
|
|
const hasWorkflowTarget = value.workflowTarget !== undefined;
|
|
exactKeys(
|
|
value,
|
|
[
|
|
'projectId',
|
|
'runId',
|
|
'mutationId',
|
|
'eventId',
|
|
'subject',
|
|
'policyFence',
|
|
...(hasWorkflowTarget ? ['workflowTarget'] : []),
|
|
],
|
|
);
|
|
if (typeof value.eventId !== 'string' || !EVENT_ID_PATTERN.test(value.eventId)) {
|
|
throw new InvalidClusterRunCancellationError('eventId is invalid');
|
|
}
|
|
let subject: Readonly<SecuritySubject>;
|
|
let fence: Readonly<SecurityPolicyFence> | null;
|
|
try {
|
|
subject = normalizeProjectPolicySubject(value.subject);
|
|
fence = normalizeSecurityPolicyDecision({
|
|
effect: 'allow',
|
|
reasons: ['role_grant'],
|
|
fence: value.policyFence,
|
|
}).fence;
|
|
} catch {
|
|
throw new InvalidClusterRunCancellationError(
|
|
'authorization authority is invalid',
|
|
);
|
|
}
|
|
if (!fence || fence.bindingVersion === null) {
|
|
throw new InvalidClusterRunCancellationError(
|
|
'authorization fence is incomplete',
|
|
);
|
|
}
|
|
let workflowTarget:
|
|
| Readonly<ClusterRunCancellationWorkflowTarget>
|
|
| undefined;
|
|
if (hasWorkflowTarget) {
|
|
const target = value.workflowTarget;
|
|
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
|
throw new InvalidClusterRunCancellationError(
|
|
'workflowTarget is invalid',
|
|
);
|
|
}
|
|
exactKeys(target, ['packageName', 'workflowId']);
|
|
if (
|
|
typeof target.packageName !== 'string' ||
|
|
!PACKAGE_NAME_PATTERN.test(target.packageName) ||
|
|
typeof target.workflowId !== 'string' ||
|
|
!WORKFLOW_ID_PATTERN.test(target.workflowId)
|
|
) {
|
|
throw new InvalidClusterRunCancellationError(
|
|
'workflowTarget is invalid',
|
|
);
|
|
}
|
|
workflowTarget = Object.freeze({
|
|
packageName: target.packageName,
|
|
workflowId: target.workflowId,
|
|
});
|
|
}
|
|
return Object.freeze({
|
|
projectId: identifier(value.projectId, 'projectId'),
|
|
runId: identifier(value.runId, 'runId'),
|
|
mutationId: identifier(value.mutationId, 'mutationId'),
|
|
eventId: value.eventId,
|
|
subject,
|
|
policyFence: fence,
|
|
...(workflowTarget === undefined ? {} : { workflowTarget }),
|
|
});
|
|
}
|
|
|
|
export function normalizeClusterRunCancellationResult(
|
|
value: ClusterRunCancellationResult,
|
|
): Readonly<ClusterRunCancellationResult> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidClusterRunCancellationError('result is invalid');
|
|
}
|
|
const hasCancellation = value.cancelRequestedAtMs !== undefined;
|
|
exactKeys(
|
|
value,
|
|
hasCancellation
|
|
? [
|
|
'status', 'projectId', 'runId', 'runStatus', 'runVersion',
|
|
'eventSequence', 'cancelRequestedAtMs', 'cancelReason',
|
|
]
|
|
: [
|
|
'status', 'projectId', 'runId', 'runStatus', 'runVersion',
|
|
'eventSequence',
|
|
],
|
|
);
|
|
if (
|
|
!CLUSTER_RUN_CANCELLATION_STATUSES.includes(value.status) ||
|
|
!RUN_STATUSES.includes(value.runStatus) ||
|
|
(value.status === 'accepted' && !hasCancellation) ||
|
|
(value.status === 'already_requested' && !hasCancellation) ||
|
|
(value.status === 'already_terminal' && !TERMINAL.has(value.runStatus)) ||
|
|
(value.status !== 'already_terminal' && TERMINAL.has(value.runStatus))
|
|
) {
|
|
throw new InvalidClusterRunCancellationError('result state is invalid');
|
|
}
|
|
let cancelRequestedAtMs: number | undefined;
|
|
let cancelReason: ClusterRunCancellationResult['cancelReason'];
|
|
if (hasCancellation) {
|
|
cancelRequestedAtMs = timestamp(
|
|
value.cancelRequestedAtMs,
|
|
'cancelRequestedAtMs',
|
|
);
|
|
if (!CANCEL_REASONS.has(value.cancelReason ?? '')) {
|
|
throw new InvalidClusterRunCancellationError('cancelReason is invalid');
|
|
}
|
|
cancelReason = value.cancelReason;
|
|
} else if (value.cancelReason !== undefined) {
|
|
throw new InvalidClusterRunCancellationError('cancellation shape is invalid');
|
|
}
|
|
return Object.freeze({
|
|
status: value.status,
|
|
projectId: identifier(value.projectId, 'projectId'),
|
|
runId: identifier(value.runId, 'runId'),
|
|
runStatus: value.runStatus,
|
|
runVersion: counter(value.runVersion, 'runVersion', 1),
|
|
eventSequence: counter(value.eventSequence, 'eventSequence', 0),
|
|
...(cancelRequestedAtMs === undefined
|
|
? {}
|
|
: { cancelRequestedAtMs, cancelReason: cancelReason! }),
|
|
});
|
|
}
|
|
|
|
export function createClusterRunCancellationResponseBody(
|
|
value: ClusterRunCancellationResult,
|
|
): Readonly<ClusterRunCancellationResponseBody> {
|
|
return Object.freeze({
|
|
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
|
...normalizeClusterRunCancellationResult(value),
|
|
});
|
|
}
|
|
|
|
export function parseClusterRunCancellationResponseBody(
|
|
value: unknown,
|
|
): Readonly<ClusterRunCancellationResponseBody> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidClusterRunCancellationError('response body is invalid');
|
|
}
|
|
const { schema, ...result } = value as Record<string, unknown>;
|
|
if (schema !== CLUSTER_RUN_CANCELLATION_SCHEMA) {
|
|
throw new InvalidClusterRunCancellationError('schema is invalid');
|
|
}
|
|
return createClusterRunCancellationResponseBody(
|
|
result as unknown as ClusterRunCancellationResult,
|
|
);
|
|
}
|
|
|
|
export type ClusterRunCancellationAllowedRole = Extract<
|
|
ProjectRole,
|
|
'owner' | 'admin' | 'operator'
|
|
>;
|
|
|
|
// Profile-neutral names are canonical for new Local and Cluster consumers.
|
|
// Cluster-prefixed names remain source-compatible throughout the 3.0 Alpha.
|
|
export const RUN_CANCELLATION_STATUSES = CLUSTER_RUN_CANCELLATION_STATUSES;
|
|
export type RunCancellationStatus = ClusterRunCancellationStatus;
|
|
export type RunCancellationRequestBody = ClusterRunCancellationRequestBody;
|
|
export type RunCancellationWorkflowTarget =
|
|
ClusterRunCancellationWorkflowTarget;
|
|
export type RunCancellationCommand = ClusterRunCancellationCommand;
|
|
export type RunCancellationResult = ClusterRunCancellationResult;
|
|
export type RunCancellationResponseBody = ClusterRunCancellationResponseBody;
|
|
export type RunCancellationRepository = ClusterRunCancellationRepository;
|
|
export type RunCancellationFenceReason = ClusterRunCancellationFenceReason;
|
|
export type RunCancellationAllowedRole = ClusterRunCancellationAllowedRole;
|
|
export const InvalidRunCancellationError =
|
|
InvalidClusterRunCancellationError;
|
|
export const RunCancellationNotFoundError =
|
|
ClusterRunCancellationNotFoundError;
|
|
export const RunCancellationFenceRejectedError =
|
|
ClusterRunCancellationFenceRejectedError;
|
|
export const RunCancellationUnavailableError =
|
|
ClusterRunCancellationUnavailableError;
|
|
export const parseRunCancellationRequestBody =
|
|
parseClusterRunCancellationRequestBody;
|
|
export const normalizeRunCancellationCommand =
|
|
normalizeClusterRunCancellationCommand;
|
|
export const normalizeRunCancellationResult =
|
|
normalizeClusterRunCancellationResult;
|
|
export const createRunCancellationResponseBody =
|
|
createClusterRunCancellationResponseBody;
|
|
export const parseRunCancellationResponseBody =
|
|
parseClusterRunCancellationResponseBody;
|