mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+423
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user