feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,593 @@
/** Automation-management application service boundary. */
import {
InvalidTaskDefinitionError,
TaskDefinitionConflictError,
TaskDefinitionUnavailableError,
assertTaskDefinitionIdentifier,
assertTaskDefinitionPageSize,
normalizeAppendTaskDefinitionRevisionCommand,
normalizeTaskDefinitionCursor,
type AppendTaskDefinitionRevisionCommand,
type TaskDefinitionCursor,
type TaskDefinitionPage,
type TaskDefinitionRecord,
} from '@qinglong/runtime-core/task-definition';
import {
InvalidTaskDefinitionAdministrationReadError,
InvalidTaskDefinitionAdministrationMutationError,
TaskDefinitionAdministrationAuthorizationFenceConflictError,
TaskDefinitionAdministrationMutationConflictError,
TaskDefinitionAdministrationReadConflictError,
type TaskDefinitionAdministrationRepository,
type TaskDefinitionAdministrationSource,
} from '@qinglong/runtime-core/task-definition-administration';
import {
InvalidTriggerError,
TriggerConflictError,
TriggerUnavailableError,
assertTriggerIdentifier,
assertTriggerPageSize,
normalizeAppendTriggerRevisionCommand,
normalizeTriggerCursor,
type AppendTriggerRevisionCommand,
type TriggerCursor,
type TriggerPage,
type TriggerRecord,
} from '@qinglong/runtime-core/trigger';
import {
InvalidTriggerAdministrationReadError,
InvalidTriggerAdministrationMutationError,
TriggerAdministrationAuthorizationFenceConflictError,
TriggerAdministrationMutationConflictError,
TriggerAdministrationReadConflictError,
type TriggerAdministrationRepository,
type TriggerAdministrationSource,
} from '@qinglong/runtime-core/trigger-administration';
import type { ProjectPermission } from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const AUDIT_EVENT_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const STRONG_USER_ASSURANCES = new Set(['multi_factor', 'hardware']);
export interface ClusterAutomationManagementPolicy {
authorize(
principal: Readonly<SecurityPrincipal>,
projectId: string,
permission: ProjectPermission,
): Promise<Readonly<SecurityPolicyDecision>>;
}
export interface ClusterAutomationManagementService {
publishTask(request: Readonly<{
requestId: string;
command: AppendTaskDefinitionRevisionCommand;
principal: SecurityPrincipal;
}>): Promise<Readonly<{
status: 'created' | 'updated' | 'existing';
definition: TaskDefinitionRecord;
}>>;
publishTrigger(request: Readonly<{
requestId: string;
command: AppendTriggerRevisionCommand;
principal: SecurityPrincipal;
}>): Promise<Readonly<{
status: 'created' | 'updated' | 'existing';
trigger: TriggerRecord;
}>>;
inspectTask(request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
taskId: string;
principal: SecurityPrincipal;
}>): Promise<TaskDefinitionRecord | null>;
listTasks(request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
limit: number;
after?: TaskDefinitionCursor;
principal: SecurityPrincipal;
}>): Promise<TaskDefinitionPage>;
inspectTrigger(request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
triggerId: string;
principal: SecurityPrincipal;
}>): Promise<TriggerRecord | null>;
listTriggers(request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
limit: number;
after?: TriggerCursor;
principal: SecurityPrincipal;
}>): Promise<TriggerPage>;
}
export interface ClusterAutomationManagementOptions {
readonly policy: ClusterAutomationManagementPolicy;
readonly taskDefinitions: TaskDefinitionAdministrationRepository &
TaskDefinitionAdministrationSource;
readonly triggers: TriggerAdministrationRepository &
TriggerAdministrationSource;
readonly now?: () => number;
}
export class ClusterAutomationManagementRequestError extends TypeError {
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_REQUEST_INVALID';
constructor() {
super('Cluster automation management request is invalid');
this.name = 'ClusterAutomationManagementRequestError';
}
}
export class ClusterAutomationManagementAuthorizationError extends Error {
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_FORBIDDEN';
constructor() {
super('Cluster automation management is forbidden');
this.name = 'ClusterAutomationManagementAuthorizationError';
}
}
export class ClusterAutomationManagementConflictError extends Error {
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_CONFLICT';
constructor() {
super('Cluster automation management conflicts with durable state');
this.name = 'ClusterAutomationManagementConflictError';
}
}
export class ClusterAutomationManagementUnavailableError extends Error {
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_UNAVAILABLE';
constructor() {
super('Cluster automation management is unavailable');
this.name = 'ClusterAutomationManagementUnavailableError';
}
}
function exactRequest(value: unknown): void {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).length !== 3 ||
!Object.hasOwn(value, 'requestId') ||
!Object.hasOwn(value, 'command') ||
!Object.hasOwn(value, 'principal') ||
typeof (value as { requestId?: unknown }).requestId !== 'string' ||
!REQUEST_ID_PATTERN.test((value as { requestId: string }).requestId)
) {
throw new ClusterAutomationManagementRequestError();
}
}
function mapMutationError(error: unknown): never {
if (
error instanceof InvalidTaskDefinitionError ||
error instanceof InvalidTaskDefinitionAdministrationMutationError ||
error instanceof InvalidTriggerError ||
error instanceof InvalidTriggerAdministrationMutationError
) {
throw new ClusterAutomationManagementRequestError();
}
if (
error instanceof TaskDefinitionConflictError ||
error instanceof TaskDefinitionAdministrationAuthorizationFenceConflictError ||
error instanceof TaskDefinitionAdministrationMutationConflictError ||
error instanceof TriggerConflictError ||
error instanceof TriggerAdministrationAuthorizationFenceConflictError ||
error instanceof TriggerAdministrationMutationConflictError
) {
throw new ClusterAutomationManagementConflictError();
}
if (
error instanceof TaskDefinitionUnavailableError ||
error instanceof TriggerUnavailableError
) {
throw new ClusterAutomationManagementUnavailableError();
}
throw new ClusterAutomationManagementUnavailableError();
}
function mapReadError(error: unknown): never {
if (
error instanceof InvalidTaskDefinitionError ||
error instanceof InvalidTaskDefinitionAdministrationReadError ||
error instanceof InvalidTriggerError ||
error instanceof InvalidTriggerAdministrationReadError
) {
throw new ClusterAutomationManagementRequestError();
}
if (
error instanceof TaskDefinitionAdministrationAuthorizationFenceConflictError ||
error instanceof TaskDefinitionAdministrationReadConflictError ||
error instanceof TriggerAdministrationAuthorizationFenceConflictError ||
error instanceof TriggerAdministrationReadConflictError
) {
throw new ClusterAutomationManagementConflictError();
}
throw new ClusterAutomationManagementUnavailableError();
}
function exactReadBase(
value: unknown,
required: readonly string[],
optional: readonly string[] = [],
): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ClusterAutomationManagementRequestError();
}
const keys = Object.keys(value);
if (
!required.every((key) => keys.includes(key)) ||
keys.some((key) => !required.includes(key) && !optional.includes(key)) ||
typeof (value as { requestId?: unknown }).requestId !== 'string' ||
!REQUEST_ID_PATTERN.test((value as { requestId: string }).requestId) ||
typeof (value as { auditEventId?: unknown }).auditEventId !== 'string' ||
!AUDIT_EVENT_ID_PATTERN.test(
(value as { auditEventId: string }).auditEventId,
)
) {
throw new ClusterAutomationManagementRequestError();
}
}
function readAudit(
request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
}>,
operationId: 'task.read' | 'trigger.read',
authority: Readonly<{
principal: Readonly<SecurityPrincipal>;
decision: Readonly<SecurityPolicyDecision>;
observedAtMs: number;
}>,
) {
return Object.freeze({
eventId: request.auditEventId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
subject: authority.principal.subject,
authenticationId: authority.principal.authenticationId,
outcome: 'allowed' as const,
reasons: authority.decision.reasons,
fence: authority.decision.fence,
occurredAtMs: authority.observedAtMs,
});
}
export function createClusterAutomationManagementService(
options: ClusterAutomationManagementOptions,
): Readonly<ClusterAutomationManagementService> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) =>
key !== 'policy' &&
key !== 'taskDefinitions' &&
key !== 'triggers' &&
key !== 'now',
) ||
!options.policy ||
typeof options.policy.authorize !== 'function' ||
!options.taskDefinitions ||
typeof options.taskDefinitions.appendAuthorizedTaskDefinitionRevision !==
'function' ||
typeof options.taskDefinitions.findAuthorizedCurrentTaskDefinition !==
'function' ||
typeof options.taskDefinitions.listAuthorizedTaskDefinitions !==
'function' ||
!options.triggers ||
typeof options.triggers.appendAuthorizedTriggerRevision !== 'function' ||
typeof options.triggers.findAuthorizedCurrentTrigger !== 'function' ||
typeof options.triggers.listAuthorizedTriggers !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new TypeError('Cluster automation management options are invalid');
}
const now = options.now ?? Date.now;
const authorize = async (
principalValue: SecurityPrincipal,
projectId: string,
permission: ProjectPermission,
) => {
const observedAtMs = now();
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
throw new ClusterAutomationManagementUnavailableError();
}
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
} catch {
throw new ClusterAutomationManagementAuthorizationError();
}
if (
principal.subject.type !== 'user' ||
!STRONG_USER_ASSURANCES.has(principal.assurance)
) {
throw new ClusterAutomationManagementAuthorizationError();
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = await options.policy.authorize(
principal,
projectId,
permission,
);
} catch {
throw new ClusterAutomationManagementUnavailableError();
}
if (
decision.effect !== 'allow' ||
decision.fence === null ||
decision.fence.bindingVersion === null
) {
throw new ClusterAutomationManagementAuthorizationError();
}
return Object.freeze({ principal, decision, observedAtMs });
};
return Object.freeze({
async publishTask(
request: Parameters<ClusterAutomationManagementService['publishTask']>[0],
) {
exactRequest(request);
let command: Readonly<AppendTaskDefinitionRevisionCommand>;
try {
command = normalizeAppendTaskDefinitionRevisionCommand(request.command);
} catch (error) {
return mapMutationError(error);
}
const operation =
command.expectedRevision === null ? 'task.create' : 'task.update';
const authority = await authorize(
request.principal,
command.projectId,
operation,
);
try {
return await options.taskDefinitions.appendAuthorizedTaskDefinitionRevision(
{
command,
actor: authority.principal.subject,
fence: authority.decision.fence!,
audit: {
eventId: command.mutationId,
requestId: request.requestId,
operationId: operation,
projectId: command.projectId,
subject: authority.principal.subject,
authenticationId: authority.principal.authenticationId,
outcome: 'allowed',
reasons: authority.decision.reasons,
fence: authority.decision.fence,
occurredAtMs: authority.observedAtMs,
},
},
);
} catch (error) {
return mapMutationError(error);
}
},
async publishTrigger(
request: Parameters<ClusterAutomationManagementService['publishTrigger']>[0],
) {
exactRequest(request);
let command: Readonly<AppendTriggerRevisionCommand>;
try {
command = normalizeAppendTriggerRevisionCommand(request.command);
} catch (error) {
return mapMutationError(error);
}
const operation =
command.expectedRevision === null
? 'trigger.create'
: 'trigger.update';
const authority = await authorize(
request.principal,
command.projectId,
operation,
);
try {
return await options.triggers.appendAuthorizedTriggerRevision({
command,
actor: authority.principal.subject,
fence: authority.decision.fence!,
audit: {
eventId: command.mutationId,
requestId: request.requestId,
operationId: operation,
projectId: command.projectId,
subject: authority.principal.subject,
authenticationId: authority.principal.authenticationId,
outcome: 'allowed',
reasons: authority.decision.reasons,
fence: authority.decision.fence,
occurredAtMs: authority.observedAtMs,
},
});
} catch (error) {
return mapMutationError(error);
}
},
async inspectTask(
request: Parameters<ClusterAutomationManagementService['inspectTask']>[0],
) {
exactReadBase(request, [
'auditEventId',
'principal',
'projectId',
'requestId',
'taskId',
]);
try {
assertTaskDefinitionIdentifier(request.projectId, 'projectId');
assertTaskDefinitionIdentifier(request.taskId, 'taskId');
} catch {
throw new ClusterAutomationManagementRequestError();
}
const authority = await authorize(
request.principal,
request.projectId,
'task.read',
);
try {
return await options.taskDefinitions.findAuthorizedCurrentTaskDefinition(
{
projectId: request.projectId,
taskId: request.taskId,
actor: authority.principal.subject,
fence: authority.decision.fence!,
audit: readAudit(request, 'task.read', authority),
},
);
} catch (error) {
return mapReadError(error);
}
},
async listTasks(
request: Parameters<ClusterAutomationManagementService['listTasks']>[0],
) {
exactReadBase(
request,
[
'auditEventId',
'limit',
'principal',
'projectId',
'requestId',
],
['after'],
);
let after: TaskDefinitionCursor | undefined;
try {
assertTaskDefinitionIdentifier(request.projectId, 'projectId');
assertTaskDefinitionPageSize(request.limit);
after = Object.hasOwn(request, 'after')
? normalizeTaskDefinitionCursor(request.after as TaskDefinitionCursor)
: undefined;
} catch {
throw new ClusterAutomationManagementRequestError();
}
const authority = await authorize(
request.principal,
request.projectId,
'task.read',
);
try {
return await options.taskDefinitions.listAuthorizedTaskDefinitions({
projectId: request.projectId,
limit: request.limit,
...(after ? { after } : {}),
actor: authority.principal.subject,
fence: authority.decision.fence!,
audit: readAudit(request, 'task.read', authority),
});
} catch (error) {
return mapReadError(error);
}
},
async inspectTrigger(
request: Parameters<
ClusterAutomationManagementService['inspectTrigger']
>[0],
) {
exactReadBase(request, [
'auditEventId',
'principal',
'projectId',
'requestId',
'triggerId',
]);
try {
assertTriggerIdentifier(request.projectId, 'projectId');
assertTriggerIdentifier(request.triggerId, 'triggerId');
} catch {
throw new ClusterAutomationManagementRequestError();
}
const authority = await authorize(
request.principal,
request.projectId,
'trigger.read',
);
try {
return await options.triggers.findAuthorizedCurrentTrigger({
projectId: request.projectId,
triggerId: request.triggerId,
actor: authority.principal.subject,
fence: authority.decision.fence!,
audit: readAudit(request, 'trigger.read', authority),
});
} catch (error) {
return mapReadError(error);
}
},
async listTriggers(
request: Parameters<
ClusterAutomationManagementService['listTriggers']
>[0],
) {
exactReadBase(
request,
[
'auditEventId',
'limit',
'principal',
'projectId',
'requestId',
],
['after'],
);
let after: TriggerCursor | undefined;
try {
assertTriggerIdentifier(request.projectId, 'projectId');
assertTriggerPageSize(request.limit);
after = Object.hasOwn(request, 'after')
? normalizeTriggerCursor(request.after as TriggerCursor)
: undefined;
} catch {
throw new ClusterAutomationManagementRequestError();
}
const authority = await authorize(
request.principal,
request.projectId,
'trigger.read',
);
try {
return await options.triggers.listAuthorizedTriggers({
projectId: request.projectId,
limit: request.limit,
...(after ? { after } : {}),
actor: authority.principal.subject,
fence: authority.decision.fence!,
audit: readAudit(request, 'trigger.read', authority),
});
} catch (error) {
return mapReadError(error);
}
},
});
}
@@ -0,0 +1,107 @@
#!/usr/bin/env node
import {
startClusterAutomationManagementProcess,
type ClusterAutomationManagementProcessRuntime,
} from './automationManagementProcess';
const USAGE = 'Usage: ql3-automation-manage';
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
return Object.freeze({
schemaVersion: 1,
component: 'qinglong3-automation-management',
event: 'management_failed',
name:
typeof candidate?.name === 'string' && candidate.name.length <= 128
? candidate.name
: 'Error',
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
? { code: candidate.code }
: {}),
});
}
function emit(value: Readonly<Record<string, unknown>>): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
async function run(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
if (argv.length !== 0) {
process.stderr.write(
`${JSON.stringify({
code: 'QL3_AUTOMATION_MANAGEMENT_CLI_USAGE_INVALID',
message: USAGE,
})}\n`,
);
process.exitCode = 64;
return;
}
let runtime: Readonly<ClusterAutomationManagementProcessRuntime>;
try {
runtime = await startClusterAutomationManagementProcess({
environment: process.env,
onError() {
emit({
schemaVersion: 1,
component: 'qinglong3-automation-management',
event: 'management_unavailable',
});
},
});
} catch (error) {
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
process.exitCode = 1;
return;
}
if (runtime.status === 'disabled') {
emit({
schemaVersion: 1,
component: 'qinglong3-automation-management',
event: 'management_disabled',
});
return;
}
emit({
schemaVersion: 1,
component: 'qinglong3-automation-management',
event: 'management_started',
address: runtime.address,
identityGeneration: runtime.identity.generation,
databaseContractVersion: runtime.database.contractVersion,
databaseMigrationCount: runtime.database.migrationIds.length,
});
let stopping: Promise<void> | undefined;
const stop = (): Promise<void> => {
stopping ??= runtime.close().then(() => {
emit({
schemaVersion: 1,
component: 'qinglong3-automation-management',
event: 'management_stopped',
});
});
return stopping;
};
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
void stop().then(
() => {
process.exitCode = 0;
},
(error) => {
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
process.exitCode = 1;
},
);
});
}
}
void run(process.argv.slice(2));
@@ -0,0 +1,318 @@
import {
ClusterPluginPackageManagementClientRequestError,
executeClusterAuthenticatedManagementClient,
type ClusterAuthenticatedManagementClientResult,
type ClusterPluginPackageManagementClientConnectionOptions,
type ClusterPluginPackageManagementClientPaths,
} from '../management-support/pluginPackageManagementClient';
import {
normalizeClusterAutomationManagementCommand,
type ClusterAutomationManagementCommand,
type ClusterAutomationManagementTransportResult,
} from './automationManagementTransport';
const MANAGEMENT_PATH = '/api/v3/automations/management';
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export type ClusterAutomationManagementClientPaths =
ClusterPluginPackageManagementClientPaths;
export type ClusterAutomationManagementClientConnectionOptions =
ClusterPluginPackageManagementClientConnectionOptions;
export type ClusterAutomationManagementClientResult =
ClusterAuthenticatedManagementClientResult<ClusterAutomationManagementTransportResult>;
function invalid(): never {
throw new ClusterPluginPackageManagementClientRequestError();
}
function exactRecord(
value: unknown,
keys: readonly string[],
): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
const record = value as Record<string, unknown>;
const actual = Object.keys(record).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
invalid();
}
return record;
}
function identifier(value: unknown): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 128 ||
CONTROL_PATTERN.test(value)
) {
invalid();
}
return value;
}
function positiveRevision(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 1) invalid();
return value as number;
}
function digest(value: unknown): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) invalid();
return value;
}
function taskResultSummary(
value: unknown,
projectId: string,
taskId?: string,
): Record<string, unknown> {
const task = exactRecord(value, [
'projectId',
'taskId',
'revision',
'kind',
'enabled',
'contentDigest',
'updatedAtMs',
]);
if (
identifier(task.projectId) !== projectId ||
(taskId !== undefined && identifier(task.taskId) !== taskId) ||
identifier(task.kind).length > 64 ||
typeof task.enabled !== 'boolean' ||
!Number.isSafeInteger(task.updatedAtMs) ||
(task.updatedAtMs as number) < 0
) {
invalid();
}
identifier(task.taskId);
positiveRevision(task.revision);
digest(task.contentDigest);
return task;
}
function triggerResultSummary(
value: unknown,
projectId: string,
triggerId?: string,
): Record<string, unknown> {
const trigger = exactRecord(value, [
'projectId',
'triggerId',
'revision',
'taskId',
'taskRevision',
'taskContentDigest',
'enabled',
'contentDigest',
'updatedAtMs',
]);
if (
identifier(trigger.projectId) !== projectId ||
(triggerId !== undefined && identifier(trigger.triggerId) !== triggerId) ||
typeof trigger.enabled !== 'boolean' ||
!Number.isSafeInteger(trigger.updatedAtMs) ||
(trigger.updatedAtMs as number) < 0
) {
invalid();
}
identifier(trigger.triggerId);
identifier(trigger.taskId);
positiveRevision(trigger.revision);
positiveRevision(trigger.taskRevision);
digest(trigger.taskContentDigest);
digest(trigger.contentDigest);
return trigger;
}
function validateListPage(
envelope: Record<string, unknown>,
itemsKey: 'tasks' | 'triggers',
limit: number,
afterId: string | undefined,
idKey: 'taskId' | 'triggerId',
validateItem: (value: unknown) => Record<string, unknown>,
): void {
if (
!Array.isArray(envelope[itemsKey]) ||
(envelope[itemsKey] as unknown[]).length > limit ||
typeof envelope.truncated !== 'boolean'
) {
invalid();
}
let previous = afterId;
for (const itemValue of envelope[itemsKey] as unknown[]) {
const item = validateItem(itemValue);
const current = identifier(item[idKey]);
if (previous !== undefined && current <= previous) invalid();
previous = current;
}
if (envelope.truncated) {
const next = exactRecord(envelope.next, [idKey]);
const nextId = identifier(next[idKey]);
if (previous === undefined || nextId !== previous) invalid();
} else if (envelope.next !== null) {
invalid();
}
}
export function validateClusterAutomationManagementClientResult(
value: unknown,
command: Readonly<ClusterAutomationManagementCommand>,
): Readonly<ClusterAutomationManagementTransportResult> {
const operation = command.operation;
if (operation === 'task.inspect') {
const envelope = exactRecord(value, [
'schemaVersion',
'operation',
'status',
'task',
]);
if (
envelope.schemaVersion !== 1 ||
envelope.operation !== operation ||
!['found', 'absent'].includes(String(envelope.status)) ||
(envelope.status === 'absent') !== (envelope.task === null)
) {
invalid();
}
if (envelope.task !== null) {
taskResultSummary(
envelope.task,
command.request.projectId,
command.request.taskId,
);
}
return Object.freeze(
envelope as unknown as ClusterAutomationManagementTransportResult,
);
}
if (operation === 'trigger.inspect') {
const envelope = exactRecord(value, [
'schemaVersion',
'operation',
'status',
'trigger',
]);
if (
envelope.schemaVersion !== 1 ||
envelope.operation !== operation ||
!['found', 'absent'].includes(String(envelope.status)) ||
(envelope.status === 'absent') !== (envelope.trigger === null)
) {
invalid();
}
if (envelope.trigger !== null) {
triggerResultSummary(
envelope.trigger,
command.request.projectId,
command.request.triggerId,
);
}
return Object.freeze(
envelope as unknown as ClusterAutomationManagementTransportResult,
);
}
if (operation === 'task.list') {
const envelope = exactRecord(value, [
'schemaVersion',
'operation',
'tasks',
'truncated',
'next',
]);
if (envelope.schemaVersion !== 1 || envelope.operation !== operation) {
invalid();
}
validateListPage(
envelope,
'tasks',
command.request.limit,
command.request.after?.taskId,
'taskId',
(item) => taskResultSummary(item, command.request.projectId),
);
return Object.freeze(
envelope as unknown as ClusterAutomationManagementTransportResult,
);
}
if (operation === 'trigger.list') {
const envelope = exactRecord(value, [
'schemaVersion',
'operation',
'triggers',
'truncated',
'next',
]);
if (envelope.schemaVersion !== 1 || envelope.operation !== operation) {
invalid();
}
validateListPage(
envelope,
'triggers',
command.request.limit,
command.request.after?.triggerId,
'triggerId',
(item) => triggerResultSummary(item, command.request.projectId),
);
return Object.freeze(
envelope as unknown as ClusterAutomationManagementTransportResult,
);
}
const envelope = exactRecord(
value,
operation === 'task.publish'
? ['schemaVersion', 'operation', 'status', 'task']
: ['schemaVersion', 'operation', 'status', 'trigger'],
);
if (
envelope.schemaVersion !== 1 ||
envelope.operation !== operation ||
!['created', 'updated', 'existing'].includes(String(envelope.status))
) {
invalid();
}
if (command.operation === 'task.publish') {
const requested = command.request.command;
taskResultSummary(envelope.task, requested.projectId, requested.taskId);
} else {
const requested = command.request.command;
const trigger = triggerResultSummary(
envelope.trigger,
requested.projectId,
requested.triggerId,
);
if (
identifier(trigger.taskId) !== requested.taskId ||
positiveRevision(trigger.taskRevision) !== requested.taskRevision ||
digest(trigger.taskContentDigest) !== requested.taskContentDigest
) {
invalid();
}
}
return Object.freeze(
envelope as unknown as ClusterAutomationManagementTransportResult,
);
}
const PROTOCOL = Object.freeze({
managementPath: MANAGEMENT_PATH,
clientCertificate: 'required' as const,
normalizeCommand: normalizeClusterAutomationManagementCommand,
validateResult: validateClusterAutomationManagementClientResult,
});
export async function executeClusterAutomationManagementClient(
paths: ClusterAutomationManagementClientPaths,
connectionOptions?: ClusterAutomationManagementClientConnectionOptions,
): Promise<Readonly<ClusterAutomationManagementClientResult>> {
return executeClusterAuthenticatedManagementClient(
paths,
PROTOCOL,
connectionOptions,
);
}
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import { executeClusterAutomationManagementClient } from './automationManagementClient';
import { ClusterPluginPackageManagementClientRemoteError } from '../management-support/pluginPackageManagementClient';
const USAGE =
'Usage: ql3-automation-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
function parseArguments(
argv: readonly string[],
): Readonly<{
configFile: string;
commandFile: string;
assertionFile: string;
}> | null {
if (argv.length !== 3) return null;
const values = new Map<string, string>();
for (const argument of argv) {
const match = /^--(config|command|assertion)=(\/.+)$/.exec(argument);
if (!match || values.has(match[1]!)) return null;
values.set(match[1]!, match[2]!);
}
if (
!values.has('config') ||
!values.has('command') ||
!values.has('assertion')
) {
return null;
}
return Object.freeze({
configFile: values.get('config')!,
commandFile: values.get('command')!,
assertionFile: values.get('assertion')!,
});
}
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
const candidate = error as { readonly code?: unknown };
return Object.freeze({
schemaVersion: 1,
component: 'qinglong3-automation-management-client',
event: 'command_failed',
code:
typeof candidate?.code === 'string' && candidate.code.length <= 128
? candidate.code
: 'QL3_AUTOMATION_MANAGEMENT_CLIENT_FAILED',
...(error instanceof ClusterPluginPackageManagementClientRemoteError
? {
statusCode: error.statusCode,
responseCode: error.responseCode,
requestId: error.requestId,
...(error.retryAfterSeconds === null
? {}
: { retryAfterSeconds: error.retryAfterSeconds }),
}
: {}),
});
}
async function run(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
const paths = parseArguments(argv);
if (!paths) {
process.stderr.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-automation-management-client',
event: 'usage_invalid',
code: 'QL3_AUTOMATION_MANAGEMENT_CLIENT_USAGE_INVALID',
})}\n`,
);
process.exitCode = 64;
return;
}
try {
const result = await executeClusterAutomationManagementClient(paths);
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-automation-management-client',
event: 'command_completed',
requestId: result.requestId,
result: result.result,
})}\n`,
);
} catch (error) {
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
process.exitCode = 1;
}
}
void run(process.argv.slice(2));
@@ -0,0 +1,24 @@
import {
CLUSTER_AUTOMATION_MANAGEMENT_PATH,
startClusterPluginPackageManagementHttp,
type ClusterPluginPackageManagementHttpApplication,
type StartClusterPluginPackageManagementHttpOptions,
} from '../management-support/pluginPackageManagementHttp';
export type ClusterAutomationManagementHttpApplication =
ClusterPluginPackageManagementHttpApplication;
export type StartClusterAutomationManagementHttpOptions = Omit<
StartClusterPluginPackageManagementHttpOptions,
'managementPath'
>;
/** Starts the shared bounded OIDC/mTLS HTTPS adapter on the automation-only path. */
export function startClusterAutomationManagementHttp(
options: StartClusterAutomationManagementHttpOptions,
): Promise<Readonly<ClusterAutomationManagementHttpApplication>> {
return startClusterPluginPackageManagementHttp({
...options,
managementPath: CLUSTER_AUTOMATION_MANAGEMENT_PATH,
});
}
@@ -0,0 +1,590 @@
import type {
OpenPostgresDatabase,
PostgresDatabaseResource,
} from '@qinglong/runtime-core';
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
import {
PostgresAutomationManagementIdentityKeysetLedgerRepository,
PostgresProjectPolicyRepository,
PostgresTaskDefinitionAdministrationRepository,
PostgresTriggerAdministrationRepository,
assertPostgresAutomationManagerSchemaReady,
createPostgresDatabaseOpener,
isPostgresTlsDnsServername,
loadPostgresCertificateAuthorityFile,
loadPostgresConnectionEnvironment,
type PostgresConnectionOptions,
type PostgresPoolOptions,
type PostgresSchemaReadinessReport,
} from '@qinglong/cluster-postgres/automation-manager';
import {
absoluteManagementEnvironmentFile,
booleanManagementEnvironmentValue,
boundedManagementEnvironmentValue,
integerManagementEnvironmentValue,
readManagementTlsFile,
} from '../management-support/managementProcessSupport';
import {
createClusterAutomationIdentityKeysetFile,
type ClusterPluginPackageIdentityKeysetFile,
type ClusterPluginPackageIdentityKeysetSnapshot,
} from '../management-support/pluginPackageIdentityKeyset';
import { createClusterAutomationManagementService } from './automationManagement';
import {
startClusterAutomationManagementHttp,
type ClusterAutomationManagementHttpApplication,
type StartClusterAutomationManagementHttpOptions,
} from './automationManagementHttp';
import { createClusterAutomationManagementTransport } from './automationManagementTransport';
import { validateClusterManagementClientTrust } from '../worker-credential/management-server/workerCredentialManagementMutualTls';
const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.:-]{0,254}$/;
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
export type ClusterAutomationManagementProcessEnvironment = Readonly<
Record<string, string | undefined>
>;
export type ClusterAutomationManagementProcessConfig =
| Readonly<{ enabled: false }>
| Readonly<{
enabled: true;
profile: 'cluster-admin';
host: string;
port: number;
certificateFile: string;
privateKeyFile: string;
clientCertificateAuthorityFile: string;
clientCertificateRevocationListFile: string;
identityKeysetFile: string;
http: Readonly<{
maxBodyBytes: number;
maxConnections: number;
maxConcurrentRequests: number;
requestTimeoutMs: number;
drainTimeoutMs: number;
rateWindowMs: number;
peerRequestLimit: number;
globalRequestLimit: number;
maxRateLimitPeers: number;
}>;
database: Readonly<{
connection: PostgresConnectionOptions;
pool: PostgresPoolOptions;
}>;
}>;
export type ClusterAutomationManagementProcessRuntime =
| Readonly<{
status: 'disabled';
close(): Promise<void>;
}>
| Readonly<{
status: 'active';
address: Readonly<{ host: string; port: number }>;
database: PostgresSchemaReadinessReport;
identity: ClusterPluginPackageIdentityKeysetSnapshot;
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
close(): Promise<void>;
}>;
export interface StartClusterAutomationManagementProcessOptions {
readonly environment: ClusterAutomationManagementProcessEnvironment;
readonly openDatabase?: OpenPostgresDatabase;
readonly identities?: ClusterPluginPackageIdentityKeysetFile;
readonly assertReady?: (
pool: PostgresDatabaseResource['pool'],
) => Promise<PostgresSchemaReadinessReport>;
readonly startHttp?: (
options: StartClusterAutomationManagementHttpOptions,
) => Promise<Readonly<ClusterAutomationManagementHttpApplication>>;
readonly now?: () => number;
readonly onError?: (error: unknown) => void;
}
export class ClusterAutomationManagementProcessConfigError extends TypeError {
readonly code = 'QL3_AUTOMATION_MANAGEMENT_PROCESS_CONFIG_INVALID';
constructor(message: string) {
super(`Automation management process configuration is invalid: ${message}`);
this.name = 'ClusterAutomationManagementProcessConfigError';
}
}
function configFailure(
message: string,
): ClusterAutomationManagementProcessConfigError {
return new ClusterAutomationManagementProcessConfigError(message);
}
function boundedValue(
environment: ClusterAutomationManagementProcessEnvironment,
name: string,
maximumLength: number,
required = false,
): string | undefined {
return boundedManagementEnvironmentValue(
environment,
name,
maximumLength,
configFailure,
required,
);
}
function booleanValue(
environment: ClusterAutomationManagementProcessEnvironment,
name: string,
): boolean {
return booleanManagementEnvironmentValue(environment, name, configFailure);
}
function integerValue(
environment: ClusterAutomationManagementProcessEnvironment,
name: string,
fallback: number,
minimum: number,
maximum: number,
): number {
return integerManagementEnvironmentValue(
environment,
name,
fallback,
minimum,
maximum,
configFailure,
);
}
function absoluteFile(
environment: ClusterAutomationManagementProcessEnvironment,
name: string,
): string {
return absoluteManagementEnvironmentFile(environment, name, configFailure);
}
function loadConnection(
environment: ClusterAutomationManagementProcessEnvironment,
): Readonly<{
connection: PostgresConnectionOptions;
pool: PostgresPoolOptions;
}> {
let connection: PostgresConnectionOptions;
try {
connection = loadPostgresConnectionEnvironment(environment, {
connectionString: 'QL3_POSTGRES_AUTOMATION_MANAGER_URL',
host: 'QL3_POSTGRES_AUTOMATION_MANAGER_HOST',
port: 'QL3_POSTGRES_AUTOMATION_MANAGER_PORT',
database: 'QL3_POSTGRES_AUTOMATION_MANAGER_DATABASE',
user: 'QL3_POSTGRES_AUTOMATION_MANAGER_USER',
password: 'QL3_POSTGRES_AUTOMATION_MANAGER_PASSWORD',
});
} catch (error) {
throw configFailure(
error instanceof Error
? error.message
: 'PostgreSQL automation manager connection is invalid',
);
}
const mode =
environment.QL3_POSTGRES_AUTOMATION_MANAGER_TLS_MODE ?? 'verify-full';
if (mode !== 'verify-full' && mode !== 'disable') {
throw configFailure(
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_MODE must be verify-full or disable',
);
}
if (
mode === 'disable' &&
!booleanValue(environment, 'QL3_POSTGRES_AUTOMATION_MANAGER_ALLOW_INSECURE')
) {
throw configFailure(
'disabling automation manager PostgreSQL TLS requires QL3_POSTGRES_AUTOMATION_MANAGER_ALLOW_INSECURE=true',
);
}
const servername = boundedValue(
environment,
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_SERVERNAME',
253,
);
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
throw configFailure(
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_SERVERNAME must be an explicit DNS name',
);
}
const caFile = boundedValue(
environment,
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_CA_FILE',
4_096,
);
if (mode === 'disable' && caFile !== undefined) {
throw configFailure(
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_CA_FILE cannot be used when TLS is disabled',
);
}
let ca: string | undefined;
if (caFile !== undefined) {
try {
ca = loadPostgresCertificateAuthorityFile(caFile);
} catch {
throw configFailure(
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_CA_FILE is invalid',
);
}
}
const applicationName =
boundedValue(
environment,
'QL3_POSTGRES_AUTOMATION_MANAGER_APPLICATION_NAME',
63,
) ?? 'qinglong3-automation-manager';
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
throw configFailure(
'QL3_POSTGRES_AUTOMATION_MANAGER_APPLICATION_NAME is invalid',
);
}
return Object.freeze({
connection: Object.freeze({
...connection,
tls:
mode === 'disable'
? Object.freeze({ mode: 'disable' as const })
: Object.freeze({
mode: 'verify-full' as const,
servername: servername!,
...(ca === undefined ? {} : { ca }),
}),
}),
pool: Object.freeze({
applicationName,
maxConnections: integerValue(
environment,
'QL3_POSTGRES_AUTOMATION_MANAGER_POOL_MAX',
2,
1,
4,
),
idleTimeoutMs: integerValue(
environment,
'QL3_POSTGRES_AUTOMATION_MANAGER_IDLE_TIMEOUT_MS',
10_000,
1_000,
60_000,
),
connectionTimeoutMs: integerValue(
environment,
'QL3_POSTGRES_AUTOMATION_MANAGER_CONNECTION_TIMEOUT_MS',
5_000,
100,
60_000,
),
}),
});
}
export function loadClusterAutomationManagementProcessConfig(
environment: ClusterAutomationManagementProcessEnvironment,
): Readonly<ClusterAutomationManagementProcessConfig> {
if (!environment || typeof environment !== 'object') {
throw configFailure('environment is invalid');
}
if (!booleanValue(environment, 'QL3_AUTOMATION_MANAGEMENT_ENABLED')) {
return Object.freeze({ enabled: false as const });
}
if (environment.QL3_PROFILE !== 'cluster-admin') {
throw configFailure(
'QL3_PROFILE must be cluster-admin when automation management is enabled',
);
}
const host =
boundedValue(environment, 'QL3_AUTOMATION_MANAGEMENT_HOST', 255) ??
'0.0.0.0';
if (!SAFE_HOST.test(host)) {
throw configFailure('QL3_AUTOMATION_MANAGEMENT_HOST is invalid');
}
const http = Object.freeze({
maxBodyBytes: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_MAX_BODY_BYTES',
64 * 1024,
1_024,
256 * 1024,
),
maxConnections: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_MAX_CONNECTIONS',
32,
1,
512,
),
maxConcurrentRequests: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
16,
1,
256,
),
requestTimeoutMs: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_REQUEST_TIMEOUT_MS',
10_000,
1_000,
60_000,
),
drainTimeoutMs: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_DRAIN_TIMEOUT_MS',
5_000,
100,
60_000,
),
rateWindowMs: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_RATE_WINDOW_MS',
60_000,
1_000,
5 * 60_000,
),
peerRequestLimit: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_PEER_REQUEST_LIMIT',
60,
1,
10_000,
),
globalRequestLimit: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
600,
1,
100_000,
),
maxRateLimitPeers: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_MAX_RATE_LIMIT_PEERS',
1_024,
1,
16_384,
),
});
if (http.globalRequestLimit < http.peerRequestLimit) {
throw configFailure(
'global request limit cannot be below the peer request limit',
);
}
return Object.freeze({
enabled: true as const,
profile: 'cluster-admin' as const,
host,
port: integerValue(
environment,
'QL3_AUTOMATION_MANAGEMENT_PORT',
8_445,
1,
65_535,
),
certificateFile: absoluteFile(
environment,
'QL3_AUTOMATION_MANAGEMENT_TLS_CERT_FILE',
),
privateKeyFile: absoluteFile(
environment,
'QL3_AUTOMATION_MANAGEMENT_TLS_KEY_FILE',
),
clientCertificateAuthorityFile: absoluteFile(
environment,
'QL3_AUTOMATION_MANAGEMENT_CLIENT_CA_FILE',
),
clientCertificateRevocationListFile: absoluteFile(
environment,
'QL3_AUTOMATION_MANAGEMENT_CLIENT_CRL_FILE',
),
identityKeysetFile: absoluteFile(
environment,
'QL3_AUTOMATION_MANAGEMENT_IDENTITY_KEYSET_FILE',
),
http,
database: loadConnection(environment),
});
}
function readTlsFile(filePath: string, privateMaterial: boolean): Buffer {
return readManagementTlsFile(filePath, privateMaterial, configFailure);
}
export async function startClusterAutomationManagementProcess(
options: StartClusterAutomationManagementProcessOptions,
): Promise<Readonly<ClusterAutomationManagementProcessRuntime>> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) =>
![
'environment',
'openDatabase',
'identities',
'assertReady',
'startHttp',
'now',
'onError',
].includes(key),
) ||
!options.environment ||
typeof options.environment !== 'object' ||
(options.openDatabase !== undefined &&
typeof options.openDatabase !== 'function') ||
(options.identities !== undefined &&
(typeof options.identities.reload !== 'function' ||
typeof options.identities.bind !== 'function')) ||
(options.assertReady !== undefined &&
typeof options.assertReady !== 'function') ||
(options.startHttp !== undefined &&
typeof options.startHttp !== 'function') ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.onError !== undefined && typeof options.onError !== 'function')
) {
throw configFailure('options are invalid');
}
const config = loadClusterAutomationManagementProcessConfig(
options.environment,
);
if (!config.enabled) {
return Object.freeze({
status: 'disabled' as const,
close: () => Promise.resolve(),
});
}
const now = options.now ?? Date.now;
let http: Readonly<ClusterAutomationManagementHttpApplication> | undefined;
let database: PostgresDatabaseResource | undefined;
let unavailableError: unknown;
let closePromise: Promise<void> | undefined;
const report = (error: unknown): void => {
try {
options.onError?.(error);
} catch {
// Diagnostics do not own availability or cleanup.
}
};
const openDatabase =
options.openDatabase ??
createPostgresDatabaseOpener({
role: 'automation-manager',
connection: config.database.connection,
pool: config.database.pool,
onPoolError(error) {
const firstAvailabilityError = unavailableError === undefined;
unavailableError ??= error;
http?.withdraw(error);
if (firstAvailabilityError) report(error);
},
});
try {
database = await openDatabase();
const evidence = await (
options.assertReady ?? assertPostgresAutomationManagerSchemaReady
)(database.pool);
if (unavailableError !== undefined) throw unavailableError;
const identities =
options.identities ??
createClusterAutomationIdentityKeysetFile({
filePath: config.identityKeysetFile,
now,
ledger: new PostgresAutomationManagementIdentityKeysetLedgerRepository(
database.pool,
'automation-management',
),
});
const identity = await identities.reload();
const policy = new ProjectPolicyEngine(
new PostgresProjectPolicyRepository(database.pool),
);
const service = createClusterAutomationManagementService({
policy,
taskDefinitions: new PostgresTaskDefinitionAdministrationRepository(
database.pool,
),
triggers: new PostgresTriggerAdministrationRepository(database.pool),
now,
});
const transport = createClusterAutomationManagementTransport({
service,
now,
});
const privateKey = readTlsFile(config.privateKeyFile, true);
try {
const certificate = readTlsFile(config.certificateFile, false);
const clientCertificateAuthority = readTlsFile(
config.clientCertificateAuthorityFile,
false,
);
const clientCertificateRevocationList = readTlsFile(
config.clientCertificateRevocationListFile,
false,
);
validateClusterManagementClientTrust(
clientCertificateAuthority,
clientCertificateRevocationList,
now(),
configFailure,
);
http = await (options.startHttp ?? startClusterAutomationManagementHttp)({
host: config.host,
port: config.port,
tls: {
privateKey,
certificate,
clientCertificateAuthority,
clientCertificateRevocationList,
},
transport,
identities,
limits: config.http,
now,
onError: report,
});
} finally {
privateKey.fill(0);
}
if (unavailableError !== undefined) http.withdraw(unavailableError);
return Object.freeze({
status: 'active' as const,
address: http.address,
database: evidence,
identity,
availabilityStatus: () => http?.availabilityStatus() ?? 'stopped',
close(): Promise<void> {
if (closePromise) return closePromise;
closePromise = (async () => {
let primaryError: unknown;
try {
await http?.close();
} catch (error) {
primaryError = error;
}
try {
await database?.close();
} catch (error) {
primaryError ??= error;
}
if (primaryError) throw primaryError;
})();
return closePromise;
},
});
} catch (error) {
try {
await http?.close();
} catch {
// Preserve startup failure.
}
try {
await database?.close();
} catch {
// Preserve startup failure.
}
throw error;
}
}
@@ -0,0 +1,423 @@
import {
assertTaskDefinitionIdentifier,
assertTaskDefinitionPageSize,
normalizeTaskDefinitionCursor,
type AppendTaskDefinitionRevisionCommand,
type TaskDefinitionRecord,
} from '@qinglong/runtime-core/task-definition';
import {
assertTriggerIdentifier,
assertTriggerPageSize,
normalizeTriggerCursor,
type AppendTriggerRevisionCommand,
type TriggerRecord,
} from '@qinglong/runtime-core/trigger';
import {
normalizeSecurityPrincipal,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import type { ClusterAutomationManagementService } from './automationManagement';
const STRONG_CLUSTER_ASSURANCES = new Set(['multi_factor', 'hardware']);
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const AUDIT_EVENT_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
export interface ClusterAutomationManagementAuthentication {
authenticate(): Promise<Readonly<SecurityPrincipal> | null>;
}
export type ClusterAutomationManagementCommand =
| Readonly<{
schemaVersion: 1;
operation: 'task.publish';
request: Readonly<{
requestId: string;
command: AppendTaskDefinitionRevisionCommand;
}>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'trigger.publish';
request: Readonly<{
requestId: string;
command: AppendTriggerRevisionCommand;
}>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'task.inspect';
request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
taskId: string;
}>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'task.list';
request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
limit: number;
after?: Readonly<{ taskId: string }>;
}>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'trigger.inspect';
request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
triggerId: string;
}>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'trigger.list';
request: Readonly<{
requestId: string;
auditEventId: string;
projectId: string;
limit: number;
after?: Readonly<{ triggerId: string }>;
}>;
}>;
export type ClusterAutomationManagementTransportResult =
| Readonly<{
schemaVersion: 1;
operation: 'task.publish';
status: 'created' | 'updated' | 'existing';
task: ReturnType<typeof taskSummary>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'trigger.publish';
status: 'created' | 'updated' | 'existing';
trigger: ReturnType<typeof triggerSummary>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'task.inspect';
status: 'found' | 'absent';
task: ReturnType<typeof taskSummary> | null;
}>
| Readonly<{
schemaVersion: 1;
operation: 'task.list';
tasks: readonly ReturnType<typeof taskSummary>[];
truncated: boolean;
next: Readonly<{ taskId: string }> | null;
}>
| Readonly<{
schemaVersion: 1;
operation: 'trigger.inspect';
status: 'found' | 'absent';
trigger: ReturnType<typeof triggerSummary> | null;
}>
| Readonly<{
schemaVersion: 1;
operation: 'trigger.list';
triggers: readonly ReturnType<typeof triggerSummary>[];
truncated: boolean;
next: Readonly<{ triggerId: string }> | null;
}>;
export interface ClusterAutomationManagementTransport {
execute(
command: unknown,
authentication: ClusterAutomationManagementAuthentication,
): Promise<Readonly<ClusterAutomationManagementTransportResult>>;
}
export class ClusterAutomationManagementTransportConfigurationError extends TypeError {
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_CONFIGURATION_INVALID';
constructor() {
super('Cluster automation transport configuration is invalid');
this.name = 'ClusterAutomationManagementTransportConfigurationError';
}
}
export class ClusterAutomationManagementTransportRequestError extends TypeError {
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_REQUEST_INVALID';
constructor() {
super('Cluster automation transport request is invalid');
this.name = 'ClusterAutomationManagementTransportRequestError';
}
}
export class ClusterAutomationManagementTransportAuthenticationError extends Error {
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_AUTHENTICATION_REQUIRED';
constructor() {
super('Cluster automation transport requires a strong User principal');
this.name = 'ClusterAutomationManagementTransportAuthenticationError';
}
}
export class ClusterAutomationManagementTransportUnavailableError extends Error {
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_UNAVAILABLE';
constructor() {
super('Cluster automation transport is unavailable');
this.name = 'ClusterAutomationManagementTransportUnavailableError';
}
}
function normalizeCommand(value: unknown): ClusterAutomationManagementCommand {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).length !== 3 ||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
![
'task.publish',
'trigger.publish',
'task.inspect',
'task.list',
'trigger.inspect',
'trigger.list',
].includes(String((value as { operation?: unknown }).operation)) ||
!Object.hasOwn(value, 'request') ||
!(value as { request?: unknown }).request ||
typeof (value as { request: unknown }).request !== 'object' ||
Array.isArray((value as { request: unknown }).request) ||
!Object.hasOwn((value as { request: object }).request, 'requestId')
) {
throw new ClusterAutomationManagementTransportRequestError();
}
const operation = (value as { operation: string }).operation;
const request = (value as { request: Record<string, unknown> }).request;
const keys = Object.keys(request);
const required = operation.endsWith('.publish')
? ['command', 'requestId']
: operation.endsWith('.inspect')
? [
'auditEventId',
operation.startsWith('task.') ? 'taskId' : 'triggerId',
'projectId',
'requestId',
]
: ['auditEventId', 'limit', 'projectId', 'requestId'];
const optional = operation.endsWith('.list') ? ['after'] : [];
if (
!required.every((key) => keys.includes(key)) ||
keys.some((key) => !required.includes(key) && !optional.includes(key))
) {
throw new ClusterAutomationManagementTransportRequestError();
}
if (
typeof request.requestId !== 'string' ||
!REQUEST_ID_PATTERN.test(request.requestId)
) {
throw new ClusterAutomationManagementTransportRequestError();
}
if (!operation.endsWith('.publish')) {
if (
typeof request.auditEventId !== 'string' ||
!AUDIT_EVENT_ID_PATTERN.test(request.auditEventId) ||
typeof request.projectId !== 'string'
) {
throw new ClusterAutomationManagementTransportRequestError();
}
try {
if (operation.startsWith('task.')) {
assertTaskDefinitionIdentifier(request.projectId, 'projectId');
if (operation === 'task.inspect') {
assertTaskDefinitionIdentifier(request.taskId as string, 'taskId');
} else {
assertTaskDefinitionPageSize(request.limit as number);
if (Object.hasOwn(request, 'after')) {
normalizeTaskDefinitionCursor(
request.after as Readonly<{ taskId: string }>,
);
}
}
} else {
assertTriggerIdentifier(request.projectId, 'projectId');
if (operation === 'trigger.inspect') {
assertTriggerIdentifier(request.triggerId as string, 'triggerId');
} else {
assertTriggerPageSize(request.limit as number);
if (Object.hasOwn(request, 'after')) {
normalizeTriggerCursor(
request.after as Readonly<{ triggerId: string }>,
);
}
}
}
} catch {
throw new ClusterAutomationManagementTransportRequestError();
}
}
return value as ClusterAutomationManagementCommand;
}
export function normalizeClusterAutomationManagementCommand(
value: unknown,
): Readonly<ClusterAutomationManagementCommand> {
return normalizeCommand(value);
}
function taskSummary(definition: Readonly<TaskDefinitionRecord>) {
return Object.freeze({
projectId: definition.projectId,
taskId: definition.taskId,
revision: definition.revision,
kind: definition.kind,
enabled: definition.enabled,
contentDigest: definition.contentDigest,
updatedAtMs: definition.updatedAtMs,
});
}
function triggerSummary(trigger: Readonly<TriggerRecord>) {
return Object.freeze({
projectId: trigger.projectId,
triggerId: trigger.triggerId,
revision: trigger.revision,
taskId: trigger.taskId,
taskRevision: trigger.taskRevision,
taskContentDigest: trigger.taskContentDigest,
enabled: trigger.enabled,
contentDigest: trigger.contentDigest,
updatedAtMs: trigger.updatedAtMs,
});
}
export function createClusterAutomationManagementTransport(options: Readonly<{
service: ClusterAutomationManagementService;
now?: () => number;
}>): Readonly<ClusterAutomationManagementTransport> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some((key) => key !== 'service' && key !== 'now') ||
!options.service ||
typeof options.service.publishTask !== 'function' ||
typeof options.service.publishTrigger !== 'function' ||
typeof options.service.inspectTask !== 'function' ||
typeof options.service.listTasks !== 'function' ||
typeof options.service.inspectTrigger !== 'function' ||
typeof options.service.listTriggers !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new ClusterAutomationManagementTransportConfigurationError();
}
const now = options.now ?? Date.now;
return Object.freeze({
async execute(
commandValue: unknown,
authentication: ClusterAutomationManagementAuthentication,
) {
const command = normalizeCommand(commandValue);
if (
!authentication ||
typeof authentication !== 'object' ||
Array.isArray(authentication) ||
Object.keys(authentication).length !== 1 ||
typeof authentication.authenticate !== 'function'
) {
throw new ClusterAutomationManagementTransportConfigurationError();
}
let candidate: Readonly<SecurityPrincipal> | null;
try {
candidate = await authentication.authenticate();
} catch {
throw new ClusterAutomationManagementTransportUnavailableError();
}
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(candidate as SecurityPrincipal, now());
} catch {
throw new ClusterAutomationManagementTransportAuthenticationError();
}
if (
principal.subject.type !== 'user' ||
!STRONG_CLUSTER_ASSURANCES.has(principal.assurance)
) {
throw new ClusterAutomationManagementTransportAuthenticationError();
}
if (command.operation === 'task.publish') {
const result = await options.service.publishTask({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status: result.status,
task: taskSummary(result.definition),
});
}
if (command.operation === 'trigger.publish') {
const result = await options.service.publishTrigger({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status: result.status,
trigger: triggerSummary(result.trigger),
});
}
if (command.operation === 'task.inspect') {
const task = await options.service.inspectTask({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status: task ? ('found' as const) : ('absent' as const),
task: task ? taskSummary(task) : null,
});
}
if (command.operation === 'task.list') {
const page = await options.service.listTasks({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
tasks: Object.freeze(page.definitions.map(taskSummary)),
truncated: page.truncated,
next: page.next ?? null,
});
}
if (command.operation === 'trigger.inspect') {
const trigger = await options.service.inspectTrigger({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status: trigger ? ('found' as const) : ('absent' as const),
trigger: trigger ? triggerSummary(trigger) : null,
});
}
const page = await options.service.listTriggers({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
triggers: Object.freeze(page.triggers.map(triggerSummary)),
truncated: page.truncated,
next: page.next ?? null,
});
},
});
}