mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
import {
|
|
assertApprovedActionLeaseDuration,
|
|
assertApprovedActionLeaseIdentity,
|
|
assertApprovedActionPageSize,
|
|
assertApprovedActionResultCode,
|
|
type ApprovedActionDispatchExecutionSnapshot,
|
|
} from './approvedActionDispatchExecution';
|
|
import {
|
|
assertApprovalMutationId,
|
|
assertApprovalTimestamp,
|
|
} from './approvalRequest';
|
|
import {
|
|
assertProjectPolicyProjectId,
|
|
normalizePolicySubject,
|
|
type PolicySubject,
|
|
} from './projectPolicy';
|
|
|
|
export const APPROVED_ACTION_RECOVERY_CONTROL_STATUSES = [
|
|
'armed',
|
|
'leased',
|
|
'manual_required',
|
|
'resolved',
|
|
] as const;
|
|
|
|
export const APPROVED_ACTION_RECOVERY_FINDINGS = [
|
|
'verified_succeeded',
|
|
'verified_failed',
|
|
'still_running',
|
|
'missing',
|
|
'conflict',
|
|
'unsupported',
|
|
'unavailable',
|
|
] as const;
|
|
|
|
export const APPROVED_ACTION_RECOVERY_SOURCES = [
|
|
'automatic_evidence',
|
|
'human',
|
|
] as const;
|
|
|
|
export const APPROVED_ACTION_RECOVERY_DECISIONS = [
|
|
'confirm_succeeded',
|
|
'confirm_failed',
|
|
'abandon_unknown',
|
|
] as const;
|
|
|
|
export type ApprovedActionRecoveryControlStatus =
|
|
(typeof APPROVED_ACTION_RECOVERY_CONTROL_STATUSES)[number];
|
|
export type ApprovedActionRecoveryFinding =
|
|
(typeof APPROVED_ACTION_RECOVERY_FINDINGS)[number];
|
|
export type ApprovedActionRecoverySource =
|
|
(typeof APPROVED_ACTION_RECOVERY_SOURCES)[number];
|
|
export type ApprovedActionRecoveryDecision =
|
|
(typeof APPROVED_ACTION_RECOVERY_DECISIONS)[number];
|
|
|
|
export interface ApprovedActionRecoveryControlRecord {
|
|
dispatchId: string;
|
|
projectId: string;
|
|
executionVersion: number;
|
|
status: ApprovedActionRecoveryControlStatus;
|
|
version: number;
|
|
nextScanAtMs: number | null;
|
|
leaseOwner: string | null;
|
|
leaseToken: string | null;
|
|
leaseExpiresAtMs: number | null;
|
|
findingCount: number;
|
|
lastFindingMutationId: string | null;
|
|
lastFinding: ApprovedActionRecoveryFinding | null;
|
|
lastResultCode: string | null;
|
|
lastEvidenceDigest: string | null;
|
|
resolutionMutationId: string | null;
|
|
createdAtMs: number;
|
|
updatedAtMs: number;
|
|
}
|
|
|
|
export interface ApprovedActionRecoveryResolutionRecord {
|
|
dispatchId: string;
|
|
projectId: string;
|
|
executionVersion: number;
|
|
mutationId: string;
|
|
source: ApprovedActionRecoverySource;
|
|
decision: ApprovedActionRecoveryDecision;
|
|
evidenceDigest: string | null;
|
|
reasonCode: string;
|
|
resolvedBy: Readonly<PolicySubject> | null;
|
|
resolvedAtMs: number;
|
|
}
|
|
|
|
export interface ApprovedActionRecoverySnapshot {
|
|
action: Readonly<ApprovedActionDispatchExecutionSnapshot>;
|
|
recovery: Readonly<ApprovedActionRecoveryControlRecord>;
|
|
resolution: Readonly<ApprovedActionRecoveryResolutionRecord> | null;
|
|
}
|
|
|
|
export interface ApprovedActionRecoveryCursor {
|
|
nextScanAtMs: number;
|
|
dispatchId: string;
|
|
}
|
|
|
|
export const MAX_APPROVED_ACTION_RECOVERY_VERSION = 2_147_483_647;
|
|
export const MAX_APPROVED_ACTION_RECOVERY_FINDINGS = 2_147_483_647;
|
|
|
|
const EVIDENCE_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
|
|
export class InvalidApprovedActionRecoveryError extends TypeError {
|
|
constructor(message: string) {
|
|
super(`Approved action recovery is invalid: ${message}`);
|
|
this.name = 'InvalidApprovedActionRecoveryError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionRecoveryFenceRejectedError extends Error {
|
|
readonly code = 'APPROVED_ACTION_RECOVERY_FENCE_REJECTED';
|
|
|
|
constructor() {
|
|
super('Approved action recovery fence was rejected');
|
|
this.name = 'ApprovedActionRecoveryFenceRejectedError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionRecoveryBindingConflictError extends Error {
|
|
readonly code = 'APPROVED_ACTION_RECOVERY_BINDING_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Approved action recovery identity does not match its execution');
|
|
this.name = 'ApprovedActionRecoveryBindingConflictError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionRecoveryRepositoryError extends Error {
|
|
readonly code = 'APPROVED_ACTION_RECOVERY_REPOSITORY_ERROR';
|
|
|
|
constructor() {
|
|
super('Approved action recovery repository is unavailable');
|
|
this.name = 'ApprovedActionRecoveryRepositoryError';
|
|
}
|
|
}
|
|
|
|
function assertExactKeys(
|
|
name: string,
|
|
value: object,
|
|
expected: readonly string[],
|
|
): void {
|
|
const keys = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
if (
|
|
keys.length !== canonical.length ||
|
|
keys.some((key, index) => key !== canonical[index])
|
|
) {
|
|
throw new InvalidApprovedActionRecoveryError(`${name} shape is invalid`);
|
|
}
|
|
}
|
|
|
|
function assertInteger(
|
|
name: string,
|
|
value: number,
|
|
minimum: number,
|
|
maximum: number,
|
|
): void {
|
|
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
throw new InvalidApprovedActionRecoveryError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
function allNull(values: readonly unknown[]): boolean {
|
|
return values.every((value) => value === null);
|
|
}
|
|
|
|
function allPresent(values: readonly unknown[]): boolean {
|
|
return values.every((value) => value !== null);
|
|
}
|
|
|
|
export function assertApprovedActionEvidenceDigest(value: string): void {
|
|
if (typeof value !== 'string' || !EVIDENCE_DIGEST_PATTERN.test(value)) {
|
|
throw new InvalidApprovedActionRecoveryError(
|
|
'evidence digest must be a lowercase SHA-256 digest',
|
|
);
|
|
}
|
|
}
|
|
|
|
export function normalizeApprovedActionRecoveryCursor(
|
|
value: ApprovedActionRecoveryCursor,
|
|
): Readonly<ApprovedActionRecoveryCursor> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovedActionRecoveryError('cursor must be an object');
|
|
}
|
|
assertExactKeys('cursor', value, ['nextScanAtMs', 'dispatchId']);
|
|
assertApprovalTimestamp('nextScanAtMs', value.nextScanAtMs);
|
|
assertApprovalMutationId(value.dispatchId);
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
export function normalizeApprovedActionRecoveryControlRecord(
|
|
value: ApprovedActionRecoveryControlRecord,
|
|
): Readonly<ApprovedActionRecoveryControlRecord> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovedActionRecoveryError(
|
|
'recovery control must be an object',
|
|
);
|
|
}
|
|
assertExactKeys('recovery control', value, [
|
|
'dispatchId',
|
|
'projectId',
|
|
'executionVersion',
|
|
'status',
|
|
'version',
|
|
'nextScanAtMs',
|
|
'leaseOwner',
|
|
'leaseToken',
|
|
'leaseExpiresAtMs',
|
|
'findingCount',
|
|
'lastFindingMutationId',
|
|
'lastFinding',
|
|
'lastResultCode',
|
|
'lastEvidenceDigest',
|
|
'resolutionMutationId',
|
|
'createdAtMs',
|
|
'updatedAtMs',
|
|
]);
|
|
assertApprovalMutationId(value.dispatchId);
|
|
assertProjectPolicyProjectId(value.projectId);
|
|
if (!APPROVED_ACTION_RECOVERY_CONTROL_STATUSES.includes(value.status)) {
|
|
throw new InvalidApprovedActionRecoveryError('status is invalid');
|
|
}
|
|
assertInteger(
|
|
'execution version',
|
|
value.executionVersion,
|
|
1,
|
|
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
|
);
|
|
assertInteger(
|
|
'version',
|
|
value.version,
|
|
0,
|
|
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
|
);
|
|
assertInteger(
|
|
'finding count',
|
|
value.findingCount,
|
|
0,
|
|
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
|
);
|
|
assertApprovalTimestamp('createdAtMs', value.createdAtMs);
|
|
assertApprovalTimestamp('updatedAtMs', value.updatedAtMs);
|
|
if (value.updatedAtMs < value.createdAtMs) {
|
|
throw new InvalidApprovedActionRecoveryError('timestamps are invalid');
|
|
}
|
|
if (value.nextScanAtMs !== null) {
|
|
assertApprovalTimestamp('nextScanAtMs', value.nextScanAtMs);
|
|
if (value.nextScanAtMs <= value.updatedAtMs) {
|
|
throw new InvalidApprovedActionRecoveryError(
|
|
'next scan must be after the latest update',
|
|
);
|
|
}
|
|
}
|
|
const lease = [value.leaseOwner, value.leaseToken, value.leaseExpiresAtMs];
|
|
if (!allNull(lease) && !allPresent(lease)) {
|
|
throw new InvalidApprovedActionRecoveryError('lease tuple is incomplete');
|
|
}
|
|
const hasLease = allPresent(lease);
|
|
if (hasLease) {
|
|
assertApprovedActionLeaseIdentity(value.leaseOwner!);
|
|
assertApprovedActionLeaseIdentity(value.leaseToken!);
|
|
assertApprovalTimestamp('leaseExpiresAtMs', value.leaseExpiresAtMs!);
|
|
assertApprovedActionLeaseDuration(
|
|
value.leaseExpiresAtMs! - value.updatedAtMs,
|
|
);
|
|
}
|
|
const finding = [
|
|
value.lastFindingMutationId,
|
|
value.lastFinding,
|
|
value.lastResultCode,
|
|
];
|
|
if (!allNull(finding) && !allPresent(finding)) {
|
|
throw new InvalidApprovedActionRecoveryError('finding tuple is incomplete');
|
|
}
|
|
if (value.lastFinding !== null) {
|
|
assertApprovalMutationId(value.lastFindingMutationId!);
|
|
if (!APPROVED_ACTION_RECOVERY_FINDINGS.includes(value.lastFinding)) {
|
|
throw new InvalidApprovedActionRecoveryError('finding is invalid');
|
|
}
|
|
assertApprovedActionResultCode(value.lastResultCode!);
|
|
}
|
|
if (
|
|
(value.findingCount === 0) !==
|
|
(value.lastFindingMutationId === null &&
|
|
value.lastFinding === null &&
|
|
value.lastResultCode === null)
|
|
) {
|
|
throw new InvalidApprovedActionRecoveryError('finding count is invalid');
|
|
}
|
|
if (value.lastEvidenceDigest !== null) {
|
|
assertApprovedActionEvidenceDigest(value.lastEvidenceDigest);
|
|
}
|
|
if (value.resolutionMutationId !== null) {
|
|
assertApprovalMutationId(value.resolutionMutationId);
|
|
}
|
|
if (
|
|
value.status === 'armed' &&
|
|
(value.nextScanAtMs === null ||
|
|
hasLease ||
|
|
value.resolutionMutationId !== null)
|
|
) {
|
|
throw new InvalidApprovedActionRecoveryError('armed tuple is invalid');
|
|
}
|
|
if (
|
|
value.status === 'leased' &&
|
|
(!hasLease ||
|
|
value.nextScanAtMs !== value.leaseExpiresAtMs ||
|
|
value.resolutionMutationId !== null)
|
|
) {
|
|
throw new InvalidApprovedActionRecoveryError('leased tuple is invalid');
|
|
}
|
|
if (
|
|
value.status === 'manual_required' &&
|
|
(value.nextScanAtMs !== null ||
|
|
hasLease ||
|
|
value.findingCount < 1 ||
|
|
value.resolutionMutationId !== null)
|
|
) {
|
|
throw new InvalidApprovedActionRecoveryError(
|
|
'manual-required tuple is invalid',
|
|
);
|
|
}
|
|
if (
|
|
value.status === 'resolved' &&
|
|
(value.nextScanAtMs !== null ||
|
|
hasLease ||
|
|
value.resolutionMutationId === null)
|
|
) {
|
|
throw new InvalidApprovedActionRecoveryError('resolved tuple is invalid');
|
|
}
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
export function normalizeApprovedActionRecoveryResolutionRecord(
|
|
value: ApprovedActionRecoveryResolutionRecord,
|
|
): Readonly<ApprovedActionRecoveryResolutionRecord> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovedActionRecoveryError(
|
|
'recovery resolution must be an object',
|
|
);
|
|
}
|
|
assertExactKeys('recovery resolution', value, [
|
|
'dispatchId',
|
|
'projectId',
|
|
'executionVersion',
|
|
'mutationId',
|
|
'source',
|
|
'decision',
|
|
'evidenceDigest',
|
|
'reasonCode',
|
|
'resolvedBy',
|
|
'resolvedAtMs',
|
|
]);
|
|
assertApprovalMutationId(value.dispatchId);
|
|
assertProjectPolicyProjectId(value.projectId);
|
|
assertInteger(
|
|
'execution version',
|
|
value.executionVersion,
|
|
1,
|
|
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
|
);
|
|
assertApprovalMutationId(value.mutationId);
|
|
if (!APPROVED_ACTION_RECOVERY_SOURCES.includes(value.source)) {
|
|
throw new InvalidApprovedActionRecoveryError('source is invalid');
|
|
}
|
|
if (!APPROVED_ACTION_RECOVERY_DECISIONS.includes(value.decision)) {
|
|
throw new InvalidApprovedActionRecoveryError('decision is invalid');
|
|
}
|
|
assertApprovedActionResultCode(value.reasonCode);
|
|
assertApprovalTimestamp('resolvedAtMs', value.resolvedAtMs);
|
|
if (value.evidenceDigest !== null) {
|
|
assertApprovedActionEvidenceDigest(value.evidenceDigest);
|
|
}
|
|
const resolvedBy = value.resolvedBy
|
|
? normalizePolicySubject(value.resolvedBy)
|
|
: null;
|
|
if (
|
|
value.source === 'automatic_evidence' &&
|
|
(value.evidenceDigest === null ||
|
|
resolvedBy !== null ||
|
|
value.decision === 'abandon_unknown')
|
|
) {
|
|
throw new InvalidApprovedActionRecoveryError(
|
|
'automatic resolution tuple is invalid',
|
|
);
|
|
}
|
|
if (value.source === 'human' && (!resolvedBy || resolvedBy.type !== 'user')) {
|
|
throw new InvalidApprovedActionRecoveryError(
|
|
'human resolution requires a User',
|
|
);
|
|
}
|
|
return Object.freeze({ ...value, resolvedBy });
|
|
}
|
|
|
|
export function assertApprovedActionRecoveryPageSize(value: number): void {
|
|
assertApprovedActionPageSize(value);
|
|
}
|
|
|
|
export function assertApprovedActionRecoveryLeaseDuration(value: number): void {
|
|
assertApprovedActionLeaseDuration(value);
|
|
}
|