mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,80 @@
|
||||
# `@qinglong/cluster-control`
|
||||
|
||||
This private workspace package is the composition root for the QingLong 3.0
|
||||
`cluster-control` profile artifact.
|
||||
|
||||
It owns the readiness-first database lifecycle and exposes both the proven
|
||||
runtime pool and a public-contract PostgreSQL RunRepository to a caller-supplied
|
||||
stack factory. It depends only on public `@qinglong/runtime-core` and
|
||||
`@qinglong/cluster-postgres/runtime` exports, so the resident control plane does
|
||||
not load executable migration DDL. It must never deep-import the legacy root
|
||||
`back/**` tree.
|
||||
|
||||
Public subpath exports now separate responsibilities:
|
||||
|
||||
- `@qinglong/cluster-control/application` owns the probe listener, activation,
|
||||
admission drain, runtime stack, Pool and listener shutdown order;
|
||||
- `@qinglong/cluster-control/availability` provides the one-way, timer-free
|
||||
`ready -> unavailable` fence used by the PostgreSQL Pool error path;
|
||||
- `@qinglong/cluster-control/http` provides bounded `/livez`, `/readyz` and
|
||||
fail-closed `/api/v3` admission transport;
|
||||
- `@qinglong/cluster-control/config` parses the Profile gate before reading the
|
||||
runtime database credential/API credential pepper and defaults PostgreSQL to
|
||||
verified TLS;
|
||||
- `@qinglong/cluster-control/api-credential` validates the versioned `ql3c`
|
||||
bearer format and authenticates a stable subject with a constant-time,
|
||||
peppered digest comparison;
|
||||
- `@qinglong/cluster-control/admission` resolves a route, authenticates a stable
|
||||
principal, evaluates Policy and records the security decision before the HTTP
|
||||
adapter is allowed to read the bounded request body;
|
||||
- `@qinglong/cluster-control/routes` compiles an immutable, bounded and
|
||||
non-overlapping route table whose operation, permission, Project path
|
||||
parameter and query allowlist are fixed at startup.
|
||||
- `@qinglong/cluster-control/run-routes` defines the first reviewed business
|
||||
route: a Project-scoped `run.get` point query that returns only an explicit
|
||||
low-sensitive DTO and masks cross-Project existence.
|
||||
- `@qinglong/cluster-control/production` is the only reviewed production route
|
||||
composition, fixes the current allowlist to `run.get` and `run.cancel`, and
|
||||
atomically derives the runtime Pool plus its availability fence from one
|
||||
enabled configuration so deployments cannot miswire those authorities;
|
||||
- `@qinglong/cluster-control/s3-artifact-store` is a separately lazy-loaded,
|
||||
cluster-only immutable Artifact adapter; neither the package root nor the
|
||||
production API entrypoint loads the AWS SDK;
|
||||
- `@qinglong/cluster-control/worker-ingress` owns the separately gated TLS 1.3
|
||||
mutual-TLS listener and exposes explicit secure-context reload without
|
||||
acquiring database adapter or CA-signing authority;
|
||||
- `@qinglong/cluster-control/worker-ingress-config` loads bounded server
|
||||
identity, 1–16 client CA certificates and an optional CRL after the Profile
|
||||
gate, builds only the dedicated Worker HTTP/Pool options, and validates the
|
||||
immutable S3 Artifact binding;
|
||||
- `@qinglong/cluster-control/worker-runtime-port` constructs the frozen
|
||||
in-process offer/ACK/Artifact/completion/lease capability boundary without
|
||||
exposing the runtime Pool;
|
||||
- `@qinglong/cluster-control/worker-ingress-production` combines that port with
|
||||
the independent Worker credential/Session/attestation/audit Pool.
|
||||
|
||||
The cluster assembly now supplies real PostgreSQL API Credential, Project
|
||||
Policy and write-only Security Audit repositories, plus a bounded recovery
|
||||
candidate source. After the caller's recovery reports safe convergence, the
|
||||
bootstrap independently verifies PostgreSQL has no orphaned or expired-lease
|
||||
Run/Attempt candidate; a false-safe summary cannot open admission. Admission accepts only a
|
||||
route resolver produced by the reviewed registry factory; a caller cannot
|
||||
silently replace it with an ad-hoc resolver. The tested vertical path is HTTP
|
||||
bearer authentication → fenced Project Policy → durable low-sensitive audit →
|
||||
bounded body/handler.
|
||||
|
||||
The production application registers the reviewed `run.get` and `run.cancel`
|
||||
routes through one static allowlist and, when explicitly enabled, starts the
|
||||
independent 5801 mTLS Worker listener after readiness/recovery. Runtime
|
||||
Run/Attempt/Lease mutation stays behind the injected capability port;
|
||||
`ql3_worker_ingress` receives no such database grant. Artifact S3 support is
|
||||
loaded only on that enabled path, so disabled Cluster and all local Profiles do
|
||||
not load its provider.
|
||||
|
||||
Identity/credential administration remains in the separate short-lived
|
||||
cluster-admin authority, and the resident HTTP surfaces retain bounded
|
||||
authentication overload shields. Remaining incubation gaps include Cluster
|
||||
Secret material provider/rotation, Remote Worker expiry/retry production
|
||||
lifecycle, audit retention/export/alerting and real multi-Pod
|
||||
operator/proxy/STONITH capacity evidence. The generic admission pipeline must
|
||||
not be wired to an allow-all authenticator or Policy in production.
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"name": "@qinglong/cluster-control",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 cluster-control composition root",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/application-runtime/clusterControlRuntime.js",
|
||||
"types": "dist/application-runtime/clusterControlRuntime.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/application-runtime/clusterControlRuntime.d.ts",
|
||||
"require": "./dist/application-runtime/clusterControlRuntime.js",
|
||||
"default": "./dist/application-runtime/clusterControlRuntime.js"
|
||||
},
|
||||
"./application": {
|
||||
"types": "./dist/application-runtime/application.d.ts",
|
||||
"require": "./dist/application-runtime/application.js",
|
||||
"default": "./dist/application-runtime/application.js"
|
||||
},
|
||||
"./availability": {
|
||||
"types": "./dist/database/availability.d.ts",
|
||||
"require": "./dist/database/availability.js",
|
||||
"default": "./dist/database/availability.js"
|
||||
},
|
||||
"./production": {
|
||||
"types": "./dist/application-runtime/productionApplication.d.ts",
|
||||
"require": "./dist/application-runtime/productionApplication.js",
|
||||
"default": "./dist/application-runtime/productionApplication.js"
|
||||
},
|
||||
"./ai-production": {
|
||||
"types": "./dist/application-runtime/aiProductionApplication.d.ts",
|
||||
"require": "./dist/application-runtime/aiProductionApplication.js",
|
||||
"default": "./dist/application-runtime/aiProductionApplication.js"
|
||||
},
|
||||
"./http": {
|
||||
"types": "./dist/transport/httpSurface.d.ts",
|
||||
"require": "./dist/transport/httpSurface.js",
|
||||
"default": "./dist/transport/httpSurface.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/production-process/config.d.ts",
|
||||
"require": "./dist/production-process/config.js",
|
||||
"default": "./dist/production-process/config.js"
|
||||
},
|
||||
"./process": {
|
||||
"types": "./dist/production-process/processApplication.d.ts",
|
||||
"require": "./dist/production-process/processApplication.js",
|
||||
"default": "./dist/production-process/processApplication.js"
|
||||
},
|
||||
"./admission": {
|
||||
"types": "./dist/transport/admissionPipeline.d.ts",
|
||||
"require": "./dist/transport/admissionPipeline.js",
|
||||
"default": "./dist/transport/admissionPipeline.js"
|
||||
},
|
||||
"./routes": {
|
||||
"types": "./dist/transport/routeRegistry.d.ts",
|
||||
"require": "./dist/transport/routeRegistry.js",
|
||||
"default": "./dist/transport/routeRegistry.js"
|
||||
},
|
||||
"./run-routes": {
|
||||
"types": "./dist/run/runReadRoute.d.ts",
|
||||
"require": "./dist/run/runReadRoute.js",
|
||||
"default": "./dist/run/runReadRoute.js"
|
||||
},
|
||||
"./task-routes": {
|
||||
"types": "./dist/task/taskListRoute.d.ts",
|
||||
"require": "./dist/task/taskListRoute.js",
|
||||
"default": "./dist/task/taskListRoute.js"
|
||||
},
|
||||
"./prompt-routes": {
|
||||
"types": "./dist/plugin-package/prompt/pluginPackagePromptRoutes.d.ts",
|
||||
"require": "./dist/plugin-package/prompt/pluginPackagePromptRoutes.js",
|
||||
"default": "./dist/plugin-package/prompt/pluginPackagePromptRoutes.js"
|
||||
},
|
||||
"./api-credential": {
|
||||
"types": "./dist/authentication/apiCredentialAuthenticator.d.ts",
|
||||
"require": "./dist/authentication/apiCredentialAuthenticator.js",
|
||||
"default": "./dist/authentication/apiCredentialAuthenticator.js"
|
||||
},
|
||||
"./worker-ingress": {
|
||||
"types": "./dist/worker-ingress/workerIngressApplication.d.ts",
|
||||
"require": "./dist/worker-ingress/workerIngressApplication.js",
|
||||
"default": "./dist/worker-ingress/workerIngressApplication.js"
|
||||
},
|
||||
"./worker-ingress-config": {
|
||||
"types": "./dist/worker-ingress/workerIngressConfig.d.ts",
|
||||
"require": "./dist/worker-ingress/workerIngressConfig.js",
|
||||
"default": "./dist/worker-ingress/workerIngressConfig.js"
|
||||
},
|
||||
"./worker-ingress-production": {
|
||||
"types": "./dist/worker-ingress/productionWorkerIngress.d.ts",
|
||||
"require": "./dist/worker-ingress/productionWorkerIngress.js",
|
||||
"default": "./dist/worker-ingress/productionWorkerIngress.js"
|
||||
},
|
||||
"./worker-runtime-port": {
|
||||
"types": "./dist/remote-execution/workerRuntimePort.d.ts",
|
||||
"require": "./dist/remote-execution/workerRuntimePort.js",
|
||||
"default": "./dist/remote-execution/workerRuntimePort.js"
|
||||
},
|
||||
"./remote-dispatch": {
|
||||
"types": "./dist/remote-execution/remoteWorkerDispatcher.d.ts",
|
||||
"require": "./dist/remote-execution/remoteWorkerDispatcher.js",
|
||||
"default": "./dist/remote-execution/remoteWorkerDispatcher.js"
|
||||
},
|
||||
"./remote-activation": {
|
||||
"types": "./dist/remote-execution/remoteRunActivationService.d.ts",
|
||||
"require": "./dist/remote-execution/remoteRunActivationService.js",
|
||||
"default": "./dist/remote-execution/remoteRunActivationService.js"
|
||||
},
|
||||
"./remote-secret-delivery": {
|
||||
"types": "./dist/remote-execution/remoteWorkerSecretDeliveryService.d.ts",
|
||||
"require": "./dist/remote-execution/remoteWorkerSecretDeliveryService.js",
|
||||
"default": "./dist/remote-execution/remoteWorkerSecretDeliveryService.js"
|
||||
},
|
||||
"./mounted-secret-provider": {
|
||||
"types": "./dist/remote-execution/mountedSecretProvider.d.ts",
|
||||
"require": "./dist/remote-execution/mountedSecretProvider.js",
|
||||
"default": "./dist/remote-execution/mountedSecretProvider.js"
|
||||
},
|
||||
"./remote-completion": {
|
||||
"types": "./dist/remote-execution/remoteWorkerCompletionService.d.ts",
|
||||
"require": "./dist/remote-execution/remoteWorkerCompletionService.js",
|
||||
"default": "./dist/remote-execution/remoteWorkerCompletionService.js"
|
||||
},
|
||||
"./s3-artifact-store": {
|
||||
"types": "./dist/artifact/s3ArtifactStore.d.ts",
|
||||
"require": "./dist/artifact/s3ArtifactStore.js",
|
||||
"default": "./dist/artifact/s3ArtifactStore.js"
|
||||
},
|
||||
"./lease-control": {
|
||||
"types": "./dist/remote-execution/remoteWorkerLeaseControlService.d.ts",
|
||||
"require": "./dist/remote-execution/remoteWorkerLeaseControlService.js",
|
||||
"default": "./dist/remote-execution/remoteWorkerLeaseControlService.js"
|
||||
},
|
||||
"./workflow-scheduler": {
|
||||
"types": "./dist/scheduling/workflowScheduler.d.ts",
|
||||
"require": "./dist/scheduling/workflowScheduler.js",
|
||||
"default": "./dist/scheduling/workflowScheduler.js"
|
||||
},
|
||||
"./workflow-administration": {
|
||||
"types": "./dist/plugin-package/workflow/pluginPackageWorkflowAdministration.d.ts",
|
||||
"require": "./dist/plugin-package/workflow/pluginPackageWorkflowAdministration.js",
|
||||
"default": "./dist/plugin-package/workflow/pluginPackageWorkflowAdministration.js"
|
||||
},
|
||||
"./workflow-routes": {
|
||||
"types": "./dist/plugin-package/workflow/pluginPackageWorkflowRoute.d.ts",
|
||||
"require": "./dist/plugin-package/workflow/pluginPackageWorkflowRoute.js",
|
||||
"default": "./dist/plugin-package/workflow/pluginPackageWorkflowRoute.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"bin": {
|
||||
"ql3-cluster-control": "dist/cli.js",
|
||||
"ql3-cluster-control-ai": "dist/aiCli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs",
|
||||
"test:integration": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/postgres.integration.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1093.0",
|
||||
"@qinglong/cluster-postgres": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*",
|
||||
"croner": "7.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@qinglong/ai": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@qinglong/ai": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@qinglong/ai": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import type { ModelGatewayProfileAudit } from '@qinglong/ai/profile';
|
||||
|
||||
import {
|
||||
loadProductionClusterAiConfig,
|
||||
startProductionClusterAiControlApplication,
|
||||
} from './application-runtime/aiProductionApplication';
|
||||
import {
|
||||
runProductionClusterControlProcess,
|
||||
type ClusterControlProcessEvent,
|
||||
type ClusterControlProcessSignal,
|
||||
type ClusterControlProcessSignalSource,
|
||||
} from './production-process/processApplication';
|
||||
|
||||
const USAGE = 'Usage: ql3-cluster-control-ai';
|
||||
|
||||
const nodeSignals: ClusterControlProcessSignalSource = Object.freeze({
|
||||
subscribe(listener: (signal: ClusterControlProcessSignal) => void) {
|
||||
const handlers = Object.freeze({
|
||||
SIGINT: () => listener('SIGINT' as const),
|
||||
SIGTERM: () => listener('SIGTERM' as const),
|
||||
});
|
||||
process.once('SIGINT', handlers.SIGINT);
|
||||
process.once('SIGTERM', handlers.SIGTERM);
|
||||
return () => {
|
||||
process.off('SIGINT', handlers.SIGINT);
|
||||
process.off('SIGTERM', handlers.SIGTERM);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function write(record: object): void {
|
||||
process.stdout.write(`${JSON.stringify(record)}\n`);
|
||||
}
|
||||
|
||||
function emit(record: ClusterControlProcessEvent): void {
|
||||
write(record);
|
||||
}
|
||||
|
||||
function audit(record: Readonly<ModelGatewayProfileAudit>): void {
|
||||
write(
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-ai',
|
||||
level: record.state === 'failed' ? 'error' : 'info',
|
||||
event: 'activation',
|
||||
profile: record.profile,
|
||||
state: record.state,
|
||||
...(record.maxConcurrent === undefined
|
||||
? {}
|
||||
: { maxConcurrent: record.maxConcurrent }),
|
||||
...(record.recoveryLimit === undefined
|
||||
? {}
|
||||
: { recoveryLimit: record.recoveryLimit }),
|
||||
...(record.recovered === undefined
|
||||
? {}
|
||||
: { recovered: record.recovered }),
|
||||
...(record.alreadyCompleted === undefined
|
||||
? {}
|
||||
: { alreadyCompleted: record.alreadyCompleted }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function main(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_CLUSTER_AI_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ai = loadProductionClusterAiConfig(process.env);
|
||||
const stopResult = await runProductionClusterControlProcess({
|
||||
environment: process.env,
|
||||
signals: nodeSignals,
|
||||
emit,
|
||||
start: (control) =>
|
||||
startProductionClusterAiControlApplication({ control, ai, audit }),
|
||||
});
|
||||
if (stopResult !== 'stopped') process.exitCode = 1;
|
||||
} catch (error) {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-ai',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' ? candidate.name : 'Error',
|
||||
...(typeof candidate?.code === 'string'
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,365 @@
|
||||
import type { ModelGatewayProfileAudit } from '@qinglong/ai/profile';
|
||||
import { BoundModelProviderCredentialProvider } from '@qinglong/ai/provider-credential';
|
||||
import { PostgresModelProviderCredentialReader } from '@qinglong/ai/postgres-model-provider-credential-storage';
|
||||
import { loadProjectedModelGatewayProviderAuthority } from '@qinglong/ai/projected-model-gateway-authority';
|
||||
import { createProjectedModelProviderSecretMaterialProvider } from '@qinglong/ai/projected-model-provider-secret-material';
|
||||
import { createPluginPackagePromptOutputProjectedKeyring } from '@qinglong/ai/plugin-package-prompt-output-projected-keyring';
|
||||
import {
|
||||
bootstrapPostgresPluginPackagePromptApplication,
|
||||
type BootstrapPostgresPluginPackagePromptApplicationResult,
|
||||
} from '@qinglong/ai/postgres-plugin-package-prompt-application';
|
||||
import { createPostgresDatabaseOpener } from '@qinglong/cluster-postgres/runtime';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { ClusterControlStopResult } from '@qinglong/runtime-core';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
import type { ClusterControlApplicationResult } from './application';
|
||||
import type {
|
||||
ClusterControlEnvironment,
|
||||
EnabledClusterControlConfig,
|
||||
} from '../production-process/config';
|
||||
import {
|
||||
startProductionClusterControlApplication,
|
||||
type ProductionClusterControlApplicationOptions,
|
||||
} from './productionApplication';
|
||||
|
||||
export interface EnabledProductionClusterAiConfig {
|
||||
readonly enabled: true;
|
||||
readonly providerAuthorityFile: string;
|
||||
readonly secretRootDirectory: string;
|
||||
readonly promptOutputKeyringRootDirectory?: string;
|
||||
readonly maxConcurrent: number;
|
||||
readonly recoveryLimit: number;
|
||||
readonly databaseMaxConnections: number;
|
||||
}
|
||||
|
||||
export interface ProductionClusterAiControlApplicationOptions {
|
||||
readonly control: ProductionClusterControlApplicationOptions;
|
||||
readonly ai: EnabledProductionClusterAiConfig;
|
||||
readonly audit: (
|
||||
record: Readonly<ModelGatewayProfileAudit>,
|
||||
) => void | Promise<void>;
|
||||
readonly startControl?: typeof startProductionClusterControlApplication;
|
||||
readonly bootstrapPrompt?: typeof bootstrapPostgresPluginPackagePromptApplication;
|
||||
}
|
||||
|
||||
export class ProductionClusterAiConfigError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_AI_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Cluster AI configuration is invalid: ${message}`);
|
||||
this.name = 'ProductionClusterAiConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterControlEnvironment,
|
||||
name: string,
|
||||
defaultValue: boolean,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw new ProductionClusterAiConfigError(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
environment: ClusterControlEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return fallback;
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new ProductionClusterAiConfigError(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new ProductionClusterAiConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requiredPath(
|
||||
environment: ClusterControlEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
const value = environment[name];
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 2 ||
|
||||
value.length > 4096 ||
|
||||
!value.startsWith('/') ||
|
||||
/[\0\r\n]/.test(value)
|
||||
) {
|
||||
throw new ProductionClusterAiConfigError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Parsed only by the explicit AI process entrypoint; the default CLI ignores it. */
|
||||
export function loadProductionClusterAiConfig(
|
||||
environment: ClusterControlEnvironment,
|
||||
): EnabledProductionClusterAiConfig {
|
||||
if (
|
||||
!environment ||
|
||||
typeof environment !== 'object' ||
|
||||
Array.isArray(environment) ||
|
||||
!booleanValue(environment, 'QL3_CLUSTER_AI_ENABLED', false)
|
||||
) {
|
||||
throw new ProductionClusterAiConfigError(
|
||||
'QL3_CLUSTER_AI_ENABLED must be true',
|
||||
);
|
||||
}
|
||||
const promptOutputEnabled = booleanValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_AI_PROMPT_OUTPUT_ENABLED',
|
||||
false,
|
||||
);
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
providerAuthorityFile: requiredPath(
|
||||
environment,
|
||||
'QL3_CLUSTER_AI_PROVIDER_AUTHORITY_FILE',
|
||||
),
|
||||
secretRootDirectory: requiredPath(
|
||||
environment,
|
||||
'QL3_CLUSTER_AI_SECRET_ROOT',
|
||||
),
|
||||
...(promptOutputEnabled
|
||||
? {
|
||||
promptOutputKeyringRootDirectory: requiredPath(
|
||||
environment,
|
||||
'QL3_CLUSTER_AI_PROMPT_OUTPUT_KEYRING_ROOT',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
maxConcurrent: boundedInteger(
|
||||
environment,
|
||||
'QL3_CLUSTER_AI_MAX_CONCURRENT',
|
||||
4,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
recoveryLimit: boundedInteger(
|
||||
environment,
|
||||
'QL3_CLUSTER_AI_RECOVERY_LIMIT',
|
||||
32,
|
||||
1,
|
||||
128,
|
||||
),
|
||||
databaseMaxConnections: boundedInteger(
|
||||
environment,
|
||||
'QL3_CLUSTER_AI_DATABASE_MAX_CONNECTIONS',
|
||||
4,
|
||||
1,
|
||||
16,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function aiDatabaseOpener(
|
||||
control: EnabledClusterControlConfig,
|
||||
ai: EnabledProductionClusterAiConfig,
|
||||
onUnavailable: (error: Error) => void,
|
||||
) {
|
||||
return createPostgresDatabaseOpener({
|
||||
role: 'runtime',
|
||||
connection: control.database.connection,
|
||||
pool: {
|
||||
...control.database.pool,
|
||||
applicationName: 'qinglong-cluster-ai',
|
||||
maxConnections: ai.databaseMaxConnections,
|
||||
},
|
||||
onPoolError: onUnavailable,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit AI-enabled composition root. It keeps the normal control image and
|
||||
* process AI-free while sharing the reviewed authentication/Policy pipeline
|
||||
* and route registry when the separate AI image entrypoint is selected.
|
||||
*/
|
||||
export async function startProductionClusterAiControlApplication(
|
||||
options: ProductionClusterAiControlApplicationOptions,
|
||||
): Promise<ClusterControlApplicationResult> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.audit !== 'function'
|
||||
) {
|
||||
throw new TypeError('Production Cluster AI application options are invalid');
|
||||
}
|
||||
const startControl =
|
||||
options.startControl ?? startProductionClusterControlApplication;
|
||||
const bootstrapPrompt =
|
||||
options.bootstrapPrompt ?? bootstrapPostgresPluginPackagePromptApplication;
|
||||
if (typeof startControl !== 'function' || typeof bootstrapPrompt !== 'function') {
|
||||
throw new TypeError('Production Cluster AI application factories are invalid');
|
||||
}
|
||||
const secretMaterial =
|
||||
await createProjectedModelProviderSecretMaterialProvider({
|
||||
rootDirectory: options.ai.secretRootDirectory,
|
||||
});
|
||||
const promptOutputKeys =
|
||||
options.ai.promptOutputKeyringRootDirectory === undefined
|
||||
? undefined
|
||||
: await createPluginPackagePromptOutputProjectedKeyring({
|
||||
rootDirectory: options.ai.promptOutputKeyringRootDirectory,
|
||||
});
|
||||
let aiDatabase:
|
||||
| Awaited<ReturnType<ReturnType<typeof createPostgresDatabaseOpener>>>
|
||||
| undefined;
|
||||
let resolveAiUnavailable: ((error: Error) => void) | undefined;
|
||||
let aiUnavailableError: Error | undefined;
|
||||
const aiUnavailable = new Promise<Error>((resolve) => {
|
||||
resolveAiUnavailable = resolve;
|
||||
});
|
||||
const onAiUnavailable = (error: Error): void => {
|
||||
aiUnavailableError ??= error;
|
||||
resolveAiUnavailable?.(aiUnavailableError);
|
||||
resolveAiUnavailable = undefined;
|
||||
};
|
||||
let promptApplication:
|
||||
| BootstrapPostgresPluginPackagePromptApplicationResult
|
||||
| undefined;
|
||||
let controlApplication: ClusterControlApplicationResult | undefined;
|
||||
let stopPromise: Promise<ClusterControlStopResult> | undefined;
|
||||
let promptOutputPolicy: ProjectPolicyEngine | undefined;
|
||||
const promptOutputReadAuthorizer = Object.freeze({
|
||||
async authorize(request: Readonly<{
|
||||
principal: Parameters<ProjectPolicyEngine['authorize']>[0];
|
||||
projectId: string;
|
||||
}>) {
|
||||
if (!aiDatabase) {
|
||||
throw new Error('Cluster AI database is unavailable during output read');
|
||||
}
|
||||
promptOutputPolicy ??= new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(aiDatabase.pool),
|
||||
);
|
||||
const decision = await promptOutputPolicy.authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
'artifact.read',
|
||||
);
|
||||
return decision.effect === 'allow'
|
||||
? Object.freeze({ effect: 'allow' as const })
|
||||
: Object.freeze({
|
||||
effect: decision.effect,
|
||||
reasonCode: 'artifact_read_denied',
|
||||
});
|
||||
},
|
||||
});
|
||||
const stop = async (): Promise<ClusterControlStopResult> => {
|
||||
stopPromise ??= (async () => {
|
||||
const controlResult =
|
||||
controlApplication?.status === 'active'
|
||||
? await controlApplication.stop()
|
||||
: 'stopped';
|
||||
const promptResult = await promptApplication?.stop();
|
||||
return controlResult === 'stopped' &&
|
||||
(promptResult === undefined || promptResult === 'stopped')
|
||||
? 'stopped'
|
||||
: 'timed_out';
|
||||
})();
|
||||
return stopPromise;
|
||||
};
|
||||
try {
|
||||
const openDatabase = aiDatabaseOpener(
|
||||
options.control.config,
|
||||
options.ai,
|
||||
onAiUnavailable,
|
||||
);
|
||||
promptApplication = await bootstrapPrompt({
|
||||
enabled: true,
|
||||
async openDatabase() {
|
||||
if (aiDatabase) {
|
||||
throw new Error('Cluster AI database was opened more than once');
|
||||
}
|
||||
aiDatabase = await openDatabase();
|
||||
return aiDatabase;
|
||||
},
|
||||
async loadProviders() {
|
||||
if (!aiDatabase) {
|
||||
throw new Error('Cluster AI database is unavailable during provider load');
|
||||
}
|
||||
const credentialStorage = new PostgresModelProviderCredentialReader(
|
||||
aiDatabase.pool,
|
||||
);
|
||||
const credentials = new BoundModelProviderCredentialProvider({
|
||||
bindings: credentialStorage,
|
||||
secrets: secretMaterial,
|
||||
audit: credentialStorage,
|
||||
});
|
||||
return loadProjectedModelGatewayProviderAuthority({
|
||||
configFile: options.ai.providerAuthorityFile,
|
||||
credentials,
|
||||
});
|
||||
},
|
||||
audit: options.audit,
|
||||
maxConcurrent: options.ai.maxConcurrent,
|
||||
recoveryLimit: options.ai.recoveryLimit,
|
||||
...(promptOutputKeys === undefined
|
||||
? {}
|
||||
: {
|
||||
promptOutputKeys,
|
||||
promptOutputRead: { authorizer: promptOutputReadAuthorizer },
|
||||
}),
|
||||
});
|
||||
if (promptApplication.status !== 'active') {
|
||||
throw new Error('Cluster AI Prompt application did not activate');
|
||||
}
|
||||
controlApplication = await startControl({
|
||||
...options.control,
|
||||
promptCatalog: {
|
||||
capability: promptApplication.promptCatalog,
|
||||
},
|
||||
promptExecution: {
|
||||
capability: promptApplication.promptExecutions,
|
||||
},
|
||||
promptExecutionInspection: {
|
||||
capability: promptApplication.promptExecutionInspections,
|
||||
},
|
||||
...(promptApplication.promptOutputs === undefined
|
||||
? {}
|
||||
: {
|
||||
promptOutputRead: {
|
||||
capability: promptApplication.promptOutputs,
|
||||
},
|
||||
}),
|
||||
...(promptApplication.promptExecutionOutputs === undefined
|
||||
? {}
|
||||
: {
|
||||
promptExecutionOutputRead: {
|
||||
capability: promptApplication.promptExecutionOutputs,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (controlApplication.status !== 'active') {
|
||||
throw new Error('AI-enabled cluster-control did not activate');
|
||||
}
|
||||
const activeControl = controlApplication;
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: activeControl.address,
|
||||
evidence: activeControl.evidence,
|
||||
recovery: activeControl.recovery,
|
||||
unavailable: Promise.race([activeControl.unavailable, aiUnavailable]),
|
||||
availabilityStatus() {
|
||||
return aiUnavailableError
|
||||
? 'unavailable'
|
||||
: activeControl.availabilityStatus();
|
||||
},
|
||||
stop,
|
||||
});
|
||||
} catch (error) {
|
||||
await stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
ClusterControlActivationAudit,
|
||||
ClusterControlActivationStack,
|
||||
ClusterControlReadinessEvidence,
|
||||
ClusterControlRuntimeActivationResult,
|
||||
ClusterControlStartupRecoverySummary,
|
||||
ClusterControlStopResult,
|
||||
DeploymentProfile,
|
||||
OpenPostgresDatabase,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
bootstrapClusterControlRuntime,
|
||||
type ClusterControlAssemblyInput,
|
||||
type ClusterControlRecoveryRuntimeOptions,
|
||||
type ClusterRunCancellationConvergenceRuntimeOptions,
|
||||
type ClusterSchedulerRuntimeOptions,
|
||||
type ClusterWorkerRuntimeDependencies,
|
||||
} from './clusterControlRuntime';
|
||||
import { assertClusterControlApiCredentialPepper } from '../authentication/apiCredentialAuthenticator';
|
||||
import {
|
||||
startClusterControlHttpSurface,
|
||||
type ClusterControlAdmissionPipeline,
|
||||
type ClusterControlHttpAddress,
|
||||
type ClusterControlHttpSurfaceOptions,
|
||||
} from '../transport/httpSurface';
|
||||
import type { ClusterControlAvailabilitySource } from '../database/availability';
|
||||
|
||||
export interface ClusterControlApplicationStack {
|
||||
reconcile(): Promise<ClusterControlStartupRecoverySummary>;
|
||||
startLifecycles(): Promise<boolean>;
|
||||
admission: ClusterControlAdmissionPipeline;
|
||||
stop(): Promise<ClusterControlStopResult>;
|
||||
}
|
||||
|
||||
export interface ClusterControlApplicationOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly apiCredentialPepper?: string;
|
||||
readonly recovery?: ClusterControlRecoveryRuntimeOptions;
|
||||
readonly scheduler?: ClusterSchedulerRuntimeOptions;
|
||||
readonly cancellationConvergence?: ClusterRunCancellationConvergenceRuntimeOptions;
|
||||
readonly workerRuntime?: ClusterWorkerRuntimeDependencies;
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly availability: ClusterControlAvailabilitySource;
|
||||
readonly http: ClusterControlHttpSurfaceOptions;
|
||||
readonly create: (
|
||||
input: ClusterControlAssemblyInput,
|
||||
) => ClusterControlApplicationStack;
|
||||
readonly audit: (
|
||||
record: ClusterControlActivationAudit,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type ClusterControlApplicationResult =
|
||||
| { readonly status: 'disabled'; stop(): Promise<'stopped'> }
|
||||
| {
|
||||
readonly status: 'active';
|
||||
readonly address: ClusterControlHttpAddress;
|
||||
readonly evidence: ClusterControlReadinessEvidence;
|
||||
readonly recovery: ClusterControlStartupRecoverySummary;
|
||||
readonly unavailable: Promise<Error>;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
stop(): Promise<ClusterControlStopResult>;
|
||||
};
|
||||
|
||||
export class ClusterControlDatabaseUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_CONTROL_DATABASE_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster-control database became unavailable');
|
||||
this.name = 'ClusterControlDatabaseUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function inactiveBootstrap(
|
||||
options: ClusterControlApplicationOptions,
|
||||
): Promise<ClusterControlRuntimeActivationResult> {
|
||||
return bootstrapClusterControlRuntime({
|
||||
...(options.enabled === undefined ? {} : { enabled: options.enabled }),
|
||||
profile: options.profile,
|
||||
...(options.apiCredentialPepper === undefined
|
||||
? {}
|
||||
: { apiCredentialPepper: options.apiCredentialPepper }),
|
||||
...(options.recovery === undefined ? {} : { recovery: options.recovery }),
|
||||
...(options.scheduler === undefined
|
||||
? {}
|
||||
: { scheduler: options.scheduler }),
|
||||
...(options.cancellationConvergence === undefined
|
||||
? {}
|
||||
: { cancellationConvergence: options.cancellationConvergence }),
|
||||
...(options.workerRuntime === undefined
|
||||
? {}
|
||||
: { workerRuntime: options.workerRuntime }),
|
||||
openDatabase: options.openDatabase,
|
||||
create() {
|
||||
throw new Error('Inactive cluster-control unexpectedly created a stack');
|
||||
},
|
||||
audit: options.audit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the cluster probe surface before database readiness, then installs the
|
||||
* /api/v3 admission handler only after recovery and lifecycles are safe. Stop
|
||||
* withdraws and drains admission before stack, Pool and listener shutdown.
|
||||
*/
|
||||
export async function startClusterControlApplication(
|
||||
options: ClusterControlApplicationOptions,
|
||||
): Promise<ClusterControlApplicationResult> {
|
||||
const enabled = options.enabled ?? false;
|
||||
if (!enabled || options.profile !== 'cluster-control') {
|
||||
const inactive = await inactiveBootstrap(options);
|
||||
if (inactive.status !== 'disabled') {
|
||||
throw new Error('Inactive cluster-control unexpectedly became active');
|
||||
}
|
||||
return inactive;
|
||||
}
|
||||
|
||||
assertClusterControlApiCredentialPepper(options.apiCredentialPepper ?? '');
|
||||
const apiCredentialPepper = options.apiCredentialPepper!;
|
||||
if (
|
||||
!options.availability ||
|
||||
typeof options.availability.subscribe !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control availability source is invalid');
|
||||
}
|
||||
|
||||
const http = await startClusterControlHttpSurface(options.http);
|
||||
let activation: ClusterControlRuntimeActivationResult | undefined;
|
||||
let unavailableError: Error | undefined;
|
||||
let unavailableStopPromise: Promise<ClusterControlStopResult> | undefined;
|
||||
let availabilityStatus: 'ready' | 'unavailable' | 'stopped' = 'ready';
|
||||
let unsubscribeAvailability: (() => void) | undefined;
|
||||
let resolveUnavailable: ((error: Error) => void) | undefined;
|
||||
const unavailable = new Promise<Error>((resolve) => {
|
||||
resolveUnavailable = resolve;
|
||||
});
|
||||
const withdrawForUnavailable = (error: Error): Promise<void> => {
|
||||
unavailableError ??= error;
|
||||
availabilityStatus = 'unavailable';
|
||||
resolveUnavailable?.(unavailableError);
|
||||
resolveUnavailable = undefined;
|
||||
if (!activation || activation.status === 'disabled')
|
||||
return Promise.resolve();
|
||||
unavailableStopPromise ??= activation.stop();
|
||||
return unavailableStopPromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
try {
|
||||
unsubscribeAvailability = options.availability.subscribe(
|
||||
withdrawForUnavailable,
|
||||
);
|
||||
activation = await bootstrapClusterControlRuntime({
|
||||
enabled: true,
|
||||
profile: options.profile,
|
||||
apiCredentialPepper,
|
||||
...(options.recovery === undefined ? {} : { recovery: options.recovery }),
|
||||
...(options.scheduler === undefined
|
||||
? {}
|
||||
: { scheduler: options.scheduler }),
|
||||
...(options.cancellationConvergence === undefined
|
||||
? {}
|
||||
: { cancellationConvergence: options.cancellationConvergence }),
|
||||
...(options.workerRuntime === undefined
|
||||
? {}
|
||||
: { workerRuntime: options.workerRuntime }),
|
||||
openDatabase: options.openDatabase,
|
||||
create(input): ClusterControlActivationStack {
|
||||
const application = options.create(input);
|
||||
if (
|
||||
!application ||
|
||||
typeof application !== 'object' ||
|
||||
!application.admission ||
|
||||
typeof application.admission.prepare !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster-control application stack has no admission pipeline',
|
||||
);
|
||||
}
|
||||
return {
|
||||
reconcile: () => application.reconcile(),
|
||||
startLifecycles: () => application.startLifecycles(),
|
||||
installAdmission: () =>
|
||||
http.installAdmission(input.evidence, application.admission),
|
||||
stop: () => application.stop(),
|
||||
};
|
||||
},
|
||||
audit: options.audit,
|
||||
});
|
||||
if (activation.status === 'disabled') {
|
||||
unsubscribeAvailability();
|
||||
await http.close();
|
||||
return activation;
|
||||
}
|
||||
if (unavailableError) {
|
||||
await withdrawForUnavailable(unavailableError);
|
||||
throw new ClusterControlDatabaseUnavailableError();
|
||||
}
|
||||
const activeActivation = activation;
|
||||
|
||||
let stopPromise: Promise<ClusterControlStopResult> | undefined;
|
||||
return {
|
||||
status: 'active',
|
||||
address: http.address,
|
||||
evidence: activeActivation.evidence,
|
||||
recovery: activeActivation.recovery,
|
||||
unavailable,
|
||||
availabilityStatus: () => availabilityStatus,
|
||||
stop() {
|
||||
if (stopPromise) return stopPromise;
|
||||
availabilityStatus = 'stopped';
|
||||
unsubscribeAvailability?.();
|
||||
unsubscribeAvailability = undefined;
|
||||
stopPromise = (async () => {
|
||||
let result: ClusterControlStopResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
result = await activeActivation.stop();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await http.close();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result!;
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
unsubscribeAvailability?.();
|
||||
try {
|
||||
await http.close();
|
||||
} catch {
|
||||
// Preserve the readiness/assembly/activation failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
activateClusterControlRuntime,
|
||||
ClusterRunLostRetryCoordinator,
|
||||
ClusterRunCancellationConvergenceCoordinator,
|
||||
ClusterControlRecoveryEvidenceRegistry,
|
||||
ClusterControlRecoveryConvergenceVerifier,
|
||||
ClusterControlRecoverySupervisor,
|
||||
ClusterControlStartupRecoveryCoordinator,
|
||||
EvidenceBasedClusterControlRecoveryProcessor,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_CLAIMS_PER_PASS,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_CLAIM_LEASE_MS,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_EVIDENCE_TIMEOUT_MS,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_RETRY_DELAY_MS,
|
||||
MAX_CLUSTER_CONTROL_STARTUP_RECOVERY_PASSES,
|
||||
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGE_SIZE,
|
||||
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGES_PER_CYCLE,
|
||||
type ClusterControlActivationAudit,
|
||||
type ClusterControlActivationStack,
|
||||
type ClusterControlReadinessEvidence,
|
||||
type ClusterControlRecoveryExecutorEvidenceProvider,
|
||||
type ClusterControlRuntimeActivationResult,
|
||||
type ClusterControlStopResult,
|
||||
type DeploymentProfile,
|
||||
type OpenPostgresDatabase,
|
||||
type PostgresDatabaseResource,
|
||||
type PostgresPool,
|
||||
type ProjectPolicyRepository,
|
||||
type RunRepository,
|
||||
type ClusterRunCancellationConvergenceCycleResult,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type { ClusterRunCancellationRepository } from '@qinglong/runtime-core/cluster-run-cancellation';
|
||||
import type { ProjectRunListReader } from '@qinglong/runtime-core/project-run-list';
|
||||
import type { ClusterScheduleStore } from '@qinglong/runtime-core/cluster-scheduler';
|
||||
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
||||
import type { TriggerSource } from '@qinglong/runtime-core/trigger';
|
||||
import type { ClusterTaskExecutionRevisionSource } from '@qinglong/runtime-core/cluster-execution-revision';
|
||||
import type {
|
||||
ProjectToolDefinitionSnapshotRepository,
|
||||
ProjectToolDefinitionSnapshotSourceRepository,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
|
||||
import type { ToolExecutionCompletionRepository } from '@qinglong/runtime-core/tool-execution-completion';
|
||||
import type { ToolExecutionFailureCompletionRepository } from '@qinglong/runtime-core/tool-execution-failure-completion';
|
||||
import type { ToolExecutionStartBarrierRepository } from '@qinglong/runtime-core/tool-execution-start-barrier';
|
||||
import type { ToolInvocationArtifactRepository } from '@qinglong/runtime-core/tool-invocation-artifact';
|
||||
import type { ToolResultKeyCatalogReader } from '@qinglong/runtime-core/tool-result-key-catalog';
|
||||
import type { ToolExecutionResultRekeyReader } from '@qinglong/runtime-core/tool-result-rekey';
|
||||
import {
|
||||
assertPostgresSchemaReady,
|
||||
PostgresClusterControlRecoverySource,
|
||||
PostgresClusterControlRecoveryClaimRepository,
|
||||
PostgresClusterControlRecoveryResolutionRepository,
|
||||
PostgresClusterRuntimeRecoverySource,
|
||||
PostgresClusterRunLostRetryRepository,
|
||||
PostgresClusterRunCancellationRepository,
|
||||
PostgresClusterRunCancellationConvergenceRepository,
|
||||
PostgresProjectPolicyRepository,
|
||||
PostgresApiCredentialRepository,
|
||||
PostgresSecurityAuditRepository,
|
||||
PostgresRunRepository,
|
||||
PostgresWorkerExecutionAttestationRepository,
|
||||
PostgresTaskDefinitionSource,
|
||||
PostgresTaskExecutionRevisionSource,
|
||||
PostgresTriggerSource,
|
||||
PostgresClusterScheduleRepository,
|
||||
PostgresRemoteWorkerAttestationEvidenceProvider,
|
||||
PostgresProjectToolDefinitionSnapshotRepository,
|
||||
PostgresStepRunRepository,
|
||||
PostgresToolExecutionCompletionRepository,
|
||||
PostgresToolExecutionFailureCompletionRepository,
|
||||
PostgresToolExecutionStartBarrierRepository,
|
||||
PostgresToolInvocationArtifactRepository,
|
||||
PostgresToolResultKeyCatalogReader,
|
||||
PostgresToolResultRekeyReader,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/runtime';
|
||||
import { PostgresPluginPackageWorkflowFrontierRepository } from '@qinglong/cluster-postgres/plugin-package-workflow-frontier';
|
||||
import { PostgresPluginPackageWorkflowTaskAttemptAdmissionRepository } from '@qinglong/cluster-postgres/plugin-package-workflow-task-attempt-admission';
|
||||
import { PostgresTaskStartRepository } from '@qinglong/cluster-postgres/task-start';
|
||||
import { PostgresPluginPackageAutomationPublicationRepository } from '@qinglong/cluster-postgres/plugin-package-automation-publication';
|
||||
import { PostgresPluginPackageMaterializedRevisionRepository } from '@qinglong/cluster-postgres/plugin-package-materialized-revision';
|
||||
import {
|
||||
PostgresAuthorizedPluginPackageWorkflowAdmissionRepository,
|
||||
PostgresAuthorizedPluginPackageWorkflowRunEventListRepository,
|
||||
PostgresAuthorizedPluginPackageWorkflowRunInspectionRepository,
|
||||
PostgresAuthorizedPluginPackageWorkflowRunListRepository,
|
||||
PostgresAuthorizedPluginPackageWorkflowStepRunListRepository,
|
||||
} from '@qinglong/cluster-postgres/plugin-package-workflow-administration';
|
||||
import {
|
||||
assertClusterControlApiCredentialPepper,
|
||||
createClusterControlApiCredentialAuthenticator,
|
||||
} from '../authentication/apiCredentialAuthenticator';
|
||||
import type {
|
||||
ClusterControlRequestAuthenticator,
|
||||
ClusterControlSecurityAuditSink,
|
||||
} from '../transport/admissionPipeline';
|
||||
import {
|
||||
MAX_CLUSTER_SCHEDULE_CLAIMS_PER_CYCLE,
|
||||
ClusterSchedulerCoordinator,
|
||||
ClusterSchedulerLifecycle,
|
||||
type ClusterSchedulerCycleSummary,
|
||||
} from '../scheduling/scheduler';
|
||||
import { ClusterWorkflowSchedulerCoordinator } from '../scheduling/workflowScheduler';
|
||||
import { ClusterRuntimeSchedulerCoordinator } from '../scheduling/runtimeScheduler';
|
||||
import { ClusterRunCancellationConvergenceLifecycle } from '../run/runCancellationLifecycle';
|
||||
import type { TaskStartRepository } from '@qinglong/runtime-core/task-start';
|
||||
import {
|
||||
createClusterWorkerRuntimePort,
|
||||
type ClusterWorkerRuntimeDependencies,
|
||||
type ClusterWorkerRuntimePort,
|
||||
} from '../remote-execution/workerRuntimePort';
|
||||
import {
|
||||
createClusterPluginPackageWorkflowAdministrationCapability,
|
||||
type ClusterPluginPackageWorkflowAdministrationCapability,
|
||||
} from '../plugin-package/workflow/pluginPackageWorkflowAdministration';
|
||||
|
||||
export interface ClusterTrustedToolStorage {
|
||||
readonly invocationArtifacts: ToolInvocationArtifactRepository;
|
||||
readonly stepRuns: StepRunRepository;
|
||||
readonly startBarriers: ToolExecutionStartBarrierRepository;
|
||||
readonly completions: ToolExecutionCompletionRepository;
|
||||
readonly failureCompletions: ToolExecutionFailureCompletionRepository;
|
||||
readonly resultKeyCatalog: ToolResultKeyCatalogReader;
|
||||
readonly resultRekeys: ToolExecutionResultRekeyReader;
|
||||
readonly toolDefinitionSnapshots: ProjectToolDefinitionSnapshotRepository &
|
||||
ProjectToolDefinitionSnapshotSourceRepository;
|
||||
}
|
||||
|
||||
export interface ClusterControlAssemblyInput {
|
||||
readonly evidence: ClusterControlReadinessEvidence;
|
||||
readonly policies: ProjectPolicyRepository;
|
||||
readonly runs: RunRepository & ProjectRunListReader;
|
||||
readonly runCancellation: ClusterRunCancellationRepository;
|
||||
readonly taskStart: TaskStartRepository;
|
||||
readonly taskDefinitions: TaskDefinitionSource;
|
||||
readonly taskExecutionRevisions: ClusterTaskExecutionRevisionSource;
|
||||
readonly triggers: TriggerSource;
|
||||
readonly schedules: ClusterScheduleStore;
|
||||
readonly trustedToolStorage: ClusterTrustedToolStorage;
|
||||
readonly authenticator: ClusterControlRequestAuthenticator;
|
||||
readonly securityAudit: ClusterControlSecurityAuditSink;
|
||||
readonly workflowAdministration: ClusterPluginPackageWorkflowAdministrationCapability;
|
||||
readonly workerRuntime?: ClusterWorkerRuntimePort;
|
||||
}
|
||||
|
||||
export interface ClusterControlRecoveryRuntimeOptions {
|
||||
readonly ownerId: string;
|
||||
readonly providers?: readonly ClusterControlRecoveryExecutorEvidenceProvider[];
|
||||
readonly claimLimit?: number;
|
||||
readonly claimLeaseMs?: number;
|
||||
readonly retryDelayMs?: number;
|
||||
readonly providerTimeoutMs?: number;
|
||||
readonly maxStartupPasses?: number;
|
||||
}
|
||||
|
||||
export interface ClusterSchedulerRuntimeOptions {
|
||||
readonly ownerId?: string;
|
||||
readonly claimLeaseMs?: number;
|
||||
readonly maxClaimsPerCycle?: number;
|
||||
readonly misfireGraceMs?: number;
|
||||
readonly intervalMs?: number;
|
||||
readonly stopTimeoutMs?: number;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: ClusterSchedulerCycleSummary,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterRunCancellationConvergenceRuntimeOptions {
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
readonly intervalMs?: number;
|
||||
readonly stopTimeoutMs?: number;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: Readonly<ClusterRunCancellationConvergenceCycleResult>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterControlBootstrapOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly apiCredentialPepper?: string;
|
||||
readonly recovery?: ClusterControlRecoveryRuntimeOptions;
|
||||
readonly scheduler?: ClusterSchedulerRuntimeOptions;
|
||||
readonly cancellationConvergence?: ClusterRunCancellationConvergenceRuntimeOptions;
|
||||
readonly workerRuntime?: ClusterWorkerRuntimeDependencies;
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly create: (
|
||||
input: ClusterControlAssemblyInput,
|
||||
) => ClusterControlActivationStack;
|
||||
readonly audit: (
|
||||
record: ClusterControlActivationAudit,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface PreparedRecoveryRuntime {
|
||||
readonly providers: readonly ClusterControlRecoveryExecutorEvidenceProvider[];
|
||||
readonly providerTimeoutMs: number;
|
||||
readonly ownerId: string;
|
||||
readonly claimLimit: number;
|
||||
readonly claimLeaseMs: number;
|
||||
readonly retryDelayMs: number;
|
||||
readonly maxStartupPasses: number;
|
||||
}
|
||||
|
||||
interface PreparedSchedulerRuntime {
|
||||
readonly ownerId: string;
|
||||
readonly claimLeaseMs: number;
|
||||
readonly maxClaimsPerCycle: number;
|
||||
readonly misfireGraceMs: number;
|
||||
readonly intervalMs: number;
|
||||
readonly stopTimeoutMs: number;
|
||||
readonly onDiagnostic?: ClusterSchedulerRuntimeOptions['onDiagnostic'];
|
||||
}
|
||||
|
||||
interface PreparedCancellationConvergenceRuntime {
|
||||
readonly pageSize: number;
|
||||
readonly maxPages: number;
|
||||
readonly intervalMs: number;
|
||||
readonly stopTimeoutMs: number;
|
||||
readonly onDiagnostic?: ClusterRunCancellationConvergenceRuntimeOptions['onDiagnostic'];
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
name: string,
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const normalized = value ?? fallback;
|
||||
if (
|
||||
!Number.isSafeInteger(normalized) ||
|
||||
normalized < minimum ||
|
||||
normalized > maximum
|
||||
) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function prepareRecoveryRuntime(
|
||||
options: ClusterControlRecoveryRuntimeOptions | undefined,
|
||||
): PreparedRecoveryRuntime {
|
||||
if (!options) {
|
||||
throw new TypeError(
|
||||
'Enabled cluster-control requires bounded recovery configuration',
|
||||
);
|
||||
}
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(options.ownerId)) {
|
||||
throw new TypeError('Cluster-control recovery ownerId is invalid');
|
||||
}
|
||||
const claimLeaseMs = boundedInteger(
|
||||
'Cluster-control recovery claim lease',
|
||||
options.claimLeaseMs,
|
||||
30_000,
|
||||
1_000,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_CLAIM_LEASE_MS,
|
||||
);
|
||||
const providerTimeoutMs = boundedInteger(
|
||||
'Cluster-control recovery evidence timeout',
|
||||
options.providerTimeoutMs,
|
||||
5_000,
|
||||
1,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_EVIDENCE_TIMEOUT_MS,
|
||||
);
|
||||
if (providerTimeoutMs + 250 > claimLeaseMs) {
|
||||
throw new RangeError(
|
||||
'Cluster-control recovery evidence timeout must leave at least 250ms for fenced settlement',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
providers: Object.freeze([...(options.providers ?? [])]),
|
||||
providerTimeoutMs,
|
||||
ownerId: options.ownerId,
|
||||
claimLimit: boundedInteger(
|
||||
'Cluster-control recovery claim limit',
|
||||
options.claimLimit,
|
||||
16,
|
||||
1,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_CLAIMS_PER_PASS,
|
||||
),
|
||||
claimLeaseMs,
|
||||
retryDelayMs: boundedInteger(
|
||||
'Cluster-control recovery retry delay',
|
||||
options.retryDelayMs,
|
||||
5_000,
|
||||
0,
|
||||
MAX_CLUSTER_CONTROL_RECOVERY_RETRY_DELAY_MS,
|
||||
),
|
||||
maxStartupPasses: boundedInteger(
|
||||
'Cluster-control startup recovery passes',
|
||||
options.maxStartupPasses,
|
||||
8,
|
||||
1,
|
||||
MAX_CLUSTER_CONTROL_STARTUP_RECOVERY_PASSES,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function prepareSchedulerRuntime(
|
||||
options: ClusterSchedulerRuntimeOptions | undefined,
|
||||
fallbackOwnerId: string,
|
||||
): PreparedSchedulerRuntime {
|
||||
const allowedKeys = new Set([
|
||||
'claimLeaseMs',
|
||||
'intervalMs',
|
||||
'maxClaimsPerCycle',
|
||||
'misfireGraceMs',
|
||||
'onDiagnostic',
|
||||
'ownerId',
|
||||
'stopTimeoutMs',
|
||||
]);
|
||||
if (
|
||||
options !== undefined &&
|
||||
(!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => !allowedKeys.has(key)))
|
||||
) {
|
||||
throw new TypeError('Cluster scheduler configuration is invalid');
|
||||
}
|
||||
const ownerId = options?.ownerId ?? fallbackOwnerId;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(ownerId)) {
|
||||
throw new TypeError('Cluster scheduler ownerId is invalid');
|
||||
}
|
||||
if (
|
||||
options?.onDiagnostic !== undefined &&
|
||||
typeof options.onDiagnostic !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster scheduler diagnostic sink is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
ownerId,
|
||||
claimLeaseMs: boundedInteger(
|
||||
'Cluster scheduler claim lease',
|
||||
options?.claimLeaseMs,
|
||||
30_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
maxClaimsPerCycle: boundedInteger(
|
||||
'Cluster scheduler claim budget',
|
||||
options?.maxClaimsPerCycle,
|
||||
16,
|
||||
1,
|
||||
MAX_CLUSTER_SCHEDULE_CLAIMS_PER_CYCLE,
|
||||
),
|
||||
misfireGraceMs: boundedInteger(
|
||||
'Cluster scheduler misfire grace',
|
||||
options?.misfireGraceMs,
|
||||
30_000,
|
||||
0,
|
||||
5 * 60_000,
|
||||
),
|
||||
intervalMs: boundedInteger(
|
||||
'Cluster scheduler interval',
|
||||
options?.intervalMs,
|
||||
1_000,
|
||||
250,
|
||||
60 * 60_000,
|
||||
),
|
||||
stopTimeoutMs: boundedInteger(
|
||||
'Cluster scheduler stop timeout',
|
||||
options?.stopTimeoutMs,
|
||||
10_000,
|
||||
100,
|
||||
30_000,
|
||||
),
|
||||
...(options?.onDiagnostic === undefined
|
||||
? {}
|
||||
: { onDiagnostic: options.onDiagnostic }),
|
||||
});
|
||||
}
|
||||
|
||||
function prepareCancellationConvergenceRuntime(
|
||||
options: ClusterRunCancellationConvergenceRuntimeOptions | undefined,
|
||||
): PreparedCancellationConvergenceRuntime {
|
||||
const allowedKeys = new Set([
|
||||
'intervalMs',
|
||||
'maxPages',
|
||||
'onDiagnostic',
|
||||
'pageSize',
|
||||
'stopTimeoutMs',
|
||||
]);
|
||||
if (
|
||||
options !== undefined &&
|
||||
(!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => !allowedKeys.has(key)))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Run cancellation convergence configuration is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
options?.onDiagnostic !== undefined &&
|
||||
typeof options.onDiagnostic !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Run cancellation convergence diagnostic sink is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
pageSize: boundedInteger(
|
||||
'Cluster Run cancellation convergence page size',
|
||||
options?.pageSize,
|
||||
32,
|
||||
1,
|
||||
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGE_SIZE,
|
||||
),
|
||||
maxPages: boundedInteger(
|
||||
'Cluster Run cancellation convergence page limit',
|
||||
options?.maxPages,
|
||||
4,
|
||||
1,
|
||||
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGES_PER_CYCLE,
|
||||
),
|
||||
intervalMs: boundedInteger(
|
||||
'Cluster Run cancellation convergence interval',
|
||||
options?.intervalMs,
|
||||
1_000,
|
||||
250,
|
||||
60 * 60_000,
|
||||
),
|
||||
stopTimeoutMs: boundedInteger(
|
||||
'Cluster Run cancellation convergence stop timeout',
|
||||
options?.stopTimeoutMs,
|
||||
10_000,
|
||||
100,
|
||||
30_000,
|
||||
),
|
||||
...(options?.onDiagnostic === undefined
|
||||
? {}
|
||||
: { onDiagnostic: options.onDiagnostic }),
|
||||
});
|
||||
}
|
||||
|
||||
function readinessEvidence(
|
||||
report: PostgresSchemaReadinessReport,
|
||||
): ClusterControlReadinessEvidence {
|
||||
return Object.freeze({
|
||||
contractName: report.contractName,
|
||||
contractVersion: report.contractVersion,
|
||||
serverMajor: report.serverMajor,
|
||||
migrationIds: Object.freeze([...report.migrationIds]),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the cluster database around the readiness-first activation gate.
|
||||
* Repository and service construction happens through create() only after the
|
||||
* runtime role, migration history and catalog contract are proven ready.
|
||||
*/
|
||||
export async function bootstrapClusterControlRuntime(
|
||||
options: ClusterControlBootstrapOptions,
|
||||
): Promise<ClusterControlRuntimeActivationResult> {
|
||||
let recoveryRuntime: PreparedRecoveryRuntime | undefined;
|
||||
let schedulerRuntime: PreparedSchedulerRuntime | undefined;
|
||||
let cancellationConvergenceRuntime:
|
||||
| PreparedCancellationConvergenceRuntime
|
||||
| undefined;
|
||||
let recoveryRegistry: ClusterControlRecoveryEvidenceRegistry | undefined;
|
||||
if ((options.enabled ?? false) && options.profile === 'cluster-control') {
|
||||
assertClusterControlApiCredentialPepper(options.apiCredentialPepper ?? '');
|
||||
recoveryRuntime = prepareRecoveryRuntime(options.recovery);
|
||||
schedulerRuntime = prepareSchedulerRuntime(
|
||||
options.scheduler,
|
||||
recoveryRuntime.ownerId,
|
||||
);
|
||||
cancellationConvergenceRuntime = prepareCancellationConvergenceRuntime(
|
||||
options.cancellationConvergence,
|
||||
);
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const closeDatabase = (): Promise<void> => {
|
||||
if (!database) return Promise.resolve();
|
||||
closePromise ??= Promise.resolve().then(() => database!.close());
|
||||
return closePromise;
|
||||
};
|
||||
|
||||
try {
|
||||
const activation = await activateClusterControlRuntime({
|
||||
...(options.enabled === undefined ? {} : { enabled: options.enabled }),
|
||||
profile: options.profile,
|
||||
readiness: {
|
||||
async assertReady() {
|
||||
if (database) {
|
||||
throw new Error(
|
||||
'Cluster-control database was opened more than once',
|
||||
);
|
||||
}
|
||||
database = await options.openDatabase();
|
||||
return readinessEvidence(
|
||||
await assertPostgresSchemaReady(database.pool),
|
||||
);
|
||||
},
|
||||
},
|
||||
create(evidence) {
|
||||
if (!database) {
|
||||
throw new Error(
|
||||
'Cluster-control database is unavailable after readiness',
|
||||
);
|
||||
}
|
||||
const recovery = new PostgresClusterControlRecoverySource(
|
||||
database.pool,
|
||||
);
|
||||
const recoveryClaims =
|
||||
new PostgresClusterControlRecoveryClaimRepository(database.pool);
|
||||
const recoveryTransitions =
|
||||
new PostgresClusterControlRecoveryResolutionRepository(database.pool);
|
||||
if (!recoveryRuntime) {
|
||||
throw new Error(
|
||||
'Cluster-control recovery runtime is unavailable after readiness',
|
||||
);
|
||||
}
|
||||
if (!schedulerRuntime) {
|
||||
throw new Error(
|
||||
'Cluster scheduler runtime is unavailable after readiness',
|
||||
);
|
||||
}
|
||||
if (!cancellationConvergenceRuntime) {
|
||||
throw new Error(
|
||||
'Cluster Run cancellation convergence runtime is unavailable after readiness',
|
||||
);
|
||||
}
|
||||
const remoteAttestations =
|
||||
new PostgresWorkerExecutionAttestationRepository(database.pool);
|
||||
recoveryRegistry = new ClusterControlRecoveryEvidenceRegistry(
|
||||
[
|
||||
...recoveryRuntime.providers,
|
||||
new PostgresRemoteWorkerAttestationEvidenceProvider(
|
||||
database.pool,
|
||||
remoteAttestations,
|
||||
),
|
||||
],
|
||||
{ timeoutMs: recoveryRuntime.providerTimeoutMs },
|
||||
);
|
||||
const recoveryProcessor =
|
||||
new EvidenceBasedClusterControlRecoveryProcessor(
|
||||
recoveryTransitions,
|
||||
recoveryRegistry,
|
||||
{ retryDelayMs: recoveryRuntime.retryDelayMs },
|
||||
);
|
||||
const recoverySupervisor = new ClusterControlRecoverySupervisor(
|
||||
recoveryClaims,
|
||||
recoveryProcessor,
|
||||
{
|
||||
ownerId: recoveryRuntime.ownerId,
|
||||
limit: recoveryRuntime.claimLimit,
|
||||
leaseMs: recoveryRuntime.claimLeaseMs,
|
||||
retryDelayMs: recoveryRuntime.retryDelayMs,
|
||||
},
|
||||
);
|
||||
const recoveryCoordinator =
|
||||
new ClusterControlStartupRecoveryCoordinator(recoverySupervisor, {
|
||||
maxPasses: recoveryRuntime.maxStartupPasses,
|
||||
});
|
||||
const runtimeRecoverySupervisor = new ClusterControlRecoverySupervisor(
|
||||
new PostgresClusterControlRecoveryClaimRepository(
|
||||
database.pool,
|
||||
randomUUID,
|
||||
(queryable) => new PostgresClusterRuntimeRecoverySource(queryable),
|
||||
),
|
||||
recoveryProcessor,
|
||||
{
|
||||
ownerId: recoveryRuntime.ownerId,
|
||||
limit: recoveryRuntime.claimLimit,
|
||||
leaseMs: recoveryRuntime.claimLeaseMs,
|
||||
retryDelayMs: recoveryRuntime.retryDelayMs,
|
||||
},
|
||||
);
|
||||
const schedules = new PostgresClusterScheduleRepository(database.pool);
|
||||
const runs = new PostgresRunRepository(database.pool);
|
||||
const trustedToolStorage: ClusterTrustedToolStorage = Object.freeze({
|
||||
invocationArtifacts: new PostgresToolInvocationArtifactRepository(
|
||||
database.pool,
|
||||
),
|
||||
stepRuns: new PostgresStepRunRepository(database.pool),
|
||||
startBarriers: new PostgresToolExecutionStartBarrierRepository(
|
||||
database.pool,
|
||||
),
|
||||
completions: new PostgresToolExecutionCompletionRepository(
|
||||
database.pool,
|
||||
),
|
||||
failureCompletions:
|
||||
new PostgresToolExecutionFailureCompletionRepository(database.pool),
|
||||
resultKeyCatalog: new PostgresToolResultKeyCatalogReader(
|
||||
database.pool,
|
||||
),
|
||||
resultRekeys: new PostgresToolResultRekeyReader(database.pool),
|
||||
toolDefinitionSnapshots:
|
||||
new PostgresProjectToolDefinitionSnapshotRepository(database.pool),
|
||||
});
|
||||
const workflowScheduler = new ClusterWorkflowSchedulerCoordinator(
|
||||
new ClusterSchedulerCoordinator(schedules, {
|
||||
ownerId: schedulerRuntime.ownerId,
|
||||
claimLeaseMs: schedulerRuntime.claimLeaseMs,
|
||||
maxClaimsPerCycle: schedulerRuntime.maxClaimsPerCycle,
|
||||
misfireGraceMs: schedulerRuntime.misfireGraceMs,
|
||||
}),
|
||||
new PostgresPluginPackageWorkflowFrontierRepository(database.pool),
|
||||
new PostgresPluginPackageWorkflowTaskAttemptAdmissionRepository(
|
||||
database.pool,
|
||||
),
|
||||
{
|
||||
frontierPageSize: 32,
|
||||
frontierMaxPages: 4,
|
||||
taskAttemptPageSize: 32,
|
||||
taskAttemptMaxPages: 4,
|
||||
},
|
||||
);
|
||||
const runtimeScheduler = new ClusterRuntimeSchedulerCoordinator(
|
||||
runtimeRecoverySupervisor,
|
||||
new ClusterRunLostRetryCoordinator(
|
||||
new PostgresClusterRunLostRetryRepository(database.pool),
|
||||
{ pageSize: 16 },
|
||||
),
|
||||
workflowScheduler,
|
||||
);
|
||||
const schedulerLifecycle = new ClusterSchedulerLifecycle(
|
||||
runtimeScheduler,
|
||||
{
|
||||
intervalMs: schedulerRuntime.intervalMs,
|
||||
stopTimeoutMs: schedulerRuntime.stopTimeoutMs,
|
||||
...(schedulerRuntime.onDiagnostic === undefined
|
||||
? {}
|
||||
: { onDiagnostic: schedulerRuntime.onDiagnostic }),
|
||||
},
|
||||
);
|
||||
const cancellationConvergenceLifecycle =
|
||||
new ClusterRunCancellationConvergenceLifecycle(
|
||||
new ClusterRunCancellationConvergenceCoordinator(
|
||||
new PostgresClusterRunCancellationConvergenceRepository(
|
||||
database.pool,
|
||||
),
|
||||
{
|
||||
pageSize: cancellationConvergenceRuntime.pageSize,
|
||||
maxPages: cancellationConvergenceRuntime.maxPages,
|
||||
},
|
||||
),
|
||||
{
|
||||
intervalMs: cancellationConvergenceRuntime.intervalMs,
|
||||
stopTimeoutMs: cancellationConvergenceRuntime.stopTimeoutMs,
|
||||
...(cancellationConvergenceRuntime.onDiagnostic === undefined
|
||||
? {}
|
||||
: {
|
||||
onDiagnostic: cancellationConvergenceRuntime.onDiagnostic,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const runCancellation = new PostgresClusterRunCancellationRepository(
|
||||
database.pool,
|
||||
);
|
||||
const taskStart = new PostgresTaskStartRepository(database.pool);
|
||||
const application = options.create({
|
||||
evidence,
|
||||
authenticator: createClusterControlApiCredentialAuthenticator(
|
||||
new PostgresApiCredentialRepository(database.pool),
|
||||
options.apiCredentialPepper ?? '',
|
||||
),
|
||||
policies: new PostgresProjectPolicyRepository(database.pool),
|
||||
runs,
|
||||
runCancellation,
|
||||
taskStart,
|
||||
taskDefinitions: new PostgresTaskDefinitionSource(database.pool),
|
||||
taskExecutionRevisions: new PostgresTaskExecutionRevisionSource(
|
||||
database.pool,
|
||||
),
|
||||
triggers: new PostgresTriggerSource(database.pool),
|
||||
schedules,
|
||||
trustedToolStorage,
|
||||
securityAudit: new PostgresSecurityAuditRepository(database.pool),
|
||||
workflowAdministration:
|
||||
createClusterPluginPackageWorkflowAdministrationCapability(
|
||||
new PostgresPluginPackageAutomationPublicationRepository(
|
||||
database.pool,
|
||||
),
|
||||
new PostgresPluginPackageMaterializedRevisionRepository(
|
||||
database.pool,
|
||||
),
|
||||
new PostgresAuthorizedPluginPackageWorkflowAdmissionRepository(
|
||||
database.pool,
|
||||
),
|
||||
new PostgresAuthorizedPluginPackageWorkflowRunInspectionRepository(
|
||||
database.pool,
|
||||
),
|
||||
new PostgresAuthorizedPluginPackageWorkflowRunListRepository(
|
||||
database.pool,
|
||||
),
|
||||
new PostgresAuthorizedPluginPackageWorkflowStepRunListRepository(
|
||||
database.pool,
|
||||
),
|
||||
new PostgresAuthorizedPluginPackageWorkflowRunEventListRepository(
|
||||
database.pool,
|
||||
),
|
||||
runCancellation,
|
||||
),
|
||||
...(options.workerRuntime === undefined
|
||||
? {}
|
||||
: {
|
||||
workerRuntime: createClusterWorkerRuntimePort(
|
||||
database.pool,
|
||||
options.workerRuntime,
|
||||
),
|
||||
}),
|
||||
});
|
||||
const convergence = new ClusterControlRecoveryConvergenceVerifier(
|
||||
recovery,
|
||||
);
|
||||
return {
|
||||
async reconcile() {
|
||||
const outstanding = await convergence.verify();
|
||||
if (!outstanding.safe) {
|
||||
const system = await recoveryCoordinator.reconcile();
|
||||
if (
|
||||
!system.safe ||
|
||||
system.remaining !== 0 ||
|
||||
system.failed !== 0
|
||||
) {
|
||||
return system;
|
||||
}
|
||||
}
|
||||
const summary = await application.reconcile();
|
||||
if (
|
||||
!summary.safe ||
|
||||
summary.remaining !== 0 ||
|
||||
summary.failed !== 0
|
||||
) {
|
||||
return summary;
|
||||
}
|
||||
return convergence.verify();
|
||||
},
|
||||
async startLifecycles() {
|
||||
if (!(await application.startLifecycles())) return false;
|
||||
schedulerLifecycle.start();
|
||||
cancellationConvergenceLifecycle.start();
|
||||
return true;
|
||||
},
|
||||
installAdmission: () => application.installAdmission(),
|
||||
async stop() {
|
||||
recoveryRegistry?.dispose();
|
||||
let schedulerStatus: 'stopped' | 'timed_out' = 'stopped';
|
||||
let cancellationStatus: 'stopped' | 'timed_out' = 'stopped';
|
||||
let applicationStatus: ClusterControlStopResult = 'stopped';
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
cancellationStatus = (
|
||||
await cancellationConvergenceLifecycle.stopAndDrain()
|
||||
).status;
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
schedulerStatus = (await schedulerLifecycle.stopAndDrain())
|
||||
.status;
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
try {
|
||||
applicationStatus = await application.stop();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return cancellationStatus === 'timed_out' ||
|
||||
schedulerStatus === 'timed_out' ||
|
||||
applicationStatus === 'timed_out'
|
||||
? 'timed_out'
|
||||
: 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
audit: options.audit,
|
||||
});
|
||||
if (activation.status === 'disabled') return activation;
|
||||
|
||||
let stopPromise: Promise<ClusterControlStopResult> | undefined;
|
||||
return {
|
||||
...activation,
|
||||
stop() {
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
let result: ClusterControlStopResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
result = await activation.stop();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await closeDatabase();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result!;
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
recoveryRegistry?.dispose();
|
||||
try {
|
||||
await closeDatabase();
|
||||
} catch {
|
||||
// Preserve the readiness/assembly/activation failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
ClusterControlActivationAudit,
|
||||
ClusterControlActivationStack,
|
||||
ClusterControlReadinessEvidence,
|
||||
ClusterControlRuntimeActivationResult,
|
||||
ClusterControlStopResult,
|
||||
DeploymentProfile,
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
ProjectPolicyRepository,
|
||||
RunRepository,
|
||||
ClusterRunCancellationRepository,
|
||||
ClusterControlRequestAuthenticator,
|
||||
ClusterControlSecurityAuditSink,
|
||||
ClusterControlRecoveryExecutorEvidenceProvider,
|
||||
ClusterScheduleStore,
|
||||
};
|
||||
|
||||
export { ClusterRunCancellationConvergenceLifecycle } from '../run/runCancellationLifecycle';
|
||||
|
||||
export * from '../scheduling/scheduler';
|
||||
export * from '../scheduling/workflowScheduler';
|
||||
export * from '../scheduling/runtimeScheduler';
|
||||
export * from '../remote-execution/remoteWorkerDispatcher';
|
||||
export * from '../remote-execution/workerRuntimePort';
|
||||
@@ -0,0 +1,410 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
ClusterControlStartupRecoverySummary,
|
||||
ClusterControlStopResult,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type { RemoteWorkerSecretValueProvider } from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import {
|
||||
startClusterControlApplication,
|
||||
type ClusterControlApplicationOptions,
|
||||
type ClusterControlApplicationResult,
|
||||
type ClusterControlApplicationStack,
|
||||
} from './application';
|
||||
import {
|
||||
createClusterControlDatabaseBinding,
|
||||
type EnabledClusterControlConfig,
|
||||
} from '../production-process/config';
|
||||
import {
|
||||
createClusterControlAdmissionPipeline,
|
||||
createClusterControlProjectPolicyAuthorizer,
|
||||
} from '../transport/admissionPipeline';
|
||||
import { createClusterControlRouteRegistry } from '../transport/routeRegistry';
|
||||
import { CLUSTER_CONTROL_HTTP_DEFAULTS } from '../transport/httpSurface';
|
||||
import { createClusterControlRunReadRoute } from '../run/runReadRoute';
|
||||
import { createClusterControlRunListRoute } from '../run/runListRoute';
|
||||
import { createClusterControlRunEventListRoute } from '../run/runEventListRoute';
|
||||
import { createClusterControlRunStepListRoute } from '../run/runStepListRoute';
|
||||
import { createClusterControlTaskListRoute } from '../task/taskListRoute';
|
||||
import { createClusterControlTaskReadRoute } from '../task/taskReadRoute';
|
||||
import { createClusterControlTaskStartRoute } from '../task/taskStartRoute';
|
||||
import {
|
||||
createClusterControlPluginPackagePromptExecutionRoute,
|
||||
type ClusterPluginPackagePromptExecutionCapability,
|
||||
} from '../plugin-package/prompt/pluginPackagePromptExecutionRoute';
|
||||
import {
|
||||
createClusterControlPluginPackagePromptCatalogRoute,
|
||||
type ClusterPluginPackagePromptCatalogCapability,
|
||||
} from '../plugin-package/prompt/pluginPackagePromptCatalogRoute';
|
||||
import {
|
||||
createClusterControlPluginPackagePromptOutputReadRoute,
|
||||
type ClusterPluginPackagePromptOutputReadCapability,
|
||||
} from '../plugin-package/prompt/pluginPackagePromptOutputReadRoute';
|
||||
import {
|
||||
createClusterControlPluginPackagePromptExecutionInspectionRoute,
|
||||
type ClusterPluginPackagePromptExecutionInspectionCapability,
|
||||
} from '../plugin-package/prompt/pluginPackagePromptExecutionInspectionRoute';
|
||||
import {
|
||||
createClusterControlPluginPackagePromptExecutionOutputReadRoute,
|
||||
type ClusterPluginPackagePromptExecutionOutputReadCapability,
|
||||
} from '../plugin-package/prompt/pluginPackagePromptExecutionOutputReadRoute';
|
||||
import {
|
||||
createClusterControlRunCancellationRoute,
|
||||
type ClusterRunCancellationEventIdFactory,
|
||||
} from '../run/runCancellationRoute';
|
||||
import type { ClusterControlAssemblyInput } from './clusterControlRuntime';
|
||||
import type { ClusterRemoteWorkerArtifactStore } from '../remote-execution/remoteWorkerCompletionService';
|
||||
import type { EnabledClusterWorkerIngressConfig } from '../worker-ingress/workerIngressConfig';
|
||||
import {
|
||||
startProductionClusterWorkerIngress,
|
||||
type ProductionClusterWorkerIngressOptions as ProductionClusterWorkerIngressStarterOptions,
|
||||
} from '../worker-ingress/productionWorkerIngress';
|
||||
import type { ClusterWorkerIngressApplicationResult } from '../worker-ingress/workerIngressApplication';
|
||||
import { createClusterControlPluginPackageWorkflowRoutes } from '../plugin-package/workflow/pluginPackageWorkflowRoute';
|
||||
|
||||
export const PRODUCTION_CLUSTER_CONTROL_ROUTE_OPERATIONS = Object.freeze([
|
||||
'task.get',
|
||||
'task.list',
|
||||
'task.start',
|
||||
'run.get',
|
||||
'run.list',
|
||||
'run.events.list',
|
||||
'run.steps.list',
|
||||
'run.cancel',
|
||||
'workflow.read',
|
||||
'workflow.run.read',
|
||||
'workflow.run.list',
|
||||
'workflow.step.list',
|
||||
'workflow.event.list',
|
||||
'workflow.start',
|
||||
'workflow.cancel',
|
||||
] as const);
|
||||
export const PRODUCTION_CLUSTER_CONTROL_OPTIONAL_ROUTE_OPERATIONS =
|
||||
Object.freeze([
|
||||
'prompt.read',
|
||||
'prompt.execute',
|
||||
'prompt.execution.read',
|
||||
'prompt.execution.output.read',
|
||||
'prompt.output.read',
|
||||
] as const);
|
||||
|
||||
export interface ProductionClusterControlAssemblyOptions {
|
||||
readonly createEventId?: ClusterRunCancellationEventIdFactory;
|
||||
readonly promptCatalog?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptCatalogCapability;
|
||||
}>;
|
||||
readonly promptExecution?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptExecutionCapability;
|
||||
readonly maxExecutionMs?: number;
|
||||
readonly now?: () => number;
|
||||
}>;
|
||||
readonly promptExecutionInspection?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptExecutionInspectionCapability;
|
||||
readonly now?: () => number;
|
||||
}>;
|
||||
readonly promptOutputRead?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptOutputReadCapability;
|
||||
}>;
|
||||
readonly promptExecutionOutputRead?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptExecutionOutputReadCapability;
|
||||
}>;
|
||||
readonly workerIngress?: Readonly<{
|
||||
readonly config: EnabledClusterWorkerIngressConfig;
|
||||
readonly onDiagnostic?: (error: unknown) => void | Promise<void>;
|
||||
}>;
|
||||
readonly startWorkerIngress?: (
|
||||
options: ProductionClusterWorkerIngressStarterOptions,
|
||||
) => Promise<ClusterWorkerIngressApplicationResult>;
|
||||
}
|
||||
|
||||
export interface ProductionClusterWorkerIngressOptions {
|
||||
readonly config: EnabledClusterWorkerIngressConfig;
|
||||
readonly artifactStore: ClusterRemoteWorkerArtifactStore;
|
||||
readonly secretProvider?: RemoteWorkerSecretValueProvider;
|
||||
readonly onDiagnostic?: (error: unknown) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProductionClusterControlApplicationOptions
|
||||
extends Omit<
|
||||
ClusterControlApplicationOptions,
|
||||
| 'create'
|
||||
| 'enabled'
|
||||
| 'profile'
|
||||
| 'apiCredentialPepper'
|
||||
| 'openDatabase'
|
||||
| 'availability'
|
||||
| 'http'
|
||||
| 'workerRuntime'
|
||||
> {
|
||||
readonly config: EnabledClusterControlConfig;
|
||||
readonly createEventId?: ClusterRunCancellationEventIdFactory;
|
||||
readonly promptCatalog?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptCatalogCapability;
|
||||
}>;
|
||||
readonly promptExecution?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptExecutionCapability;
|
||||
}>;
|
||||
readonly promptExecutionInspection?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptExecutionInspectionCapability;
|
||||
}>;
|
||||
readonly promptOutputRead?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptOutputReadCapability;
|
||||
}>;
|
||||
readonly promptExecutionOutputRead?: Readonly<{
|
||||
readonly capability: ClusterPluginPackagePromptExecutionOutputReadCapability;
|
||||
}>;
|
||||
readonly workerIngress?: ProductionClusterWorkerIngressOptions;
|
||||
}
|
||||
|
||||
const SAFE_RECOVERY: Readonly<ClusterControlStartupRecoverySummary> =
|
||||
Object.freeze({ safe: true, remaining: 0, failed: 0 });
|
||||
|
||||
function eventIdFactory(
|
||||
candidate: ClusterRunCancellationEventIdFactory | undefined,
|
||||
): ClusterRunCancellationEventIdFactory {
|
||||
if (candidate !== undefined && typeof candidate !== 'function') {
|
||||
throw new TypeError(
|
||||
'Production cluster-control event ID factory is invalid',
|
||||
);
|
||||
}
|
||||
return candidate ?? randomUUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reviewed production business surface. Bootstrap still owns PostgreSQL,
|
||||
* startup recovery, the scheduler and cancellation convergence; this stack
|
||||
* owns only the exact route allowlist and its admission pipeline.
|
||||
*/
|
||||
export function createProductionClusterControlApplicationStack(
|
||||
input: ClusterControlAssemblyInput,
|
||||
options: ProductionClusterControlAssemblyOptions = {},
|
||||
): ClusterControlApplicationStack {
|
||||
const createEventId = eventIdFactory(options.createEventId);
|
||||
const workerIngressStarter =
|
||||
options.startWorkerIngress ?? startProductionClusterWorkerIngress;
|
||||
if (typeof workerIngressStarter !== 'function') {
|
||||
throw new TypeError('Production Worker ingress starter is invalid');
|
||||
}
|
||||
const routeDefinitions = [
|
||||
createClusterControlTaskReadRoute(input.taskDefinitions),
|
||||
createClusterControlTaskListRoute(input.taskDefinitions),
|
||||
createClusterControlTaskStartRoute(input.taskStart, createEventId),
|
||||
createClusterControlRunReadRoute(input.runs),
|
||||
createClusterControlRunListRoute(input.runs),
|
||||
createClusterControlRunEventListRoute(input.runs),
|
||||
createClusterControlRunStepListRoute(
|
||||
input.runs,
|
||||
input.trustedToolStorage.stepRuns,
|
||||
),
|
||||
createClusterControlRunCancellationRoute(
|
||||
input.runCancellation,
|
||||
createEventId,
|
||||
),
|
||||
...createClusterControlPluginPackageWorkflowRoutes(
|
||||
input.workflowAdministration,
|
||||
Date.now,
|
||||
createEventId,
|
||||
),
|
||||
...(options.promptCatalog === undefined
|
||||
? []
|
||||
: [
|
||||
createClusterControlPluginPackagePromptCatalogRoute(
|
||||
options.promptCatalog.capability,
|
||||
),
|
||||
]),
|
||||
...(options.promptExecution === undefined
|
||||
? []
|
||||
: [
|
||||
createClusterControlPluginPackagePromptExecutionRoute(
|
||||
options.promptExecution.capability,
|
||||
{
|
||||
...(options.promptExecution.maxExecutionMs === undefined
|
||||
? {}
|
||||
: { maxExecutionMs: options.promptExecution.maxExecutionMs }),
|
||||
...(options.promptExecution.now === undefined
|
||||
? {}
|
||||
: { now: options.promptExecution.now }),
|
||||
createEventId,
|
||||
},
|
||||
),
|
||||
]),
|
||||
...(options.promptExecutionInspection === undefined
|
||||
? []
|
||||
: [
|
||||
createClusterControlPluginPackagePromptExecutionInspectionRoute(
|
||||
options.promptExecutionInspection.capability,
|
||||
{
|
||||
...(options.promptExecutionInspection.now === undefined
|
||||
? {}
|
||||
: { now: options.promptExecutionInspection.now }),
|
||||
createEventId,
|
||||
},
|
||||
),
|
||||
]),
|
||||
...(options.promptOutputRead === undefined
|
||||
? []
|
||||
: [
|
||||
createClusterControlPluginPackagePromptOutputReadRoute(
|
||||
options.promptOutputRead.capability,
|
||||
),
|
||||
]),
|
||||
...(options.promptExecutionOutputRead === undefined
|
||||
? []
|
||||
: [
|
||||
createClusterControlPluginPackagePromptExecutionOutputReadRoute(
|
||||
options.promptExecutionOutputRead.capability,
|
||||
),
|
||||
]),
|
||||
];
|
||||
const routes = createClusterControlRouteRegistry(routeDefinitions);
|
||||
const expectedRouteCount =
|
||||
PRODUCTION_CLUSTER_CONTROL_ROUTE_OPERATIONS.length +
|
||||
(options.promptCatalog === undefined ? 0 : 1) +
|
||||
(options.promptExecution === undefined ? 0 : 1) +
|
||||
(options.promptExecutionInspection === undefined ? 0 : 1) +
|
||||
(options.promptOutputRead === undefined ? 0 : 1) +
|
||||
(options.promptExecutionOutputRead === undefined ? 0 : 1);
|
||||
if (routes.size !== expectedRouteCount) {
|
||||
throw new Error('Production cluster-control route allowlist is incomplete');
|
||||
}
|
||||
const admission = createClusterControlAdmissionPipeline({
|
||||
routes,
|
||||
authenticator: input.authenticator,
|
||||
policy: createClusterControlProjectPolicyAuthorizer(input.policies),
|
||||
audit: input.securityAudit,
|
||||
});
|
||||
let workerIngress:
|
||||
| Extract<ClusterWorkerIngressApplicationResult, { status: 'active' }>
|
||||
| undefined;
|
||||
let workerIngressStart:
|
||||
| Promise<
|
||||
Extract<ClusterWorkerIngressApplicationResult, { status: 'active' }>
|
||||
>
|
||||
| undefined;
|
||||
let workerIngressUnavailable: unknown;
|
||||
let workerIngressStop: Promise<'stopped'> | undefined;
|
||||
const stopWorkerIngress = (): Promise<'stopped'> => {
|
||||
if (!workerIngress) return Promise.resolve('stopped' as const);
|
||||
workerIngressStop ??= workerIngress.stop();
|
||||
return workerIngressStop;
|
||||
};
|
||||
const startWorkerIngress = async (): Promise<void> => {
|
||||
const ingress = options.workerIngress;
|
||||
if (!ingress) return;
|
||||
if (!input.workerRuntime) {
|
||||
throw new Error(
|
||||
'Production Worker ingress requires an injected runtime service port',
|
||||
);
|
||||
}
|
||||
workerIngressStart ??= (async () => {
|
||||
const result = await workerIngressStarter({
|
||||
config: ingress.config,
|
||||
runtime: input.workerRuntime!,
|
||||
onPoolError(error) {
|
||||
workerIngressUnavailable ??= error;
|
||||
void Promise.resolve(ingress.onDiagnostic?.(error)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
void stopWorkerIngress().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
if (result.status !== 'active') {
|
||||
throw new Error('Production Worker ingress did not activate');
|
||||
}
|
||||
workerIngress = result;
|
||||
if (workerIngressUnavailable !== undefined) {
|
||||
await stopWorkerIngress();
|
||||
throw new Error(
|
||||
'Production Worker ingress database became unavailable during activation',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
await workerIngressStart;
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async reconcile(): Promise<ClusterControlStartupRecoverySummary> {
|
||||
return SAFE_RECOVERY;
|
||||
},
|
||||
async startLifecycles(): Promise<boolean> {
|
||||
await startWorkerIngress();
|
||||
return true;
|
||||
},
|
||||
admission,
|
||||
async stop(): Promise<ClusterControlStopResult> {
|
||||
await stopWorkerIngress();
|
||||
return 'stopped';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts cluster-control with the reviewed production route allowlist. */
|
||||
export function startProductionClusterControlApplication(
|
||||
options: ProductionClusterControlApplicationOptions,
|
||||
): Promise<ClusterControlApplicationResult> {
|
||||
const createEventId = eventIdFactory(options.createEventId);
|
||||
const {
|
||||
createEventId: _ignoredCreateEventId,
|
||||
config,
|
||||
workerIngress,
|
||||
promptCatalog,
|
||||
promptExecution,
|
||||
promptExecutionInspection,
|
||||
promptOutputRead,
|
||||
promptExecutionOutputRead,
|
||||
...applicationOptions
|
||||
} = options;
|
||||
const database = createClusterControlDatabaseBinding(config);
|
||||
return startClusterControlApplication({
|
||||
...applicationOptions,
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
apiCredentialPepper: config.security.apiCredentialPepper,
|
||||
http: config.http,
|
||||
...(workerIngress === undefined
|
||||
? {}
|
||||
: {
|
||||
workerRuntime: {
|
||||
artifactStore: workerIngress.artifactStore,
|
||||
...(workerIngress.secretProvider === undefined
|
||||
? {}
|
||||
: { secretProvider: workerIngress.secretProvider }),
|
||||
},
|
||||
}),
|
||||
...database,
|
||||
create: (input) =>
|
||||
createProductionClusterControlApplicationStack(input, {
|
||||
createEventId,
|
||||
...(promptCatalog === undefined ? {} : { promptCatalog }),
|
||||
...(promptExecution === undefined
|
||||
? {}
|
||||
: {
|
||||
promptExecution: {
|
||||
capability: promptExecution.capability,
|
||||
maxExecutionMs: Math.max(
|
||||
1,
|
||||
(config.http.requestTimeoutMs ??
|
||||
CLUSTER_CONTROL_HTTP_DEFAULTS.requestTimeoutMs) - 100,
|
||||
),
|
||||
},
|
||||
}),
|
||||
...(promptExecutionInspection === undefined
|
||||
? {}
|
||||
: { promptExecutionInspection }),
|
||||
...(promptOutputRead === undefined ? {} : { promptOutputRead }),
|
||||
...(promptExecutionOutputRead === undefined
|
||||
? {}
|
||||
: { promptExecutionOutputRead }),
|
||||
...(workerIngress === undefined
|
||||
? {}
|
||||
: {
|
||||
workerIngress: {
|
||||
config: workerIngress.config,
|
||||
...(workerIngress.onDiagnostic === undefined
|
||||
? {}
|
||||
: { onDiagnostic: workerIngress.onDiagnostic }),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
// Artifact owns immutable S3 evidence, checksum validation, and conditional promotion.
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
ChecksumAlgorithm,
|
||||
ChecksumMode,
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
MetadataDirective,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
ServerSideEncryption,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
MAX_REMOTE_WORKER_ARTIFACT_BYTES,
|
||||
REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
|
||||
normalizeRemoteWorkerArtifactReceipt,
|
||||
type RemoteWorkerArtifactReceipt,
|
||||
} from '@qinglong/runtime-core/remote-worker-completion';
|
||||
import type {
|
||||
ClusterRemoteWorkerArtifactLookup,
|
||||
ClusterRemoteWorkerArtifactStorageCommand,
|
||||
ClusterRemoteWorkerArtifactStore,
|
||||
} from '../remote-execution/remoteWorkerCompletionService';
|
||||
|
||||
const DEFAULT_PREFIX = 'qinglong/v3/worker-artifacts';
|
||||
const METADATA_SCHEMA = 'qinglong-remote-worker-artifact-v1';
|
||||
const TEMPORARY_METADATA_SCHEMA =
|
||||
'qinglong-remote-worker-artifact-temporary-v1';
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const BUCKET_PATTERN = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
|
||||
const PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_=-]{0,254}$/;
|
||||
const TEMPORARY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
type S3SendClient = Pick<S3Client, 'send'>;
|
||||
|
||||
export type S3ClusterRemoteWorkerArtifactEncryption =
|
||||
| Readonly<{ readonly mode: 's3' }>
|
||||
| Readonly<{ readonly mode: 'kms'; readonly keyId: string }>;
|
||||
|
||||
export interface S3ClusterRemoteWorkerArtifactStoreDiagnostic {
|
||||
readonly operation: 'temporary_object_cleanup';
|
||||
}
|
||||
|
||||
export interface S3ClusterRemoteWorkerArtifactStoreOptions {
|
||||
readonly client: S3SendClient;
|
||||
readonly bucket: string;
|
||||
readonly prefix?: string;
|
||||
readonly expectedBucketOwner?: string;
|
||||
readonly encryption: S3ClusterRemoteWorkerArtifactEncryption;
|
||||
readonly createTemporaryId?: () => string;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
context: Readonly<S3ClusterRemoteWorkerArtifactStoreDiagnostic>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface S3ClusterRemoteWorkerArtifactClientOptions {
|
||||
readonly region: string;
|
||||
readonly endpoint?: string;
|
||||
readonly forcePathStyle?: boolean;
|
||||
}
|
||||
|
||||
export function createS3ClusterRemoteWorkerArtifactClient(
|
||||
options: S3ClusterRemoteWorkerArtifactClientOptions,
|
||||
): S3Client {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!/^[a-z0-9][a-z0-9-]{0,62}$/.test(options.region) ||
|
||||
(options.endpoint !== undefined &&
|
||||
typeof options.endpoint !== 'string') ||
|
||||
(options.forcePathStyle !== undefined &&
|
||||
typeof options.forcePathStyle !== 'boolean')
|
||||
) {
|
||||
throw configurationError('client options are invalid');
|
||||
}
|
||||
return new S3Client({
|
||||
region: options.region,
|
||||
...(options.endpoint === undefined
|
||||
? {}
|
||||
: { endpoint: options.endpoint }),
|
||||
forcePathStyle: options.forcePathStyle ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export class S3ClusterRemoteWorkerArtifactStoreError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'unavailable' | 'integrity_mismatch',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`S3 Remote Worker Artifact store failed: ${reason}`, options);
|
||||
this.name = 'S3ClusterRemoteWorkerArtifactStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparedOptions {
|
||||
readonly client: S3SendClient;
|
||||
readonly bucket: string;
|
||||
readonly prefix: string;
|
||||
readonly expectedBucketOwner?: string;
|
||||
readonly encryption: Readonly<{
|
||||
readonly ServerSideEncryption: 'AES256' | 'aws:kms';
|
||||
readonly SSEKMSKeyId?: string;
|
||||
}>;
|
||||
readonly createTemporaryId: () => string;
|
||||
readonly onDiagnostic?: S3ClusterRemoteWorkerArtifactStoreOptions['onDiagnostic'];
|
||||
}
|
||||
|
||||
type ArtifactAuthority = Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
logArtifactId: string;
|
||||
}>;
|
||||
|
||||
type NormalizedStorageCommand = ArtifactAuthority &
|
||||
Readonly<{
|
||||
byteLength: number;
|
||||
truncated?: boolean;
|
||||
}>;
|
||||
|
||||
const DIAGNOSTIC_CONTEXT = Object.freeze({
|
||||
operation: 'temporary_object_cleanup' as const,
|
||||
});
|
||||
|
||||
function configurationError(message: string): TypeError {
|
||||
return new TypeError(`S3 Remote Worker Artifact store is invalid: ${message}`);
|
||||
}
|
||||
|
||||
function prepareOptions(
|
||||
options: S3ClusterRemoteWorkerArtifactStoreOptions,
|
||||
): PreparedOptions {
|
||||
const allowedKeys = new Set([
|
||||
'bucket',
|
||||
'client',
|
||||
'createTemporaryId',
|
||||
'encryption',
|
||||
'expectedBucketOwner',
|
||||
'onDiagnostic',
|
||||
'prefix',
|
||||
]);
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => !allowedKeys.has(key)) ||
|
||||
typeof options.client?.send !== 'function'
|
||||
) {
|
||||
throw configurationError('options are invalid');
|
||||
}
|
||||
if (
|
||||
!BUCKET_PATTERN.test(options.bucket) ||
|
||||
options.bucket.includes('..') ||
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(options.bucket)
|
||||
) {
|
||||
throw configurationError('bucket is invalid');
|
||||
}
|
||||
const prefix = options.prefix ?? DEFAULT_PREFIX;
|
||||
if (
|
||||
!PREFIX_PATTERN.test(prefix) ||
|
||||
prefix.startsWith('/') ||
|
||||
prefix.endsWith('/') ||
|
||||
prefix.includes('//') ||
|
||||
prefix.split('/').some((segment) => segment === '.' || segment === '..')
|
||||
) {
|
||||
throw configurationError('prefix is invalid');
|
||||
}
|
||||
if (
|
||||
options.expectedBucketOwner !== undefined &&
|
||||
!/^\d{12}$/.test(options.expectedBucketOwner)
|
||||
) {
|
||||
throw configurationError('expected bucket owner is invalid');
|
||||
}
|
||||
const encryption = options.encryption;
|
||||
if (!encryption || typeof encryption !== 'object' || Array.isArray(encryption)) {
|
||||
throw configurationError('encryption is required');
|
||||
}
|
||||
let preparedEncryption: PreparedOptions['encryption'];
|
||||
if (
|
||||
encryption.mode === 's3' &&
|
||||
Object.keys(encryption).length === 1
|
||||
) {
|
||||
preparedEncryption = Object.freeze({
|
||||
ServerSideEncryption: ServerSideEncryption.AES256,
|
||||
});
|
||||
} else if (
|
||||
encryption.mode === 'kms' &&
|
||||
Object.keys(encryption).length === 2 &&
|
||||
typeof encryption.keyId === 'string' &&
|
||||
encryption.keyId.length >= 1 &&
|
||||
encryption.keyId.length <= 2048 &&
|
||||
!/[\u0000-\u001f\u007f]/.test(encryption.keyId)
|
||||
) {
|
||||
preparedEncryption = Object.freeze({
|
||||
ServerSideEncryption: ServerSideEncryption.aws_kms,
|
||||
SSEKMSKeyId: encryption.keyId,
|
||||
});
|
||||
} else {
|
||||
throw configurationError('encryption is invalid');
|
||||
}
|
||||
if (
|
||||
options.createTemporaryId !== undefined &&
|
||||
typeof options.createTemporaryId !== 'function'
|
||||
) {
|
||||
throw configurationError('temporary ID factory is invalid');
|
||||
}
|
||||
if (
|
||||
options.onDiagnostic !== undefined &&
|
||||
typeof options.onDiagnostic !== 'function'
|
||||
) {
|
||||
throw configurationError('diagnostic sink is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
client: options.client,
|
||||
bucket: options.bucket,
|
||||
prefix,
|
||||
...(options.expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { expectedBucketOwner: options.expectedBucketOwner }),
|
||||
encryption: preparedEncryption,
|
||||
createTemporaryId: options.createTemporaryId ?? randomUUID,
|
||||
...(options.onDiagnostic === undefined
|
||||
? {}
|
||||
: { onDiagnostic: options.onDiagnostic }),
|
||||
});
|
||||
}
|
||||
|
||||
function receiptCandidate(
|
||||
authority: ArtifactAuthority,
|
||||
byteLength: number,
|
||||
sha256: string,
|
||||
truncated?: boolean,
|
||||
status: RemoteWorkerArtifactReceipt['status'] = 'stored',
|
||||
): Readonly<RemoteWorkerArtifactReceipt> {
|
||||
return normalizeRemoteWorkerArtifactReceipt({
|
||||
status,
|
||||
...authority,
|
||||
byteLength,
|
||||
sha256,
|
||||
...(truncated === undefined ? {} : { truncated }),
|
||||
});
|
||||
}
|
||||
|
||||
function exactObjectShape(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
name: string,
|
||||
): asserts value is Readonly<Record<string, unknown>> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationError(`${name} is invalid`);
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !Object.hasOwn(value, key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw configurationError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStorageCommand(
|
||||
command: Readonly<ClusterRemoteWorkerArtifactStorageCommand>,
|
||||
): NormalizedStorageCommand {
|
||||
exactObjectShape(
|
||||
command,
|
||||
['attemptId', 'byteLength', 'logArtifactId', 'projectId', 'runId'],
|
||||
['truncated'],
|
||||
'storage command',
|
||||
);
|
||||
const normalized = receiptCandidate(
|
||||
command as unknown as ClusterRemoteWorkerArtifactStorageCommand,
|
||||
command.byteLength as number,
|
||||
'0'.repeat(64),
|
||||
command.truncated as boolean | undefined,
|
||||
);
|
||||
return Object.freeze({
|
||||
projectId: normalized.projectId,
|
||||
runId: normalized.runId,
|
||||
attemptId: normalized.attemptId,
|
||||
logArtifactId: normalized.logArtifactId,
|
||||
byteLength: normalized.byteLength,
|
||||
...(normalized.truncated === undefined
|
||||
? {}
|
||||
: { truncated: normalized.truncated }),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLookup(
|
||||
lookup: Readonly<ClusterRemoteWorkerArtifactLookup>,
|
||||
): ArtifactAuthority {
|
||||
exactObjectShape(
|
||||
lookup,
|
||||
['attemptId', 'logArtifactId', 'projectId', 'runId'],
|
||||
[],
|
||||
'lookup',
|
||||
);
|
||||
const normalized = receiptCandidate(
|
||||
lookup as unknown as ClusterRemoteWorkerArtifactLookup,
|
||||
0,
|
||||
'0'.repeat(64),
|
||||
);
|
||||
return Object.freeze({
|
||||
projectId: normalized.projectId,
|
||||
runId: normalized.runId,
|
||||
attemptId: normalized.attemptId,
|
||||
logArtifactId: normalized.logArtifactId,
|
||||
});
|
||||
}
|
||||
|
||||
function lookupFromCommand(
|
||||
command: NormalizedStorageCommand,
|
||||
): ArtifactAuthority {
|
||||
return Object.freeze({
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
logArtifactId: command.logArtifactId,
|
||||
});
|
||||
}
|
||||
|
||||
function identityDigest(authority: ArtifactAuthority): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong/remote-worker-artifact-identity@v1\0', 'utf8')
|
||||
.update(authority.projectId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(authority.runId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(authority.attemptId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(authority.logArtifactId, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function fieldDigest(domain: string, value: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`qinglong/remote-worker-artifact-${domain}@v1\0`, 'utf8')
|
||||
.update(value, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function finalObjectKey(prefix: string, authority: ArtifactAuthority): string {
|
||||
const digest = identityDigest(authority);
|
||||
return `${prefix}/objects/${digest.slice(0, 2)}/${digest}`;
|
||||
}
|
||||
|
||||
function temporaryObjectKey(prefix: string, createId: () => string): string {
|
||||
const id = createId();
|
||||
if (typeof id !== 'string' || !TEMPORARY_ID_PATTERN.test(id)) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable');
|
||||
}
|
||||
return `${prefix}/temporary/${id}`;
|
||||
}
|
||||
|
||||
function temporaryOwnershipDigest(): string {
|
||||
const authority = randomBytes(32);
|
||||
try {
|
||||
return createHash('sha256')
|
||||
.update('qinglong/remote-worker-artifact-temporary-owner@v1\0', 'utf8')
|
||||
.update(authority)
|
||||
.digest('hex');
|
||||
} finally {
|
||||
authority.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function finalMetadata(
|
||||
command: NormalizedStorageCommand,
|
||||
sha256: string,
|
||||
): Readonly<Record<string, string>> {
|
||||
return Object.freeze({
|
||||
'ql3-schema': METADATA_SCHEMA,
|
||||
'ql3-project-sha256': fieldDigest('project', command.projectId),
|
||||
'ql3-run-sha256': fieldDigest('run', command.runId),
|
||||
'ql3-attempt-sha256': fieldDigest('attempt', command.attemptId),
|
||||
'ql3-log-artifact-sha256': fieldDigest(
|
||||
'log-artifact',
|
||||
command.logArtifactId,
|
||||
),
|
||||
'ql3-byte-length': String(command.byteLength),
|
||||
'ql3-content-sha256': sha256,
|
||||
'ql3-truncated': command.truncated === undefined
|
||||
? 'omitted'
|
||||
: String(command.truncated),
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalChecksum(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length !== 44) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
try {
|
||||
if (decoded.byteLength !== 32 || decoded.toString('base64') !== value) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
|
||||
}
|
||||
return decoded.toString('hex');
|
||||
} finally {
|
||||
decoded.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function metadataValue(
|
||||
metadata: Readonly<Record<string, string | undefined>> | undefined,
|
||||
name: string,
|
||||
): string {
|
||||
const value = metadata?.[name];
|
||||
if (typeof value !== 'string') {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseStoredReceipt(
|
||||
authority: ArtifactAuthority,
|
||||
output: Readonly<{
|
||||
ContentLength?: number | undefined;
|
||||
ContentType?: string | undefined;
|
||||
ChecksumSHA256?: string | undefined;
|
||||
Metadata?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
}>,
|
||||
): Readonly<RemoteWorkerArtifactReceipt> {
|
||||
const metadata = output.Metadata;
|
||||
const lengthText = metadataValue(metadata, 'ql3-byte-length');
|
||||
const byteLength = Number(lengthText);
|
||||
const sha256 = metadataValue(metadata, 'ql3-content-sha256');
|
||||
const truncatedText = metadataValue(metadata, 'ql3-truncated');
|
||||
if (
|
||||
metadataValue(metadata, 'ql3-schema') !== METADATA_SCHEMA ||
|
||||
metadataValue(metadata, 'ql3-project-sha256') !==
|
||||
fieldDigest('project', authority.projectId) ||
|
||||
metadataValue(metadata, 'ql3-run-sha256') !==
|
||||
fieldDigest('run', authority.runId) ||
|
||||
metadataValue(metadata, 'ql3-attempt-sha256') !==
|
||||
fieldDigest('attempt', authority.attemptId) ||
|
||||
metadataValue(metadata, 'ql3-log-artifact-sha256') !==
|
||||
fieldDigest('log-artifact', authority.logArtifactId) ||
|
||||
!Number.isSafeInteger(byteLength) ||
|
||||
byteLength < 0 ||
|
||||
byteLength > MAX_REMOTE_WORKER_ARTIFACT_BYTES ||
|
||||
lengthText !== String(byteLength) ||
|
||||
output.ContentLength !== byteLength ||
|
||||
output.ContentType !== REMOTE_WORKER_ARTIFACT_CONTENT_TYPE ||
|
||||
!SHA256_PATTERN.test(sha256) ||
|
||||
canonicalChecksum(output.ChecksumSHA256) !== sha256 ||
|
||||
!['omitted', 'true', 'false'].includes(truncatedText)
|
||||
) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
|
||||
}
|
||||
return receiptCandidate(
|
||||
authority,
|
||||
byteLength,
|
||||
sha256,
|
||||
truncatedText === 'omitted' ? undefined : truncatedText === 'true',
|
||||
'already_stored',
|
||||
);
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const value = error as {
|
||||
name?: unknown;
|
||||
Code?: unknown;
|
||||
$metadata?: { httpStatusCode?: unknown };
|
||||
};
|
||||
return value.name === 'NotFound' ||
|
||||
value.name === 'NoSuchKey' ||
|
||||
value.Code === 'NoSuchKey' ||
|
||||
value.$metadata?.httpStatusCode === 404;
|
||||
}
|
||||
|
||||
function requestOptions(signal?: AbortSignal): { abortSignal: AbortSignal } | undefined {
|
||||
return signal === undefined ? undefined : { abortSignal: signal };
|
||||
}
|
||||
|
||||
function copySource(bucket: string, key: string): string {
|
||||
return [bucket, ...key.split('/')]
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
class ArtifactContentDigest {
|
||||
private readonly hash = createHash('sha256');
|
||||
private consumedBytes = 0;
|
||||
private complete = false;
|
||||
private digestValue?: string;
|
||||
|
||||
constructor(
|
||||
private readonly content: AsyncIterable<Uint8Array>,
|
||||
private readonly expectedBytes: number,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {
|
||||
if (!content || typeof content[Symbol.asyncIterator] !== 'function') {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
async *stream(): AsyncGenerator<Buffer> {
|
||||
if (this.complete || this.consumedBytes !== 0) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable');
|
||||
}
|
||||
if (this.signal?.aborted) throw this.signal.reason;
|
||||
for await (const chunk of this.content) {
|
||||
if (this.signal?.aborted) throw this.signal.reason;
|
||||
if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
|
||||
}
|
||||
this.consumedBytes += chunk.byteLength;
|
||||
if (this.consumedBytes > this.expectedBytes) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
|
||||
}
|
||||
this.hash.update(chunk);
|
||||
yield Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
if (this.consumedBytes !== this.expectedBytes) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('integrity_mismatch');
|
||||
}
|
||||
if (this.signal?.aborted) throw this.signal.reason;
|
||||
this.digestValue = this.hash.digest('hex');
|
||||
this.complete = true;
|
||||
}
|
||||
|
||||
async consume(): Promise<string> {
|
||||
for await (const _chunk of this.stream()) {
|
||||
// Hash a replay without allocating or contacting object storage.
|
||||
}
|
||||
return this.digest();
|
||||
}
|
||||
|
||||
digest(): string {
|
||||
if (!this.complete || !this.digestValue) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable');
|
||||
}
|
||||
return this.digestValue;
|
||||
}
|
||||
|
||||
isComplete(): boolean {
|
||||
return this.complete;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared immutable S3 adapter. A unique temporary upload is checksummed first,
|
||||
* then promoted by one destination-conditional server-side copy. Permanent
|
||||
* objects are never overwritten or deleted by this adapter.
|
||||
*/
|
||||
export class S3ClusterRemoteWorkerArtifactStore
|
||||
implements ClusterRemoteWorkerArtifactStore {
|
||||
private readonly options: PreparedOptions;
|
||||
|
||||
constructor(options: S3ClusterRemoteWorkerArtifactStoreOptions) {
|
||||
this.options = prepareOptions(options);
|
||||
}
|
||||
|
||||
async inspect(
|
||||
lookup: Readonly<ClusterRemoteWorkerArtifactLookup>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Readonly<RemoteWorkerArtifactReceipt> | undefined> {
|
||||
const authority = normalizeLookup(lookup);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
try {
|
||||
const output = await this.options.client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: finalObjectKey(this.options.prefix, authority),
|
||||
ChecksumMode: ChecksumMode.ENABLED,
|
||||
...(this.options.expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { ExpectedBucketOwner: this.options.expectedBucketOwner }),
|
||||
}),
|
||||
requestOptions(signal),
|
||||
);
|
||||
return parseStoredReceipt(authority, output);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return undefined;
|
||||
if (error instanceof S3ClusterRemoteWorkerArtifactStoreError) throw error;
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async put(
|
||||
value: Readonly<ClusterRemoteWorkerArtifactStorageCommand>,
|
||||
content: AsyncIterable<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Readonly<RemoteWorkerArtifactReceipt>> {
|
||||
const command = normalizeStorageCommand(value);
|
||||
const digest = new ArtifactContentDigest(
|
||||
content,
|
||||
command.byteLength,
|
||||
signal,
|
||||
);
|
||||
const lookup = lookupFromCommand(command);
|
||||
const existing = await this.inspect(lookup, signal);
|
||||
if (existing) {
|
||||
const incomingSha256 = await digest.consume();
|
||||
if (
|
||||
existing.byteLength !== command.byteLength ||
|
||||
existing.truncated !== command.truncated ||
|
||||
existing.sha256 !== incomingSha256
|
||||
) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError(
|
||||
'integrity_mismatch',
|
||||
);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const temporaryKey = temporaryObjectKey(
|
||||
this.options.prefix,
|
||||
this.options.createTemporaryId,
|
||||
);
|
||||
const temporaryOwner = temporaryOwnershipDigest();
|
||||
let temporaryOwned = false;
|
||||
let result: Readonly<RemoteWorkerArtifactReceipt> | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
const body = Readable.from(digest.stream(), { objectMode: false });
|
||||
try {
|
||||
await this.options.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: temporaryKey,
|
||||
Body: body,
|
||||
ContentLength: command.byteLength,
|
||||
ContentType: REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
|
||||
ChecksumAlgorithm: ChecksumAlgorithm.SHA256,
|
||||
IfNoneMatch: '*',
|
||||
Metadata: {
|
||||
'ql3-schema': TEMPORARY_METADATA_SCHEMA,
|
||||
'ql3-owner-sha256': temporaryOwner,
|
||||
},
|
||||
...this.options.encryption,
|
||||
...(this.options.expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { ExpectedBucketOwner: this.options.expectedBucketOwner }),
|
||||
}),
|
||||
requestOptions(signal),
|
||||
);
|
||||
temporaryOwned = true;
|
||||
} catch (error) {
|
||||
if (!digest.isComplete()) throw error;
|
||||
} finally {
|
||||
body.destroy();
|
||||
}
|
||||
const sha256 = digest.digest();
|
||||
await this.assertTemporaryObject(
|
||||
temporaryKey,
|
||||
temporaryOwner,
|
||||
command.byteLength,
|
||||
sha256,
|
||||
signal,
|
||||
);
|
||||
temporaryOwned = true;
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
await this.options.client.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: finalObjectKey(this.options.prefix, command),
|
||||
CopySource: copySource(this.options.bucket, temporaryKey),
|
||||
ContentType: REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
|
||||
MetadataDirective: MetadataDirective.REPLACE,
|
||||
Metadata: finalMetadata(command, sha256),
|
||||
ChecksumAlgorithm: ChecksumAlgorithm.SHA256,
|
||||
IfNoneMatch: '*',
|
||||
...this.options.encryption,
|
||||
...(this.options.expectedBucketOwner === undefined
|
||||
? {}
|
||||
: {
|
||||
ExpectedBucketOwner: this.options.expectedBucketOwner,
|
||||
CopySourceExpectedBucketOwner:
|
||||
this.options.expectedBucketOwner,
|
||||
}),
|
||||
}),
|
||||
requestOptions(signal),
|
||||
);
|
||||
copied = true;
|
||||
} catch {
|
||||
// A 409/412 race or a lost successful response is resolved only by
|
||||
// inspecting the immutable destination below.
|
||||
}
|
||||
const stored = await this.inspect(lookup, signal);
|
||||
if (
|
||||
!stored ||
|
||||
stored.byteLength !== command.byteLength ||
|
||||
stored.truncated !== command.truncated ||
|
||||
stored.sha256 !== sha256
|
||||
) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError(
|
||||
'integrity_mismatch',
|
||||
);
|
||||
}
|
||||
result = Object.freeze({
|
||||
...stored,
|
||||
status: copied ? 'stored' as const : 'already_stored' as const,
|
||||
});
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
|
||||
if (temporaryOwned) {
|
||||
try {
|
||||
await this.options.client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: temporaryKey,
|
||||
...(this.options.expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { ExpectedBucketOwner: this.options.expectedBucketOwner }),
|
||||
}),
|
||||
requestOptions(signal),
|
||||
);
|
||||
} catch (error) {
|
||||
if (primaryError === undefined) {
|
||||
try {
|
||||
await this.options.onDiagnostic?.(error, DIAGNOSTIC_CONTEXT);
|
||||
} catch {
|
||||
// Diagnostics cannot reverse a durable immutable promotion.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (primaryError !== undefined) {
|
||||
if (primaryError instanceof S3ClusterRemoteWorkerArtifactStoreError) {
|
||||
throw primaryError;
|
||||
}
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable', {
|
||||
cause: primaryError,
|
||||
});
|
||||
}
|
||||
return result!;
|
||||
}
|
||||
|
||||
private async assertTemporaryObject(
|
||||
key: string,
|
||||
ownerSha256: string,
|
||||
byteLength: number,
|
||||
sha256: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
let output;
|
||||
try {
|
||||
output = await this.options.client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: key,
|
||||
ChecksumMode: ChecksumMode.ENABLED,
|
||||
...(this.options.expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { ExpectedBucketOwner: this.options.expectedBucketOwner }),
|
||||
}),
|
||||
requestOptions(signal),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError('unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
output.ContentLength !== byteLength ||
|
||||
output.ContentType !== REMOTE_WORKER_ARTIFACT_CONTENT_TYPE ||
|
||||
output.Metadata?.['ql3-schema'] !== TEMPORARY_METADATA_SCHEMA ||
|
||||
output.Metadata?.['ql3-owner-sha256'] !== ownerSha256 ||
|
||||
canonicalChecksum(output.ChecksumSHA256) !== sha256
|
||||
) {
|
||||
throw new S3ClusterRemoteWorkerArtifactStoreError(
|
||||
'integrity_mismatch',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Artifact owns lazy production binding without widening the Worker runtime port.
|
||||
import {
|
||||
createS3ClusterRemoteWorkerArtifactClient,
|
||||
S3ClusterRemoteWorkerArtifactStore,
|
||||
} from './s3ArtifactStore';
|
||||
import type { ClusterRemoteWorkerArtifactStore } from '../remote-execution/remoteWorkerCompletionService';
|
||||
import type { ClusterWorkerArtifactS3Config } from '../worker-ingress/workerIngressConfig';
|
||||
|
||||
export interface ClusterWorkerArtifactBinding {
|
||||
readonly store: ClusterRemoteWorkerArtifactStore;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createClusterWorkerArtifactBinding(
|
||||
config: ClusterWorkerArtifactS3Config,
|
||||
): Readonly<ClusterWorkerArtifactBinding> {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
throw new TypeError('Cluster Worker Artifact binding config is invalid');
|
||||
}
|
||||
const client = createS3ClusterRemoteWorkerArtifactClient({
|
||||
region: config.region,
|
||||
...(config.endpoint === undefined
|
||||
? {}
|
||||
: { endpoint: config.endpoint }),
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
});
|
||||
const store = new S3ClusterRemoteWorkerArtifactStore({
|
||||
client,
|
||||
bucket: config.bucket,
|
||||
...(config.prefix === undefined ? {} : { prefix: config.prefix }),
|
||||
...(config.expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { expectedBucketOwner: config.expectedBucketOwner }),
|
||||
encryption: config.encryption,
|
||||
});
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
store,
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
client.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Authentication owns credential verification and bounded Principal issuance.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
ApiCredentialUnavailableError,
|
||||
LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
assertApiCredentialPepperKeyId,
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRepository,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { ClusterControlAdmissionMetadata } from '../transport/httpSurface';
|
||||
import type { ClusterControlRequestAuthenticator } from '../transport/admissionPipeline';
|
||||
|
||||
export const CLUSTER_CONTROL_API_CREDENTIAL_LIMITS = Object.freeze({
|
||||
principalTtlMs: 60_000,
|
||||
maxPrincipalTtlMs: 300_000,
|
||||
secretBytes: 32,
|
||||
});
|
||||
|
||||
export class ClusterControlApiCredentialConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster-control API credential configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterControlApiCredentialConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterControlApiCredentialUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_CONTROL_API_CREDENTIAL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster-control API credential authentication is unavailable');
|
||||
this.name = 'ClusterControlApiCredentialUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClusterControlApiCredentialAuthenticatorOptions {
|
||||
readonly principalTtlMs?: number;
|
||||
readonly pepperKeyId?: string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
const AUTHORIZATION_PATTERN =
|
||||
/^Bearer ql3c_([A-Za-z0-9][A-Za-z0-9._:-]{0,63})_([A-Za-z0-9_-]{43})$/;
|
||||
const PEPPER_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const DIGEST_DOMAIN = Buffer.from('qinglong-api-credential-v1\0', 'utf8');
|
||||
|
||||
function decodeSecret(name: string, value: string): Buffer {
|
||||
if (typeof value !== 'string' || !PEPPER_PATTERN.test(value)) {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
`${name} must be canonical base64url for 32 bytes`,
|
||||
);
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
decoded.byteLength !== CLUSTER_CONTROL_API_CREDENTIAL_LIMITS.secretBytes ||
|
||||
decoded.toString('base64url') !== value
|
||||
) {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
`${name} must be canonical base64url for 32 bytes`,
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function assertClusterControlApiCredentialPepper(value: string): void {
|
||||
const decoded = decodeSecret('pepper', value);
|
||||
decoded.fill(0);
|
||||
}
|
||||
|
||||
function principalTtl(value: number | undefined): number {
|
||||
const resolved =
|
||||
value ?? CLUSTER_CONTROL_API_CREDENTIAL_LIMITS.principalTtlMs;
|
||||
if (
|
||||
!Number.isSafeInteger(resolved) ||
|
||||
resolved < 1_000 ||
|
||||
resolved > CLUSTER_CONTROL_API_CREDENTIAL_LIMITS.maxPrincipalTtlMs
|
||||
) {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
'principalTtlMs is invalid',
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function digest(pepper: Buffer, credentialId: string, secret: Buffer): Buffer {
|
||||
return createHmac('sha256', pepper)
|
||||
.update(DIGEST_DOMAIN)
|
||||
.update(credentialId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(secret)
|
||||
.digest();
|
||||
}
|
||||
|
||||
export function apiCredentialSecretDigest(
|
||||
pepperBase64Url: string,
|
||||
credentialId: string,
|
||||
secretBase64Url: string,
|
||||
): string {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(credentialId)) {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
'credentialId is invalid',
|
||||
);
|
||||
}
|
||||
const pepper = decodeSecret('pepper', pepperBase64Url);
|
||||
const secret = decodeSecret('secret', secretBase64Url);
|
||||
let result: Buffer | undefined;
|
||||
try {
|
||||
result = digest(pepper, credentialId, secret);
|
||||
return result.toString('hex');
|
||||
} finally {
|
||||
result?.fill(0);
|
||||
pepper.fill(0);
|
||||
secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAuthorization(
|
||||
metadata: ClusterControlAdmissionMetadata,
|
||||
): { readonly credentialId: string; readonly secret: Buffer } | null {
|
||||
const value = metadata.headers.authorization;
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = AUTHORIZATION_PATTERN.exec(value);
|
||||
if (!match) return null;
|
||||
let secret: Buffer;
|
||||
try {
|
||||
secret = decodeSecret('bearer secret', match[2]!);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({ credentialId: match[1]!, secret });
|
||||
}
|
||||
|
||||
export function createClusterControlApiCredentialAuthenticator(
|
||||
repository: ApiCredentialRepository,
|
||||
pepperBase64Url: string,
|
||||
options: ClusterControlApiCredentialAuthenticatorOptions = {},
|
||||
): ClusterControlRequestAuthenticator {
|
||||
if (!repository || typeof repository.resolve !== 'function') {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
'repository is invalid',
|
||||
);
|
||||
}
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(options);
|
||||
if (
|
||||
keys.some(
|
||||
(key) =>
|
||||
key !== 'principalTtlMs' && key !== 'pepperKeyId' && key !== 'now',
|
||||
)
|
||||
) {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
'options shape is invalid',
|
||||
);
|
||||
}
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw new ClusterControlApiCredentialConfigurationError('now is invalid');
|
||||
}
|
||||
const pepperKeyId =
|
||||
options.pepperKeyId ?? LEGACY_API_CREDENTIAL_PEPPER_KEY_ID;
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(pepperKeyId);
|
||||
} catch {
|
||||
throw new ClusterControlApiCredentialConfigurationError(
|
||||
'pepperKeyId is invalid',
|
||||
);
|
||||
}
|
||||
const pepper = decodeSecret('pepper', pepperBase64Url);
|
||||
const ttlMs = principalTtl(options.principalTtlMs);
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return Object.freeze({
|
||||
async authenticate(
|
||||
metadata: ClusterControlAdmissionMetadata,
|
||||
): Promise<Readonly<SecurityPrincipal> | null> {
|
||||
const parsed = parseAuthorization(metadata);
|
||||
if (!parsed) return null;
|
||||
const presentedDigest = digest(
|
||||
pepper,
|
||||
parsed.credentialId,
|
||||
parsed.secret,
|
||||
);
|
||||
parsed.secret.fill(0);
|
||||
let candidate;
|
||||
try {
|
||||
candidate = await repository.resolve(parsed.credentialId);
|
||||
} catch (error) {
|
||||
presentedDigest.fill(0);
|
||||
if (error instanceof ApiCredentialUnavailableError) {
|
||||
throw new ClusterControlApiCredentialUnavailableError();
|
||||
}
|
||||
throw new ClusterControlApiCredentialUnavailableError();
|
||||
}
|
||||
if (metadata.signal.aborted) {
|
||||
presentedDigest.fill(0);
|
||||
throw new ClusterControlApiCredentialUnavailableError();
|
||||
}
|
||||
let record;
|
||||
try {
|
||||
record = candidate ? normalizeApiCredentialRecord(candidate) : null;
|
||||
} catch {
|
||||
presentedDigest.fill(0);
|
||||
throw new ClusterControlApiCredentialUnavailableError();
|
||||
}
|
||||
if (record && record.pepperKeyId !== pepperKeyId) {
|
||||
presentedDigest.fill(0);
|
||||
throw new ClusterControlApiCredentialUnavailableError();
|
||||
}
|
||||
const storedDigest = record
|
||||
? Buffer.from(record.secretDigest, 'hex')
|
||||
: Buffer.alloc(32);
|
||||
const matches = timingSafeEqual(presentedDigest, storedDigest);
|
||||
presentedDigest.fill(0);
|
||||
storedDigest.fill(0);
|
||||
if (!record || !matches) return null;
|
||||
const nowMs = now();
|
||||
if (
|
||||
!Number.isSafeInteger(nowMs) ||
|
||||
nowMs < 0 ||
|
||||
record.state !== 'active' ||
|
||||
record.subjectStatus !== 'active' ||
|
||||
record.notBeforeAtMs > nowMs ||
|
||||
record.expiresAtMs <= nowMs
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expiresAtMs = Math.min(record.expiresAtMs, nowMs + ttlMs);
|
||||
try {
|
||||
return normalizeSecurityPrincipal(
|
||||
{
|
||||
subject: record.subject,
|
||||
authenticationId: `api_credential:${record.credentialId}:${record.version}`,
|
||||
authenticatedAtMs: nowMs,
|
||||
expiresAtMs,
|
||||
assurance:
|
||||
record.subject.type === 'user' ? 'single_factor' : 'service',
|
||||
},
|
||||
nowMs,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterControlApiCredentialUnavailableError();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Authentication owns its bounded pre-body overload shield.
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
export interface ClusterControlAuthenticationShieldOptions {
|
||||
readonly windowMs: number;
|
||||
readonly maxRequestsPerPeer: number;
|
||||
readonly maxRequestsGlobal: number;
|
||||
readonly maxTrackedPeers: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export type ClusterControlAuthenticationShieldRejectionReason =
|
||||
| 'capacity'
|
||||
| 'clock'
|
||||
| 'global'
|
||||
| 'peer';
|
||||
|
||||
export type ClusterControlAuthenticationShieldResult =
|
||||
| {
|
||||
readonly allowed: true;
|
||||
/**
|
||||
* Returns this provisional attempt budget after the pre-body admission
|
||||
* preflight has succeeded. Idempotent and scoped to the exact windows
|
||||
* consumed by this result.
|
||||
*/
|
||||
refund(): void;
|
||||
}
|
||||
| {
|
||||
readonly allowed: false;
|
||||
readonly reason: ClusterControlAuthenticationShieldRejectionReason;
|
||||
readonly retryAfterMs: number;
|
||||
};
|
||||
|
||||
export interface ClusterControlAuthenticationShield {
|
||||
consume(
|
||||
peerAddress: string | undefined,
|
||||
): ClusterControlAuthenticationShieldResult;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface PeerWindow {
|
||||
readonly startedAt: number;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
const FINGERPRINT_KEY_BYTES = 32;
|
||||
const MAX_PEER_ADDRESS_BYTES = 128;
|
||||
const MAX_PRUNE_PER_ATTEMPT = 64;
|
||||
const UNKNOWN_PEER = '<unknown-transport-peer>';
|
||||
|
||||
function normalizedPeerAddress(peerAddress: string | undefined): string {
|
||||
if (
|
||||
typeof peerAddress !== 'string' ||
|
||||
peerAddress.length === 0 ||
|
||||
Buffer.byteLength(peerAddress) > MAX_PEER_ADDRESS_BYTES ||
|
||||
/[\0\r\n]/.test(peerAddress)
|
||||
) {
|
||||
return UNKNOWN_PEER;
|
||||
}
|
||||
return peerAddress;
|
||||
}
|
||||
|
||||
function remainingWindow(
|
||||
now: number,
|
||||
startedAt: number,
|
||||
windowMs: number,
|
||||
): number {
|
||||
return Math.max(1, Math.ceil(windowMs - (now - startedAt)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a process-local overload shield for authentication attempts. It is
|
||||
* deliberately not an authorization or distributed quota authority: every
|
||||
* cluster-control replica owns a bounded, disposable window.
|
||||
*/
|
||||
export function createClusterControlAuthenticationShield(
|
||||
options: ClusterControlAuthenticationShieldOptions,
|
||||
): ClusterControlAuthenticationShield {
|
||||
const now = options.now ?? (() => performance.now());
|
||||
const fingerprintKey = randomBytes(FINGERPRINT_KEY_BYTES);
|
||||
const peers = new Map<string, PeerWindow>();
|
||||
let globalWindow: PeerWindow | undefined;
|
||||
let lastNow = 0;
|
||||
let closed = false;
|
||||
|
||||
const fingerprint = (peerAddress: string | undefined): string =>
|
||||
createHmac('sha256', fingerprintKey)
|
||||
.update('qinglong.cluster-control.authentication-peer\0')
|
||||
.update(normalizedPeerAddress(peerAddress))
|
||||
.digest('base64url');
|
||||
|
||||
const pruneExpired = (currentTime: number): void => {
|
||||
let scanned = 0;
|
||||
for (const [key, window] of peers) {
|
||||
if (scanned >= MAX_PRUNE_PER_ATTEMPT) return;
|
||||
scanned += 1;
|
||||
if (currentTime - window.startedAt >= options.windowMs) {
|
||||
peers.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const accepted = (
|
||||
peer: string,
|
||||
peerStartedAt: number,
|
||||
globalStartedAt: number,
|
||||
): ClusterControlAuthenticationShieldResult => {
|
||||
let completed = false;
|
||||
return Object.freeze({
|
||||
allowed: true as const,
|
||||
refund() {
|
||||
if (completed || closed) return;
|
||||
completed = true;
|
||||
if (
|
||||
globalWindow?.startedAt === globalStartedAt &&
|
||||
globalWindow.count > 0
|
||||
) {
|
||||
globalWindow = {
|
||||
startedAt: globalWindow.startedAt,
|
||||
count: globalWindow.count - 1,
|
||||
};
|
||||
}
|
||||
const currentPeerWindow = peers.get(peer);
|
||||
if (
|
||||
currentPeerWindow?.startedAt === peerStartedAt &&
|
||||
currentPeerWindow.count > 0
|
||||
) {
|
||||
if (currentPeerWindow.count === 1) peers.delete(peer);
|
||||
else {
|
||||
peers.set(peer, {
|
||||
startedAt: currentPeerWindow.startedAt,
|
||||
count: currentPeerWindow.count - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
consume(peerAddress) {
|
||||
if (closed) {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
reason: 'clock',
|
||||
retryAfterMs: options.windowMs,
|
||||
});
|
||||
}
|
||||
|
||||
let currentTime: number;
|
||||
try {
|
||||
currentTime = now();
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
reason: 'clock',
|
||||
retryAfterMs: options.windowMs,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(currentTime) ||
|
||||
currentTime < 0 ||
|
||||
currentTime < lastNow
|
||||
) {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
reason: 'clock',
|
||||
retryAfterMs: options.windowMs,
|
||||
});
|
||||
}
|
||||
lastNow = currentTime;
|
||||
|
||||
if (
|
||||
!globalWindow ||
|
||||
currentTime - globalWindow.startedAt >= options.windowMs
|
||||
) {
|
||||
globalWindow = { startedAt: currentTime, count: 0 };
|
||||
}
|
||||
if (globalWindow.count >= options.maxRequestsGlobal) {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
reason: 'global',
|
||||
retryAfterMs: remainingWindow(
|
||||
currentTime,
|
||||
globalWindow.startedAt,
|
||||
options.windowMs,
|
||||
),
|
||||
});
|
||||
}
|
||||
globalWindow = {
|
||||
startedAt: globalWindow.startedAt,
|
||||
count: globalWindow.count + 1,
|
||||
};
|
||||
|
||||
const peer = fingerprint(peerAddress);
|
||||
let peerWindow = peers.get(peer);
|
||||
if (
|
||||
peerWindow &&
|
||||
currentTime - peerWindow.startedAt >= options.windowMs
|
||||
) {
|
||||
peers.delete(peer);
|
||||
peerWindow = undefined;
|
||||
}
|
||||
if (peerWindow) {
|
||||
if (peerWindow.count >= options.maxRequestsPerPeer) {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
reason: 'peer',
|
||||
retryAfterMs: remainingWindow(
|
||||
currentTime,
|
||||
peerWindow.startedAt,
|
||||
options.windowMs,
|
||||
),
|
||||
});
|
||||
}
|
||||
peers.delete(peer);
|
||||
peers.set(peer, {
|
||||
startedAt: peerWindow.startedAt,
|
||||
count: peerWindow.count + 1,
|
||||
});
|
||||
return accepted(peer, peerWindow.startedAt, globalWindow.startedAt);
|
||||
}
|
||||
|
||||
if (peers.size >= options.maxTrackedPeers) pruneExpired(currentTime);
|
||||
if (peers.size >= options.maxTrackedPeers) {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
reason: 'capacity',
|
||||
retryAfterMs: options.windowMs,
|
||||
});
|
||||
}
|
||||
peers.set(peer, { startedAt: currentTime, count: 1 });
|
||||
return accepted(peer, currentTime, globalWindow.startedAt);
|
||||
},
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
peers.clear();
|
||||
globalWindow = undefined;
|
||||
fingerprintKey.fill(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
runProductionClusterControlProcess,
|
||||
type ClusterControlProcessEvent,
|
||||
type ClusterControlProcessSignal,
|
||||
type ClusterControlProcessSignalSource,
|
||||
} from './production-process/processApplication';
|
||||
|
||||
const USAGE = 'Usage: ql3-cluster-control';
|
||||
|
||||
const nodeSignals: ClusterControlProcessSignalSource = Object.freeze({
|
||||
subscribe(
|
||||
listener: (signal: ClusterControlProcessSignal) => void,
|
||||
) {
|
||||
const handlers: Readonly<
|
||||
Record<ClusterControlProcessSignal, () => void>
|
||||
> = Object.freeze({
|
||||
SIGINT: () => listener('SIGINT'),
|
||||
SIGTERM: () => listener('SIGTERM'),
|
||||
});
|
||||
process.once('SIGINT', handlers.SIGINT);
|
||||
process.once('SIGTERM', handlers.SIGTERM);
|
||||
return () => {
|
||||
process.off('SIGINT', handlers.SIGINT);
|
||||
process.off('SIGTERM', handlers.SIGTERM);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function emit(record: ClusterControlProcessEvent): void {
|
||||
process.stdout.write(`${JSON.stringify(record)}\n`);
|
||||
}
|
||||
|
||||
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-cluster-control',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(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_CLUSTER_CONTROL_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stopResult = await runProductionClusterControlProcess({
|
||||
environment: process.env,
|
||||
signals: nodeSignals,
|
||||
emit,
|
||||
});
|
||||
if (stopResult !== 'stopped') process.exitCode = 1;
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,93 @@
|
||||
// Database owns the one-way Pool failure to admission-withdrawal fence.
|
||||
export type ClusterControlAvailabilityStatus =
|
||||
| 'available'
|
||||
| 'unavailable'
|
||||
| 'disposed';
|
||||
|
||||
export type ClusterControlUnavailableListener = (
|
||||
error: Error,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface ClusterControlAvailabilitySource {
|
||||
subscribe(listener: ClusterControlUnavailableListener): () => void;
|
||||
}
|
||||
|
||||
export type ClusterControlAvailabilitySignalResult =
|
||||
| 'signaled'
|
||||
| 'already_unavailable'
|
||||
| 'disposed';
|
||||
|
||||
/**
|
||||
* A bounded one-way bridge from pg.Pool availability errors to the application
|
||||
* admission owner. It deliberately has one listener and no timer, retry loop,
|
||||
* error history or path back to available.
|
||||
*/
|
||||
export class ClusterControlAvailabilityFence
|
||||
implements ClusterControlAvailabilitySource
|
||||
{
|
||||
private currentStatus: ClusterControlAvailabilityStatus = 'available';
|
||||
private listener: ClusterControlUnavailableListener | undefined;
|
||||
private reason: Error | undefined;
|
||||
private notification: Promise<void> | undefined;
|
||||
|
||||
get status(): ClusterControlAvailabilityStatus {
|
||||
return this.currentStatus;
|
||||
}
|
||||
|
||||
subscribe(listener: ClusterControlUnavailableListener): () => void {
|
||||
if (typeof listener !== 'function') {
|
||||
throw new TypeError('Cluster-control availability listener is invalid');
|
||||
}
|
||||
if (this.currentStatus === 'disposed') {
|
||||
throw new Error('Cluster-control availability fence is disposed');
|
||||
}
|
||||
if (this.listener) {
|
||||
throw new Error('Cluster-control availability listener is already bound');
|
||||
}
|
||||
this.listener = listener;
|
||||
if (this.currentStatus === 'unavailable') {
|
||||
// An early signal has already returned to its producer. The subscriber
|
||||
// owns the delayed notification, so contain its rejection here.
|
||||
void this.notify().catch(() => undefined);
|
||||
}
|
||||
let subscribed = true;
|
||||
return () => {
|
||||
if (!subscribed) return;
|
||||
subscribed = false;
|
||||
if (this.listener === listener) this.listener = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
signal(error: Error): Promise<ClusterControlAvailabilitySignalResult> {
|
||||
if (!(error instanceof Error)) {
|
||||
return Promise.reject(
|
||||
new TypeError('Cluster-control availability error is invalid'),
|
||||
);
|
||||
}
|
||||
if (this.currentStatus === 'disposed') return Promise.resolve('disposed');
|
||||
if (this.currentStatus === 'unavailable') {
|
||||
return (this.notification ?? Promise.resolve()).then(
|
||||
() => 'already_unavailable' as const,
|
||||
);
|
||||
}
|
||||
this.currentStatus = 'unavailable';
|
||||
this.reason = error;
|
||||
return this.notify().then(() => 'signaled' as const);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.currentStatus === 'disposed') return;
|
||||
this.currentStatus = 'disposed';
|
||||
this.listener = undefined;
|
||||
this.reason = undefined;
|
||||
}
|
||||
|
||||
private notify(): Promise<void> {
|
||||
if (this.notification) return this.notification;
|
||||
if (!this.listener || !this.reason) return Promise.resolve();
|
||||
const listener = this.listener;
|
||||
const reason = this.reason;
|
||||
this.notification = Promise.resolve().then(() => listener(reason));
|
||||
return this.notification;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import type { ClusterControlAdmissionResponse } from '../../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PROMPT_CATALOG_RESPONSE_SCHEMA =
|
||||
'qinglong/plugin-package-prompt-catalog@v1' as const;
|
||||
|
||||
export interface ClusterPluginPackagePromptCatalogCapability {
|
||||
inspect(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
schema: typeof CLUSTER_PLUGIN_PACKAGE_PROMPT_CATALOG_RESPONSE_SCHEMA;
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
found: boolean;
|
||||
publicationState: 'active' | 'withdrawn' | 'absent' | null;
|
||||
prompts: readonly Readonly<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
parameters: readonly Readonly<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
required: boolean;
|
||||
}>[];
|
||||
}>[];
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createClusterControlPluginPackagePromptCatalogRoute(
|
||||
capability: ClusterPluginPackagePromptCatalogCapability,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!capability || typeof capability.inspect !== 'function') {
|
||||
throw new TypeError('Cluster-control Prompt catalog capability is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/prompts',
|
||||
operationId: 'prompt.read',
|
||||
permission: 'model.invoke',
|
||||
projectParameter: 'projectId' as const,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (
|
||||
authorized.request.body !== null ||
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName)
|
||||
) {
|
||||
return response(400, { code: 'invalid_prompt_catalog_request' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.inspect(
|
||||
authorized.projectId,
|
||||
parameters.packageName,
|
||||
);
|
||||
if (
|
||||
result.schema !==
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_CATALOG_RESPONSE_SCHEMA ||
|
||||
result.projectId !== authorized.projectId ||
|
||||
result.packageName !== parameters.packageName
|
||||
) {
|
||||
return response(503, { code: 'prompt_catalog_unavailable' });
|
||||
}
|
||||
return response(200, result);
|
||||
} catch {
|
||||
return response(503, { code: 'prompt_catalog_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_EXECUTION_INSPECTION_ROUTE =
|
||||
Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/prompts/{promptId}/executions/{executionRequestId}',
|
||||
operationId: 'prompt.execution.read',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
export interface ClusterPluginPackagePromptExecutionInspectionRouteOptions {
|
||||
readonly now?: () => number;
|
||||
readonly createEventId?: () => string;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePromptExecutionInspectionCapability {
|
||||
inspectAuthorized(input: Readonly<{
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
actor: Readonly<SecuritySubject>;
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
audit: Readonly<SecurityAuditRecord>;
|
||||
}>): Promise<Readonly<{ found: boolean }>>;
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const PROMPT_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
return error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Exact, content-free recovery read keyed by the caller-known requestId. */
|
||||
export function createClusterControlPluginPackagePromptExecutionInspectionRoute(
|
||||
capability: ClusterPluginPackagePromptExecutionInspectionCapability,
|
||||
options: ClusterPluginPackagePromptExecutionInspectionRouteOptions = {},
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!capability ||
|
||||
typeof capability.inspectAuthorized !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.createEventId !== undefined &&
|
||||
typeof options.createEventId !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster-control Prompt execution inspection capability is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const createEventId = options.createEventId ?? randomUUID;
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_EXECUTION_INSPECTION_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
const observedAtMs = now();
|
||||
const auditEventId = createEventId();
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.promptId !== 'string' ||
|
||||
!PROMPT_ID.test(parameters.promptId) ||
|
||||
typeof parameters.executionRequestId !== 'string' ||
|
||||
!IDENTITY.test(parameters.executionRequestId) ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null ||
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < 0 ||
|
||||
typeof auditEventId !== 'string' ||
|
||||
!UUID_V4.test(auditEventId)
|
||||
) {
|
||||
return response(503, { code: 'prompt_execution_inspection_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.inspectAuthorized({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
promptId: parameters.promptId,
|
||||
executionRequestId: parameters.executionRequestId,
|
||||
actor: authorized.principal.subject,
|
||||
fence: {
|
||||
projectVersion: authorized.policyFence.projectVersion,
|
||||
bindingVersion: authorized.policyFence.bindingVersion,
|
||||
},
|
||||
audit: normalizeSecurityAuditRecord({
|
||||
eventId: auditEventId,
|
||||
requestId: authorized.request.requestId,
|
||||
operationId: 'prompt.execution.read',
|
||||
projectId: authorized.projectId,
|
||||
subject: authorized.principal.subject,
|
||||
authenticationId: authorized.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_policy_allowed'],
|
||||
fence: authorized.policyFence,
|
||||
occurredAtMs: observedAtMs,
|
||||
}),
|
||||
});
|
||||
return result.found
|
||||
? response(200, { ...result })
|
||||
: response(404, { code: 'prompt_execution_not_found' });
|
||||
} catch (error) {
|
||||
return errorCode(error) ===
|
||||
'PLUGIN_PACKAGE_PROMPT_EXECUTION_INSPECTION_AUTHORIZATION_FENCE_CONFLICT'
|
||||
? response(409, { code: 'authorization_fence_conflict' })
|
||||
: response(503, {
|
||||
code: 'prompt_execution_inspection_unavailable',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
// Plugin Package Prompt owns request-keyed durable output recovery.
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_OUTPUT_READ_RESPONSE_SCHEMA =
|
||||
'qinglong/cluster-plugin-package-prompt-execution-output-read-response@v1' as const;
|
||||
export const CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_EXECUTION_OUTPUT_READ_ROUTE =
|
||||
Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/prompts/{promptId}/executions/{executionRequestId}/output',
|
||||
operationId: 'prompt.execution.output.read',
|
||||
permission: 'artifact.read',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
export interface ClusterPluginPackagePromptExecutionOutputReadCapability {
|
||||
read(command: Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
}>): Promise<unknown>;
|
||||
}
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const PROMPT_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
||||
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const KEY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const FINISH_REASONS = new Set([
|
||||
'stop',
|
||||
'length',
|
||||
'content_filter',
|
||||
'tool_call',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
const expected = [
|
||||
...required,
|
||||
...optional.filter((key) => key in record),
|
||||
].sort();
|
||||
return keys.length === expected.length &&
|
||||
keys.every((key, index) => key === expected[index])
|
||||
? record
|
||||
: null;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function exactTarget(
|
||||
value: Record<string, unknown>,
|
||||
expected: Readonly<{
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
}>,
|
||||
): boolean {
|
||||
return (
|
||||
value.projectId === expected.projectId &&
|
||||
value.packageName === expected.packageName &&
|
||||
value.promptId === expected.promptId &&
|
||||
value.executionRequestId === expected.executionRequestId
|
||||
);
|
||||
}
|
||||
|
||||
function availableView(
|
||||
value: unknown,
|
||||
expected: Readonly<{
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
}>,
|
||||
): Readonly<Record<string, unknown>> | null {
|
||||
const envelope = exactRecord(value, [
|
||||
'executionRequestId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'promptId',
|
||||
'reference',
|
||||
'result',
|
||||
'schema',
|
||||
'status',
|
||||
]);
|
||||
if (
|
||||
!envelope ||
|
||||
envelope.schema !==
|
||||
'qinglong/plugin-package-prompt-execution-output-read-result@v1' ||
|
||||
envelope.status !== 'available' ||
|
||||
!exactTarget(envelope, expected)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const reference = exactRecord(envelope.reference, [
|
||||
'algorithm',
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'contentDigest',
|
||||
'invocationId',
|
||||
'keyId',
|
||||
'outputBytes',
|
||||
'projectId',
|
||||
'retentionEligibleAtMs',
|
||||
'retentionPolicyDigest',
|
||||
'runId',
|
||||
'schema',
|
||||
'stepRunId',
|
||||
]);
|
||||
const result = exactRecord(envelope.result, [
|
||||
'finishReason',
|
||||
'model',
|
||||
'provider',
|
||||
'text',
|
||||
'usage',
|
||||
]);
|
||||
const usage = result
|
||||
? exactRecord(
|
||||
result.usage,
|
||||
['inputTokens', 'outputTokens', 'totalTokens'],
|
||||
['costMicros'],
|
||||
)
|
||||
: null;
|
||||
if (
|
||||
!reference ||
|
||||
!result ||
|
||||
!usage ||
|
||||
reference.schema !==
|
||||
'qinglong/plugin-package-prompt-output-artifact-reference@v1' ||
|
||||
reference.algorithm !== 'aes-256-gcm' ||
|
||||
reference.projectId !== expected.projectId ||
|
||||
typeof reference.runId !== 'string' ||
|
||||
!RUN_ID.test(reference.runId) ||
|
||||
typeof reference.artifactId !== 'string' ||
|
||||
!IDENTITY.test(reference.artifactId) ||
|
||||
typeof reference.artifactDigest !== 'string' ||
|
||||
!DIGEST.test(reference.artifactDigest) ||
|
||||
typeof reference.stepRunId !== 'string' ||
|
||||
!IDENTITY.test(reference.stepRunId) ||
|
||||
typeof reference.invocationId !== 'string' ||
|
||||
!IDENTITY.test(reference.invocationId) ||
|
||||
typeof reference.contentDigest !== 'string' ||
|
||||
!DIGEST.test(reference.contentDigest) ||
|
||||
!nonNegativeInteger(reference.outputBytes) ||
|
||||
reference.outputBytes > 1024 * 1024 ||
|
||||
typeof reference.retentionPolicyDigest !== 'string' ||
|
||||
!DIGEST.test(reference.retentionPolicyDigest) ||
|
||||
!nonNegativeInteger(reference.retentionEligibleAtMs) ||
|
||||
typeof reference.keyId !== 'string' ||
|
||||
!KEY_ID.test(reference.keyId) ||
|
||||
typeof result.provider !== 'string' ||
|
||||
!MODEL_ID.test(result.provider) ||
|
||||
typeof result.model !== 'string' ||
|
||||
!MODEL_ID.test(result.model) ||
|
||||
typeof result.text !== 'string' ||
|
||||
Buffer.byteLength(result.text, 'utf8') > 1024 * 1024 ||
|
||||
!FINISH_REASONS.has(result.finishReason as string) ||
|
||||
!nonNegativeInteger(usage.inputTokens) ||
|
||||
!nonNegativeInteger(usage.outputTokens) ||
|
||||
!nonNegativeInteger(usage.totalTokens) ||
|
||||
usage.totalTokens !== usage.inputTokens + usage.outputTokens ||
|
||||
(usage.costMicros !== undefined && !nonNegativeInteger(usage.costMicros))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
schema:
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_OUTPUT_READ_RESPONSE_SCHEMA,
|
||||
status: 'available',
|
||||
...expected,
|
||||
reference: Object.freeze({ ...reference }),
|
||||
result: Object.freeze({
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
text: result.text,
|
||||
finishReason: result.finishReason,
|
||||
usage: Object.freeze({ ...usage }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterControlPluginPackagePromptExecutionOutputReadRoute(
|
||||
capability: ClusterPluginPackagePromptExecutionOutputReadCapability,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!capability || typeof capability.read !== 'function') {
|
||||
throw new TypeError(
|
||||
'Cluster-control Prompt execution output read capability is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_EXECUTION_OUTPUT_READ_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (
|
||||
authorized.request.body !== null ||
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.promptId !== 'string' ||
|
||||
!PROMPT_ID.test(parameters.promptId) ||
|
||||
typeof parameters.executionRequestId !== 'string' ||
|
||||
!IDENTITY.test(parameters.executionRequestId)
|
||||
) {
|
||||
return response(400, {
|
||||
code: 'invalid_prompt_execution_output_read_request',
|
||||
});
|
||||
}
|
||||
const expected = Object.freeze({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
promptId: parameters.promptId,
|
||||
executionRequestId: parameters.executionRequestId,
|
||||
});
|
||||
try {
|
||||
const result = await capability.read({
|
||||
principal: authorized.principal,
|
||||
...expected,
|
||||
});
|
||||
const notFound = exactRecord(result, [
|
||||
'executionRequestId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'promptId',
|
||||
'schema',
|
||||
'status',
|
||||
]);
|
||||
if (
|
||||
notFound &&
|
||||
notFound.schema ===
|
||||
'qinglong/plugin-package-prompt-execution-output-read-result@v1' &&
|
||||
notFound.status === 'not_found' &&
|
||||
exactTarget(notFound, expected)
|
||||
) {
|
||||
return response(404, { code: 'prompt_execution_output_not_found' });
|
||||
}
|
||||
const view = availableView(result, expected);
|
||||
return view
|
||||
? response(200, view)
|
||||
: response(503, {
|
||||
code: 'prompt_execution_output_read_unavailable',
|
||||
});
|
||||
} catch {
|
||||
return response(503, {
|
||||
code: 'prompt_execution_output_read_unavailable',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
// Plugin Package Prompt owns bounded, Policy-fenced model execution admission.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import type { ClusterControlAdmissionResponse } from '../../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_REQUEST_SCHEMA =
|
||||
'qinglong/cluster-plugin-package-prompt-execution-request@v2' as const;
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_RESPONSE_SCHEMA =
|
||||
'qinglong/cluster-plugin-package-prompt-execution-response@v2' as const;
|
||||
export const CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_EXECUTION_ROUTE =
|
||||
Object.freeze({
|
||||
method: 'POST' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/prompts/{promptId}/executions',
|
||||
operationId: 'prompt.execute',
|
||||
permission: 'model.invoke',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS = Object.freeze({
|
||||
maxParameters: 64,
|
||||
maxParameterValueBytes: 64 * 1024,
|
||||
maxOutputTokens: 32_768,
|
||||
maxExecutionMs: 120_000,
|
||||
minOutputRetentionMs: 60 * 60_000,
|
||||
maxOutputRetentionMs: 365 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
export type ClusterPluginPackagePromptOutputIntent =
|
||||
| Readonly<{ mode: 'live_only' }>
|
||||
| Readonly<{
|
||||
mode: 'durable_artifact';
|
||||
retentionPolicy: Readonly<{
|
||||
revision: string;
|
||||
retentionMs: number;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export interface ClusterPluginPackagePromptExecutionCommand {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly promptId: string;
|
||||
readonly requestId: string;
|
||||
readonly traceId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<{
|
||||
readonly projectVersion: number;
|
||||
readonly bindingVersion: number;
|
||||
}>;
|
||||
readonly parameters: Readonly<Record<string, string>>;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly maxOutputTokens: number;
|
||||
readonly temperature?: number;
|
||||
readonly deadlineAtMs: number;
|
||||
readonly plannedAtMs: number;
|
||||
readonly output?: Readonly<ClusterPluginPackagePromptOutputIntent>;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePromptExecutionCapability {
|
||||
execute(command: Readonly<ClusterPluginPackagePromptExecutionCommand>): Promise<
|
||||
Readonly<{
|
||||
readonly status: 'executed' | 'resumed' | 'existing';
|
||||
readonly admission: Readonly<{
|
||||
readonly requestId: string;
|
||||
readonly invocationId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
}>;
|
||||
readonly finalization: Readonly<{ readonly runStatus: string }>;
|
||||
readonly result: unknown | null;
|
||||
readonly outputArtifact?: unknown;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePromptExecutionRouteOptions {
|
||||
readonly maxExecutionMs?: number;
|
||||
readonly now?: () => number;
|
||||
readonly createEventId?: () => string;
|
||||
}
|
||||
|
||||
class InvalidPromptExecutionRequestError extends TypeError {}
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function invalid(): never {
|
||||
throw new InvalidPromptExecutionRequestError();
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): void {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER.test(value)) return invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, maximum: number): number {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > maximum
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function parameters(value: unknown): Readonly<Record<string, string>> {
|
||||
const record = dataRecord(value);
|
||||
const names = Object.keys(record).sort();
|
||||
if (
|
||||
names.length > CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS.maxParameters
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const normalized = Object.create(null) as Record<string, string>;
|
||||
for (const name of names) {
|
||||
const parameter = record[name];
|
||||
if (
|
||||
!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(name) ||
|
||||
typeof parameter !== 'string' ||
|
||||
Buffer.byteLength(parameter, 'utf8') >
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS.maxParameterValueBytes
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
normalized[name] = parameter;
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function outputIntent(
|
||||
value: unknown,
|
||||
): Readonly<ClusterPluginPackagePromptOutputIntent> {
|
||||
const output = dataRecord(value);
|
||||
if (output.mode === 'live_only') {
|
||||
exactKeys(output, ['mode']);
|
||||
return Object.freeze({ mode: 'live_only' as const });
|
||||
}
|
||||
if (output.mode !== 'durable_artifact') return invalid();
|
||||
exactKeys(output, ['mode', 'retentionPolicy']);
|
||||
const retention = dataRecord(output.retentionPolicy);
|
||||
exactKeys(retention, ['retentionMs', 'revision']);
|
||||
if (
|
||||
typeof retention.revision !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(retention.revision) ||
|
||||
!Number.isSafeInteger(retention.retentionMs) ||
|
||||
(retention.retentionMs as number) <
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS.minOutputRetentionMs ||
|
||||
(retention.retentionMs as number) >
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS.maxOutputRetentionMs
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: 'durable_artifact' as const,
|
||||
retentionPolicy: Object.freeze({
|
||||
revision: retention.revision,
|
||||
retentionMs: retention.retentionMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
typeof error.code !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return error.code;
|
||||
}
|
||||
|
||||
function executionError(error: unknown): ClusterControlAdmissionResponse {
|
||||
const code = errorCode(error);
|
||||
if (
|
||||
code === 'PLUGIN_PACKAGE_PROMPT_ADMISSION_NOT_ALLOWED' ||
|
||||
code === 'PLUGIN_PACKAGE_PROMPT_ADMISSION_CONFLICT' ||
|
||||
code === 'PLUGIN_PACKAGE_PROMPT_EXECUTION_IN_PROGRESS' ||
|
||||
code === 'PLUGIN_PACKAGE_PROMPT_RESOLUTION_REQUIRED' ||
|
||||
code === 'MODEL_INVOCATION_CONFLICT' ||
|
||||
code === 'MODEL_INVOCATION_REPLAY_BLOCKED'
|
||||
) {
|
||||
return response(409, { code: 'prompt_execution_conflict' });
|
||||
}
|
||||
if (code === 'MODEL_GATEWAY_BUSY' || code === 'MODEL_PROJECT_QUOTA_EXCEEDED') {
|
||||
return response(429, { code: 'prompt_execution_capacity_exceeded' });
|
||||
}
|
||||
if (code === 'MODEL_POLICY_DENIED' || code === 'MODEL_BUDGET_EXCEEDED') {
|
||||
return response(422, { code: 'prompt_execution_policy_rejected' });
|
||||
}
|
||||
if (code === 'MODEL_INVOCATION_DEADLINE_EXCEEDED') {
|
||||
return response(504, { code: 'prompt_execution_deadline_exceeded' });
|
||||
}
|
||||
if (code === 'MODEL_INVOCATION_ABORTED') {
|
||||
return response(408, { code: 'prompt_execution_aborted' });
|
||||
}
|
||||
if (code === 'PLUGIN_PACKAGE_PROMPT_EXECUTION_PLAN_INVALID') {
|
||||
return response(400, { code: 'invalid_prompt_execution_request' });
|
||||
}
|
||||
return response(503, { code: 'prompt_execution_unavailable' });
|
||||
}
|
||||
|
||||
function parseBody(value: unknown, maximumExecutionMs: number) {
|
||||
const body = dataRecord(value);
|
||||
exactKeys(
|
||||
body,
|
||||
[
|
||||
'schema',
|
||||
'requestId',
|
||||
'traceId',
|
||||
'parameters',
|
||||
'provider',
|
||||
'model',
|
||||
'maxOutputTokens',
|
||||
'timeoutMs',
|
||||
],
|
||||
['output', 'temperature'],
|
||||
);
|
||||
if (body.schema !== CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_REQUEST_SCHEMA) {
|
||||
return invalid();
|
||||
}
|
||||
const temperature = body.temperature;
|
||||
const output =
|
||||
body.output === undefined ? undefined : outputIntent(body.output);
|
||||
if (
|
||||
temperature !== undefined &&
|
||||
(typeof temperature !== 'number' ||
|
||||
!Number.isFinite(temperature) ||
|
||||
temperature < 0 ||
|
||||
temperature > 2)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: identifier(body.requestId),
|
||||
traceId: identifier(body.traceId),
|
||||
parameters: parameters(body.parameters),
|
||||
provider: identifier(body.provider),
|
||||
model: identifier(body.model),
|
||||
maxOutputTokens: positiveInteger(
|
||||
body.maxOutputTokens,
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS.maxOutputTokens,
|
||||
),
|
||||
timeoutMs: positiveInteger(body.timeoutMs, maximumExecutionMs),
|
||||
...(output === undefined ? {} : { output }),
|
||||
...(temperature === undefined ? {} : { temperature }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterControlPluginPackagePromptExecutionRoute(
|
||||
capability: ClusterPluginPackagePromptExecutionCapability,
|
||||
options: ClusterPluginPackagePromptExecutionRouteOptions = {},
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!capability || typeof capability.execute !== 'function') {
|
||||
throw new TypeError('Cluster-control Prompt execution capability is invalid');
|
||||
}
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Cluster-control Prompt execution route options are invalid');
|
||||
}
|
||||
const maximumExecutionMs =
|
||||
options.maxExecutionMs ??
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS.maxExecutionMs;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumExecutionMs) ||
|
||||
maximumExecutionMs < 1 ||
|
||||
maximumExecutionMs >
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_LIMITS.maxExecutionMs ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.createEventId !== undefined &&
|
||||
typeof options.createEventId !== 'function')
|
||||
) {
|
||||
throw new TypeError('Cluster-control Prompt execution route options are invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const createEventId = options.createEventId ?? randomUUID;
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_EXECUTION_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
routeParameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
let body;
|
||||
try {
|
||||
body = parseBody(authorized.request.body, maximumExecutionMs);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_prompt_execution_request' });
|
||||
}
|
||||
const projectId = authorized.projectId;
|
||||
const packageName = routeParameters.packageName;
|
||||
const promptId = routeParameters.promptId;
|
||||
const fence = authorized.policyFence;
|
||||
const plannedAtMs = now();
|
||||
const auditEventId = createEventId();
|
||||
if (
|
||||
projectId === null ||
|
||||
typeof packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(packageName) ||
|
||||
typeof promptId !== 'string' ||
|
||||
!IDENTIFIER.test(promptId) ||
|
||||
!fence ||
|
||||
fence.bindingVersion === null ||
|
||||
!Number.isSafeInteger(plannedAtMs) ||
|
||||
plannedAtMs < 0 ||
|
||||
typeof auditEventId !== 'string' ||
|
||||
!UUID_V4.test(auditEventId)
|
||||
) {
|
||||
return response(503, { code: 'prompt_execution_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.execute({
|
||||
projectId,
|
||||
packageName,
|
||||
promptId,
|
||||
requestId: body.requestId,
|
||||
traceId: body.traceId,
|
||||
auditEventId,
|
||||
principal: authorized.principal,
|
||||
policyFence: Object.freeze({
|
||||
projectVersion: fence.projectVersion,
|
||||
bindingVersion: fence.bindingVersion,
|
||||
}),
|
||||
parameters: body.parameters,
|
||||
provider: body.provider,
|
||||
model: body.model,
|
||||
maxOutputTokens: body.maxOutputTokens,
|
||||
...(body.temperature === undefined
|
||||
? {}
|
||||
: { temperature: body.temperature }),
|
||||
...(body.output === undefined ? {} : { output: body.output }),
|
||||
plannedAtMs,
|
||||
deadlineAtMs: plannedAtMs + body.timeoutMs,
|
||||
signal: authorized.request.signal,
|
||||
});
|
||||
return response(200, {
|
||||
schema: CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_RESPONSE_SCHEMA,
|
||||
status: result.status,
|
||||
replayed: result.status === 'existing',
|
||||
requestId: result.admission.requestId,
|
||||
invocationId: result.admission.invocationId,
|
||||
runId: result.admission.runId,
|
||||
stepRunId: result.admission.stepRunId,
|
||||
runStatus: result.finalization.runStatus,
|
||||
result: result.result,
|
||||
...(result.outputArtifact === undefined
|
||||
? {}
|
||||
: { outputArtifact: result.outputArtifact }),
|
||||
});
|
||||
} catch (error) {
|
||||
return executionError(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
// Plugin Package Prompt owns its capability-free durable output projection.
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PROMPT_OUTPUT_READ_RESPONSE_SCHEMA =
|
||||
'qinglong/cluster-plugin-package-prompt-output-read-response@v1' as const;
|
||||
export const CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_OUTPUT_READ_ROUTE =
|
||||
Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/prompt-output-artifacts/{artifactId}',
|
||||
operationId: 'prompt.output.read',
|
||||
permission: 'artifact.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze(['artifact_digest']),
|
||||
});
|
||||
|
||||
export interface ClusterPluginPackagePromptOutputReadCapability {
|
||||
read(command: Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
projectId: string;
|
||||
runId: string;
|
||||
artifactId: string;
|
||||
artifactDigest: string;
|
||||
}>): Promise<unknown>;
|
||||
}
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
|
||||
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const KEY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const FINISH_REASONS = new Set([
|
||||
'stop',
|
||||
'length',
|
||||
'content_filter',
|
||||
'tool_call',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
const expected = [...required, ...optional.filter((key) => key in record)].sort();
|
||||
return keys.length === expected.length &&
|
||||
keys.every((key, index) => key === expected[index])
|
||||
? record
|
||||
: null;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function availableView(
|
||||
value: unknown,
|
||||
expected: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
artifactId: string;
|
||||
artifactDigest: string;
|
||||
}>,
|
||||
): Readonly<Record<string, unknown>> | null {
|
||||
const resultEnvelope = exactRecord(value, [
|
||||
'schema',
|
||||
'status',
|
||||
'reference',
|
||||
'result',
|
||||
]);
|
||||
if (
|
||||
!resultEnvelope ||
|
||||
resultEnvelope.schema !==
|
||||
'qinglong/plugin-package-prompt-output-read-result@v1' ||
|
||||
resultEnvelope.status !== 'available'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const reference = exactRecord(resultEnvelope.reference, [
|
||||
'algorithm',
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'contentDigest',
|
||||
'invocationId',
|
||||
'keyId',
|
||||
'outputBytes',
|
||||
'projectId',
|
||||
'retentionEligibleAtMs',
|
||||
'retentionPolicyDigest',
|
||||
'runId',
|
||||
'schema',
|
||||
'stepRunId',
|
||||
]);
|
||||
const result = exactRecord(resultEnvelope.result, [
|
||||
'finishReason',
|
||||
'model',
|
||||
'provider',
|
||||
'text',
|
||||
'usage',
|
||||
]);
|
||||
const usage = result ? exactRecord(result.usage, [
|
||||
'inputTokens',
|
||||
'outputTokens',
|
||||
'totalTokens',
|
||||
], ['costMicros']) : null;
|
||||
if (
|
||||
!reference ||
|
||||
!result ||
|
||||
!usage ||
|
||||
reference.schema !==
|
||||
'qinglong/plugin-package-prompt-output-artifact-reference@v1' ||
|
||||
reference.algorithm !== 'aes-256-gcm' ||
|
||||
reference.projectId !== expected.projectId ||
|
||||
reference.runId !== expected.runId ||
|
||||
reference.artifactId !== expected.artifactId ||
|
||||
reference.artifactDigest !== expected.artifactDigest ||
|
||||
typeof reference.stepRunId !== 'string' ||
|
||||
!IDENTITY.test(reference.stepRunId) ||
|
||||
typeof reference.invocationId !== 'string' ||
|
||||
!IDENTITY.test(reference.invocationId) ||
|
||||
typeof reference.contentDigest !== 'string' ||
|
||||
!DIGEST.test(reference.contentDigest) ||
|
||||
!nonNegativeInteger(reference.outputBytes) ||
|
||||
reference.outputBytes > 1024 * 1024 ||
|
||||
typeof reference.retentionPolicyDigest !== 'string' ||
|
||||
!DIGEST.test(reference.retentionPolicyDigest) ||
|
||||
!nonNegativeInteger(reference.retentionEligibleAtMs) ||
|
||||
typeof reference.keyId !== 'string' ||
|
||||
!KEY_ID.test(reference.keyId) ||
|
||||
typeof result.provider !== 'string' ||
|
||||
!MODEL_ID.test(result.provider) ||
|
||||
typeof result.model !== 'string' ||
|
||||
!MODEL_ID.test(result.model) ||
|
||||
typeof result.text !== 'string' ||
|
||||
Buffer.byteLength(result.text, 'utf8') > 1024 * 1024 ||
|
||||
!FINISH_REASONS.has(result.finishReason as string) ||
|
||||
!nonNegativeInteger(usage.inputTokens) ||
|
||||
!nonNegativeInteger(usage.outputTokens) ||
|
||||
!nonNegativeInteger(usage.totalTokens) ||
|
||||
usage.totalTokens !== usage.inputTokens + usage.outputTokens ||
|
||||
(usage.costMicros !== undefined && !nonNegativeInteger(usage.costMicros))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: CLUSTER_PLUGIN_PACKAGE_PROMPT_OUTPUT_READ_RESPONSE_SCHEMA,
|
||||
status: 'available',
|
||||
reference: Object.freeze({ ...reference }),
|
||||
result: Object.freeze({
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
text: result.text,
|
||||
finishReason: result.finishReason,
|
||||
usage: Object.freeze({ ...usage }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterControlPluginPackagePromptOutputReadRoute(
|
||||
capability: ClusterPluginPackagePromptOutputReadCapability,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!capability || typeof capability.read !== 'function') {
|
||||
throw new TypeError('Cluster-control Prompt output read capability is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_PLUGIN_PACKAGE_PROMPT_OUTPUT_READ_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
const artifactDigestValues =
|
||||
authorized.request.query.artifact_digest;
|
||||
if (
|
||||
authorized.request.body !== null ||
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.runId !== 'string' ||
|
||||
!RUN_ID.test(parameters.runId) ||
|
||||
typeof parameters.artifactId !== 'string' ||
|
||||
!IDENTITY.test(parameters.artifactId) ||
|
||||
!Array.isArray(artifactDigestValues) ||
|
||||
artifactDigestValues.length !== 1 ||
|
||||
typeof artifactDigestValues[0] !== 'string' ||
|
||||
!DIGEST.test(artifactDigestValues[0])
|
||||
) {
|
||||
return response(400, { code: 'invalid_prompt_output_read_request' });
|
||||
}
|
||||
const expected = Object.freeze({
|
||||
projectId: authorized.projectId,
|
||||
runId: parameters.runId,
|
||||
artifactId: parameters.artifactId,
|
||||
artifactDigest: artifactDigestValues[0],
|
||||
});
|
||||
try {
|
||||
const result = await capability.read({
|
||||
principal: authorized.principal,
|
||||
...expected,
|
||||
});
|
||||
const notFound = exactRecord(result, ['schema', 'status']);
|
||||
if (
|
||||
notFound &&
|
||||
notFound.schema ===
|
||||
'qinglong/plugin-package-prompt-output-read-result@v1' &&
|
||||
notFound.status === 'not_found'
|
||||
) {
|
||||
return response(404, { code: 'prompt_output_not_found' });
|
||||
}
|
||||
const view = availableView(result, expected);
|
||||
return view
|
||||
? response(200, view)
|
||||
: response(503, { code: 'prompt_output_read_unavailable' });
|
||||
} catch {
|
||||
return response(503, { code: 'prompt_output_read_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Plugin Package Prompt owns its stable execution and output-read route surface.
|
||||
export * from './pluginPackagePromptExecutionRoute';
|
||||
export * from './pluginPackagePromptCatalogRoute';
|
||||
export * from './pluginPackagePromptExecutionInspectionRoute';
|
||||
export * from './pluginPackagePromptExecutionOutputReadRoute';
|
||||
export * from './pluginPackagePromptOutputReadRoute';
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
// Plugin Package Workflow owns inspection and durable authorized admission.
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import { normalizeSecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import type {
|
||||
PluginPackageAutomationPublication,
|
||||
PluginPackageAutomationPublicationRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type {
|
||||
PluginPackageMaterializedRevision,
|
||||
PluginPackageMaterializedRevisionRepository,
|
||||
PluginPackageWorkflowResource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import type {
|
||||
PluginPackageWorkflowAdministrationRepository,
|
||||
PluginPackageWorkflowRunEventListRepository,
|
||||
PluginPackageWorkflowRunEventListResult,
|
||||
PluginPackageWorkflowRunInspectionRepository,
|
||||
PluginPackageWorkflowRunInspectionResult,
|
||||
PluginPackageWorkflowRunListRepository,
|
||||
PluginPackageWorkflowRunListResult,
|
||||
PluginPackageWorkflowStepRunListRepository,
|
||||
PluginPackageWorkflowStepRunListResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
import type {
|
||||
ClusterRunCancellationRepository,
|
||||
ClusterRunCancellationResult,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation';
|
||||
import {
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
type PluginPackageWorkflowAdmissionReceipt,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
export interface ClusterPluginPackageWorkflowSummary {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
readonly steps: readonly Readonly<{
|
||||
id: string;
|
||||
task: string;
|
||||
needs: readonly string[];
|
||||
}>[];
|
||||
}
|
||||
|
||||
export interface StartClusterPluginPackageWorkflowCommand {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly workflowId: string;
|
||||
readonly planId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunIds: Readonly<Record<string, string>>;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly plannedAtMs: number;
|
||||
}
|
||||
|
||||
export interface CancelClusterPluginPackageWorkflowCommand {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly workflowId: string;
|
||||
readonly runId: string;
|
||||
readonly mutationId: string;
|
||||
readonly eventId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
}
|
||||
|
||||
export interface InspectClusterPluginPackageWorkflowRunCommand {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly workflowId: string;
|
||||
readonly runId: string;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ListClusterPluginPackageWorkflowRunsCommand {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly workflowId: string;
|
||||
readonly limit: number;
|
||||
readonly after: Readonly<{ admittedAtMs: number; runId: string }> | null;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ListClusterPluginPackageWorkflowStepRunsCommand {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly workflowId: string;
|
||||
readonly runId: string;
|
||||
readonly limit: number;
|
||||
readonly after: Readonly<{ stepKey: string; id: string }> | null;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ListClusterPluginPackageWorkflowRunEventsCommand {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly workflowId: string;
|
||||
readonly runId: string;
|
||||
readonly limit: number;
|
||||
readonly afterSequence: number;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageWorkflowAdministrationCapability {
|
||||
inspect(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
found: boolean;
|
||||
publicationState: PluginPackageAutomationPublication['state'] | null;
|
||||
workflows: readonly Readonly<ClusterPluginPackageWorkflowSummary>[];
|
||||
}>
|
||||
>;
|
||||
start(command: Readonly<StartClusterPluginPackageWorkflowCommand>): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
}>
|
||||
>;
|
||||
cancel(
|
||||
command: Readonly<CancelClusterPluginPackageWorkflowCommand>,
|
||||
): Promise<Readonly<ClusterRunCancellationResult>>;
|
||||
inspectRun(
|
||||
command: Readonly<InspectClusterPluginPackageWorkflowRunCommand>,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunInspectionResult>>;
|
||||
listRuns(
|
||||
command: Readonly<ListClusterPluginPackageWorkflowRunsCommand>,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunListResult>>;
|
||||
listStepRuns(
|
||||
command: Readonly<ListClusterPluginPackageWorkflowStepRunsCommand>,
|
||||
): Promise<Readonly<PluginPackageWorkflowStepRunListResult>>;
|
||||
listRunEvents(
|
||||
command: Readonly<ListClusterPluginPackageWorkflowRunEventsCommand>,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunEventListResult>>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageWorkflowNotFoundError extends Error {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_WORKFLOW_NOT_FOUND';
|
||||
|
||||
constructor() {
|
||||
super('Active Plugin Package Workflow is not available');
|
||||
this.name = 'ClusterPluginPackageWorkflowNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageWorkflowConflictError extends Error {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_WORKFLOW_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Plugin Package Workflow request conflicts with durable identity');
|
||||
this.name = 'ClusterPluginPackageWorkflowConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageWorkflowUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_WORKFLOW_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Plugin Package Workflow administration is unavailable');
|
||||
this.name = 'ClusterPluginPackageWorkflowUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function summary(
|
||||
workflow: Readonly<PluginPackageWorkflowResource>,
|
||||
): Readonly<ClusterPluginPackageWorkflowSummary> {
|
||||
return Object.freeze({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
enabled: workflow.enabled,
|
||||
steps: Object.freeze(
|
||||
workflow.steps.map((step) =>
|
||||
Object.freeze({
|
||||
id: step.id,
|
||||
task: step.task,
|
||||
needs: Object.freeze([...step.needs]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function sameReplay(
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
command: Readonly<StartClusterPluginPackageWorkflowCommand>,
|
||||
): boolean {
|
||||
const requested = Object.entries(command.stepRunIds).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const stored = plan.steps
|
||||
.map((step) => [step.stepKey, step.stepRunId] as const)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
return (
|
||||
plan.planId === command.planId &&
|
||||
plan.runId === command.runId &&
|
||||
plan.target.projectId === command.projectId &&
|
||||
plan.target.packageName === command.packageName &&
|
||||
plan.target.workflowId === command.workflowId &&
|
||||
stored.length === requested.length &&
|
||||
stored.every(
|
||||
([key, id], index) =>
|
||||
key === requested[index]?.[0] && id === requested[index]?.[1],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageWorkflowAdministrationCapability(
|
||||
publications: Pick<
|
||||
PluginPackageAutomationPublicationRepository,
|
||||
'findCurrent'
|
||||
>,
|
||||
revisions: Pick<PluginPackageMaterializedRevisionRepository, 'find'>,
|
||||
admissions: PluginPackageWorkflowAdministrationRepository,
|
||||
runInspections: PluginPackageWorkflowRunInspectionRepository,
|
||||
runLists: PluginPackageWorkflowRunListRepository,
|
||||
stepRunLists: PluginPackageWorkflowStepRunListRepository,
|
||||
runEventLists: PluginPackageWorkflowRunEventListRepository,
|
||||
cancellations: ClusterRunCancellationRepository,
|
||||
taskSpecSemanticRegistry: TaskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(),
|
||||
): ClusterPluginPackageWorkflowAdministrationCapability {
|
||||
if (
|
||||
!publications ||
|
||||
typeof publications.findCurrent !== 'function' ||
|
||||
!revisions ||
|
||||
typeof revisions.find !== 'function' ||
|
||||
!admissions ||
|
||||
typeof admissions.findPlanByPlanId !== 'function' ||
|
||||
typeof admissions.admitAuthorized !== 'function' ||
|
||||
!runInspections ||
|
||||
typeof runInspections.inspectRunAuthorized !== 'function' ||
|
||||
!runLists ||
|
||||
typeof runLists.listRunsAuthorized !== 'function' ||
|
||||
!stepRunLists ||
|
||||
typeof stepRunLists.listStepRunsAuthorized !== 'function' ||
|
||||
!runEventLists ||
|
||||
typeof runEventLists.listRunEventsAuthorized !== 'function' ||
|
||||
!cancellations ||
|
||||
typeof cancellations.requestUserCancellation !== 'function' ||
|
||||
!(taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package Workflow administration dependencies are invalid',
|
||||
);
|
||||
}
|
||||
|
||||
async function currentTarget(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<Readonly<{
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
revision: Readonly<PluginPackageMaterializedRevision>;
|
||||
}> | null> {
|
||||
try {
|
||||
const publication = await publications.findCurrent(
|
||||
projectId,
|
||||
packageName,
|
||||
);
|
||||
if (!publication) return null;
|
||||
const revision = await revisions.find(
|
||||
publication.target.generationDigest,
|
||||
);
|
||||
if (
|
||||
!revision ||
|
||||
revision.revisionDigest !==
|
||||
publication.target.materializedRevisionDigest
|
||||
) {
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
return Object.freeze({ publication, revision });
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterPluginPackageWorkflowUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async inspect(projectId: string, packageName: string) {
|
||||
const target = await currentTarget(projectId, packageName);
|
||||
return target
|
||||
? Object.freeze({
|
||||
found: true,
|
||||
publicationState: target.publication.state,
|
||||
workflows: Object.freeze(
|
||||
target.publication.definitions.workflows.map(summary),
|
||||
),
|
||||
})
|
||||
: Object.freeze({
|
||||
found: false,
|
||||
publicationState: null,
|
||||
workflows: Object.freeze([]),
|
||||
});
|
||||
},
|
||||
|
||||
async start(command: Readonly<StartClusterPluginPackageWorkflowCommand>) {
|
||||
let plan = await admissions.findPlanByPlanId(command.planId);
|
||||
if (plan) {
|
||||
if (!sameReplay(plan, command)) {
|
||||
throw new ClusterPluginPackageWorkflowConflictError();
|
||||
}
|
||||
} else {
|
||||
const target = await currentTarget(
|
||||
command.projectId,
|
||||
command.packageName,
|
||||
);
|
||||
const workflow = target?.publication.definitions.workflows.find(
|
||||
({ id }) => id === command.workflowId,
|
||||
);
|
||||
if (
|
||||
!target ||
|
||||
target.publication.state !== 'active' ||
|
||||
!workflow?.enabled
|
||||
) {
|
||||
throw new ClusterPluginPackageWorkflowNotFoundError();
|
||||
}
|
||||
try {
|
||||
plan = createPluginPackageWorkflowExecutionPlan({
|
||||
planId: command.planId,
|
||||
runId: command.runId,
|
||||
workflowId: command.workflowId,
|
||||
stepRunIds: command.stepRunIds,
|
||||
publication: target.publication,
|
||||
revision: target.revision,
|
||||
taskSpecSemanticRegistry,
|
||||
plannedAtMs: command.plannedAtMs,
|
||||
});
|
||||
} catch {
|
||||
throw new ClusterPluginPackageWorkflowConflictError();
|
||||
}
|
||||
}
|
||||
if (command.policyFence.bindingVersion === null) {
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
const admitted = await admissions.admitAuthorized({
|
||||
plan,
|
||||
actor: command.principal.subject,
|
||||
fence: {
|
||||
projectVersion: command.policyFence.projectVersion,
|
||||
bindingVersion: command.policyFence.bindingVersion,
|
||||
},
|
||||
audit: normalizeSecurityAuditRecord({
|
||||
eventId: command.planId,
|
||||
requestId: command.planId,
|
||||
operationId: 'workflow.start',
|
||||
projectId: command.projectId,
|
||||
subject: command.principal.subject,
|
||||
authenticationId: command.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_policy_allowed'],
|
||||
fence: command.policyFence,
|
||||
occurredAtMs: plan.plannedAtMs,
|
||||
}),
|
||||
});
|
||||
return Object.freeze({
|
||||
status: admitted.status,
|
||||
plan,
|
||||
receipt: admitted.receipt,
|
||||
});
|
||||
},
|
||||
|
||||
async cancel(command: Readonly<CancelClusterPluginPackageWorkflowCommand>) {
|
||||
if (command.policyFence.bindingVersion === null) {
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
return cancellations.requestUserCancellation({
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
mutationId: command.mutationId,
|
||||
eventId: command.eventId,
|
||||
subject: command.principal.subject,
|
||||
policyFence: command.policyFence,
|
||||
workflowTarget: {
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async inspectRun(
|
||||
command: Readonly<InspectClusterPluginPackageWorkflowRunCommand>,
|
||||
) {
|
||||
if (command.policyFence.bindingVersion === null) {
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
return runInspections.inspectRunAuthorized({
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
actor: command.principal.subject,
|
||||
fence: {
|
||||
projectVersion: command.policyFence.projectVersion,
|
||||
bindingVersion: command.policyFence.bindingVersion,
|
||||
},
|
||||
audit: normalizeSecurityAuditRecord({
|
||||
eventId: command.auditEventId,
|
||||
requestId: command.requestId,
|
||||
operationId: 'workflow.run.read',
|
||||
projectId: command.projectId,
|
||||
subject: command.principal.subject,
|
||||
authenticationId: command.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_policy_allowed'],
|
||||
fence: command.policyFence,
|
||||
occurredAtMs: command.observedAtMs,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async listRuns(
|
||||
command: Readonly<ListClusterPluginPackageWorkflowRunsCommand>,
|
||||
) {
|
||||
if (command.policyFence.bindingVersion === null) {
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
return runLists.listRunsAuthorized({
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
limit: command.limit,
|
||||
after: command.after,
|
||||
actor: command.principal.subject,
|
||||
fence: {
|
||||
projectVersion: command.policyFence.projectVersion,
|
||||
bindingVersion: command.policyFence.bindingVersion,
|
||||
},
|
||||
audit: normalizeSecurityAuditRecord({
|
||||
eventId: command.auditEventId,
|
||||
requestId: command.requestId,
|
||||
operationId: 'workflow.run.list',
|
||||
projectId: command.projectId,
|
||||
subject: command.principal.subject,
|
||||
authenticationId: command.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_policy_allowed'],
|
||||
fence: command.policyFence,
|
||||
occurredAtMs: command.observedAtMs,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async listStepRuns(
|
||||
command: Readonly<ListClusterPluginPackageWorkflowStepRunsCommand>,
|
||||
) {
|
||||
if (command.policyFence.bindingVersion === null) {
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
return stepRunLists.listStepRunsAuthorized({
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
limit: command.limit,
|
||||
after: command.after,
|
||||
actor: command.principal.subject,
|
||||
fence: {
|
||||
projectVersion: command.policyFence.projectVersion,
|
||||
bindingVersion: command.policyFence.bindingVersion,
|
||||
},
|
||||
audit: normalizeSecurityAuditRecord({
|
||||
eventId: command.auditEventId,
|
||||
requestId: command.requestId,
|
||||
operationId: 'workflow.step.list',
|
||||
projectId: command.projectId,
|
||||
subject: command.principal.subject,
|
||||
authenticationId: command.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_policy_allowed'],
|
||||
fence: command.policyFence,
|
||||
occurredAtMs: command.observedAtMs,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async listRunEvents(
|
||||
command: Readonly<ListClusterPluginPackageWorkflowRunEventsCommand>,
|
||||
) {
|
||||
if (command.policyFence.bindingVersion === null) {
|
||||
throw new ClusterPluginPackageWorkflowUnavailableError();
|
||||
}
|
||||
return runEventLists.listRunEventsAuthorized({
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
limit: command.limit,
|
||||
afterSequence: command.afterSequence,
|
||||
actor: command.principal.subject,
|
||||
fence: {
|
||||
projectVersion: command.policyFence.projectVersion,
|
||||
bindingVersion: command.policyFence.bindingVersion,
|
||||
},
|
||||
audit: normalizeSecurityAuditRecord({
|
||||
eventId: command.auditEventId,
|
||||
requestId: command.requestId,
|
||||
operationId: 'workflow.event.list',
|
||||
projectId: command.projectId,
|
||||
subject: command.principal.subject,
|
||||
authenticationId: command.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_policy_allowed'],
|
||||
fence: command.policyFence,
|
||||
occurredAtMs: command.observedAtMs,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
// Plugin Package Workflow owns its bounded inspect/start/cancel transport adapter.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
createClusterRunCancellationResponseBody,
|
||||
parseClusterRunCancellationRequestBody,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation';
|
||||
import {
|
||||
DEFAULT_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE,
|
||||
DEFAULT_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE,
|
||||
DEFAULT_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
import type { ClusterControlAdmissionResponse } from '../../transport/httpSurface';
|
||||
import type { ClusterPluginPackageWorkflowAdministrationCapability } from './pluginPackageWorkflowAdministration';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_WORKFLOW_LIST_RESPONSE_SCHEMA =
|
||||
'qinglong/cluster-plugin-package-workflow-list@v1' as const;
|
||||
export const CLUSTER_PLUGIN_PACKAGE_WORKFLOW_START_REQUEST_SCHEMA =
|
||||
'qinglong/cluster-plugin-package-workflow-start-request@v1' as const;
|
||||
export const CLUSTER_PLUGIN_PACKAGE_WORKFLOW_START_RESPONSE_SCHEMA =
|
||||
'qinglong/cluster-plugin-package-workflow-start-response@v1' as const;
|
||||
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const RESOURCE_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
||||
|
||||
function parseRunListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{
|
||||
limit: number;
|
||||
after: Readonly<{ admittedAtMs: number; runId: string }> | null;
|
||||
}> {
|
||||
const limitValues = query.limit;
|
||||
const admittedAtValues = query.after_admitted_at_ms;
|
||||
const runIdValues = query.after_run_id;
|
||||
if (
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(admittedAtValues !== undefined && admittedAtValues.length !== 1) ||
|
||||
(runIdValues !== undefined && runIdValues.length !== 1) ||
|
||||
(admittedAtValues === undefined) !== (runIdValues === undefined)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const limit =
|
||||
limitValues === undefined
|
||||
? DEFAULT_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE
|
||||
: Number(limitValues[0]);
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE ||
|
||||
(limitValues !== undefined && String(limit) !== limitValues[0])
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
if (admittedAtValues === undefined || runIdValues === undefined) {
|
||||
return Object.freeze({ limit, after: null });
|
||||
}
|
||||
const admittedAtMs = Number(admittedAtValues[0]);
|
||||
const runId = runIdValues[0]!;
|
||||
if (
|
||||
!Number.isSafeInteger(admittedAtMs) ||
|
||||
admittedAtMs < 0 ||
|
||||
String(admittedAtMs) !== admittedAtValues[0] ||
|
||||
!UUID_V4.test(runId)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
limit,
|
||||
after: Object.freeze({ admittedAtMs, runId }),
|
||||
});
|
||||
}
|
||||
|
||||
function parseStepRunListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{
|
||||
limit: number;
|
||||
after: Readonly<{ stepKey: string; id: string }> | null;
|
||||
}> {
|
||||
const limitValues = query.limit;
|
||||
const stepKeyValues = query.after_step_key;
|
||||
const stepRunIdValues = query.after_step_run_id;
|
||||
if (
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(stepKeyValues !== undefined && stepKeyValues.length !== 1) ||
|
||||
(stepRunIdValues !== undefined && stepRunIdValues.length !== 1) ||
|
||||
(stepKeyValues === undefined) !== (stepRunIdValues === undefined)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const limit =
|
||||
limitValues === undefined
|
||||
? DEFAULT_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE
|
||||
: Number(limitValues[0]);
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE ||
|
||||
(limitValues !== undefined && String(limit) !== limitValues[0])
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
if (stepKeyValues === undefined || stepRunIdValues === undefined) {
|
||||
return Object.freeze({ limit, after: null });
|
||||
}
|
||||
const stepKey = stepKeyValues[0]!;
|
||||
const id = stepRunIdValues[0]!;
|
||||
if (!RESOURCE_ID.test(stepKey) || !UUID_V4.test(id)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
limit,
|
||||
after: Object.freeze({ stepKey, id }),
|
||||
});
|
||||
}
|
||||
|
||||
function parseRunEventListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{ limit: number; afterSequence: number }> {
|
||||
const limitValues = query.limit;
|
||||
const afterValues = query.after_sequence;
|
||||
if (
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(afterValues !== undefined && afterValues.length !== 1)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const limit =
|
||||
limitValues === undefined
|
||||
? DEFAULT_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE
|
||||
: Number(limitValues[0]);
|
||||
const afterSequence = afterValues === undefined ? 0 : Number(afterValues[0]);
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE ||
|
||||
(limitValues !== undefined && String(limit) !== limitValues[0]) ||
|
||||
!Number.isSafeInteger(afterSequence) ||
|
||||
afterSequence < 0 ||
|
||||
(afterValues !== undefined && String(afterSequence) !== afterValues[0])
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({ limit, afterSequence });
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseBody(value: unknown): Readonly<{
|
||||
planId: string;
|
||||
runId: string;
|
||||
stepRunIds: Readonly<Record<string, string>>;
|
||||
}> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(body).sort().join(',') !== 'planId,runId,schema,stepRunIds' ||
|
||||
body.schema !== CLUSTER_PLUGIN_PACKAGE_WORKFLOW_START_REQUEST_SCHEMA ||
|
||||
typeof body.planId !== 'string' ||
|
||||
!UUID_V4.test(body.planId) ||
|
||||
typeof body.runId !== 'string' ||
|
||||
!UUID_V4.test(body.runId) ||
|
||||
!body.stepRunIds ||
|
||||
typeof body.stepRunIds !== 'object' ||
|
||||
Array.isArray(body.stepRunIds) ||
|
||||
Object.getPrototypeOf(body.stepRunIds) !== Object.prototype
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const entries = Object.entries(body.stepRunIds as Record<string, unknown>);
|
||||
if (
|
||||
entries.length < 1 ||
|
||||
entries.length > 128 ||
|
||||
entries.some(
|
||||
([key, id]) =>
|
||||
!RESOURCE_ID.test(key) || typeof id !== 'string' || !UUID_V4.test(id),
|
||||
) ||
|
||||
new Set(entries.map(([, id]) => id)).size !== entries.length
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
planId: body.planId,
|
||||
runId: body.runId,
|
||||
stepRunIds: Object.freeze(
|
||||
Object.fromEntries(entries) as Record<string, string>,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown): ClusterControlAdmissionResponse {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
if (code === 'CLUSTER_PLUGIN_PACKAGE_WORKFLOW_NOT_FOUND') {
|
||||
return response(404, { code: 'workflow_not_found' });
|
||||
}
|
||||
if (
|
||||
code === 'CLUSTER_PLUGIN_PACKAGE_WORKFLOW_CONFLICT' ||
|
||||
code === 'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_MUTATION_CONFLICT' ||
|
||||
code === 'PLUGIN_PACKAGE_WORKFLOW_ADMISSION_CONFLICT'
|
||||
) {
|
||||
return response(409, { code: 'workflow_start_conflict' });
|
||||
}
|
||||
if (
|
||||
code ===
|
||||
'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT'
|
||||
) {
|
||||
return response(409, { code: 'authorization_fence_changed' });
|
||||
}
|
||||
if (code === 'CLUSTER_RUN_CANCELLATION_NOT_FOUND') {
|
||||
return response(404, { code: 'workflow_run_not_found' });
|
||||
}
|
||||
if (code === 'CLUSTER_RUN_CANCELLATION_FENCE_REJECTED') {
|
||||
const candidateReason =
|
||||
error && typeof error === 'object' && 'reason' in error
|
||||
? (error as { reason?: unknown }).reason
|
||||
: null;
|
||||
const reason =
|
||||
candidateReason === 'authorization_changed' ||
|
||||
candidateReason === 'project_mismatch' ||
|
||||
candidateReason === 'state_mismatch'
|
||||
? candidateReason
|
||||
: 'state_mismatch';
|
||||
return response(409, {
|
||||
code: 'workflow_cancellation_fence_rejected',
|
||||
reason,
|
||||
});
|
||||
}
|
||||
return response(503, { code: 'workflow_administration_unavailable' });
|
||||
}
|
||||
|
||||
function runInspectionErrorResponse(
|
||||
error: unknown,
|
||||
): ClusterControlAdmissionResponse {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
if (
|
||||
code ===
|
||||
'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT'
|
||||
) {
|
||||
return response(409, { code: 'authorization_fence_changed' });
|
||||
}
|
||||
return response(503, { code: 'workflow_run_query_unavailable' });
|
||||
}
|
||||
|
||||
function runListErrorResponse(error: unknown): ClusterControlAdmissionResponse {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
if (
|
||||
code ===
|
||||
'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT'
|
||||
) {
|
||||
return response(409, { code: 'authorization_fence_changed' });
|
||||
}
|
||||
return response(503, { code: 'workflow_run_list_unavailable' });
|
||||
}
|
||||
|
||||
function stepRunListErrorResponse(
|
||||
error: unknown,
|
||||
): ClusterControlAdmissionResponse {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
if (
|
||||
code ===
|
||||
'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT'
|
||||
) {
|
||||
return response(409, { code: 'authorization_fence_changed' });
|
||||
}
|
||||
return response(503, { code: 'workflow_step_run_query_unavailable' });
|
||||
}
|
||||
|
||||
function runEventListErrorResponse(
|
||||
error: unknown,
|
||||
): ClusterControlAdmissionResponse {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
if (
|
||||
code ===
|
||||
'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT'
|
||||
) {
|
||||
return response(409, { code: 'authorization_fence_changed' });
|
||||
}
|
||||
return response(503, { code: 'workflow_run_event_query_unavailable' });
|
||||
}
|
||||
|
||||
export function createClusterControlPluginPackageWorkflowRoutes(
|
||||
capability: ClusterPluginPackageWorkflowAdministrationCapability,
|
||||
now: () => number = Date.now,
|
||||
createEventId: () => string = randomUUID,
|
||||
): readonly Readonly<ClusterControlRouteDefinition>[] {
|
||||
if (
|
||||
!capability ||
|
||||
typeof capability.inspect !== 'function' ||
|
||||
typeof capability.inspectRun !== 'function' ||
|
||||
typeof capability.listRuns !== 'function' ||
|
||||
typeof capability.listStepRuns !== 'function' ||
|
||||
typeof capability.listRunEvents !== 'function' ||
|
||||
typeof capability.start !== 'function' ||
|
||||
typeof capability.cancel !== 'function' ||
|
||||
typeof now !== 'function' ||
|
||||
typeof createEventId !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Workflow capability is invalid');
|
||||
}
|
||||
const common = {
|
||||
projectParameter: 'projectId' as const,
|
||||
};
|
||||
return Object.freeze([
|
||||
Object.freeze({
|
||||
...common,
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/workflows',
|
||||
operationId: 'workflow.read',
|
||||
permission: 'run.read',
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName)
|
||||
) {
|
||||
return response(503, { code: 'workflow_administration_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.inspect(
|
||||
authorized.projectId,
|
||||
parameters.packageName,
|
||||
);
|
||||
return response(200, {
|
||||
schema: CLUSTER_PLUGIN_PACKAGE_WORKFLOW_LIST_RESPONSE_SCHEMA,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
...common,
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/workflows/{workflowId}/runs',
|
||||
operationId: 'workflow.run.list',
|
||||
permission: 'run.read',
|
||||
allowedQuery: Object.freeze([
|
||||
'after_admitted_at_ms',
|
||||
'after_run_id',
|
||||
'limit',
|
||||
]),
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
let page;
|
||||
try {
|
||||
page = parseRunListQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_workflow_run_query' });
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.workflowId !== 'string' ||
|
||||
!RESOURCE_ID.test(parameters.workflowId) ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null ||
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < 0
|
||||
) {
|
||||
return response(503, { code: 'workflow_run_list_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.listRuns({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
workflowId: parameters.workflowId,
|
||||
limit: page.limit,
|
||||
after: page.after,
|
||||
requestId: authorized.request.requestId,
|
||||
auditEventId: createEventId(),
|
||||
principal: authorized.principal,
|
||||
policyFence: authorized.policyFence,
|
||||
observedAtMs,
|
||||
});
|
||||
return response(200, { ...result });
|
||||
} catch (error) {
|
||||
return runListErrorResponse(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
...common,
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/workflows/{workflowId}/runs/{runId}',
|
||||
operationId: 'workflow.run.read',
|
||||
permission: 'run.read',
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
const observedAtMs = now();
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.workflowId !== 'string' ||
|
||||
!RESOURCE_ID.test(parameters.workflowId) ||
|
||||
typeof parameters.runId !== 'string' ||
|
||||
!UUID_V4.test(parameters.runId) ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null ||
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < 0
|
||||
) {
|
||||
return response(503, { code: 'workflow_run_query_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.inspectRun({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
workflowId: parameters.workflowId,
|
||||
runId: parameters.runId,
|
||||
requestId: authorized.request.requestId,
|
||||
auditEventId: createEventId(),
|
||||
principal: authorized.principal,
|
||||
policyFence: authorized.policyFence,
|
||||
observedAtMs,
|
||||
});
|
||||
return result.found
|
||||
? response(200, { ...result })
|
||||
: response(404, { code: 'workflow_run_not_found' });
|
||||
} catch (error) {
|
||||
return runInspectionErrorResponse(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
...common,
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/workflows/{workflowId}/runs/{runId}/steps',
|
||||
operationId: 'workflow.step.list',
|
||||
permission: 'run.read',
|
||||
allowedQuery: Object.freeze([
|
||||
'after_step_key',
|
||||
'after_step_run_id',
|
||||
'limit',
|
||||
]),
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
let page;
|
||||
try {
|
||||
page = parseStepRunListQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_step_run_query' });
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.workflowId !== 'string' ||
|
||||
!RESOURCE_ID.test(parameters.workflowId) ||
|
||||
typeof parameters.runId !== 'string' ||
|
||||
!UUID_V4.test(parameters.runId) ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null ||
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < 0
|
||||
) {
|
||||
return response(503, { code: 'workflow_step_run_query_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.listStepRuns({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
workflowId: parameters.workflowId,
|
||||
runId: parameters.runId,
|
||||
limit: page.limit,
|
||||
after: page.after,
|
||||
requestId: authorized.request.requestId,
|
||||
auditEventId: createEventId(),
|
||||
principal: authorized.principal,
|
||||
policyFence: authorized.policyFence,
|
||||
observedAtMs,
|
||||
});
|
||||
return result.found
|
||||
? response(200, { ...result })
|
||||
: response(404, { code: 'workflow_run_not_found' });
|
||||
} catch (error) {
|
||||
return stepRunListErrorResponse(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
...common,
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/workflows/{workflowId}/runs/{runId}/events',
|
||||
operationId: 'workflow.event.list',
|
||||
permission: 'run.read',
|
||||
allowedQuery: Object.freeze(['after_sequence', 'limit']),
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
let page;
|
||||
try {
|
||||
page = parseRunEventListQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_run_event_query' });
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.workflowId !== 'string' ||
|
||||
!RESOURCE_ID.test(parameters.workflowId) ||
|
||||
typeof parameters.runId !== 'string' ||
|
||||
!UUID_V4.test(parameters.runId) ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null ||
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < 0
|
||||
) {
|
||||
return response(503, {
|
||||
code: 'workflow_run_event_query_unavailable',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await capability.listRunEvents({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
workflowId: parameters.workflowId,
|
||||
runId: parameters.runId,
|
||||
limit: page.limit,
|
||||
afterSequence: page.afterSequence,
|
||||
requestId: authorized.request.requestId,
|
||||
auditEventId: createEventId(),
|
||||
principal: authorized.principal,
|
||||
policyFence: authorized.policyFence,
|
||||
observedAtMs,
|
||||
});
|
||||
return result.found
|
||||
? response(200, { ...result })
|
||||
: response(404, { code: 'workflow_run_not_found' });
|
||||
} catch (error) {
|
||||
return runEventListErrorResponse(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
...common,
|
||||
method: 'POST' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/workflows/{workflowId}/runs',
|
||||
operationId: 'workflow.start',
|
||||
permission: 'run.start',
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
let body;
|
||||
try {
|
||||
body = parseBody(authorized.request.body);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_workflow_start_request' });
|
||||
}
|
||||
const plannedAtMs = now();
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.workflowId !== 'string' ||
|
||||
!RESOURCE_ID.test(parameters.workflowId) ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null ||
|
||||
!Number.isSafeInteger(plannedAtMs) ||
|
||||
plannedAtMs < 0
|
||||
) {
|
||||
return response(503, { code: 'workflow_administration_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await capability.start({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
workflowId: parameters.workflowId,
|
||||
planId: body.planId,
|
||||
runId: body.runId,
|
||||
stepRunIds: body.stepRunIds,
|
||||
principal: authorized.principal,
|
||||
policyFence: authorized.policyFence,
|
||||
plannedAtMs,
|
||||
});
|
||||
return response(result.status === 'created' ? 201 : 200, {
|
||||
schema: CLUSTER_PLUGIN_PACKAGE_WORKFLOW_START_RESPONSE_SCHEMA,
|
||||
status: result.status,
|
||||
replayed: result.status === 'existing',
|
||||
planId: result.plan.planId,
|
||||
runId: result.plan.runId,
|
||||
receiptDigest: result.receipt.receiptDigest,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
...common,
|
||||
method: 'POST' as const,
|
||||
path: '/api/v3/projects/{projectId}/packages/{packageName}/workflows/{workflowId}/runs/{runId}/cancellation',
|
||||
operationId: 'workflow.cancel',
|
||||
permission: 'run.stop',
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
let body;
|
||||
try {
|
||||
body = parseClusterRunCancellationRequestBody(
|
||||
authorized.request.body,
|
||||
);
|
||||
} catch {
|
||||
return response(400, {
|
||||
code: 'invalid_workflow_cancellation_request',
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
});
|
||||
}
|
||||
if (
|
||||
authorized.projectId === null ||
|
||||
typeof parameters.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(parameters.packageName) ||
|
||||
typeof parameters.workflowId !== 'string' ||
|
||||
!RESOURCE_ID.test(parameters.workflowId) ||
|
||||
typeof parameters.runId !== 'string' ||
|
||||
!UUID_V4.test(parameters.runId) ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null
|
||||
) {
|
||||
return response(503, {
|
||||
code: 'workflow_administration_unavailable',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await capability.cancel({
|
||||
projectId: authorized.projectId,
|
||||
packageName: parameters.packageName,
|
||||
workflowId: parameters.workflowId,
|
||||
runId: parameters.runId,
|
||||
mutationId: body.mutationId,
|
||||
eventId: createEventId(),
|
||||
principal: authorized.principal,
|
||||
policyFence: authorized.policyFence,
|
||||
});
|
||||
return response(
|
||||
result.status === 'accepted' ? 202 : 200,
|
||||
createClusterRunCancellationResponseBody(result),
|
||||
);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import type {
|
||||
DeploymentProfile,
|
||||
OpenPostgresDatabase,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresConnectionEnvironment,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/runtime';
|
||||
import { ClusterControlAvailabilityFence } from '../database/availability';
|
||||
import type { ClusterControlHttpSurfaceOptions } from '../transport/httpSurface';
|
||||
|
||||
export type ClusterControlEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export interface DisabledClusterControlConfig {
|
||||
readonly enabled: false;
|
||||
readonly profile: DeploymentProfile;
|
||||
}
|
||||
|
||||
export interface EnabledClusterControlConfig {
|
||||
readonly enabled: true;
|
||||
readonly profile: 'cluster-control';
|
||||
readonly http: ClusterControlHttpSurfaceOptions;
|
||||
readonly database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
readonly security: Readonly<{
|
||||
apiCredentialPepper: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ClusterControlConfig =
|
||||
| DisabledClusterControlConfig
|
||||
| EnabledClusterControlConfig;
|
||||
|
||||
export interface ClusterControlDatabaseBinding {
|
||||
readonly availability: ClusterControlAvailabilityFence;
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
}
|
||||
|
||||
export class ClusterControlConfigError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Cluster-control configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterControlConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
const PROFILES = new Set<DeploymentProfile>([
|
||||
'edge',
|
||||
'standalone',
|
||||
'cluster-control',
|
||||
'worker',
|
||||
]);
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterControlEnvironment,
|
||||
name: string,
|
||||
defaultValue: boolean,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw new ClusterControlConfigError(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterControlEnvironment,
|
||||
name: string,
|
||||
defaultValue: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new ClusterControlConfigError(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new ClusterControlConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterControlEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') {
|
||||
if (required) throw new ClusterControlConfigError(`${name} is required`);
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > maximumLength || /[\0\r\n]/.test(value)) {
|
||||
throw new ClusterControlConfigError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function deploymentProfile(
|
||||
environment: ClusterControlEnvironment,
|
||||
): DeploymentProfile {
|
||||
const value = environment.QL_DEPLOYMENT_PROFILE ?? 'standalone';
|
||||
if (!PROFILES.has(value as DeploymentProfile)) {
|
||||
throw new ClusterControlConfigError('QL_DEPLOYMENT_PROFILE is invalid');
|
||||
}
|
||||
return value as DeploymentProfile;
|
||||
}
|
||||
|
||||
function runtimeConnection(
|
||||
environment: ClusterControlEnvironment,
|
||||
): PostgresConnectionOptions {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_RUNTIME_URL',
|
||||
host: 'QL3_POSTGRES_RUNTIME_HOST',
|
||||
port: 'QL3_POSTGRES_RUNTIME_PORT',
|
||||
database: 'QL3_POSTGRES_RUNTIME_DATABASE',
|
||||
user: 'QL3_POSTGRES_RUNTIME_USER',
|
||||
password: 'QL3_POSTGRES_RUNTIME_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClusterControlConfigError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL runtime connection is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
const mode = environment.QL3_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw new ClusterControlConfigError(
|
||||
'QL3_POSTGRES_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(environment, 'QL3_POSTGRES_ALLOW_INSECURE', false)
|
||||
) {
|
||||
throw new ClusterControlConfigError(
|
||||
'disabling PostgreSQL TLS requires QL3_POSTGRES_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw new ClusterControlConfigError(
|
||||
'QL3_POSTGRES_TLS_SERVERNAME must be an explicit DNS name for verify-full',
|
||||
);
|
||||
}
|
||||
const certificateAuthorityFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_CA_FILE',
|
||||
4096,
|
||||
);
|
||||
if (mode === 'disable' && certificateAuthorityFile !== undefined) {
|
||||
throw new ClusterControlConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let certificateAuthority: string | undefined;
|
||||
if (certificateAuthorityFile !== undefined) {
|
||||
try {
|
||||
certificateAuthority = loadPostgresCertificateAuthorityFile(
|
||||
certificateAuthorityFile,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterControlConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE must contain a bounded trusted CA bundle',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
...(certificateAuthority === undefined
|
||||
? {}
|
||||
: { ca: certificateAuthority }),
|
||||
servername: servername!,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function apiCredentialPepper(environment: ClusterControlEnvironment): string {
|
||||
const value = boundedValue(
|
||||
environment,
|
||||
'QL3_API_CREDENTIAL_PEPPER',
|
||||
64,
|
||||
true,
|
||||
)!;
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(value)) {
|
||||
throw new ClusterControlConfigError(
|
||||
'QL3_API_CREDENTIAL_PEPPER must be canonical base64url for 32 bytes',
|
||||
);
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
if (decoded.byteLength !== 32 || decoded.toString('base64url') !== value) {
|
||||
throw new ClusterControlConfigError(
|
||||
'QL3_API_CREDENTIAL_PEPPER must be canonical base64url for 32 bytes',
|
||||
);
|
||||
}
|
||||
decoded.fill(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the profile gate before reading PostgreSQL configuration. A disabled
|
||||
* cluster-control therefore does not touch its runtime credential source.
|
||||
*/
|
||||
export function loadClusterControlConfig(
|
||||
environment: ClusterControlEnvironment,
|
||||
): ClusterControlConfig {
|
||||
if (
|
||||
!environment ||
|
||||
typeof environment !== 'object' ||
|
||||
Array.isArray(environment)
|
||||
) {
|
||||
throw new ClusterControlConfigError('environment must be an object');
|
||||
}
|
||||
const profile = deploymentProfile(environment);
|
||||
const enabled = booleanValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_CONTROL_ENABLED',
|
||||
false,
|
||||
);
|
||||
if (!enabled) return Object.freeze({ enabled: false, profile });
|
||||
if (profile !== 'cluster-control') {
|
||||
throw new ClusterControlConfigError(
|
||||
'enabled runtime requires QL_DEPLOYMENT_PROFILE=cluster-control',
|
||||
);
|
||||
}
|
||||
|
||||
const applicationName =
|
||||
boundedValue(environment, 'QL3_POSTGRES_APPLICATION_NAME', 63) ??
|
||||
'qinglong-cluster-runtime';
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(applicationName)) {
|
||||
throw new ClusterControlConfigError(
|
||||
'QL3_POSTGRES_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
boundedValue(environment, 'QL3_CLUSTER_HTTP_HOST', 253) ?? '0.0.0.0';
|
||||
const config: EnabledClusterControlConfig = {
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
http: Object.freeze({
|
||||
host,
|
||||
port: integerValue(environment, 'QL3_CLUSTER_HTTP_PORT', 5800, 1, 65_535),
|
||||
maxBodyBytes: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_HTTP_MAX_BODY_BYTES',
|
||||
1024 * 1024,
|
||||
1024,
|
||||
4 * 1024 * 1024,
|
||||
),
|
||||
maxInFlightRequests: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_HTTP_MAX_IN_FLIGHT',
|
||||
64,
|
||||
1,
|
||||
1024,
|
||||
),
|
||||
authenticationRateWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_AUTH_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
60 * 60_000,
|
||||
),
|
||||
authenticationRatePerPeer: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_AUTH_RATE_PER_PEER',
|
||||
300,
|
||||
1,
|
||||
1_000_000,
|
||||
),
|
||||
authenticationRateGlobal: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_AUTH_RATE_GLOBAL',
|
||||
1_200,
|
||||
1,
|
||||
1_000_000,
|
||||
),
|
||||
authenticationRateMaxPeers: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_AUTH_RATE_MAX_PEERS',
|
||||
4_096,
|
||||
1,
|
||||
65_536,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_HTTP_REQUEST_TIMEOUT_MS',
|
||||
15_000,
|
||||
100,
|
||||
120_000,
|
||||
),
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_HTTP_DRAIN_TIMEOUT_MS',
|
||||
10_000,
|
||||
100,
|
||||
120_000,
|
||||
),
|
||||
}),
|
||||
database: Object.freeze({
|
||||
connection: runtimeConnection(environment),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_MAX_CONNECTIONS',
|
||||
8,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
security: Object.freeze({
|
||||
apiCredentialPepper: apiCredentialPepper(environment),
|
||||
}),
|
||||
};
|
||||
return Object.freeze(config);
|
||||
}
|
||||
|
||||
export function createClusterControlDatabaseBinding(
|
||||
config: EnabledClusterControlConfig,
|
||||
): ClusterControlDatabaseBinding {
|
||||
if (!config?.enabled || config.profile !== 'cluster-control') {
|
||||
throw new ClusterControlConfigError(
|
||||
'database binding requires an enabled cluster-control config',
|
||||
);
|
||||
}
|
||||
const availability = new ClusterControlAvailabilityFence();
|
||||
const openDatabase = createPostgresDatabaseOpener({
|
||||
role: 'runtime',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError(error) {
|
||||
// pg emits idle-client errors outside a request Promise. They are an
|
||||
// availability signal, never a callback exception or transaction retry.
|
||||
void availability.signal(error).catch(() => undefined);
|
||||
},
|
||||
});
|
||||
return Object.freeze({ availability, openDatabase });
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import type {
|
||||
ClusterControlActivationAudit,
|
||||
ClusterControlStopResult,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
loadClusterControlConfig,
|
||||
type ClusterControlEnvironment,
|
||||
type EnabledClusterControlConfig,
|
||||
} from './config';
|
||||
import {
|
||||
startProductionClusterControlApplication,
|
||||
type ProductionClusterControlApplicationOptions,
|
||||
} from '../application-runtime/productionApplication';
|
||||
import {
|
||||
ClusterControlDatabaseUnavailableError,
|
||||
type ClusterControlApplicationResult,
|
||||
} from '../application-runtime/application';
|
||||
import {
|
||||
loadClusterWorkerIngressConfig,
|
||||
type EnabledClusterWorkerIngressConfig,
|
||||
} from '../worker-ingress/workerIngressConfig';
|
||||
import type { ClusterWorkerArtifactBinding } from '../artifact/workerArtifactBinding';
|
||||
import type { RemoteWorkerSecretValueProvider } from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
|
||||
export type ClusterControlProcessSignal = 'SIGINT' | 'SIGTERM';
|
||||
|
||||
export interface ClusterControlProcessEvent {
|
||||
readonly schemaVersion: 1;
|
||||
readonly component: 'qinglong3-cluster-control';
|
||||
readonly level: 'info' | 'error';
|
||||
readonly event: string;
|
||||
readonly replicaId: string;
|
||||
readonly signal?: ClusterControlProcessSignal;
|
||||
readonly stopResult?: ClusterControlStopResult;
|
||||
readonly address?: Readonly<{ host: string; port: number }>;
|
||||
readonly activation?: ClusterControlActivationAudit;
|
||||
readonly diagnostic?: Readonly<{
|
||||
scope:
|
||||
| 'scheduler'
|
||||
| 'cancellation-convergence'
|
||||
| 'database'
|
||||
| 'worker-ingress';
|
||||
name: string;
|
||||
code?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClusterControlProcessSignalSource {
|
||||
subscribe(
|
||||
listener: (signal: ClusterControlProcessSignal) => void,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export type ProductionClusterControlStarter = (
|
||||
options: ProductionClusterControlApplicationOptions,
|
||||
) => Promise<ClusterControlApplicationResult>;
|
||||
|
||||
export type ClusterWorkerArtifactBindingFactory = (
|
||||
config: EnabledClusterWorkerIngressConfig['artifact'],
|
||||
) => Promise<Readonly<ClusterWorkerArtifactBinding>>;
|
||||
|
||||
export type ClusterWorkerSecretProviderFactory = (
|
||||
config: NonNullable<EnabledClusterWorkerIngressConfig['secret']>,
|
||||
) => Promise<Readonly<RemoteWorkerSecretValueProvider>>;
|
||||
|
||||
export interface ProductionClusterControlProcessOptions {
|
||||
readonly environment: ClusterControlEnvironment;
|
||||
readonly signals: ClusterControlProcessSignalSource;
|
||||
readonly emit: (event: ClusterControlProcessEvent) => void | Promise<void>;
|
||||
readonly start?: ProductionClusterControlStarter;
|
||||
readonly createWorkerArtifactBinding?: ClusterWorkerArtifactBindingFactory;
|
||||
readonly createWorkerSecretProvider?: ClusterWorkerSecretProviderFactory;
|
||||
readonly workerSecretProvider?: RemoteWorkerSecretValueProvider;
|
||||
}
|
||||
|
||||
export class ClusterControlProcessError extends Error {
|
||||
readonly code:
|
||||
| 'QL3_CLUSTER_CONTROL_PROCESS_CONFIG_INVALID'
|
||||
| 'QL3_CLUSTER_CONTROL_PROCESS_DISABLED';
|
||||
|
||||
constructor(
|
||||
code: ClusterControlProcessError['code'],
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ClusterControlProcessError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const REPLICA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function processConfiguration(environment: ClusterControlEnvironment): {
|
||||
readonly config: EnabledClusterControlConfig;
|
||||
readonly workerIngress?: EnabledClusterWorkerIngressConfig;
|
||||
readonly replicaId: string;
|
||||
} {
|
||||
const config = loadClusterControlConfig(environment);
|
||||
if (!config.enabled) {
|
||||
throw new ClusterControlProcessError(
|
||||
'QL3_CLUSTER_CONTROL_PROCESS_DISABLED',
|
||||
'The cluster-control process requires an enabled cluster-control profile',
|
||||
);
|
||||
}
|
||||
const replicaId = environment.QL3_CLUSTER_REPLICA_ID;
|
||||
if (
|
||||
typeof replicaId !== 'string' ||
|
||||
!REPLICA_ID_PATTERN.test(replicaId)
|
||||
) {
|
||||
throw new ClusterControlProcessError(
|
||||
'QL3_CLUSTER_CONTROL_PROCESS_CONFIG_INVALID',
|
||||
'QL3_CLUSTER_REPLICA_ID must be a stable safe identifier',
|
||||
);
|
||||
}
|
||||
const workerIngress = loadClusterWorkerIngressConfig(environment);
|
||||
return Object.freeze({
|
||||
config,
|
||||
replicaId,
|
||||
...(workerIngress.enabled ? { workerIngress } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkerArtifactBinding(
|
||||
config: EnabledClusterWorkerIngressConfig['artifact'],
|
||||
): Promise<Readonly<ClusterWorkerArtifactBinding>> {
|
||||
const binding = await import('../artifact/workerArtifactBinding.js');
|
||||
return binding.createClusterWorkerArtifactBinding(config);
|
||||
}
|
||||
|
||||
async function createWorkerSecretProvider(
|
||||
config: NonNullable<EnabledClusterWorkerIngressConfig['secret']>,
|
||||
): Promise<Readonly<RemoteWorkerSecretValueProvider>> {
|
||||
if (config.provider !== 'mounted-files') {
|
||||
throw new TypeError('Cluster Worker Secret provider is unsupported');
|
||||
}
|
||||
const provider = await import('../remote-execution/mountedSecretProvider.js');
|
||||
return provider.createClusterMountedSecretProvider({
|
||||
rootDirectory: config.rootDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
function diagnosticFact(
|
||||
scope: ClusterControlProcessEvent['diagnostic'] extends infer T
|
||||
? T extends { readonly scope: infer TScope }
|
||||
? TScope
|
||||
: never
|
||||
: never,
|
||||
error: unknown,
|
||||
): NonNullable<ClusterControlProcessEvent['diagnostic']> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
scope,
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function event(
|
||||
replicaId: string,
|
||||
values: Omit<
|
||||
ClusterControlProcessEvent,
|
||||
'schemaVersion' | 'component' | 'replicaId'
|
||||
>,
|
||||
): ClusterControlProcessEvent {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-control',
|
||||
replicaId,
|
||||
...values,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns exactly one production cluster-control process. It installs signal
|
||||
* handling before startup, derives every lease owner from the stable replica
|
||||
* identity, and withdraws admission through the application stop contract.
|
||||
*/
|
||||
export async function runProductionClusterControlProcess(
|
||||
options: ProductionClusterControlProcessOptions,
|
||||
): Promise<ClusterControlStopResult> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
typeof options.emit !== 'function' ||
|
||||
typeof options.signals?.subscribe !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control process options are invalid');
|
||||
}
|
||||
const { config, replicaId, workerIngress } = processConfiguration(
|
||||
options.environment,
|
||||
);
|
||||
const start = options.start ?? startProductionClusterControlApplication;
|
||||
if (typeof start !== 'function') {
|
||||
throw new TypeError('Cluster-control process starter is invalid');
|
||||
}
|
||||
|
||||
let resolveSignal:
|
||||
| ((signal: ClusterControlProcessSignal) => void)
|
||||
| undefined;
|
||||
const requestedSignal = new Promise<ClusterControlProcessSignal>((resolve) => {
|
||||
resolveSignal = resolve;
|
||||
});
|
||||
let acceptedSignal = false;
|
||||
const unsubscribe = options.signals.subscribe((signal) => {
|
||||
if (acceptedSignal) return;
|
||||
acceptedSignal = true;
|
||||
resolveSignal?.(signal);
|
||||
});
|
||||
|
||||
let artifactBinding: Readonly<ClusterWorkerArtifactBinding> | undefined;
|
||||
let workerSecretProvider = options.workerSecretProvider;
|
||||
let application: ClusterControlApplicationResult | undefined;
|
||||
let applicationStopStarted = false;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
if (workerIngress) {
|
||||
const createBinding =
|
||||
options.createWorkerArtifactBinding ?? createWorkerArtifactBinding;
|
||||
if (typeof createBinding !== 'function') {
|
||||
throw new TypeError(
|
||||
'Cluster Worker Artifact binding factory is invalid',
|
||||
);
|
||||
}
|
||||
artifactBinding = await createBinding(workerIngress.artifact);
|
||||
if (
|
||||
workerIngress.secret !== undefined &&
|
||||
workerSecretProvider === undefined
|
||||
) {
|
||||
const createProvider =
|
||||
options.createWorkerSecretProvider ?? createWorkerSecretProvider;
|
||||
if (typeof createProvider !== 'function') {
|
||||
throw new TypeError(
|
||||
'Cluster Worker Secret provider factory is invalid',
|
||||
);
|
||||
}
|
||||
workerSecretProvider = await createProvider(workerIngress.secret);
|
||||
}
|
||||
if (
|
||||
workerSecretProvider !== undefined &&
|
||||
typeof workerSecretProvider.resolve !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster Worker Secret provider is invalid');
|
||||
}
|
||||
}
|
||||
application = await start({
|
||||
config,
|
||||
recovery: { ownerId: replicaId },
|
||||
scheduler: {
|
||||
ownerId: replicaId,
|
||||
onDiagnostic(error) {
|
||||
void Promise.resolve(
|
||||
options.emit(
|
||||
event(replicaId, {
|
||||
level: 'error',
|
||||
event: 'runtime_diagnostic',
|
||||
diagnostic: diagnosticFact('scheduler', error),
|
||||
}),
|
||||
),
|
||||
).catch(() => undefined);
|
||||
},
|
||||
},
|
||||
cancellationConvergence: {
|
||||
onDiagnostic(error) {
|
||||
void Promise.resolve(
|
||||
options.emit(
|
||||
event(replicaId, {
|
||||
level: 'error',
|
||||
event: 'runtime_diagnostic',
|
||||
diagnostic: diagnosticFact(
|
||||
'cancellation-convergence',
|
||||
error,
|
||||
),
|
||||
}),
|
||||
),
|
||||
).catch(() => undefined);
|
||||
},
|
||||
},
|
||||
...(workerIngress === undefined
|
||||
? {}
|
||||
: {
|
||||
workerIngress: {
|
||||
config: workerIngress,
|
||||
artifactStore: artifactBinding!.store,
|
||||
...(workerSecretProvider === undefined
|
||||
? {}
|
||||
: { secretProvider: workerSecretProvider }),
|
||||
onDiagnostic(error: unknown) {
|
||||
void Promise.resolve(
|
||||
options.emit(
|
||||
event(replicaId, {
|
||||
level: 'error',
|
||||
event: 'runtime_diagnostic',
|
||||
diagnostic: diagnosticFact(
|
||||
'worker-ingress',
|
||||
error,
|
||||
),
|
||||
}),
|
||||
),
|
||||
).catch(() => undefined);
|
||||
},
|
||||
},
|
||||
}),
|
||||
audit(record) {
|
||||
return options.emit(
|
||||
event(replicaId, {
|
||||
level: record.state === 'failed' ? 'error' : 'info',
|
||||
event: 'activation',
|
||||
activation: Object.freeze({ ...record }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (application.status !== 'active') {
|
||||
throw new ClusterControlProcessError(
|
||||
'QL3_CLUSTER_CONTROL_PROCESS_DISABLED',
|
||||
'The cluster-control process did not activate',
|
||||
);
|
||||
}
|
||||
await options.emit(
|
||||
event(replicaId, {
|
||||
level: 'info',
|
||||
event: 'listening',
|
||||
address: application.address,
|
||||
}),
|
||||
);
|
||||
if (workerIngress) {
|
||||
await options.emit(
|
||||
event(replicaId, {
|
||||
level: 'info',
|
||||
event: 'worker_ingress_listening',
|
||||
address: Object.freeze({
|
||||
host: workerIngress.http.host ?? '0.0.0.0',
|
||||
port: workerIngress.http.port ?? 5801,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const termination = await Promise.race([
|
||||
requestedSignal.then((signal) =>
|
||||
Object.freeze({ kind: 'signal' as const, signal }),
|
||||
),
|
||||
application.unavailable.then((error) =>
|
||||
Object.freeze({ kind: 'database-unavailable' as const, error }),
|
||||
),
|
||||
]);
|
||||
if (termination.kind === 'database-unavailable') {
|
||||
await options.emit(
|
||||
event(replicaId, {
|
||||
level: 'error',
|
||||
event: 'database_unavailable',
|
||||
diagnostic: diagnosticFact('database', termination.error),
|
||||
}),
|
||||
);
|
||||
applicationStopStarted = true;
|
||||
const stopResult = await application.stop();
|
||||
await options.emit(
|
||||
event(replicaId, {
|
||||
level: stopResult === 'stopped' ? 'info' : 'error',
|
||||
event: 'stopped',
|
||||
stopResult,
|
||||
}),
|
||||
);
|
||||
throw new ClusterControlDatabaseUnavailableError();
|
||||
}
|
||||
const signal = termination.signal;
|
||||
await options.emit(
|
||||
event(replicaId, {
|
||||
level: 'info',
|
||||
event: 'shutdown_requested',
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
applicationStopStarted = true;
|
||||
const stopResult = await application.stop();
|
||||
await options.emit(
|
||||
event(replicaId, {
|
||||
level: stopResult === 'stopped' ? 'info' : 'error',
|
||||
event: 'stopped',
|
||||
stopResult,
|
||||
}),
|
||||
);
|
||||
return stopResult;
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
unsubscribe();
|
||||
resolveSignal = undefined;
|
||||
let cleanupError: unknown;
|
||||
if (
|
||||
application?.status === 'active' &&
|
||||
!applicationStopStarted
|
||||
) {
|
||||
try {
|
||||
applicationStopStarted = true;
|
||||
await application.stop();
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await artifactBinding?.close();
|
||||
} catch (error) {
|
||||
cleanupError ??= error;
|
||||
}
|
||||
if (cleanupError && primaryError === undefined) throw cleanupError;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Remote Execution owns mounted Secret resolution for authenticated delivery.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
realpath,
|
||||
} from 'node:fs/promises';
|
||||
import {
|
||||
isAbsolute,
|
||||
join,
|
||||
normalize,
|
||||
parse,
|
||||
relative,
|
||||
} from 'node:path';
|
||||
import {
|
||||
MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES,
|
||||
MAX_REMOTE_SECRET_VALUE_BYTES,
|
||||
normalizeRemoteWorkerSecretDeliveryAuthority,
|
||||
type RemoteWorkerSecretDeliveryAuthority,
|
||||
type RemoteWorkerSecretResolution,
|
||||
type RemoteWorkerSecretValueProvider,
|
||||
} from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import { parseSecretRef } from '@qinglong/runtime-core/secret-reference';
|
||||
|
||||
const MAX_SECRET_ROOT_BYTES = 4096;
|
||||
const SECRET_FILE_NAME = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface ClusterMountedSecretProviderOptions {
|
||||
/**
|
||||
* Read-only directory whose file names are SHA-256(canonical SecretRef).
|
||||
* Kubernetes projected-volume symlinks are accepted only when their resolved
|
||||
* regular file remains below this directory.
|
||||
*/
|
||||
readonly rootDirectory: string;
|
||||
}
|
||||
|
||||
export class ClusterMountedSecretProviderError extends Error {
|
||||
readonly code = 'QL3_CLUSTER_MOUNTED_SECRET_UNAVAILABLE';
|
||||
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'root_unavailable'
|
||||
| 'material_unavailable',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Cluster mounted Secret provider failed: ${reason}`, options);
|
||||
this.name = 'ClusterMountedSecretProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
function rootDirectory(value: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
parse(value).root === value ||
|
||||
normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_SECRET_ROOT_BYTES
|
||||
) {
|
||||
throw new ClusterMountedSecretProviderError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kubernetes Secret keys cannot contain a SecretRef directly. This stable,
|
||||
* non-reversible name also prevents Project/name input from becoming a path.
|
||||
*/
|
||||
export function clusterMountedSecretFileName(secretRef: string): string {
|
||||
let canonical: string;
|
||||
try {
|
||||
const parsed = parseSecretRef(secretRef);
|
||||
canonical = secretRef;
|
||||
if (
|
||||
parsed.projectId.length < 1 ||
|
||||
parsed.name.length < 1
|
||||
) throw new Error('invalid SecretRef');
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'invalid_configuration',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const name = createHash('sha256').update(canonical, 'utf8').digest('hex');
|
||||
if (!SECRET_FILE_NAME.test(name)) {
|
||||
throw new ClusterMountedSecretProviderError('invalid_configuration');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function remainsBelow(root: string, candidate: string): boolean {
|
||||
const suffix = relative(root, candidate);
|
||||
return (
|
||||
suffix.length > 0 &&
|
||||
!isAbsolute(suffix) &&
|
||||
suffix !== '..' &&
|
||||
!suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvedRoot(path: string): Promise<string> {
|
||||
try {
|
||||
const configured = await lstat(path);
|
||||
if (!configured.isDirectory() || configured.isSymbolicLink()) {
|
||||
throw new Error('root is not a direct directory');
|
||||
}
|
||||
return await realpath(path);
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'root_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readMaterial(
|
||||
root: string,
|
||||
secretRef: string,
|
||||
): Promise<Buffer> {
|
||||
const candidate = join(root, clusterMountedSecretFileName(secretRef));
|
||||
let handle;
|
||||
try {
|
||||
const target = await realpath(candidate);
|
||||
if (!remainsBelow(root, target)) {
|
||||
throw new Error('material escaped its root');
|
||||
}
|
||||
handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.size < 0 ||
|
||||
stat.size > MAX_REMOTE_SECRET_VALUE_BYTES ||
|
||||
(stat.mode & 0o111) !== 0 ||
|
||||
(stat.mode & 0o027) !== 0
|
||||
) {
|
||||
throw new Error('material metadata is unsafe');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (
|
||||
bytes.byteLength !== stat.size ||
|
||||
bytes.byteLength > MAX_REMOTE_SECRET_VALUE_BYTES ||
|
||||
(await realpath(candidate)) !== target
|
||||
) {
|
||||
bytes.fill(0);
|
||||
throw new Error('material changed while reading');
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'material_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function secretValue(bytes: Buffer): string {
|
||||
try {
|
||||
const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
if (value.includes('\0')) {
|
||||
throw new Error('Secret contains NUL');
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'material_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A zero-client, zero-watcher Cluster provider for Kubernetes Secret, CSI or
|
||||
* operator-managed projected files. Every authorized delivery resolves the
|
||||
* active files again, so atomic projection replacement rotates material
|
||||
* without a timer, cache, control restart or Kubernetes API permission.
|
||||
*/
|
||||
export class ClusterMountedSecretProvider
|
||||
implements RemoteWorkerSecretValueProvider
|
||||
{
|
||||
private readonly rootDirectory: string;
|
||||
|
||||
constructor(options: ClusterMountedSecretProviderOptions) {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new ClusterMountedSecretProviderError('invalid_configuration');
|
||||
}
|
||||
this.rootDirectory = rootDirectory(options.rootDirectory);
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
await resolvedRoot(this.rootDirectory);
|
||||
}
|
||||
|
||||
async resolve(
|
||||
authority: Readonly<RemoteWorkerSecretDeliveryAuthority>,
|
||||
): Promise<Readonly<RemoteWorkerSecretResolution>> {
|
||||
let normalized: Readonly<RemoteWorkerSecretDeliveryAuthority>;
|
||||
try {
|
||||
normalized = normalizeRemoteWorkerSecretDeliveryAuthority(authority);
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'material_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const root = await resolvedRoot(this.rootDirectory);
|
||||
const buffers: Buffer[] = [];
|
||||
try {
|
||||
const values = [];
|
||||
let totalBytes = 0;
|
||||
for (const secretRef of normalized.secretRefs) {
|
||||
const bytes = await readMaterial(root, secretRef);
|
||||
buffers.push(bytes);
|
||||
totalBytes += bytes.byteLength;
|
||||
if (totalBytes > MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES) {
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'material_unavailable',
|
||||
);
|
||||
}
|
||||
values.push(
|
||||
Object.freeze({
|
||||
secretRef,
|
||||
value: secretValue(bytes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
values: Object.freeze(values),
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const bytes of buffers) bytes.fill(0);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
for (const bytes of buffers) bytes.fill(0);
|
||||
if (error instanceof ClusterMountedSecretProviderError) throw error;
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'material_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createClusterMountedSecretProvider(
|
||||
options: ClusterMountedSecretProviderOptions,
|
||||
): Promise<Readonly<ClusterMountedSecretProvider>> {
|
||||
const provider = new ClusterMountedSecretProvider(options);
|
||||
await provider.verify();
|
||||
return provider;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Remote execution owns Worker-bound activation acknowledgements and start failure fencing.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
AcknowledgeRemoteRunRunningCommand,
|
||||
AcknowledgeRemoteRunStartingCommand,
|
||||
FailRemoteRunStartCommand,
|
||||
RemoteRunActivationRepository,
|
||||
RemoteRunActivationResult,
|
||||
} from '@qinglong/runtime-core/remote-activation';
|
||||
|
||||
export interface ClusterRemoteRunActivationPrincipal {
|
||||
readonly workerId: string;
|
||||
}
|
||||
|
||||
type ServerOwnedStartingFields = 'workerId' | 'eventId';
|
||||
type ServerOwnedRunningFields = 'workerId' | 'attemptEventId' | 'runEventId';
|
||||
|
||||
export type AcknowledgeClusterRemoteRunStartingCommand = Omit<
|
||||
AcknowledgeRemoteRunStartingCommand,
|
||||
ServerOwnedStartingFields
|
||||
>;
|
||||
|
||||
export type AcknowledgeClusterRemoteRunRunningCommand = Omit<
|
||||
AcknowledgeRemoteRunRunningCommand,
|
||||
ServerOwnedRunningFields
|
||||
>;
|
||||
|
||||
export type FailClusterRemoteRunStartCommand = Omit<
|
||||
FailRemoteRunStartCommand,
|
||||
ServerOwnedRunningFields
|
||||
>;
|
||||
|
||||
export interface ClusterRemoteRunActivationServiceOptions {
|
||||
readonly createEventId?: () => string;
|
||||
}
|
||||
|
||||
export class ClusterRemoteRunActivationService {
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RemoteRunActivationRepository,
|
||||
options: ClusterRemoteRunActivationServiceOptions = {},
|
||||
) {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.acknowledgeStarting !== 'function' ||
|
||||
typeof repository.acknowledgeRunning !== 'function' ||
|
||||
typeof repository.failStart !== 'function'
|
||||
) {
|
||||
throw new TypeError('Remote Run activation repository is invalid');
|
||||
}
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'createEventId')
|
||||
) {
|
||||
throw new TypeError('Remote Run activation service options are invalid');
|
||||
}
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
if (typeof this.createEventId !== 'function') {
|
||||
throw new TypeError('Remote Run activation event ID factory is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgeStarting(
|
||||
principal: ClusterRemoteRunActivationPrincipal,
|
||||
command: AcknowledgeClusterRemoteRunStartingCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
this.assertPrincipal(principal);
|
||||
return this.repository.acknowledgeStarting({
|
||||
...command,
|
||||
workerId: principal.workerId,
|
||||
eventId: this.createEventId(),
|
||||
});
|
||||
}
|
||||
|
||||
acknowledgeRunning(
|
||||
principal: ClusterRemoteRunActivationPrincipal,
|
||||
command: AcknowledgeClusterRemoteRunRunningCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
this.assertPrincipal(principal);
|
||||
return this.repository.acknowledgeRunning({
|
||||
...command,
|
||||
workerId: principal.workerId,
|
||||
attemptEventId: this.createEventId(),
|
||||
runEventId: this.createEventId(),
|
||||
});
|
||||
}
|
||||
|
||||
failStart(
|
||||
principal: ClusterRemoteRunActivationPrincipal,
|
||||
command: FailClusterRemoteRunStartCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
this.assertPrincipal(principal);
|
||||
return this.repository.failStart({
|
||||
...command,
|
||||
workerId: principal.workerId,
|
||||
attemptEventId: this.createEventId(),
|
||||
runEventId: this.createEventId(),
|
||||
});
|
||||
}
|
||||
|
||||
private assertPrincipal(principal: ClusterRemoteRunActivationPrincipal): void {
|
||||
if (
|
||||
!principal ||
|
||||
typeof principal !== 'object' ||
|
||||
Array.isArray(principal) ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(principal.workerId)
|
||||
) {
|
||||
throw new TypeError('Remote Run activation principal is invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// Remote execution owns immutable Artifact admission and fenced Worker completion.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
InvalidRemoteWorkerCompletionError,
|
||||
MAX_REMOTE_WORKER_ARTIFACT_HEADER_BYTES,
|
||||
RemoteWorkerCompletionFenceRejectedError,
|
||||
RemoteWorkerCompletionUnavailableError,
|
||||
normalizeRemoteWorkerArtifactReceipt,
|
||||
normalizeRemoteWorkerCompletionCommand,
|
||||
normalizeRemoteWorkerCompletionResult,
|
||||
parseRemoteWorkerArtifactUploadHeader,
|
||||
type RemoteWorkerArtifactReceipt,
|
||||
type RemoteWorkerArtifactUploadAuthorityRepository,
|
||||
type RemoteWorkerArtifactUploadCommand,
|
||||
type RemoteWorkerCompletionCommand,
|
||||
type RemoteWorkerCompletionRepository,
|
||||
type RemoteWorkerCompletionResult,
|
||||
} from '@qinglong/runtime-core/remote-worker-completion';
|
||||
|
||||
export interface ClusterRemoteWorkerArtifactStorageCommand {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly logArtifactId: string;
|
||||
readonly byteLength: number;
|
||||
readonly truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface ClusterRemoteWorkerArtifactLookup {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly logArtifactId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production implementations must be shared by every cluster-control replica
|
||||
* and provide immutable, digest-authenticated put-if-absent semantics.
|
||||
*/
|
||||
export interface ClusterRemoteWorkerArtifactStore {
|
||||
put(
|
||||
command: Readonly<ClusterRemoteWorkerArtifactStorageCommand>,
|
||||
content: AsyncIterable<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Readonly<RemoteWorkerArtifactReceipt>>;
|
||||
inspect(
|
||||
lookup: Readonly<ClusterRemoteWorkerArtifactLookup>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Readonly<RemoteWorkerArtifactReceipt> | undefined>;
|
||||
}
|
||||
|
||||
export interface ClusterRemoteWorkerArtifactUploadInput {
|
||||
readonly workerId: string;
|
||||
readonly workerSessionId: string;
|
||||
readonly contentLength: number;
|
||||
readonly chunks: AsyncIterable<Uint8Array>;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ClusterRemoteWorkerCompletionServiceOptions {
|
||||
readonly createEventId?: () => string;
|
||||
}
|
||||
|
||||
class BoundedArtifactStreamReader {
|
||||
private readonly iterator: AsyncIterator<Uint8Array>;
|
||||
private pending: Uint8Array | undefined;
|
||||
private pendingOffset = 0;
|
||||
|
||||
constructor(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {
|
||||
if (!source || typeof source[Symbol.asyncIterator] !== 'function') {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact upload stream is invalid',
|
||||
);
|
||||
}
|
||||
this.iterator = source[Symbol.asyncIterator]();
|
||||
}
|
||||
|
||||
async readExactly(byteLength: number): Promise<Buffer> {
|
||||
const result = Buffer.allocUnsafe(byteLength);
|
||||
let written = 0;
|
||||
try {
|
||||
while (written < byteLength) {
|
||||
const chunk = await this.nextChunk();
|
||||
if (!chunk) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact upload stream ended before its header',
|
||||
);
|
||||
}
|
||||
const available = chunk.byteLength - this.pendingOffset;
|
||||
const copied = Math.min(available, byteLength - written);
|
||||
Buffer.from(
|
||||
chunk.buffer,
|
||||
chunk.byteOffset + this.pendingOffset,
|
||||
copied,
|
||||
).copy(result, written);
|
||||
written += copied;
|
||||
this.pendingOffset += copied;
|
||||
if (this.pendingOffset === chunk.byteLength) {
|
||||
this.pending = undefined;
|
||||
this.pendingOffset = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.fill(0);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
content(byteLength: number): Readonly<{
|
||||
chunks: AsyncIterable<Uint8Array>;
|
||||
isComplete(): boolean;
|
||||
}> {
|
||||
let complete = false;
|
||||
let started = false;
|
||||
const self = this;
|
||||
const chunks = Object.freeze({
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
|
||||
if (started) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact content can only be consumed once',
|
||||
);
|
||||
}
|
||||
started = true;
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const chunk = await self.nextChunk();
|
||||
if (!chunk) break;
|
||||
const bytes = chunk.subarray(self.pendingOffset);
|
||||
self.pending = undefined;
|
||||
self.pendingOffset = 0;
|
||||
total += bytes.byteLength;
|
||||
if (total > byteLength) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact content exceeds its declared length',
|
||||
);
|
||||
}
|
||||
yield bytes;
|
||||
}
|
||||
if (total !== byteLength) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact content does not match its declared length',
|
||||
);
|
||||
}
|
||||
complete = true;
|
||||
},
|
||||
});
|
||||
return Object.freeze({ chunks, isComplete: () => complete });
|
||||
}
|
||||
|
||||
private async nextChunk(): Promise<Uint8Array | undefined> {
|
||||
if (this.signal?.aborted) throw this.signal.reason;
|
||||
if (this.pending) return this.pending;
|
||||
const next = await this.iterator.next();
|
||||
if (next.done) return undefined;
|
||||
if (!(next.value instanceof Uint8Array) || next.value.byteLength === 0) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact upload chunk is invalid',
|
||||
);
|
||||
}
|
||||
this.pending = next.value;
|
||||
this.pendingOffset = 0;
|
||||
return this.pending;
|
||||
}
|
||||
}
|
||||
|
||||
function storageCommand(
|
||||
command: RemoteWorkerArtifactUploadCommand,
|
||||
): Readonly<ClusterRemoteWorkerArtifactStorageCommand> {
|
||||
return Object.freeze({
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
logArtifactId: command.logArtifactId,
|
||||
byteLength: command.byteLength,
|
||||
...(command.truncated === undefined
|
||||
? {}
|
||||
: { truncated: command.truncated }),
|
||||
});
|
||||
}
|
||||
|
||||
function assertReceiptMatches(
|
||||
command: ClusterRemoteWorkerArtifactStorageCommand,
|
||||
value: RemoteWorkerArtifactReceipt,
|
||||
): Readonly<RemoteWorkerArtifactReceipt> {
|
||||
const receipt = normalizeRemoteWorkerArtifactReceipt(value);
|
||||
if (
|
||||
receipt.projectId !== command.projectId ||
|
||||
receipt.runId !== command.runId ||
|
||||
receipt.attemptId !== command.attemptId ||
|
||||
receipt.logArtifactId !== command.logArtifactId ||
|
||||
receipt.byteLength !== command.byteLength ||
|
||||
receipt.truncated !== command.truncated
|
||||
) {
|
||||
throw new RemoteWorkerCompletionUnavailableError();
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
function eventId(factory: () => string): string {
|
||||
const value = factory();
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 36 ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new RemoteWorkerCompletionUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class ClusterRemoteWorkerArtifactService {
|
||||
constructor(
|
||||
private readonly authority: RemoteWorkerArtifactUploadAuthorityRepository,
|
||||
private readonly store: ClusterRemoteWorkerArtifactStore,
|
||||
) {
|
||||
if (
|
||||
typeof authority?.authorizeArtifactUpload !== 'function' ||
|
||||
typeof store?.put !== 'function' ||
|
||||
typeof store?.inspect !== 'function'
|
||||
) {
|
||||
throw new TypeError('Remote Worker Artifact service is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async upload(
|
||||
input: ClusterRemoteWorkerArtifactUploadInput,
|
||||
): Promise<Readonly<RemoteWorkerArtifactReceipt>> {
|
||||
if (
|
||||
!input ||
|
||||
!Number.isSafeInteger(input.contentLength) ||
|
||||
input.contentLength < 6
|
||||
) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact upload envelope is invalid',
|
||||
);
|
||||
}
|
||||
const reader = new BoundedArtifactStreamReader(input.chunks, input.signal);
|
||||
const prefix = await reader.readExactly(4);
|
||||
const headerLength = prefix.readUInt32BE(0);
|
||||
prefix.fill(0);
|
||||
if (
|
||||
headerLength < 2 ||
|
||||
headerLength > MAX_REMOTE_WORKER_ARTIFACT_HEADER_BYTES
|
||||
) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact upload header length is invalid',
|
||||
);
|
||||
}
|
||||
const header = await reader.readExactly(headerLength);
|
||||
let command: Readonly<RemoteWorkerArtifactUploadCommand>;
|
||||
try {
|
||||
command = parseRemoteWorkerArtifactUploadHeader(header, {
|
||||
workerId: input.workerId,
|
||||
workerSessionId: input.workerSessionId,
|
||||
});
|
||||
} finally {
|
||||
header.fill(0);
|
||||
}
|
||||
if (input.contentLength !== 4 + headerLength + command.byteLength) {
|
||||
throw new InvalidRemoteWorkerCompletionError(
|
||||
'Artifact upload envelope length does not match its header',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.authority.authorizeArtifactUpload(command);
|
||||
const target = storageCommand(command);
|
||||
const content = reader.content(command.byteLength);
|
||||
const receipt = await this.store.put(
|
||||
target,
|
||||
content.chunks,
|
||||
input.signal,
|
||||
);
|
||||
if (!content.isComplete()) {
|
||||
throw new Error('Artifact store did not consume the complete body');
|
||||
}
|
||||
return assertReceiptMatches(target, receipt);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidRemoteWorkerCompletionError ||
|
||||
error instanceof RemoteWorkerCompletionFenceRejectedError ||
|
||||
error instanceof RemoteWorkerCompletionUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new RemoteWorkerCompletionUnavailableError({ cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterRemoteWorkerCompletionService {
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RemoteWorkerCompletionRepository,
|
||||
private readonly store: Pick<ClusterRemoteWorkerArtifactStore, 'inspect'>,
|
||||
options: ClusterRemoteWorkerCompletionServiceOptions = {},
|
||||
) {
|
||||
if (
|
||||
typeof repository?.complete !== 'function' ||
|
||||
typeof store?.inspect !== 'function' ||
|
||||
(options.createEventId !== undefined &&
|
||||
typeof options.createEventId !== 'function')
|
||||
) {
|
||||
throw new TypeError('Remote Worker completion service is invalid');
|
||||
}
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
}
|
||||
|
||||
async complete(
|
||||
value: RemoteWorkerCompletionCommand,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Readonly<RemoteWorkerCompletionResult>> {
|
||||
const command = normalizeRemoteWorkerCompletionCommand(value);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
const lookup = Object.freeze({
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
logArtifactId: command.artifact.logArtifactId,
|
||||
});
|
||||
let stored: Readonly<RemoteWorkerArtifactReceipt> | undefined;
|
||||
try {
|
||||
stored = await this.store.inspect(lookup, signal);
|
||||
} catch (error) {
|
||||
throw new RemoteWorkerCompletionUnavailableError({ cause: error });
|
||||
}
|
||||
if (!stored) {
|
||||
throw new RemoteWorkerCompletionFenceRejectedError(
|
||||
command.attemptId,
|
||||
'state_mismatch',
|
||||
);
|
||||
}
|
||||
const receipt = assertReceiptMatches(
|
||||
{
|
||||
...lookup,
|
||||
byteLength: command.artifact.byteLength,
|
||||
...(command.artifact.truncated === undefined
|
||||
? {}
|
||||
: { truncated: command.artifact.truncated }),
|
||||
},
|
||||
stored,
|
||||
);
|
||||
if (receipt.sha256 !== command.artifact.sha256) {
|
||||
throw new RemoteWorkerCompletionFenceRejectedError(
|
||||
command.attemptId,
|
||||
'replay_mismatch',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = normalizeRemoteWorkerCompletionResult(
|
||||
await this.repository.complete(Object.freeze({
|
||||
...command,
|
||||
attemptEventId: eventId(this.createEventId),
|
||||
runEventId: eventId(this.createEventId),
|
||||
})),
|
||||
);
|
||||
if (
|
||||
result.runId !== command.runId ||
|
||||
result.attemptId !== command.attemptId ||
|
||||
result.callbackSequence !== command.callbackSequence
|
||||
) {
|
||||
throw new Error('Remote Worker completion authority drifted');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof RemoteWorkerCompletionFenceRejectedError ||
|
||||
error instanceof RemoteWorkerCompletionUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new RemoteWorkerCompletionUnavailableError({ cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// Remote execution owns bounded offer selection, placement, and lease claiming.
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
ClusterDispatchCandidate,
|
||||
ClusterDispatchCandidateCursor,
|
||||
ClusterDispatchSource,
|
||||
ClusterRemoteExecutionOffer,
|
||||
} from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
assertRemoteDispatchPageSize,
|
||||
createClusterRemoteExecutionOffer,
|
||||
evaluateRemoteWorkerPlacement,
|
||||
leaseTokenMatchesDigest,
|
||||
normalizeClusterDispatchCandidate,
|
||||
} from '@qinglong/runtime-core/remote-dispatch';
|
||||
import type {
|
||||
ClusterTaskExecutionRevision,
|
||||
ClusterTaskExecutionRevisionSource,
|
||||
} from '@qinglong/runtime-core/cluster-execution-revision';
|
||||
import type {
|
||||
ClaimRunDispatchLeaseResult,
|
||||
RunDispatchLeaseRepository,
|
||||
WorkerSessionRecord,
|
||||
WorkerSessionRepository,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
assertRunDispatchId,
|
||||
assertRunDispatchLeaseDuration,
|
||||
assertRunDispatchLeaseToken,
|
||||
assertWorkerId,
|
||||
assertWorkerSessionId,
|
||||
} from '@qinglong/runtime-core';
|
||||
import { parseTaskDefinitionRevisionRef } from '@qinglong/runtime-core/task-definition-execution-compiler';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 8;
|
||||
const DEFAULT_MAX_PAGES = 2;
|
||||
const DEFAULT_MAX_CLAIMS = 8;
|
||||
const DEFAULT_LEASE_MS = 30_000;
|
||||
const MAX_PAGES = 16;
|
||||
const MAX_CLAIMS = 64;
|
||||
|
||||
export interface ClusterRemoteWorkerOfferPrincipal {
|
||||
readonly workerId: string;
|
||||
}
|
||||
|
||||
export interface ClaimClusterRemoteWorkerOfferCommand {
|
||||
readonly workerSessionId: string;
|
||||
readonly workerGeneration: number;
|
||||
/** Worker-generated stable idempotency key for this poll attempt. */
|
||||
readonly offerId: string;
|
||||
/** Worker-generated high-entropy capability; PostgreSQL stores only its digest. */
|
||||
readonly leaseToken: string;
|
||||
}
|
||||
|
||||
export interface ClusterRemoteWorkerOfferStats {
|
||||
readonly pages: number;
|
||||
readonly candidates: number;
|
||||
readonly plansUnavailable: number;
|
||||
readonly placementMismatches: number;
|
||||
readonly claimAttempts: number;
|
||||
readonly claimRaces: number;
|
||||
}
|
||||
|
||||
type MutableClusterRemoteWorkerOfferStats = {
|
||||
-readonly [Key in keyof ClusterRemoteWorkerOfferStats]: ClusterRemoteWorkerOfferStats[Key];
|
||||
};
|
||||
|
||||
export type ClaimClusterRemoteWorkerOfferResult =
|
||||
| Readonly<{
|
||||
status: 'offered';
|
||||
offer: ClusterRemoteExecutionOffer;
|
||||
stats: ClusterRemoteWorkerOfferStats;
|
||||
truncated: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'idle';
|
||||
reason:
|
||||
| 'worker_unavailable'
|
||||
| 'no_candidates'
|
||||
| 'no_match'
|
||||
| 'plans_unavailable'
|
||||
| 'claim_raced'
|
||||
| 'claim_budget_exhausted'
|
||||
| 'scan_budget_exhausted';
|
||||
stats: ClusterRemoteWorkerOfferStats;
|
||||
truncated: boolean;
|
||||
}>;
|
||||
|
||||
export class ClusterRemoteWorkerOfferFenceRejectedError extends Error {
|
||||
readonly code = 'REMOTE_WORKER_OFFER_FENCED';
|
||||
|
||||
constructor() {
|
||||
super('Remote Worker offer authority was fenced');
|
||||
this.name = 'ClusterRemoteWorkerOfferFenceRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClusterRemoteWorkerOfferClaimServiceOptions {
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
readonly maxClaimAttempts?: number;
|
||||
readonly leaseDurationMs?: number;
|
||||
readonly createEventId?: () => string;
|
||||
}
|
||||
|
||||
function bounded(name: string, value: number, minimum: number, maximum: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function emptyStats(): MutableClusterRemoteWorkerOfferStats {
|
||||
return {
|
||||
pages: 0,
|
||||
candidates: 0,
|
||||
plansUnavailable: 0,
|
||||
placementMismatches: 0,
|
||||
claimAttempts: 0,
|
||||
claimRaces: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function cursor(candidate: ClusterDispatchCandidate): ClusterDispatchCandidateCursor {
|
||||
return Object.freeze({
|
||||
priority: candidate.priority,
|
||||
queuedAtMs: candidate.queuedAtMs,
|
||||
attemptCreatedAtMs: candidate.attemptCreatedAtMs,
|
||||
attemptId: candidate.attemptId,
|
||||
});
|
||||
}
|
||||
|
||||
export class ClusterRemoteWorkerOfferClaimService {
|
||||
private readonly pageSize: number;
|
||||
private readonly maxPages: number;
|
||||
private readonly maxClaimAttempts: number;
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly source: ClusterDispatchSource,
|
||||
private readonly workers: Pick<WorkerSessionRepository, 'findById'>,
|
||||
private readonly revisions: ClusterTaskExecutionRevisionSource,
|
||||
private readonly leases: Pick<RunDispatchLeaseRepository, 'claim'>,
|
||||
options: ClusterRemoteWorkerOfferClaimServiceOptions = {},
|
||||
) {
|
||||
if (
|
||||
!source || typeof source.listClusterDispatchCandidates !== 'function' ||
|
||||
typeof source.findClusterDispatchRecovery !== 'function' ||
|
||||
!workers || typeof workers.findById !== 'function' ||
|
||||
!revisions || typeof revisions.resolveClusterTaskExecutionRevision !== 'function' ||
|
||||
!leases || typeof leases.claim !== 'function'
|
||||
) throw new TypeError('Remote Worker offer service dependencies are invalid');
|
||||
const allowed = new Set([
|
||||
'createEventId', 'leaseDurationMs', 'maxClaimAttempts', 'maxPages', 'pageSize',
|
||||
]);
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some((key) => !allowed.has(key))) {
|
||||
throw new TypeError('Remote Worker offer service options are invalid');
|
||||
}
|
||||
this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
assertRemoteDispatchPageSize(this.pageSize);
|
||||
this.maxPages = bounded('Remote Worker offer maxPages', options.maxPages ?? DEFAULT_MAX_PAGES, 1, MAX_PAGES);
|
||||
this.maxClaimAttempts = bounded('Remote Worker offer maxClaimAttempts', options.maxClaimAttempts ?? DEFAULT_MAX_CLAIMS, 1, MAX_CLAIMS);
|
||||
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_MS;
|
||||
assertRunDispatchLeaseDuration(this.leaseDurationMs);
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
if (typeof this.createEventId !== 'function') {
|
||||
throw new TypeError('Remote Worker offer event ID factory is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async claimNext(
|
||||
principal: ClusterRemoteWorkerOfferPrincipal,
|
||||
command: ClaimClusterRemoteWorkerOfferCommand,
|
||||
): Promise<ClaimClusterRemoteWorkerOfferResult> {
|
||||
this.assertCommand(principal, command);
|
||||
const stats = emptyStats();
|
||||
const recovered = await this.source.findClusterDispatchRecovery(command.offerId);
|
||||
if (recovered) {
|
||||
if (
|
||||
recovered.lease.status !== 'leased' ||
|
||||
recovered.lease.expiresAtMs <= recovered.observedAtMs ||
|
||||
!recovered.workerCurrent ||
|
||||
recovered.lease.workerId !== principal.workerId ||
|
||||
recovered.lease.workerSessionId !== command.workerSessionId ||
|
||||
recovered.lease.workerGeneration !== command.workerGeneration ||
|
||||
!leaseTokenMatchesDigest(command.leaseToken, recovered.lease.leaseTokenDigest)
|
||||
) throw new ClusterRemoteWorkerOfferFenceRejectedError();
|
||||
const revision = await this.resolveRevision(recovered.candidate);
|
||||
if (!revision) throw new ClusterRemoteWorkerOfferFenceRejectedError();
|
||||
return Object.freeze({
|
||||
status: 'offered' as const,
|
||||
offer: this.offer(
|
||||
'lease_recovery', command, recovered.candidate,
|
||||
recovered.lease, revision, 0,
|
||||
),
|
||||
stats: Object.freeze(stats),
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
let after: ClusterDispatchCandidateCursor | undefined;
|
||||
let worker: WorkerSessionRecord | null | undefined;
|
||||
let sawCandidate = false;
|
||||
let sawMatch = false;
|
||||
let sawRace = false;
|
||||
let lastTruncated = false;
|
||||
for (let pageIndex = 0; pageIndex < this.maxPages; pageIndex += 1) {
|
||||
const page = await this.source.listClusterDispatchCandidates({
|
||||
limit: this.pageSize,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
stats.pages += 1;
|
||||
lastTruncated = page.truncated;
|
||||
if (page.candidates.length > this.pageSize) {
|
||||
throw new RangeError('Remote Worker candidate source exceeded page size');
|
||||
}
|
||||
worker ??= await this.workers.findById(principal.workerId);
|
||||
if (
|
||||
!worker || worker.sessionId !== command.workerSessionId ||
|
||||
worker.generation !== command.workerGeneration ||
|
||||
worker.status !== 'online' || worker.availableSlots < 1 ||
|
||||
worker.leaseExpiresAtMs <= page.observedAtMs
|
||||
) return this.idle('worker_unavailable', stats, false);
|
||||
|
||||
for (const rawCandidate of page.candidates) {
|
||||
const candidate = normalizeClusterDispatchCandidate(rawCandidate);
|
||||
sawCandidate = true;
|
||||
stats.candidates += 1;
|
||||
const revision = await this.resolveRevision(candidate);
|
||||
if (!revision) {
|
||||
stats.plansUnavailable += 1;
|
||||
continue;
|
||||
}
|
||||
const placement = evaluateRemoteWorkerPlacement(
|
||||
worker,
|
||||
revision.placement ?? {},
|
||||
page.observedAtMs,
|
||||
);
|
||||
if (!placement.matches) {
|
||||
stats.placementMismatches += 1;
|
||||
continue;
|
||||
}
|
||||
sawMatch = true;
|
||||
if (stats.claimAttempts >= this.maxClaimAttempts) {
|
||||
return this.idle('claim_budget_exhausted', stats, true);
|
||||
}
|
||||
const eventId = this.createEventId();
|
||||
assertRunDispatchId('eventId', eventId);
|
||||
stats.claimAttempts += 1;
|
||||
const claim = await this.leases.claim({
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
workerId: principal.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
eventId,
|
||||
offerId: command.offerId,
|
||||
});
|
||||
if (claim.status === 'claimed' || claim.status === 'idempotent') {
|
||||
return Object.freeze({
|
||||
status: 'offered' as const,
|
||||
offer: this.offer(
|
||||
'new_claim', command, candidate, claim.lease, revision,
|
||||
placement.score,
|
||||
),
|
||||
stats: Object.freeze(stats),
|
||||
truncated: page.truncated,
|
||||
});
|
||||
}
|
||||
if (claim.status === 'worker_unavailable' || claim.status === 'capacity_exhausted') {
|
||||
return this.idle('worker_unavailable', stats, false);
|
||||
}
|
||||
stats.claimRaces += 1;
|
||||
sawRace = true;
|
||||
}
|
||||
if (!page.truncated || page.candidates.length === 0) break;
|
||||
const last = page.candidates.at(-1);
|
||||
if (!last) break;
|
||||
const next = page.next ?? cursor(last);
|
||||
if (after && next.attemptId === after.attemptId) {
|
||||
throw new Error('Remote Worker candidate cursor did not advance');
|
||||
}
|
||||
after = next;
|
||||
}
|
||||
if (lastTruncated) return this.idle('scan_budget_exhausted', stats, true);
|
||||
if (!sawCandidate) return this.idle('no_candidates', stats, false);
|
||||
if (stats.plansUnavailable === stats.candidates) return this.idle('plans_unavailable', stats, false);
|
||||
return this.idle(sawRace ? 'claim_raced' : sawMatch ? 'claim_raced' : 'no_match', stats, false);
|
||||
}
|
||||
|
||||
private async resolveRevision(
|
||||
candidate: ClusterDispatchCandidate,
|
||||
): Promise<ClusterTaskExecutionRevision | null> {
|
||||
let sourceRevision: number;
|
||||
try {
|
||||
sourceRevision = parseTaskDefinitionRevisionRef(candidate.taskRevision).revision;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const revision = await this.revisions.resolveClusterTaskExecutionRevision({
|
||||
projectId: candidate.projectId,
|
||||
taskId: candidate.taskId,
|
||||
sourceRevision,
|
||||
});
|
||||
if (
|
||||
!revision || revision.projectId !== candidate.projectId ||
|
||||
revision.taskId !== candidate.taskId ||
|
||||
revision.taskRevision !== candidate.taskRevision
|
||||
) return null;
|
||||
return revision;
|
||||
}
|
||||
|
||||
private offer(
|
||||
deliveryKind: ClusterRemoteExecutionOffer['deliveryKind'],
|
||||
command: ClaimClusterRemoteWorkerOfferCommand,
|
||||
candidate: ClusterDispatchCandidate,
|
||||
lease: Extract<ClaimRunDispatchLeaseResult, { lease: unknown }>['lease'],
|
||||
revision: ClusterTaskExecutionRevision,
|
||||
placementScore: number,
|
||||
): ClusterRemoteExecutionOffer {
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: command.offerId,
|
||||
deliveryKind,
|
||||
executionDigest: revision.contentDigest,
|
||||
candidate,
|
||||
worker: {
|
||||
workerId: lease.workerId,
|
||||
sessionId: lease.workerSessionId,
|
||||
generation: lease.workerGeneration,
|
||||
},
|
||||
lease,
|
||||
leaseToken: command.leaseToken,
|
||||
executionRevision: revision,
|
||||
placementScore,
|
||||
});
|
||||
}
|
||||
|
||||
private idle(
|
||||
reason: Extract<ClaimClusterRemoteWorkerOfferResult, { status: 'idle' }>['reason'],
|
||||
stats: ClusterRemoteWorkerOfferStats,
|
||||
truncated: boolean,
|
||||
): ClaimClusterRemoteWorkerOfferResult {
|
||||
return Object.freeze({
|
||||
status: 'idle' as const,
|
||||
reason,
|
||||
stats: Object.freeze({ ...stats }),
|
||||
truncated,
|
||||
});
|
||||
}
|
||||
|
||||
private assertCommand(
|
||||
principal: ClusterRemoteWorkerOfferPrincipal,
|
||||
command: ClaimClusterRemoteWorkerOfferCommand,
|
||||
): void {
|
||||
if (!principal || typeof principal !== 'object' || Array.isArray(principal)) {
|
||||
throw new TypeError('Remote Worker offer principal is invalid');
|
||||
}
|
||||
assertWorkerId(principal.workerId);
|
||||
if (!command || typeof command !== 'object' || Array.isArray(command)) {
|
||||
throw new TypeError('Remote Worker offer command is invalid');
|
||||
}
|
||||
const keys = Object.keys(command).sort().join(',');
|
||||
if (keys !== 'leaseToken,offerId,workerGeneration,workerSessionId') {
|
||||
throw new TypeError('Remote Worker offer command shape is invalid');
|
||||
}
|
||||
assertWorkerSessionId(command.workerSessionId);
|
||||
if (!Number.isSafeInteger(command.workerGeneration) || command.workerGeneration < 1) {
|
||||
throw new RangeError('Remote Worker offer generation is invalid');
|
||||
}
|
||||
assertRunDispatchId('offerId', command.offerId);
|
||||
assertRunDispatchLeaseToken(command.leaseToken);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteWorkerLeaseToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Remote execution owns fenced Worker lease renewal, release, and timeout authority.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
RemoteWorkerLeaseControlUnavailableError,
|
||||
assertRemoteWorkerLeaseControlDuration,
|
||||
normalizeRemoteWorkerLeaseControlCommand,
|
||||
normalizeRemoteWorkerLeaseControlResult,
|
||||
type RemoteWorkerLeaseControlCommand,
|
||||
type RemoteWorkerLeaseControlRepository,
|
||||
type RemoteWorkerLeaseControlResult,
|
||||
} from '@qinglong/runtime-core/remote-worker-lease-control';
|
||||
|
||||
export interface ClusterRemoteWorkerLeaseControlServiceOptions {
|
||||
readonly leaseDurationMs?: number;
|
||||
readonly createEventId?: () => string;
|
||||
}
|
||||
|
||||
function eventId(factory: () => string): string {
|
||||
const value = factory();
|
||||
if (
|
||||
typeof value !== 'string' || value.length < 1 || value.length > 36 ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) throw new RemoteWorkerLeaseControlUnavailableError();
|
||||
return value;
|
||||
}
|
||||
|
||||
export class ClusterRemoteWorkerLeaseControlService {
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RemoteWorkerLeaseControlRepository,
|
||||
options: ClusterRemoteWorkerLeaseControlServiceOptions = {},
|
||||
) {
|
||||
if (
|
||||
typeof repository?.control !== 'function' ||
|
||||
(options.createEventId !== undefined &&
|
||||
typeof options.createEventId !== 'function')
|
||||
) throw new TypeError('Remote Worker lease control service is invalid');
|
||||
const leaseDurationMs = options.leaseDurationMs ?? 30_000;
|
||||
assertRemoteWorkerLeaseControlDuration(leaseDurationMs);
|
||||
this.leaseDurationMs = leaseDurationMs;
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
}
|
||||
|
||||
async control(
|
||||
value: RemoteWorkerLeaseControlCommand,
|
||||
): Promise<Readonly<RemoteWorkerLeaseControlResult>> {
|
||||
const command = normalizeRemoteWorkerLeaseControlCommand(value);
|
||||
return normalizeRemoteWorkerLeaseControlResult(
|
||||
await this.repository.control(Object.freeze({
|
||||
...command,
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
timeoutEventId: eventId(this.createEventId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// Remote execution owns offer-bound Secret delivery without retaining plaintext authority.
|
||||
import {
|
||||
InvalidRemoteWorkerSecretDeliveryError,
|
||||
RemoteWorkerSecretDeliveryFenceRejectedError,
|
||||
RemoteWorkerSecretDeliveryUnavailableError,
|
||||
createRemoteWorkerSecretDeliveryResponseBody,
|
||||
normalizeRemoteWorkerSecretDeliveryAuthority,
|
||||
normalizeRemoteWorkerSecretDeliveryCommand,
|
||||
type RemoteWorkerSecretDeliveryAuthorityRepository,
|
||||
type RemoteWorkerSecretDeliveryCommand,
|
||||
type RemoteWorkerSecretDeliveryResult,
|
||||
type RemoteWorkerSecretValueProvider,
|
||||
} from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
|
||||
export interface ClusterRemoteWorkerSecretDeliveryPrincipal {
|
||||
readonly workerId: string;
|
||||
}
|
||||
|
||||
export type ClusterRemoteWorkerSecretDeliveryCommand = Omit<
|
||||
RemoteWorkerSecretDeliveryCommand,
|
||||
'workerId'
|
||||
>;
|
||||
|
||||
export class ClusterRemoteWorkerSecretDeliveryService {
|
||||
constructor(
|
||||
private readonly authority: RemoteWorkerSecretDeliveryAuthorityRepository,
|
||||
private readonly secrets: RemoteWorkerSecretValueProvider,
|
||||
) {
|
||||
if (
|
||||
!authority ||
|
||||
typeof authority.authorize !== 'function' ||
|
||||
!secrets ||
|
||||
typeof secrets.resolve !== 'function'
|
||||
) throw new TypeError('Remote Worker Secret delivery service is invalid');
|
||||
}
|
||||
|
||||
async deliver(
|
||||
principal: ClusterRemoteWorkerSecretDeliveryPrincipal,
|
||||
input: ClusterRemoteWorkerSecretDeliveryCommand,
|
||||
): Promise<Readonly<RemoteWorkerSecretDeliveryResult>> {
|
||||
if (
|
||||
!principal ||
|
||||
typeof principal !== 'object' ||
|
||||
Array.isArray(principal) ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(principal.workerId)
|
||||
) throw new TypeError('Remote Worker Secret delivery principal is invalid');
|
||||
const command = normalizeRemoteWorkerSecretDeliveryCommand({
|
||||
...input,
|
||||
workerId: principal.workerId,
|
||||
});
|
||||
let authorized;
|
||||
try {
|
||||
authorized = normalizeRemoteWorkerSecretDeliveryAuthority(
|
||||
await this.authority.authorize(command),
|
||||
);
|
||||
if (
|
||||
authorized.workerId !== command.workerId ||
|
||||
authorized.workerSessionId !== command.workerSessionId ||
|
||||
authorized.workerGeneration !== command.workerGeneration ||
|
||||
authorized.runId !== command.runId ||
|
||||
authorized.attemptId !== command.attemptId ||
|
||||
authorized.projectId !== command.projectId ||
|
||||
authorized.taskId !== command.taskId ||
|
||||
authorized.taskRevision !== command.taskRevision ||
|
||||
authorized.executionDigest !== command.executionDigest ||
|
||||
authorized.offerId !== command.offerId ||
|
||||
authorized.leaseGeneration !== command.leaseGeneration ||
|
||||
authorized.leaseVersion !== command.expectedLeaseVersion ||
|
||||
JSON.stringify(authorized.secretRefs) !== JSON.stringify(command.secretRefs)
|
||||
) throw new InvalidRemoteWorkerSecretDeliveryError(
|
||||
'repository authority does not match command',
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof RemoteWorkerSecretDeliveryFenceRejectedError ||
|
||||
error instanceof RemoteWorkerSecretDeliveryUnavailableError
|
||||
) throw error;
|
||||
throw new RemoteWorkerSecretDeliveryUnavailableError();
|
||||
}
|
||||
let resolution;
|
||||
try {
|
||||
resolution = await this.secrets.resolve(authorized);
|
||||
} catch {
|
||||
throw new RemoteWorkerSecretDeliveryUnavailableError();
|
||||
}
|
||||
if (!resolution) throw new RemoteWorkerSecretDeliveryUnavailableError();
|
||||
try {
|
||||
if (
|
||||
typeof resolution !== 'object' ||
|
||||
Array.isArray(resolution) ||
|
||||
Object.keys(resolution).some((key) => key !== 'values' && key !== 'dispose') ||
|
||||
(resolution.dispose !== undefined &&
|
||||
typeof resolution.dispose !== 'function')
|
||||
) throw new InvalidRemoteWorkerSecretDeliveryError(
|
||||
'provider response shape is invalid',
|
||||
);
|
||||
const body = createRemoteWorkerSecretDeliveryResponseBody({
|
||||
runId: authorized.runId,
|
||||
attemptId: authorized.attemptId,
|
||||
offerId: authorized.offerId,
|
||||
executionDigest: authorized.executionDigest,
|
||||
values: resolution.values,
|
||||
}, authorized.secretRefs);
|
||||
return Object.freeze({
|
||||
runId: body.runId,
|
||||
attemptId: body.attemptId,
|
||||
offerId: body.offerId,
|
||||
executionDigest: body.executionDigest,
|
||||
values: body.values,
|
||||
...(resolution.dispose === undefined
|
||||
? {}
|
||||
: { dispose: resolution.dispose }),
|
||||
});
|
||||
} catch (error) {
|
||||
try { await resolution.dispose?.(); } catch { /* preserve root */ }
|
||||
if (error instanceof InvalidRemoteWorkerSecretDeliveryError) {
|
||||
throw new RemoteWorkerSecretDeliveryUnavailableError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Remote execution owns the least-privilege assembly of Worker-facing runtime capabilities.
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import type { RemoteWorkerSecretValueProvider } from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import {
|
||||
PostgresClusterDispatchSource,
|
||||
PostgresRemoteRunActivationRepository,
|
||||
PostgresRemoteWorkerCompletionRepository,
|
||||
PostgresRemoteWorkerLeaseControlRepository,
|
||||
PostgresRemoteWorkerSecretDeliveryAuthorityRepository,
|
||||
PostgresRunDispatchLeaseRepository,
|
||||
PostgresTaskExecutionRevisionSource,
|
||||
PostgresWorkerSessionRepository,
|
||||
} from '@qinglong/cluster-postgres/runtime';
|
||||
import {
|
||||
ClusterRemoteWorkerOfferClaimService,
|
||||
} from './remoteWorkerDispatcher';
|
||||
import {
|
||||
ClusterRemoteRunActivationService,
|
||||
} from './remoteRunActivationService';
|
||||
import {
|
||||
ClusterRemoteWorkerSecretDeliveryService,
|
||||
} from './remoteWorkerSecretDeliveryService';
|
||||
import {
|
||||
ClusterRemoteWorkerArtifactService,
|
||||
ClusterRemoteWorkerCompletionService,
|
||||
type ClusterRemoteWorkerArtifactStore,
|
||||
} from './remoteWorkerCompletionService';
|
||||
import {
|
||||
ClusterRemoteWorkerLeaseControlService,
|
||||
} from './remoteWorkerLeaseControlService';
|
||||
import type { WorkerIngressPipelineOptions } from '../worker-ingress/workerIngressPipeline';
|
||||
|
||||
export interface ClusterWorkerRuntimeDependencies {
|
||||
readonly artifactStore: ClusterRemoteWorkerArtifactStore;
|
||||
readonly secretProvider?: RemoteWorkerSecretValueProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-process capability boundary from the runtime authority to the
|
||||
* Worker-facing transport. It exposes reviewed operations, never the runtime
|
||||
* Pool or mutation repositories.
|
||||
*/
|
||||
export interface ClusterWorkerRuntimePort {
|
||||
readonly offers: NonNullable<WorkerIngressPipelineOptions['offers']>;
|
||||
readonly activation: NonNullable<WorkerIngressPipelineOptions['activation']>;
|
||||
readonly secrets?: NonNullable<WorkerIngressPipelineOptions['secrets']>;
|
||||
readonly artifacts: NonNullable<WorkerIngressPipelineOptions['artifacts']>;
|
||||
readonly completion: NonNullable<WorkerIngressPipelineOptions['completion']>;
|
||||
readonly leaseControl: NonNullable<
|
||||
WorkerIngressPipelineOptions['leaseControl']
|
||||
>;
|
||||
}
|
||||
|
||||
export function createClusterWorkerRuntimePort(
|
||||
pool: PostgresPool,
|
||||
dependencies: ClusterWorkerRuntimeDependencies,
|
||||
): Readonly<ClusterWorkerRuntimePort> {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new TypeError('Cluster Worker runtime Pool is invalid');
|
||||
}
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
Array.isArray(dependencies)
|
||||
) {
|
||||
throw new TypeError('Cluster Worker runtime dependencies are invalid');
|
||||
}
|
||||
|
||||
const workerSessions = new PostgresWorkerSessionRepository(pool);
|
||||
const completionRepository =
|
||||
new PostgresRemoteWorkerCompletionRepository(pool);
|
||||
const secretProvider = dependencies.secretProvider;
|
||||
return Object.freeze({
|
||||
offers: new ClusterRemoteWorkerOfferClaimService(
|
||||
new PostgresClusterDispatchSource(pool),
|
||||
workerSessions,
|
||||
new PostgresTaskExecutionRevisionSource(pool),
|
||||
new PostgresRunDispatchLeaseRepository(pool),
|
||||
),
|
||||
activation: new ClusterRemoteRunActivationService(
|
||||
new PostgresRemoteRunActivationRepository(pool),
|
||||
),
|
||||
...(secretProvider === undefined
|
||||
? {}
|
||||
: {
|
||||
secrets: new ClusterRemoteWorkerSecretDeliveryService(
|
||||
new PostgresRemoteWorkerSecretDeliveryAuthorityRepository(pool),
|
||||
secretProvider,
|
||||
),
|
||||
}),
|
||||
artifacts: new ClusterRemoteWorkerArtifactService(
|
||||
completionRepository,
|
||||
dependencies.artifactStore,
|
||||
),
|
||||
completion: new ClusterRemoteWorkerCompletionService(
|
||||
completionRepository,
|
||||
dependencies.artifactStore,
|
||||
),
|
||||
leaseControl: new ClusterRemoteWorkerLeaseControlService(
|
||||
new PostgresRemoteWorkerLeaseControlRepository(pool),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Run owns bounded convergence of durable cancellation intent to terminal state.
|
||||
import type {
|
||||
ClusterRunCancellationConvergenceCoordinator,
|
||||
ClusterRunCancellationConvergenceCycleResult,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation-convergence';
|
||||
|
||||
export interface ClusterRunCancellationConvergenceLifecycleOptions {
|
||||
readonly intervalMs: number;
|
||||
readonly stopTimeoutMs: number;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: Readonly<ClusterRunCancellationConvergenceCycleResult>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterRunCancellationConvergenceLifecycleStopSummary {
|
||||
readonly status: 'stopped' | 'timed_out';
|
||||
}
|
||||
|
||||
/** One constant-cost cadence for all pending non-executing Run cancellations. */
|
||||
export class ClusterRunCancellationConvergenceLifecycle {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private inFlight:
|
||||
| Promise<Readonly<ClusterRunCancellationConvergenceCycleResult>>
|
||||
| undefined;
|
||||
private stopPromise:
|
||||
| Promise<ClusterRunCancellationConvergenceLifecycleStopSummary>
|
||||
| undefined;
|
||||
private running = false;
|
||||
private stopping = false;
|
||||
|
||||
constructor(
|
||||
private readonly coordinator: Pick<
|
||||
ClusterRunCancellationConvergenceCoordinator,
|
||||
'reconcile'
|
||||
>,
|
||||
private readonly options: ClusterRunCancellationConvergenceLifecycleOptions,
|
||||
) {
|
||||
if (
|
||||
typeof coordinator?.reconcile !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!Number.isSafeInteger(options.intervalMs) ||
|
||||
options.intervalMs < 250 ||
|
||||
options.intervalMs > 60 * 60_000 ||
|
||||
!Number.isSafeInteger(options.stopTimeoutMs) ||
|
||||
options.stopTimeoutMs < 100 ||
|
||||
options.stopTimeoutMs > 30_000 ||
|
||||
(options.onDiagnostic !== undefined &&
|
||||
typeof options.onDiagnostic !== 'function')
|
||||
) {
|
||||
throw new TypeError('Cluster Run cancellation lifecycle options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
start(): 'started' {
|
||||
if (!this.running && !this.stopping) {
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
}
|
||||
return 'started';
|
||||
}
|
||||
|
||||
runOnce(): Promise<Readonly<ClusterRunCancellationConvergenceCycleResult>> {
|
||||
if (this.stopping) {
|
||||
return Promise.reject(
|
||||
new Error('Cluster Run cancellation lifecycle is stopping'),
|
||||
);
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.coordinator.reconcile().finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
stopAndDrain(): Promise<ClusterRunCancellationConvergenceLifecycleStopSummary> {
|
||||
if (this.stopPromise) return this.stopPromise;
|
||||
this.stopping = true;
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.stopPromise = (async () => {
|
||||
const work = this.inFlight;
|
||||
if (!work) return Object.freeze({ status: 'stopped' as const });
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
work.then(
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
),
|
||||
new Promise<ClusterRunCancellationConvergenceLifecycleStopSummary>(
|
||||
(resolve) => {
|
||||
timeout = setTimeout(
|
||||
() => resolve(Object.freeze({ status: 'timed_out' as const })),
|
||||
this.options.stopTimeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
},
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
})();
|
||||
return this.stopPromise;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.running || this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (!this.running) return;
|
||||
void this.runOnce()
|
||||
.then((summary) => this.diagnostic(undefined, summary))
|
||||
.catch((error) => this.diagnostic(error))
|
||||
.finally(() => this.schedule());
|
||||
}, this.options.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private async diagnostic(
|
||||
error: unknown,
|
||||
summary?: Readonly<ClusterRunCancellationConvergenceCycleResult>,
|
||||
): Promise<void> {
|
||||
if (this.stopping) return;
|
||||
try {
|
||||
await this.options.onDiagnostic?.(error, summary);
|
||||
} catch {
|
||||
// Diagnostics cannot own or stop convergence.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Run owns its Policy-fenced durable cancellation mutation route.
|
||||
import {
|
||||
CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
ClusterRunCancellationFenceRejectedError,
|
||||
ClusterRunCancellationNotFoundError,
|
||||
ClusterRunCancellationUnavailableError,
|
||||
InvalidClusterRunCancellationError,
|
||||
createClusterRunCancellationResponseBody,
|
||||
parseClusterRunCancellationRequestBody,
|
||||
type ClusterRunCancellationRepository,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation';
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE = Object.freeze({
|
||||
method: 'POST' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/cancellation',
|
||||
operationId: 'run.cancel',
|
||||
permission: 'run.stop',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
export type ClusterRunCancellationEventIdFactory = () => string;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a durable cancellation command. Authentication, authorization and
|
||||
* the first audit complete in admission; the repository revalidates the exact
|
||||
* policy fence in the same transaction that writes the Run intent and Event.
|
||||
*/
|
||||
export function createClusterControlRunCancellationRoute(
|
||||
repository: ClusterRunCancellationRepository,
|
||||
createEventId: ClusterRunCancellationEventIdFactory,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.requestUserCancellation !== 'function' ||
|
||||
typeof createEventId !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Run cancellation route is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
let body;
|
||||
try {
|
||||
body = parseClusterRunCancellationRequestBody(
|
||||
authorized.request.body,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidClusterRunCancellationError) {
|
||||
return response(400, {
|
||||
code: 'invalid_run_cancellation_request',
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
});
|
||||
}
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
const projectId = authorized.projectId;
|
||||
const runId = parameters.runId;
|
||||
if (
|
||||
projectId === null ||
|
||||
typeof runId !== 'string' ||
|
||||
runId.length < 1 ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null
|
||||
) {
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await repository.requestUserCancellation({
|
||||
projectId,
|
||||
runId,
|
||||
mutationId: body.mutationId,
|
||||
eventId: createEventId(),
|
||||
subject: authorized.principal.subject,
|
||||
policyFence: authorized.policyFence,
|
||||
});
|
||||
return response(
|
||||
result.status === 'accepted' ? 202 : 200,
|
||||
createClusterRunCancellationResponseBody(result),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterRunCancellationNotFoundError) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
if (error instanceof ClusterRunCancellationFenceRejectedError) {
|
||||
return response(409, {
|
||||
code: 'run_cancellation_fence_rejected',
|
||||
reason: error.reason,
|
||||
});
|
||||
}
|
||||
if (
|
||||
error instanceof InvalidClusterRunCancellationError ||
|
||||
error instanceof ClusterRunCancellationUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
BoundedRunEventListProjectionUnavailableError,
|
||||
InvalidBoundedRunEventListProjectionError,
|
||||
executeBoundedRunEventListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_EVENT_LIST_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/events',
|
||||
operationId: 'run.events.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze(['after_sequence', 'limit']),
|
||||
});
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{ afterSequence?: number; limit?: number }> {
|
||||
const afterValues = query.after_sequence;
|
||||
const limitValues = query.limit;
|
||||
if (
|
||||
(afterValues !== undefined && afterValues.length !== 1) ||
|
||||
(limitValues !== undefined && limitValues.length !== 1)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawAfter = afterValues?.[0];
|
||||
const afterSequence = rawAfter === undefined ? undefined : Number(rawAfter);
|
||||
const rawLimit = limitValues?.[0];
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
(rawAfter !== undefined &&
|
||||
(!Number.isSafeInteger(afterSequence) ||
|
||||
Number(afterSequence) < 0 ||
|
||||
Number(afterSequence) > 2_147_483_647 ||
|
||||
String(afterSequence) !== rawAfter)) ||
|
||||
(rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(afterSequence === undefined ? {} : { afterSequence }),
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateRunEventListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): void {
|
||||
parseQuery(query);
|
||||
}
|
||||
|
||||
export function createClusterControlRunEventListRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'>,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
typeof runs.listEvents !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Run event list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_EVENT_LIST_ROUTE,
|
||||
validateQuery: validateRunEventListQuery,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'run_event_list_unavailable' });
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = parseQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_run_event_list_query' });
|
||||
}
|
||||
try {
|
||||
const result = await executeBoundedRunEventListProjection(
|
||||
runs,
|
||||
authorized.projectId,
|
||||
parameters.runId!,
|
||||
input,
|
||||
);
|
||||
if (!result.found) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const { found: _found, ...timeline } = result;
|
||||
return response(200, { ...timeline });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunEventListProjectionError ||
|
||||
error instanceof BoundedRunEventListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_event_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
BoundedRunListProjectionUnavailableError,
|
||||
InvalidBoundedRunListProjectionError,
|
||||
executeBoundedRunListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-list-projection';
|
||||
import type { ProjectRunListReader } from '@qinglong/runtime-core/project-run-list';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_LIST_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs',
|
||||
operationId: 'run.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze([
|
||||
'after_created_at_ms',
|
||||
'after_run_id',
|
||||
'limit',
|
||||
]),
|
||||
});
|
||||
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{
|
||||
limit?: number;
|
||||
after?: Readonly<{ createdAtMs: number; runId: string }>;
|
||||
}> {
|
||||
const limitValues = query.limit;
|
||||
const createdAtValues = query.after_created_at_ms;
|
||||
const runIdValues = query.after_run_id;
|
||||
if (
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(createdAtValues !== undefined && createdAtValues.length !== 1) ||
|
||||
(runIdValues !== undefined && runIdValues.length !== 1) ||
|
||||
(createdAtValues === undefined) !== (runIdValues === undefined)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawLimit = limitValues?.[0];
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawCreatedAtMs = createdAtValues?.[0];
|
||||
const runId = runIdValues?.[0];
|
||||
if (rawCreatedAtMs === undefined || runId === undefined) {
|
||||
return Object.freeze({ ...(limit === undefined ? {} : { limit }) });
|
||||
}
|
||||
const createdAtMs = Number(rawCreatedAtMs);
|
||||
if (
|
||||
!Number.isSafeInteger(createdAtMs) ||
|
||||
createdAtMs < 0 ||
|
||||
String(createdAtMs) !== rawCreatedAtMs ||
|
||||
!RUN_ID_PATTERN.test(runId)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
after: Object.freeze({ createdAtMs, runId }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateRunListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): void {
|
||||
parseQuery(query);
|
||||
}
|
||||
|
||||
export function createClusterControlRunListRoute(
|
||||
runs: ProjectRunListReader,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!runs || typeof runs.listRunsByProject !== 'function') {
|
||||
throw new TypeError('Cluster-control Run list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_LIST_ROUTE,
|
||||
validateQuery: validateRunListQuery,
|
||||
async handle(authorized: ClusterControlAuthorizedOperationRequest) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'run_list_unavailable' });
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = parseQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_run_list_query' });
|
||||
}
|
||||
try {
|
||||
const result = await executeBoundedRunListProjection(
|
||||
runs,
|
||||
authorized.projectId,
|
||||
input,
|
||||
);
|
||||
return response(200, { ...result });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunListProjectionError ||
|
||||
error instanceof BoundedRunListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Run owns its bounded read projection and masks cross-Project storage facts.
|
||||
import {
|
||||
EXECUTION_ORIGINS,
|
||||
RUN_STATUSES,
|
||||
type ExecutionOrigin,
|
||||
type ExecutionOwner,
|
||||
type RunRecord,
|
||||
type RunRepositoryReader,
|
||||
type RunStatus,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export interface ClusterControlRunReadRepository
|
||||
extends Pick<RunRepositoryReader, 'findRunById'> {}
|
||||
|
||||
export interface ClusterControlRunView {
|
||||
readonly id: string;
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: string;
|
||||
readonly status: RunStatus;
|
||||
readonly version: number;
|
||||
readonly eventSequence: number;
|
||||
readonly priority: number;
|
||||
readonly executionOrigin: ExecutionOrigin;
|
||||
readonly executionOwner: ExecutionOwner;
|
||||
readonly createdAtMs: number;
|
||||
readonly queuedAtMs: number | null;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly finishedAtMs: number | null;
|
||||
}
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_READ_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}',
|
||||
operationId: 'run.get',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function boundedText(value: unknown, maximum: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!CONTROL_CHARACTER_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value: unknown): value is number | undefined {
|
||||
return value === undefined || nonNegativeInteger(value);
|
||||
}
|
||||
|
||||
function projectRunView(
|
||||
run: RunRecord,
|
||||
runId: string,
|
||||
): Readonly<ClusterControlRunView> | null {
|
||||
if (
|
||||
!run ||
|
||||
typeof run !== 'object' ||
|
||||
Array.isArray(run) ||
|
||||
run.id !== runId ||
|
||||
!boundedText(run.id, 128) ||
|
||||
!boundedText(run.projectId, 128) ||
|
||||
!boundedText(run.taskId, 255) ||
|
||||
!boundedText(run.taskRevision, 255) ||
|
||||
!RUN_STATUSES.includes(run.status) ||
|
||||
!EXECUTION_ORIGINS.includes(run.executionOrigin) ||
|
||||
(run.executionOwner !== 'legacy' && run.executionOwner !== 'runtime') ||
|
||||
!Number.isSafeInteger(run.version) ||
|
||||
run.version < 0 ||
|
||||
!nonNegativeInteger(run.eventSequence) ||
|
||||
!Number.isSafeInteger(run.priority) ||
|
||||
!nonNegativeInteger(run.createdAtMs) ||
|
||||
!optionalTimestamp(run.queuedAtMs) ||
|
||||
!optionalTimestamp(run.startedAtMs) ||
|
||||
!optionalTimestamp(run.finishedAtMs)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
id: run.id,
|
||||
projectId: run.projectId,
|
||||
taskId: run.taskId,
|
||||
taskRevision: run.taskRevision,
|
||||
status: run.status,
|
||||
version: run.version,
|
||||
eventSequence: run.eventSequence,
|
||||
priority: run.priority,
|
||||
executionOrigin: run.executionOrigin,
|
||||
executionOwner: run.executionOwner,
|
||||
createdAtMs: run.createdAtMs,
|
||||
queuedAtMs: run.queuedAtMs ?? null,
|
||||
startedAtMs: run.startedAtMs ?? null,
|
||||
finishedAtMs: run.finishedAtMs ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the first reviewed cluster-control business route. The response is a
|
||||
* deliberately low-sensitive projection: refs, trigger identity, request IDs,
|
||||
* executor handles, error summaries and output locations never cross the wire.
|
||||
*/
|
||||
export function createClusterControlRunReadRoute(
|
||||
repository: ClusterControlRunReadRepository,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!repository || typeof repository.findRunById !== 'function') {
|
||||
throw new TypeError('Cluster-control Run read repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_READ_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
const runId = parameters.runId;
|
||||
if (!boundedText(runId, 128)) {
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
let run: RunRecord | null;
|
||||
try {
|
||||
run = await repository.findRunById(runId);
|
||||
} catch {
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
if (!run) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const view = projectRunView(run, runId);
|
||||
if (!view) {
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
if (view.projectId !== authorized.projectId) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
return response(200, { run: view });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export * from './runCancellationRoute';
|
||||
export * from './runListRoute';
|
||||
export * from './runEventListRoute';
|
||||
export * from './runStepListRoute';
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
BoundedRunStepListProjectionUnavailableError,
|
||||
InvalidBoundedRunStepListProjectionError,
|
||||
executeBoundedRunStepListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_STEP_LIST_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/steps',
|
||||
operationId: 'run.steps.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze(['after_step_key', 'after_step_run_id', 'limit']),
|
||||
});
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseQuery(query: Readonly<Record<string, readonly string[]>>) {
|
||||
const stepKeyValues = query.after_step_key;
|
||||
const stepRunIdValues = query.after_step_run_id;
|
||||
const limitValues = query.limit;
|
||||
if (
|
||||
(stepKeyValues !== undefined && stepKeyValues.length !== 1) ||
|
||||
(stepRunIdValues !== undefined && stepRunIdValues.length !== 1) ||
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(stepKeyValues === undefined) !== (stepRunIdValues === undefined)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const stepKey = stepKeyValues?.[0];
|
||||
const stepRunId = stepRunIdValues?.[0];
|
||||
const rawLimit = limitValues?.[0];
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
(stepKey !== undefined && !IDENTITY_PATTERN.test(stepKey)) ||
|
||||
(stepRunId !== undefined && !IDENTITY_PATTERN.test(stepRunId)) ||
|
||||
(rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(stepKey === undefined || stepRunId === undefined
|
||||
? {}
|
||||
: { after: Object.freeze({ stepKey, stepRunId }) }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateRunStepListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): void {
|
||||
parseQuery(query);
|
||||
}
|
||||
|
||||
export function createClusterControlRunStepListRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
stepRuns: Pick<StepRunRepository, 'listByRun'>,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
!stepRuns ||
|
||||
typeof stepRuns.listByRun !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Run Step list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_STEP_LIST_ROUTE,
|
||||
validateQuery: validateRunStepListQuery,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'run_step_list_unavailable' });
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = parseQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
try {
|
||||
const result = await executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
stepRuns,
|
||||
authorized.projectId,
|
||||
parameters.runId!,
|
||||
input,
|
||||
);
|
||||
if (!result.found) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const { found: _found, ...page } = result;
|
||||
return response(200, { ...page });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunStepListProjectionError ||
|
||||
error instanceof BoundedRunStepListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_step_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Scheduling owns the bounded Cron expression adapter used by the Cluster cadence.
|
||||
import type {
|
||||
LocalCronNextOccurrence,
|
||||
LocalCronSchedule,
|
||||
} from '@qinglong/runtime-core/local-scheduler';
|
||||
|
||||
interface CronerJob {
|
||||
nextRun(after: Date): Date | null;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
interface CronerConstructor {
|
||||
new (
|
||||
expression: string,
|
||||
options: Readonly<{
|
||||
timezone: string;
|
||||
paused: true;
|
||||
unref: true;
|
||||
}>,
|
||||
): CronerJob;
|
||||
}
|
||||
|
||||
export const cronerClusterNextOccurrence: LocalCronNextOccurrence = (
|
||||
schedule: LocalCronSchedule,
|
||||
afterMs: number,
|
||||
): number => {
|
||||
let job: CronerJob | undefined;
|
||||
try {
|
||||
const { Cron } = require('croner') as Readonly<{
|
||||
Cron: CronerConstructor;
|
||||
}>;
|
||||
job = new Cron(schedule.expression, {
|
||||
timezone: schedule.timezone,
|
||||
paused: true,
|
||||
unref: true,
|
||||
});
|
||||
const next = job.nextRun(new Date(afterMs));
|
||||
if (!(next instanceof Date)) {
|
||||
throw new Error('cron has no next occurrence');
|
||||
}
|
||||
return next.getTime();
|
||||
} finally {
|
||||
job?.stop();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Scheduling owns recovery and lost-retry ordering inside the shared cadence.
|
||||
import type {
|
||||
ClusterControlStartupRecoverySummary,
|
||||
ClusterRunLostRetryPageResult,
|
||||
} from '@qinglong/runtime-core';
|
||||
|
||||
import type {
|
||||
ClusterSchedulerCoordinator,
|
||||
ClusterSchedulerCycleSummary,
|
||||
} from './scheduler';
|
||||
|
||||
export interface ClusterRuntimeSchedulerMaintenanceSummary {
|
||||
readonly recovery: Readonly<ClusterControlStartupRecoverySummary>;
|
||||
readonly lostRetry: Readonly<ClusterRunLostRetryPageResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses the scheduler's single non-overlapping cadence for runtime recovery
|
||||
* and lost retry. It owns no timer, connection, cursor or per-Run state.
|
||||
*/
|
||||
export class ClusterRuntimeSchedulerCoordinator {
|
||||
private inFlight: Promise<ClusterSchedulerCycleSummary> | undefined;
|
||||
private latestMaintenance:
|
||||
| Readonly<ClusterRuntimeSchedulerMaintenanceSummary>
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly recovery: Readonly<{
|
||||
reconcile(): Promise<ClusterControlStartupRecoverySummary>;
|
||||
}>,
|
||||
private readonly lostRetry: Readonly<{
|
||||
reconcile(): Promise<Readonly<ClusterRunLostRetryPageResult>>;
|
||||
}>,
|
||||
private readonly scheduler: Pick<
|
||||
ClusterSchedulerCoordinator,
|
||||
'scheduleOnce'
|
||||
>,
|
||||
) {
|
||||
if (
|
||||
typeof recovery?.reconcile !== 'function' ||
|
||||
typeof lostRetry?.reconcile !== 'function' ||
|
||||
typeof scheduler?.scheduleOnce !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster runtime scheduler coordinator is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
scheduleOnce(): Promise<ClusterSchedulerCycleSummary> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.runCycle().finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
latestMaintenanceSummary():
|
||||
| Readonly<ClusterRuntimeSchedulerMaintenanceSummary>
|
||||
| undefined {
|
||||
return this.latestMaintenance;
|
||||
}
|
||||
|
||||
private async runCycle(): Promise<ClusterSchedulerCycleSummary> {
|
||||
const recovery = await this.recovery.reconcile();
|
||||
const lostRetry = await this.lostRetry.reconcile();
|
||||
this.latestMaintenance = Object.freeze({ recovery, lostRetry });
|
||||
return this.scheduler.scheduleOnce();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Scheduling owns bounded trigger claiming and the single non-overlapping lifecycle timer.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
MAX_CLUSTER_SCHEDULE_CLAIM_LEASE_MS,
|
||||
MIN_CLUSTER_SCHEDULE_CLAIM_LEASE_MS,
|
||||
resolveClusterScheduleDecision,
|
||||
type ClusterScheduleStore,
|
||||
} from '@qinglong/runtime-core/cluster-scheduler';
|
||||
import type { LocalCronNextOccurrence } from '@qinglong/runtime-core/local-scheduler';
|
||||
import { cronerClusterNextOccurrence } from './cronerSchedule';
|
||||
|
||||
export const MAX_CLUSTER_SCHEDULE_CLAIMS_PER_CYCLE = 256;
|
||||
|
||||
export interface ClusterSchedulerCoordinatorOptions {
|
||||
readonly ownerId: string;
|
||||
readonly claimLeaseMs?: number;
|
||||
readonly maxClaimsPerCycle?: number;
|
||||
readonly misfireGraceMs?: number;
|
||||
readonly createId?: () => string;
|
||||
readonly nextOccurrence?: LocalCronNextOccurrence;
|
||||
readonly onAdmitted?: (
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterSchedulerCycleSummary {
|
||||
readonly firstClaimAcquiredAtMs: number | null;
|
||||
readonly lastClaimAcquiredAtMs: number | null;
|
||||
readonly claimed: number;
|
||||
readonly initialized: number;
|
||||
readonly skipped: number;
|
||||
readonly admitted: number;
|
||||
readonly raced: number;
|
||||
readonly saturated: boolean;
|
||||
}
|
||||
|
||||
const OWNER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const COORDINATOR_OPTION_KEYS = new Set([
|
||||
'claimLeaseMs',
|
||||
'createId',
|
||||
'maxClaimsPerCycle',
|
||||
'misfireGraceMs',
|
||||
'nextOccurrence',
|
||||
'onAdmitted',
|
||||
'ownerId',
|
||||
]);
|
||||
|
||||
export class ClusterSchedulerCoordinator {
|
||||
private readonly ownerId: string;
|
||||
private readonly claimLeaseMs: number;
|
||||
private readonly maxClaimsPerCycle: number;
|
||||
private readonly misfireGraceMs: number;
|
||||
private readonly createId: () => string;
|
||||
private readonly nextOccurrence: LocalCronNextOccurrence;
|
||||
private readonly onAdmitted?: ClusterSchedulerCoordinatorOptions['onAdmitted'];
|
||||
|
||||
constructor(
|
||||
private readonly schedules: ClusterScheduleStore,
|
||||
options: ClusterSchedulerCoordinatorOptions,
|
||||
) {
|
||||
this.ownerId = options?.ownerId ?? '';
|
||||
this.claimLeaseMs = options?.claimLeaseMs ?? 30_000;
|
||||
this.maxClaimsPerCycle = options?.maxClaimsPerCycle ?? 16;
|
||||
this.misfireGraceMs = options?.misfireGraceMs ?? 30_000;
|
||||
this.createId = options?.createId ?? randomUUID;
|
||||
this.nextOccurrence =
|
||||
options?.nextOccurrence ?? cronerClusterNextOccurrence;
|
||||
this.onAdmitted = options?.onAdmitted;
|
||||
if (
|
||||
!schedules ||
|
||||
typeof schedules.claimNextClusterSchedule !== 'function' ||
|
||||
typeof schedules.commitClusterScheduleDecision !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => !COORDINATOR_OPTION_KEYS.has(key)) ||
|
||||
!OWNER_PATTERN.test(this.ownerId) ||
|
||||
!Number.isSafeInteger(this.claimLeaseMs) ||
|
||||
this.claimLeaseMs < MIN_CLUSTER_SCHEDULE_CLAIM_LEASE_MS ||
|
||||
this.claimLeaseMs > MAX_CLUSTER_SCHEDULE_CLAIM_LEASE_MS ||
|
||||
!Number.isSafeInteger(this.maxClaimsPerCycle) ||
|
||||
this.maxClaimsPerCycle < 1 ||
|
||||
this.maxClaimsPerCycle > MAX_CLUSTER_SCHEDULE_CLAIMS_PER_CYCLE ||
|
||||
!Number.isSafeInteger(this.misfireGraceMs) ||
|
||||
this.misfireGraceMs < 0 ||
|
||||
this.misfireGraceMs > 5 * 60_000 ||
|
||||
typeof this.createId !== 'function' ||
|
||||
typeof this.nextOccurrence !== 'function' ||
|
||||
(this.onAdmitted !== undefined && typeof this.onAdmitted !== 'function')
|
||||
) {
|
||||
throw new TypeError('Cluster scheduler coordinator options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async scheduleOnce(): Promise<ClusterSchedulerCycleSummary> {
|
||||
const stats: {
|
||||
firstClaimAcquiredAtMs: number | null;
|
||||
lastClaimAcquiredAtMs: number | null;
|
||||
claimed: number;
|
||||
initialized: number;
|
||||
skipped: number;
|
||||
admitted: number;
|
||||
raced: number;
|
||||
saturated: boolean;
|
||||
} = {
|
||||
firstClaimAcquiredAtMs: null,
|
||||
lastClaimAcquiredAtMs: null,
|
||||
claimed: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
saturated: false,
|
||||
};
|
||||
while (stats.claimed < this.maxClaimsPerCycle) {
|
||||
const claimToken = this.createId();
|
||||
const claimed = await this.schedules.claimNextClusterSchedule({
|
||||
ownerId: this.ownerId,
|
||||
claimToken,
|
||||
leaseMs: this.claimLeaseMs,
|
||||
});
|
||||
if (!claimed) break;
|
||||
if (
|
||||
claimed.claimOwner !== this.ownerId ||
|
||||
claimed.claimToken !== claimToken ||
|
||||
claimed.claimExpiresAtMs !==
|
||||
claimed.claimAcquiredAtMs + this.claimLeaseMs
|
||||
) {
|
||||
throw new TypeError('Cluster scheduler store returned a foreign claim');
|
||||
}
|
||||
stats.claimed += 1;
|
||||
stats.firstClaimAcquiredAtMs ??= claimed.claimAcquiredAtMs;
|
||||
stats.lastClaimAcquiredAtMs = claimed.claimAcquiredAtMs;
|
||||
const decision = resolveClusterScheduleDecision(
|
||||
claimed,
|
||||
this.misfireGraceMs,
|
||||
this.nextOccurrence,
|
||||
);
|
||||
const admitted = decision.disposition === 'admit';
|
||||
const result = await this.schedules.commitClusterScheduleDecision({
|
||||
claim: claimed,
|
||||
decision,
|
||||
...(admitted
|
||||
? {
|
||||
runId: this.createId(),
|
||||
attemptId: this.createId(),
|
||||
createdEventId: this.createId(),
|
||||
queuedEventId: this.createId(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (result.status === 'raced') {
|
||||
stats.raced += 1;
|
||||
continue;
|
||||
}
|
||||
if (result.disposition === 'initialize') stats.initialized += 1;
|
||||
if (result.disposition === 'skip') stats.skipped += 1;
|
||||
if (result.status === 'admitted') {
|
||||
stats.admitted += 1;
|
||||
await this.onAdmitted?.(result.runId, result.attemptId);
|
||||
}
|
||||
}
|
||||
stats.saturated = stats.claimed === this.maxClaimsPerCycle;
|
||||
return Object.freeze(stats);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClusterSchedulerLifecycleOptions {
|
||||
readonly intervalMs: number;
|
||||
readonly stopTimeoutMs: number;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: ClusterSchedulerCycleSummary,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterSchedulerLifecycleStopSummary {
|
||||
readonly status: 'stopped' | 'timed_out';
|
||||
}
|
||||
|
||||
export class ClusterSchedulerLifecycle {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private inFlight: Promise<ClusterSchedulerCycleSummary> | undefined;
|
||||
private stopPromise:
|
||||
| Promise<ClusterSchedulerLifecycleStopSummary>
|
||||
| undefined;
|
||||
private running = false;
|
||||
private stopping = false;
|
||||
|
||||
constructor(
|
||||
private readonly scheduler: Pick<
|
||||
ClusterSchedulerCoordinator,
|
||||
'scheduleOnce'
|
||||
>,
|
||||
private readonly options: ClusterSchedulerLifecycleOptions,
|
||||
) {
|
||||
if (
|
||||
!scheduler ||
|
||||
typeof scheduler.scheduleOnce !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!Number.isSafeInteger(options.intervalMs) ||
|
||||
options.intervalMs < 250 ||
|
||||
options.intervalMs > 60 * 60_000 ||
|
||||
!Number.isSafeInteger(options.stopTimeoutMs) ||
|
||||
options.stopTimeoutMs < 100 ||
|
||||
options.stopTimeoutMs > 30_000 ||
|
||||
(options.onDiagnostic !== undefined &&
|
||||
typeof options.onDiagnostic !== 'function')
|
||||
) {
|
||||
throw new TypeError('Cluster scheduler lifecycle options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
start(): 'started' {
|
||||
if (!this.running && !this.stopping) {
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
}
|
||||
return 'started';
|
||||
}
|
||||
|
||||
runOnce(): Promise<ClusterSchedulerCycleSummary> {
|
||||
if (this.stopping) {
|
||||
return Promise.reject(
|
||||
new Error('Cluster scheduler lifecycle is stopping'),
|
||||
);
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.scheduler.scheduleOnce().finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
stopAndDrain(): Promise<ClusterSchedulerLifecycleStopSummary> {
|
||||
if (this.stopPromise) return this.stopPromise;
|
||||
this.stopping = true;
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.stopPromise = (async () => {
|
||||
const work = this.inFlight;
|
||||
if (!work) return Object.freeze({ status: 'stopped' as const });
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
work.then(
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
),
|
||||
new Promise<ClusterSchedulerLifecycleStopSummary>((resolve) => {
|
||||
timeout = setTimeout(
|
||||
() => resolve(Object.freeze({ status: 'timed_out' as const })),
|
||||
this.options.stopTimeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
})();
|
||||
return this.stopPromise;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.running || this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (!this.running) return;
|
||||
void this.runOnce()
|
||||
.then((summary) => this.diagnostic(undefined, summary))
|
||||
.catch((error) => this.diagnostic(error))
|
||||
.finally(() => this.schedule());
|
||||
}, this.options.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private async diagnostic(
|
||||
error: unknown,
|
||||
summary?: ClusterSchedulerCycleSummary,
|
||||
): Promise<void> {
|
||||
if (this.stopping) return;
|
||||
try {
|
||||
await this.options.onDiagnostic?.(error, summary);
|
||||
} catch {
|
||||
// Diagnostics cannot own or stop scheduling.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Scheduling owns Workflow frontier and Task Attempt admission on the shared cadence.
|
||||
import type {
|
||||
PluginPackageWorkflowFrontierCursor,
|
||||
PluginPackageWorkflowFrontierRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-frontier';
|
||||
import type {
|
||||
PluginPackageWorkflowTaskAttemptAdmissionCursor,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission';
|
||||
|
||||
import type {
|
||||
ClusterSchedulerCoordinator,
|
||||
ClusterSchedulerCycleSummary,
|
||||
} from './scheduler';
|
||||
|
||||
export interface ClusterWorkflowSchedulerOptions {
|
||||
readonly frontierPageSize: number;
|
||||
readonly frontierMaxPages: number;
|
||||
readonly taskAttemptPageSize: number;
|
||||
readonly taskAttemptMaxPages: number;
|
||||
}
|
||||
|
||||
export interface ClusterWorkflowSchedulerCycleSummary {
|
||||
readonly frontierPages: number;
|
||||
readonly frontierScanned: number;
|
||||
readonly frontierAdvanced: number;
|
||||
readonly frontierTruncated: boolean;
|
||||
readonly taskAttemptPages: number;
|
||||
readonly taskAttemptsScanned: number;
|
||||
readonly taskAttemptsCreated: number;
|
||||
readonly taskAttemptsExisting: number;
|
||||
readonly taskAttemptsTruncated: boolean;
|
||||
}
|
||||
|
||||
function bounded(
|
||||
label: string,
|
||||
value: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
||||
throw new RangeError(`${label} must be between 1 and ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nextFrontierCursor(
|
||||
current: PluginPackageWorkflowFrontierCursor | undefined,
|
||||
next: PluginPackageWorkflowFrontierCursor | undefined,
|
||||
): PluginPackageWorkflowFrontierCursor {
|
||||
if (
|
||||
!next ||
|
||||
(current !== undefined &&
|
||||
(next.admittedAtMs < current.admittedAtMs ||
|
||||
(next.admittedAtMs === current.admittedAtMs &&
|
||||
next.planDigest <= current.planDigest)))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Workflow frontier continuation did not advance',
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function nextTaskAttemptCursor(
|
||||
current: PluginPackageWorkflowTaskAttemptAdmissionCursor | undefined,
|
||||
next: PluginPackageWorkflowTaskAttemptAdmissionCursor | undefined,
|
||||
): PluginPackageWorkflowTaskAttemptAdmissionCursor {
|
||||
if (
|
||||
!next ||
|
||||
(current !== undefined &&
|
||||
(next.readyAtMs < current.readyAtMs ||
|
||||
(next.readyAtMs === current.readyAtMs &&
|
||||
next.stepRunId <= current.stepRunId)))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Workflow Task Attempt continuation did not advance',
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the existing Cluster Scheduler cadence with Workflow frontier and
|
||||
* Task Attempt admission. It owns no timer, connection, watcher, or
|
||||
* per-Workflow state.
|
||||
*/
|
||||
export class ClusterWorkflowSchedulerCoordinator {
|
||||
private readonly frontierPageSize: number;
|
||||
private readonly frontierMaxPages: number;
|
||||
private readonly taskAttemptPageSize: number;
|
||||
private readonly taskAttemptMaxPages: number;
|
||||
private inFlight: Promise<ClusterSchedulerCycleSummary> | undefined;
|
||||
private latestWorkflow:
|
||||
| Readonly<ClusterWorkflowSchedulerCycleSummary>
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly scheduler: Pick<
|
||||
ClusterSchedulerCoordinator,
|
||||
'scheduleOnce'
|
||||
>,
|
||||
private readonly frontier: PluginPackageWorkflowFrontierRepository,
|
||||
private readonly taskAttempts: PluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
options: ClusterWorkflowSchedulerOptions,
|
||||
) {
|
||||
if (
|
||||
typeof scheduler?.scheduleOnce !== 'function' ||
|
||||
typeof frontier?.listCandidates !== 'function' ||
|
||||
typeof frontier?.advance !== 'function' ||
|
||||
typeof taskAttempts?.listCandidates !== 'function' ||
|
||||
typeof taskAttempts?.admit !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options)
|
||||
) {
|
||||
throw new TypeError('Cluster Workflow scheduler is invalid');
|
||||
}
|
||||
this.frontierPageSize = bounded(
|
||||
'Cluster Workflow frontier page size',
|
||||
options.frontierPageSize,
|
||||
64,
|
||||
);
|
||||
this.frontierMaxPages = bounded(
|
||||
'Cluster Workflow frontier page limit',
|
||||
options.frontierMaxPages,
|
||||
16,
|
||||
);
|
||||
this.taskAttemptPageSize = bounded(
|
||||
'Cluster Workflow Task Attempt page size',
|
||||
options.taskAttemptPageSize,
|
||||
64,
|
||||
);
|
||||
this.taskAttemptMaxPages = bounded(
|
||||
'Cluster Workflow Task Attempt page limit',
|
||||
options.taskAttemptMaxPages,
|
||||
16,
|
||||
);
|
||||
}
|
||||
|
||||
scheduleOnce(): Promise<ClusterSchedulerCycleSummary> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.runCycle().finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
latestWorkflowSummary():
|
||||
| Readonly<ClusterWorkflowSchedulerCycleSummary>
|
||||
| undefined {
|
||||
return this.latestWorkflow;
|
||||
}
|
||||
|
||||
private async runCycle(): Promise<ClusterSchedulerCycleSummary> {
|
||||
const scheduler = await this.scheduler.scheduleOnce();
|
||||
const frontier = await this.advanceFrontier();
|
||||
const taskAttempts = await this.admitTaskAttempts();
|
||||
this.latestWorkflow = Object.freeze({
|
||||
...frontier,
|
||||
...taskAttempts,
|
||||
});
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
private async advanceFrontier(): Promise<Readonly<{
|
||||
frontierPages: number;
|
||||
frontierScanned: number;
|
||||
frontierAdvanced: number;
|
||||
frontierTruncated: boolean;
|
||||
}>> {
|
||||
let frontierPages = 0;
|
||||
let frontierScanned = 0;
|
||||
let frontierAdvanced = 0;
|
||||
let frontierTruncated = false;
|
||||
let after: PluginPackageWorkflowFrontierCursor | undefined;
|
||||
for (let index = 0; index < this.frontierMaxPages; index += 1) {
|
||||
const page = await this.frontier.listCandidates({
|
||||
limit: this.frontierPageSize,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
if (page.candidates.length > this.frontierPageSize) {
|
||||
throw new RangeError(
|
||||
'Cluster Workflow frontier exceeded its page size',
|
||||
);
|
||||
}
|
||||
frontierPages += 1;
|
||||
frontierScanned += page.candidates.length;
|
||||
for (const candidate of page.candidates) {
|
||||
await this.frontier.advance(candidate.runId);
|
||||
frontierAdvanced += 1;
|
||||
}
|
||||
frontierTruncated = page.truncated;
|
||||
if (!page.truncated) break;
|
||||
after = nextFrontierCursor(after, page.next);
|
||||
}
|
||||
return Object.freeze({
|
||||
frontierPages,
|
||||
frontierScanned,
|
||||
frontierAdvanced,
|
||||
frontierTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
private async admitTaskAttempts(): Promise<Readonly<{
|
||||
taskAttemptPages: number;
|
||||
taskAttemptsScanned: number;
|
||||
taskAttemptsCreated: number;
|
||||
taskAttemptsExisting: number;
|
||||
taskAttemptsTruncated: boolean;
|
||||
}>> {
|
||||
let taskAttemptPages = 0;
|
||||
let taskAttemptsScanned = 0;
|
||||
let taskAttemptsCreated = 0;
|
||||
let taskAttemptsExisting = 0;
|
||||
let taskAttemptsTruncated = false;
|
||||
let after:
|
||||
| PluginPackageWorkflowTaskAttemptAdmissionCursor
|
||||
| undefined;
|
||||
for (let index = 0; index < this.taskAttemptMaxPages; index += 1) {
|
||||
const page = await this.taskAttempts.listCandidates({
|
||||
limit: this.taskAttemptPageSize,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
if (page.candidates.length > this.taskAttemptPageSize) {
|
||||
throw new RangeError(
|
||||
'Cluster Workflow Task Attempt source exceeded its page size',
|
||||
);
|
||||
}
|
||||
taskAttemptPages += 1;
|
||||
taskAttemptsScanned += page.candidates.length;
|
||||
for (const candidate of page.candidates) {
|
||||
const admitted = await this.taskAttempts.admit(
|
||||
candidate.runId,
|
||||
candidate.stepRunId,
|
||||
);
|
||||
if (admitted.status === 'created') taskAttemptsCreated += 1;
|
||||
else taskAttemptsExisting += 1;
|
||||
}
|
||||
taskAttemptsTruncated = page.truncated;
|
||||
if (!page.truncated) break;
|
||||
after = nextTaskAttemptCursor(after, page.next);
|
||||
}
|
||||
return Object.freeze({
|
||||
taskAttemptPages,
|
||||
taskAttemptsScanned,
|
||||
taskAttemptsCreated,
|
||||
taskAttemptsExisting,
|
||||
taskAttemptsTruncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
BoundedTaskListProjectionUnavailableError,
|
||||
InvalidBoundedTaskListProjectionError,
|
||||
executeBoundedTaskListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_TASK_LIST_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/tasks',
|
||||
operationId: 'task.list',
|
||||
permission: 'task.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze(['after_task_id', 'limit']),
|
||||
});
|
||||
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{
|
||||
limit?: number;
|
||||
after?: Readonly<{ taskId: string }>;
|
||||
}> {
|
||||
const limitValues = query.limit;
|
||||
const taskIdValues = query.after_task_id;
|
||||
if (
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(taskIdValues !== undefined && taskIdValues.length !== 1)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawLimit = limitValues?.[0];
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
const taskId = taskIdValues?.[0];
|
||||
if (
|
||||
(rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit)) ||
|
||||
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(taskId === undefined
|
||||
? {}
|
||||
: { after: Object.freeze({ taskId }) }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateTaskListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): void {
|
||||
parseQuery(query);
|
||||
}
|
||||
|
||||
export function createClusterControlTaskListRoute(
|
||||
tasks: Pick<TaskDefinitionSource, 'listTaskDefinitions'>,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!tasks || typeof tasks.listTaskDefinitions !== 'function') {
|
||||
throw new TypeError('Cluster-control Task list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_TASK_LIST_ROUTE,
|
||||
validateQuery: validateTaskListQuery,
|
||||
async handle(authorized: ClusterControlAuthorizedOperationRequest) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'task_list_unavailable' });
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = parseQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_task_list_query' });
|
||||
}
|
||||
try {
|
||||
const result = await executeBoundedTaskListProjection(
|
||||
tasks,
|
||||
authorized.projectId,
|
||||
input,
|
||||
);
|
||||
return response(200, { ...result });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedTaskListProjectionError ||
|
||||
error instanceof BoundedTaskListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'task_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export * from './taskReadRoute';
|
||||
export * from './taskStartRoute';
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
BoundedTaskReadProjectionUnavailableError,
|
||||
InvalidBoundedTaskReadProjectionError,
|
||||
executeBoundedTaskReadProjection,
|
||||
} from '@qinglong/runtime-core/bounded-task-read-projection';
|
||||
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_TASK_READ_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/tasks/{taskId}',
|
||||
operationId: 'task.get',
|
||||
permission: 'task.read',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createClusterControlTaskReadRoute(
|
||||
tasks: Pick<TaskDefinitionSource, 'findCurrentTaskDefinition'>,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!tasks || typeof tasks.findCurrentTaskDefinition !== 'function') {
|
||||
throw new TypeError('Cluster-control Task read repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_TASK_READ_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'task_query_unavailable' });
|
||||
}
|
||||
try {
|
||||
const projection = await executeBoundedTaskReadProjection(
|
||||
tasks,
|
||||
authorized.projectId,
|
||||
parameters.taskId!,
|
||||
);
|
||||
if (projection.found !== true) {
|
||||
return response(404, { code: 'task_not_found' });
|
||||
}
|
||||
const { found: _found, ...task } = projection;
|
||||
return response(200, { task: Object.freeze(task) });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedTaskReadProjectionError ||
|
||||
error instanceof BoundedTaskReadProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'task_query_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
TASK_START_SCHEMA,
|
||||
InvalidTaskStartError,
|
||||
TaskStartFenceRejectedError,
|
||||
TaskStartNotFoundError,
|
||||
TaskStartUnavailableError,
|
||||
createTaskStartResponseBody,
|
||||
parseTaskStartRequestBody,
|
||||
type TaskStartRepository,
|
||||
} from '@qinglong/runtime-core/task-start';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_TASK_START_ROUTE = Object.freeze({
|
||||
method: 'POST' as const,
|
||||
path: '/api/v3/projects/{projectId}/tasks/{taskId}/runs',
|
||||
operationId: 'task.start',
|
||||
permission: 'run.start',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
export type ClusterTaskStartIdFactory = () => string;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createClusterControlTaskStartRoute(
|
||||
repository: TaskStartRepository,
|
||||
createId: ClusterTaskStartIdFactory,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.startTask !== 'function' ||
|
||||
typeof createId !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Task start route is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_TASK_START_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
let body;
|
||||
try {
|
||||
body = parseTaskStartRequestBody(authorized.request.body);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTaskStartError) {
|
||||
return response(400, {
|
||||
code: 'invalid_task_start_request',
|
||||
schema: TASK_START_SCHEMA,
|
||||
});
|
||||
}
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
const projectId = authorized.projectId;
|
||||
const taskId = parameters.taskId;
|
||||
if (
|
||||
projectId === null ||
|
||||
typeof taskId !== 'string' ||
|
||||
taskId.length < 1 ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null
|
||||
) {
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await repository.startTask({
|
||||
projectId,
|
||||
taskId,
|
||||
mutationId: body.mutationId,
|
||||
expectedRevision: body.expectedRevision,
|
||||
expectedContentDigest: body.expectedContentDigest,
|
||||
runId: createId(),
|
||||
attemptId: createId(),
|
||||
createdEventId: createId(),
|
||||
queuedEventId: createId(),
|
||||
subject: authorized.principal.subject,
|
||||
policyFence: authorized.policyFence,
|
||||
});
|
||||
return response(
|
||||
result.status === 'accepted' ? 202 : 200,
|
||||
createTaskStartResponseBody(result),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TaskStartNotFoundError) {
|
||||
return response(404, { code: 'task_not_found' });
|
||||
}
|
||||
if (error instanceof TaskStartFenceRejectedError) {
|
||||
return response(409, {
|
||||
code: 'task_start_fence_rejected',
|
||||
reason: error.reason,
|
||||
});
|
||||
}
|
||||
if (
|
||||
error instanceof InvalidTaskStartError ||
|
||||
error instanceof TaskStartUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// Transport owns authenticated, Policy-fenced and synchronously audited admission.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
normalizeSecurityPolicyDecision,
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditOutcome,
|
||||
type SecurityAuditRecord,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
normalizeProjectPermission,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
ClusterControlAdmissionMetadata,
|
||||
ClusterControlAdmissionPipeline,
|
||||
} from './httpSurface';
|
||||
import {
|
||||
ClusterControlRouteResolutionError,
|
||||
isClusterControlRouteRegistry,
|
||||
type ClusterControlAuthorizedOperationRequest,
|
||||
type ClusterControlRoute,
|
||||
type ClusterControlRouteRegistry,
|
||||
} from './routeRegistry';
|
||||
|
||||
export type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRoute,
|
||||
ClusterControlRouteRegistry,
|
||||
} from './routeRegistry';
|
||||
|
||||
export interface ClusterControlRequestAuthenticator {
|
||||
authenticate(
|
||||
request: ClusterControlAdmissionMetadata,
|
||||
): SecurityPrincipal | null | Promise<SecurityPrincipal | null>;
|
||||
}
|
||||
|
||||
export interface ClusterControlPolicyRequest {
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly operationId: string;
|
||||
readonly permission: string;
|
||||
readonly projectId: string | null;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ClusterControlPolicyAuthorizer {
|
||||
authorize(
|
||||
request: ClusterControlPolicyRequest,
|
||||
): SecurityPolicyDecision | Promise<SecurityPolicyDecision>;
|
||||
}
|
||||
|
||||
export type ClusterControlSecurityAuditOutcome = SecurityAuditOutcome;
|
||||
export type ClusterControlSecurityAuditRecord = SecurityAuditRecord;
|
||||
export type ClusterControlSecurityAuditSink = SecurityAuditSink;
|
||||
|
||||
export interface ClusterControlAdmissionPipelineOptions {
|
||||
readonly routes: ClusterControlRouteRegistry;
|
||||
readonly authenticator: ClusterControlRequestAuthenticator;
|
||||
readonly policy: ClusterControlPolicyAuthorizer;
|
||||
readonly audit: ClusterControlSecurityAuditSink;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterControlAdmissionSecurityError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ClusterControlAdmissionSecurityError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapts the shared Project Policy engine to cluster admission. */
|
||||
export function createClusterControlProjectPolicyAuthorizer(
|
||||
repository: ProjectPolicyRepository,
|
||||
): ClusterControlPolicyAuthorizer {
|
||||
const engine = new ProjectPolicyEngine(repository);
|
||||
return Object.freeze({
|
||||
authorize(request: ClusterControlPolicyRequest) {
|
||||
if (request.projectId === null) {
|
||||
return Object.freeze({
|
||||
effect: 'deny' as const,
|
||||
reasons: Object.freeze(['project_scope_required']),
|
||||
fence: null,
|
||||
});
|
||||
}
|
||||
return engine.authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
normalizeProjectPermission(request.permission),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function securityError(
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
): ClusterControlAdmissionSecurityError {
|
||||
return new ClusterControlAdmissionSecurityError(statusCode, code, message);
|
||||
}
|
||||
|
||||
async function recordSecurityAudit(
|
||||
audit: ClusterControlSecurityAuditSink,
|
||||
record: Omit<SecurityAuditRecord, 'eventId' | 'occurredAtMs'>,
|
||||
now: () => number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await audit.record(
|
||||
normalizeSecurityAuditRecord({
|
||||
...record,
|
||||
eventId: randomUUID(),
|
||||
occurredAtMs: now(),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw securityError(
|
||||
503,
|
||||
'security_audit_unavailable',
|
||||
'Cluster-control security audit is unavailable',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fail-closed, two-phase admission pipeline. Route matching,
|
||||
* authentication, policy evaluation and durable security audit all complete
|
||||
* before the returned operation is allowed to receive a request body.
|
||||
*/
|
||||
export function createClusterControlAdmissionPipeline(
|
||||
options: ClusterControlAdmissionPipelineOptions,
|
||||
): ClusterControlAdmissionPipeline {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
!isClusterControlRouteRegistry(options.routes) ||
|
||||
typeof options.authenticator?.authenticate !== 'function' ||
|
||||
typeof options.policy?.authorize !== 'function' ||
|
||||
typeof options.audit?.record !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster-control admission pipeline options are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return Object.freeze({
|
||||
async prepare(request: ClusterControlAdmissionMetadata) {
|
||||
let route: ClusterControlRoute;
|
||||
try {
|
||||
const resolved = await options.routes.resolve(request);
|
||||
if (!resolved) {
|
||||
throw securityError(
|
||||
404,
|
||||
'route_not_found',
|
||||
'Cluster-control route was not found',
|
||||
);
|
||||
}
|
||||
route = resolved;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ClusterControlAdmissionSecurityError ||
|
||||
error instanceof ClusterControlRouteResolutionError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw securityError(
|
||||
503,
|
||||
'route_resolution_unavailable',
|
||||
'Cluster-control route resolution is unavailable',
|
||||
);
|
||||
}
|
||||
|
||||
let candidate: SecurityPrincipal | null;
|
||||
try {
|
||||
candidate = await options.authenticator.authenticate(request);
|
||||
} catch {
|
||||
await recordSecurityAudit(
|
||||
options.audit,
|
||||
{
|
||||
requestId: request.requestId,
|
||||
operationId: route.operationId,
|
||||
projectId: route.projectId,
|
||||
subject: null,
|
||||
authenticationId: null,
|
||||
outcome: 'authentication_unavailable',
|
||||
reasons: Object.freeze(['authentication_unavailable']),
|
||||
fence: null,
|
||||
},
|
||||
now,
|
||||
);
|
||||
throw securityError(
|
||||
503,
|
||||
'authentication_unavailable',
|
||||
'Cluster-control authentication is unavailable',
|
||||
);
|
||||
}
|
||||
if (!candidate) {
|
||||
await recordSecurityAudit(
|
||||
options.audit,
|
||||
{
|
||||
requestId: request.requestId,
|
||||
operationId: route.operationId,
|
||||
projectId: route.projectId,
|
||||
subject: null,
|
||||
authenticationId: null,
|
||||
outcome: 'authentication_rejected',
|
||||
reasons: Object.freeze(['authentication_rejected']),
|
||||
fence: null,
|
||||
},
|
||||
now,
|
||||
);
|
||||
throw securityError(
|
||||
401,
|
||||
'authentication_required',
|
||||
'Cluster-control authentication is required',
|
||||
);
|
||||
}
|
||||
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(candidate, now());
|
||||
} catch {
|
||||
await recordSecurityAudit(
|
||||
options.audit,
|
||||
{
|
||||
requestId: request.requestId,
|
||||
operationId: route.operationId,
|
||||
projectId: route.projectId,
|
||||
subject: null,
|
||||
authenticationId: null,
|
||||
outcome: 'authentication_unavailable',
|
||||
reasons: Object.freeze(['invalid_principal']),
|
||||
fence: null,
|
||||
},
|
||||
now,
|
||||
);
|
||||
throw securityError(
|
||||
503,
|
||||
'authentication_unavailable',
|
||||
'Cluster-control authentication is unavailable',
|
||||
);
|
||||
}
|
||||
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = normalizeSecurityPolicyDecision(
|
||||
await options.policy.authorize(
|
||||
Object.freeze({
|
||||
principal,
|
||||
operationId: route.operationId,
|
||||
permission: route.permission,
|
||||
projectId: route.projectId,
|
||||
signal: request.signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
await recordSecurityAudit(
|
||||
options.audit,
|
||||
{
|
||||
requestId: request.requestId,
|
||||
operationId: route.operationId,
|
||||
projectId: route.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'authorization_unavailable',
|
||||
reasons: Object.freeze(['authorization_unavailable']),
|
||||
fence: null,
|
||||
},
|
||||
now,
|
||||
);
|
||||
throw securityError(
|
||||
503,
|
||||
'authorization_unavailable',
|
||||
'Cluster-control authorization is unavailable',
|
||||
);
|
||||
}
|
||||
|
||||
const outcome =
|
||||
decision.effect === 'allow'
|
||||
? 'allowed'
|
||||
: decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied';
|
||||
await recordSecurityAudit(
|
||||
options.audit,
|
||||
{
|
||||
requestId: request.requestId,
|
||||
operationId: route.operationId,
|
||||
projectId: route.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome,
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
},
|
||||
now,
|
||||
);
|
||||
if (decision.effect === 'deny') {
|
||||
throw securityError(
|
||||
403,
|
||||
'forbidden',
|
||||
'Cluster-control operation is forbidden',
|
||||
);
|
||||
}
|
||||
if (decision.effect === 'require_approval') {
|
||||
throw securityError(
|
||||
403,
|
||||
'approval_required',
|
||||
'Cluster-control operation requires approval',
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
handle(body: unknown | null) {
|
||||
return route.handle(
|
||||
Object.freeze({
|
||||
request: Object.freeze({ ...request, body }),
|
||||
principal,
|
||||
operationId: route.operationId,
|
||||
permission: route.permission,
|
||||
projectId: route.projectId,
|
||||
policyFence: decision.fence,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
||||
// Transport owns bounded route compilation, resolution and query validation.
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type {
|
||||
ClusterControlAdmissionMetadata,
|
||||
ClusterControlAdmissionRequest,
|
||||
ClusterControlAdmissionResponse,
|
||||
ClusterControlHttpMethod,
|
||||
} from './httpSurface';
|
||||
|
||||
export const CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS = Object.freeze({
|
||||
maxRoutes: 256,
|
||||
maxPathBytes: 1024,
|
||||
maxPathSegments: 16,
|
||||
maxPathParameters: 8,
|
||||
maxQueryParameters: 16,
|
||||
maxQueryValuesPerParameter: 16,
|
||||
maxQueryValueBytes: 1024,
|
||||
});
|
||||
|
||||
export type ClusterControlRouteParameters = Readonly<Record<string, string>>;
|
||||
|
||||
export interface ClusterControlAuthorizedOperationRequest {
|
||||
readonly request: ClusterControlAdmissionRequest;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly operationId: string;
|
||||
readonly permission: string;
|
||||
readonly projectId: string | null;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence> | null;
|
||||
}
|
||||
|
||||
export interface ClusterControlRouteDefinition {
|
||||
readonly method: ClusterControlHttpMethod;
|
||||
readonly path: string;
|
||||
readonly operationId: string;
|
||||
readonly permission: string;
|
||||
readonly projectParameter: string | null;
|
||||
readonly allowedQuery?: readonly string[];
|
||||
readonly validateQuery?: (
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
) => void;
|
||||
handle(
|
||||
request: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
): ClusterControlAdmissionResponse | Promise<ClusterControlAdmissionResponse>;
|
||||
}
|
||||
|
||||
export interface ClusterControlRoute {
|
||||
readonly operationId: string;
|
||||
readonly permission: string;
|
||||
readonly projectId: string | null;
|
||||
handle(
|
||||
request: ClusterControlAuthorizedOperationRequest,
|
||||
): ClusterControlAdmissionResponse | Promise<ClusterControlAdmissionResponse>;
|
||||
}
|
||||
|
||||
export interface ClusterControlRouteResolver {
|
||||
resolve(request: ClusterControlAdmissionMetadata): ClusterControlRoute | null;
|
||||
}
|
||||
|
||||
export interface ClusterControlRouteRegistry
|
||||
extends ClusterControlRouteResolver {
|
||||
readonly contractVersion: 1;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export class ClusterControlRouteRegistryConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Cluster-control route registry is invalid: ${message}`);
|
||||
this.name = 'ClusterControlRouteRegistryConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterControlRouteResolutionError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: 400,
|
||||
readonly code: 'invalid_route_path' | 'invalid_route_query',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ClusterControlRouteResolutionError';
|
||||
}
|
||||
}
|
||||
|
||||
type CompiledSegment =
|
||||
| { readonly kind: 'literal'; readonly value: string }
|
||||
| { readonly kind: 'parameter'; readonly name: string };
|
||||
|
||||
interface CompiledRoute {
|
||||
readonly method: ClusterControlHttpMethod;
|
||||
readonly operationId: string;
|
||||
readonly permission: string;
|
||||
readonly projectParameter: string | null;
|
||||
readonly segments: readonly CompiledSegment[];
|
||||
readonly allowedQuery: ReadonlySet<string>;
|
||||
readonly validateQuery?: ClusterControlRouteDefinition['validateQuery'];
|
||||
readonly handle: ClusterControlRouteDefinition['handle'];
|
||||
}
|
||||
|
||||
const HTTP_METHODS = new Set<ClusterControlHttpMethod>([
|
||||
'DELETE',
|
||||
'GET',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'PUT',
|
||||
]);
|
||||
const OPERATION_PATTERN = /^[a-z][a-z0-9_.:-]{0,127}$/;
|
||||
const PERMISSION_PATTERN = /^[a-z][a-z0-9_.:*:-]{0,127}$/;
|
||||
const LITERAL_SEGMENT_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const PARAMETER_NAME_PATTERN = /^[a-z][A-Za-z0-9]{0,63}$/;
|
||||
const PARAMETER_SEGMENT_PATTERN = /^\{([a-z][A-Za-z0-9]{0,63})\}$/;
|
||||
const PARAMETER_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const QUERY_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const DEFINITION_KEYS = new Set([
|
||||
'allowedQuery',
|
||||
'handle',
|
||||
'method',
|
||||
'operationId',
|
||||
'path',
|
||||
'permission',
|
||||
'projectParameter',
|
||||
'validateQuery',
|
||||
]);
|
||||
const reviewedRegistries = new WeakSet<object>();
|
||||
|
||||
function configurationError(
|
||||
message: string,
|
||||
): ClusterControlRouteRegistryConfigurationError {
|
||||
return new ClusterControlRouteRegistryConfigurationError(message);
|
||||
}
|
||||
|
||||
function exactDefinitionShape(value: ClusterControlRouteDefinition): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationError('each route must be an object');
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
keys.some((key) => !DEFINITION_KEYS.has(key)) ||
|
||||
!keys.includes('method') ||
|
||||
!keys.includes('path') ||
|
||||
!keys.includes('operationId') ||
|
||||
!keys.includes('permission') ||
|
||||
!keys.includes('projectParameter') ||
|
||||
!keys.includes('handle')
|
||||
) {
|
||||
throw configurationError('route shape is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function compilePath(path: string): readonly CompiledSegment[] {
|
||||
if (
|
||||
typeof path !== 'string' ||
|
||||
Buffer.byteLength(path) >
|
||||
CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxPathBytes ||
|
||||
!path.startsWith('/api/v3/') ||
|
||||
path.endsWith('/') ||
|
||||
path.includes('//') ||
|
||||
path.includes('%') ||
|
||||
path.includes('\\') ||
|
||||
CONTROL_CHARACTER_PATTERN.test(path)
|
||||
) {
|
||||
throw configurationError('route path must be a canonical /api/v3 path');
|
||||
}
|
||||
const rawSegments = path.slice(1).split('/');
|
||||
if (
|
||||
rawSegments.length > CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxPathSegments
|
||||
) {
|
||||
throw configurationError('route path has too many segments');
|
||||
}
|
||||
const parameterNames = new Set<string>();
|
||||
const compiled = rawSegments.map((segment): CompiledSegment => {
|
||||
const parameter = PARAMETER_SEGMENT_PATTERN.exec(segment)?.[1];
|
||||
if (parameter) {
|
||||
if (parameterNames.has(parameter)) {
|
||||
throw configurationError('route path repeats a parameter');
|
||||
}
|
||||
parameterNames.add(parameter);
|
||||
return Object.freeze({ kind: 'parameter', name: parameter });
|
||||
}
|
||||
if (!LITERAL_SEGMENT_PATTERN.test(segment)) {
|
||||
throw configurationError('route path contains an invalid segment');
|
||||
}
|
||||
return Object.freeze({ kind: 'literal', value: segment });
|
||||
});
|
||||
if (
|
||||
parameterNames.size >
|
||||
CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxPathParameters
|
||||
) {
|
||||
throw configurationError('route path has too many parameters');
|
||||
}
|
||||
return Object.freeze(compiled);
|
||||
}
|
||||
|
||||
function compileAllowedQuery(
|
||||
value: readonly string[] | undefined,
|
||||
): ReadonlySet<string> {
|
||||
if (value === undefined) return new Set<string>();
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxQueryParameters
|
||||
) {
|
||||
throw configurationError('allowedQuery is invalid');
|
||||
}
|
||||
const names = new Set<string>();
|
||||
for (const name of value) {
|
||||
if (!QUERY_NAME_PATTERN.test(name) || names.has(name)) {
|
||||
throw configurationError('allowedQuery contains an invalid name');
|
||||
}
|
||||
names.add(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function compileRoute(
|
||||
definition: ClusterControlRouteDefinition,
|
||||
): CompiledRoute {
|
||||
exactDefinitionShape(definition);
|
||||
if (!HTTP_METHODS.has(definition.method)) {
|
||||
throw configurationError('route method is invalid');
|
||||
}
|
||||
if (!OPERATION_PATTERN.test(definition.operationId)) {
|
||||
throw configurationError('route operationId is invalid');
|
||||
}
|
||||
if (!PERMISSION_PATTERN.test(definition.permission)) {
|
||||
throw configurationError('route permission is invalid');
|
||||
}
|
||||
if (typeof definition.handle !== 'function') {
|
||||
throw configurationError('route handler is invalid');
|
||||
}
|
||||
if (
|
||||
definition.validateQuery !== undefined &&
|
||||
typeof definition.validateQuery !== 'function'
|
||||
) {
|
||||
throw configurationError('route query validator is invalid');
|
||||
}
|
||||
const segments = compilePath(definition.path);
|
||||
const parameterNames = new Set(
|
||||
segments.flatMap((segment) =>
|
||||
segment.kind === 'parameter' ? [segment.name] : [],
|
||||
),
|
||||
);
|
||||
if (
|
||||
definition.projectParameter !== null &&
|
||||
(!PARAMETER_NAME_PATTERN.test(definition.projectParameter) ||
|
||||
!parameterNames.has(definition.projectParameter))
|
||||
) {
|
||||
throw configurationError(
|
||||
'projectParameter must name one declared path parameter',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
method: definition.method,
|
||||
operationId: definition.operationId,
|
||||
permission: definition.permission,
|
||||
projectParameter: definition.projectParameter,
|
||||
segments,
|
||||
allowedQuery: compileAllowedQuery(definition.allowedQuery),
|
||||
...(definition.validateQuery === undefined
|
||||
? {}
|
||||
: { validateQuery: definition.validateQuery }),
|
||||
handle: definition.handle,
|
||||
});
|
||||
}
|
||||
|
||||
function routesOverlap(left: CompiledRoute, right: CompiledRoute): boolean {
|
||||
if (
|
||||
left.method !== right.method ||
|
||||
left.segments.length !== right.segments.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return left.segments.every((segment, index) => {
|
||||
const other = right.segments[index]!;
|
||||
return (
|
||||
segment.kind === 'parameter' ||
|
||||
other.kind === 'parameter' ||
|
||||
segment.value === other.value
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function validateRequestPath(path: string): readonly string[] {
|
||||
if (
|
||||
typeof path !== 'string' ||
|
||||
Buffer.byteLength(path) >
|
||||
CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxPathBytes ||
|
||||
!(path === '/api/v3' || path.startsWith('/api/v3/')) ||
|
||||
path.endsWith('/') ||
|
||||
path.includes('//') ||
|
||||
path.includes('%') ||
|
||||
path.includes('\\') ||
|
||||
CONTROL_CHARACTER_PATTERN.test(path)
|
||||
) {
|
||||
throw new ClusterControlRouteResolutionError(
|
||||
400,
|
||||
'invalid_route_path',
|
||||
'Cluster-control route path is invalid',
|
||||
);
|
||||
}
|
||||
const segments = path.slice(1).split('/');
|
||||
if (
|
||||
segments.length > CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxPathSegments ||
|
||||
segments.some((segment) => !PARAMETER_VALUE_PATTERN.test(segment))
|
||||
) {
|
||||
throw new ClusterControlRouteResolutionError(
|
||||
400,
|
||||
'invalid_route_path',
|
||||
'Cluster-control route path is invalid',
|
||||
);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function validateQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
allowed: ReadonlySet<string>,
|
||||
): void {
|
||||
if (!query || typeof query !== 'object' || Array.isArray(query)) {
|
||||
throw new ClusterControlRouteResolutionError(
|
||||
400,
|
||||
'invalid_route_query',
|
||||
'Cluster-control route query is invalid',
|
||||
);
|
||||
}
|
||||
const names = Object.keys(query);
|
||||
for (const name of names) {
|
||||
const values = query[name];
|
||||
if (
|
||||
!allowed.has(name) ||
|
||||
!Array.isArray(values) ||
|
||||
values.length === 0 ||
|
||||
values.length >
|
||||
CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxQueryValuesPerParameter ||
|
||||
values.some(
|
||||
(value) =>
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value) >
|
||||
CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxQueryValueBytes ||
|
||||
CONTROL_CHARACTER_PATTERN.test(value),
|
||||
)
|
||||
) {
|
||||
throw new ClusterControlRouteResolutionError(
|
||||
400,
|
||||
'invalid_route_query',
|
||||
'Cluster-control route query is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchRoute(
|
||||
route: CompiledRoute,
|
||||
method: ClusterControlHttpMethod,
|
||||
segments: readonly string[],
|
||||
): ClusterControlRouteParameters | null {
|
||||
if (route.method !== method || route.segments.length !== segments.length) {
|
||||
return null;
|
||||
}
|
||||
const parameters = Object.create(null) as Record<string, string>;
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const definition = route.segments[index]!;
|
||||
const value = segments[index]!;
|
||||
if (definition.kind === 'literal') {
|
||||
if (definition.value !== value) return null;
|
||||
} else {
|
||||
parameters[definition.name] = value;
|
||||
}
|
||||
}
|
||||
return Object.freeze(parameters);
|
||||
}
|
||||
|
||||
/** Returns true only for an object created by the reviewed registry factory. */
|
||||
export function isClusterControlRouteRegistry(
|
||||
value: unknown,
|
||||
): value is ClusterControlRouteRegistry {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
reviewedRegistries.has(value as object)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a bounded, immutable and non-overlapping route table. Route-owned
|
||||
* operation, permission and Project scope are resolved before authentication.
|
||||
*/
|
||||
export function createClusterControlRouteRegistry(
|
||||
definitions: readonly ClusterControlRouteDefinition[],
|
||||
): ClusterControlRouteRegistry {
|
||||
if (
|
||||
!Array.isArray(definitions) ||
|
||||
definitions.length > CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxRoutes
|
||||
) {
|
||||
throw configurationError('definitions must be a bounded array');
|
||||
}
|
||||
const routes = definitions.map(compileRoute);
|
||||
const operationIds = new Set<string>();
|
||||
for (let index = 0; index < routes.length; index += 1) {
|
||||
const route = routes[index]!;
|
||||
if (operationIds.has(route.operationId)) {
|
||||
throw configurationError('operationId must be unique');
|
||||
}
|
||||
operationIds.add(route.operationId);
|
||||
for (let otherIndex = 0; otherIndex < index; otherIndex += 1) {
|
||||
if (routesOverlap(route, routes[otherIndex]!)) {
|
||||
throw configurationError('route definitions overlap');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registry: ClusterControlRouteRegistry = {
|
||||
contractVersion: 1,
|
||||
size: routes.length,
|
||||
resolve(request) {
|
||||
const segments = validateRequestPath(request.path);
|
||||
for (const route of routes) {
|
||||
const parameters = matchRoute(route, request.method, segments);
|
||||
if (!parameters) continue;
|
||||
validateQuery(request.query, route.allowedQuery);
|
||||
if (route.validateQuery) {
|
||||
try {
|
||||
route.validateQuery(request.query);
|
||||
} catch {
|
||||
throw new ClusterControlRouteResolutionError(
|
||||
400,
|
||||
'invalid_route_query',
|
||||
'Cluster-control route query is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const projectId =
|
||||
route.projectParameter === null
|
||||
? null
|
||||
: parameters[route.projectParameter]!;
|
||||
return Object.freeze({
|
||||
operationId: route.operationId,
|
||||
permission: route.permission,
|
||||
projectId,
|
||||
handle(request: ClusterControlAuthorizedOperationRequest) {
|
||||
return route.handle(request, parameters);
|
||||
},
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
reviewedRegistries.add(registry);
|
||||
return Object.freeze(registry);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Cluster Control Worker Ingress boundary; keep production PostgreSQL composition explicit.
|
||||
import {
|
||||
assertPostgresWorkerIngressSchemaReady,
|
||||
PostgresSecurityAuditRepository,
|
||||
PostgresWorkerCredentialRepository,
|
||||
PostgresWorkerExecutionAttestationRepository,
|
||||
PostgresWorkerSessionRepository,
|
||||
} from '@qinglong/cluster-postgres/worker-ingress';
|
||||
import {
|
||||
startClusterWorkerIngressApplication,
|
||||
type ClusterWorkerIngressApplicationResult,
|
||||
} from './workerIngressApplication';
|
||||
import {
|
||||
createClusterWorkerIngressDatabaseOpener,
|
||||
createClusterWorkerIngressHttpOptions,
|
||||
type EnabledClusterWorkerIngressConfig,
|
||||
} from './workerIngressConfig';
|
||||
import {
|
||||
createWorkerCredentialAuthenticator,
|
||||
} from './workerCredentialAuthenticator';
|
||||
import {
|
||||
createWorkerIngressAdmissionPipeline,
|
||||
} from './workerIngressPipeline';
|
||||
import type { ClusterWorkerRuntimePort } from '../remote-execution/workerRuntimePort';
|
||||
|
||||
export interface ProductionClusterWorkerIngressOptions {
|
||||
readonly config: EnabledClusterWorkerIngressConfig;
|
||||
readonly runtime: ClusterWorkerRuntimePort;
|
||||
readonly onPoolError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the reviewed Worker-facing listener. The worker-ingress Pool is used
|
||||
* only for authentication, Session, attestation and audit authority. Every
|
||||
* Run/Attempt/Lease mutation crosses the injected runtime capability port.
|
||||
*/
|
||||
export async function startProductionClusterWorkerIngress(
|
||||
options: ProductionClusterWorkerIngressOptions,
|
||||
): Promise<ClusterWorkerIngressApplicationResult> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
!options.config?.enabled ||
|
||||
!options.runtime
|
||||
) {
|
||||
throw new TypeError('Production Worker ingress options are invalid');
|
||||
}
|
||||
if (
|
||||
options.onPoolError !== undefined &&
|
||||
typeof options.onPoolError !== 'function'
|
||||
) {
|
||||
throw new TypeError('Production Worker ingress Pool error sink is invalid');
|
||||
}
|
||||
const http = await createClusterWorkerIngressHttpOptions(options.config);
|
||||
const openDatabase = createClusterWorkerIngressDatabaseOpener(
|
||||
options.config,
|
||||
(error) => options.onPoolError?.(error),
|
||||
);
|
||||
return startClusterWorkerIngressApplication({
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
workerCredentialPepper:
|
||||
options.config.security.workerCredentialPepper,
|
||||
openDatabase,
|
||||
http,
|
||||
async create({ database, workerCredentialPepper }) {
|
||||
const report = await assertPostgresWorkerIngressSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
return Object.freeze({
|
||||
evidence: Object.freeze({
|
||||
contractName: report.contractName,
|
||||
contractVersion: report.contractVersion,
|
||||
serverMajor: report.serverMajor,
|
||||
migrationIds: Object.freeze([...report.migrationIds]),
|
||||
}),
|
||||
pipeline: createWorkerIngressAdmissionPipeline({
|
||||
authenticator: createWorkerCredentialAuthenticator(
|
||||
new PostgresWorkerCredentialRepository(database.pool),
|
||||
workerCredentialPepper,
|
||||
),
|
||||
workers: new PostgresWorkerSessionRepository(database.pool),
|
||||
attestations: new PostgresWorkerExecutionAttestationRepository(
|
||||
database.pool,
|
||||
),
|
||||
audit: new PostgresSecurityAuditRepository(database.pool),
|
||||
offers: options.runtime.offers,
|
||||
activation: options.runtime.activation,
|
||||
...(options.runtime.secrets === undefined
|
||||
? {}
|
||||
: { secrets: options.runtime.secrets }),
|
||||
artifacts: options.runtime.artifacts,
|
||||
completion: options.runtime.completion,
|
||||
leaseControl: options.runtime.leaseControl,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Cluster Control Worker Ingress boundary; keep Worker credential authentication explicit.
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
WorkerCredentialUnavailableError,
|
||||
normalizeWorkerCredentialRecord,
|
||||
type WorkerCredentialRepository,
|
||||
} from '@qinglong/runtime-core/worker-credential';
|
||||
import {
|
||||
assertWorkerCredentialPepper,
|
||||
workerCredentialSecretDigest,
|
||||
} from '@qinglong/runtime-core/worker-credential-token';
|
||||
import type { ClusterControlAdmissionMetadata } from '../transport/httpSurface';
|
||||
|
||||
export interface AuthenticatedWorkerPrincipal {
|
||||
readonly workerId: string;
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
readonly authenticationId: string;
|
||||
readonly authenticatedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialAuthenticator {
|
||||
authenticate(
|
||||
metadata: ClusterControlAdmissionMetadata,
|
||||
): Promise<Readonly<AuthenticatedWorkerPrincipal> | null>;
|
||||
}
|
||||
|
||||
const AUTHORIZATION =
|
||||
/^Worker ql3w_([A-Za-z0-9][A-Za-z0-9._:-]{0,63})_([A-Za-z0-9_-]{43})$/;
|
||||
|
||||
export function createWorkerCredentialAuthenticator(
|
||||
repository: WorkerCredentialRepository,
|
||||
pepper: string,
|
||||
options: Readonly<{ now?: () => number; principalTtlMs?: number }> = {},
|
||||
): WorkerCredentialAuthenticator {
|
||||
if (!repository || typeof repository.resolve !== 'function') {
|
||||
throw new TypeError('Worker credential authenticator repository is invalid');
|
||||
}
|
||||
assertWorkerCredentialPepper(pepper);
|
||||
const now = options.now ?? Date.now;
|
||||
const principalTtlMs = options.principalTtlMs ?? 60_000;
|
||||
if (
|
||||
!Number.isSafeInteger(principalTtlMs) ||
|
||||
principalTtlMs < 1_000 ||
|
||||
principalTtlMs > 300_000
|
||||
) {
|
||||
throw new RangeError('Worker credential principal TTL is invalid');
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async authenticate(metadata: ClusterControlAdmissionMetadata) {
|
||||
const header = metadata.headers.authorization;
|
||||
if (typeof header !== 'string') return null;
|
||||
const match = AUTHORIZATION.exec(header);
|
||||
if (!match) return null;
|
||||
let presented: Buffer | undefined;
|
||||
try {
|
||||
presented = Buffer.from(
|
||||
workerCredentialSecretDigest(pepper, match[1]!, match[2]!),
|
||||
'hex',
|
||||
);
|
||||
const candidate = await repository.resolve(match[1]!);
|
||||
if (metadata.signal.aborted) throw new WorkerCredentialUnavailableError();
|
||||
const record = candidate ? normalizeWorkerCredentialRecord(candidate) : null;
|
||||
const stored = record
|
||||
? Buffer.from(record.secretDigest, 'hex')
|
||||
: Buffer.alloc(32);
|
||||
const matches = timingSafeEqual(presented, stored);
|
||||
stored.fill(0);
|
||||
if (!record || !matches) return null;
|
||||
const nowMs = now();
|
||||
if (
|
||||
!Number.isSafeInteger(nowMs) ||
|
||||
nowMs < 0 ||
|
||||
record.state !== 'active' ||
|
||||
record.notBeforeAtMs > nowMs ||
|
||||
record.expiresAtMs <= nowMs
|
||||
) return null;
|
||||
return Object.freeze({
|
||||
workerId: record.workerId,
|
||||
credentialId: record.credentialId,
|
||||
credentialVersion: record.version,
|
||||
authenticationId: `worker_credential:${record.credentialId}:${record.version}`,
|
||||
authenticatedAtMs: nowMs,
|
||||
expiresAtMs: Math.min(record.expiresAtMs, nowMs + principalTtlMs),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialUnavailableError) throw error;
|
||||
throw new WorkerCredentialUnavailableError();
|
||||
} finally {
|
||||
presented?.fill(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Cluster Control Worker Ingress boundary; keep listener lifecycle authority explicit.
|
||||
import type {
|
||||
ClusterControlReadinessEvidence,
|
||||
ClusterControlAdmissionDisposer,
|
||||
DeploymentProfile,
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import { assertWorkerCredentialPepper } from '@qinglong/runtime-core/worker-credential-token';
|
||||
import { MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES } from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import {
|
||||
startClusterControlHttpSurface,
|
||||
type ClusterControlAdmissionPipeline,
|
||||
type ClusterControlHttpAddress,
|
||||
type ClusterControlHttpSurfaceOptions,
|
||||
type ClusterControlMutualTlsOptions,
|
||||
} from '../transport/httpSurface';
|
||||
|
||||
export interface ClusterWorkerIngressAssemblyInput {
|
||||
readonly database: PostgresDatabaseResource;
|
||||
readonly workerCredentialPepper: string;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerIngressAssembly {
|
||||
readonly evidence: ClusterControlReadinessEvidence;
|
||||
readonly pipeline: ClusterControlAdmissionPipeline;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerIngressApplicationOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly workerCredentialPepper?: string;
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly http: ClusterControlHttpSurfaceOptions;
|
||||
readonly create: (
|
||||
input: ClusterWorkerIngressAssemblyInput,
|
||||
) => ClusterWorkerIngressAssembly | Promise<ClusterWorkerIngressAssembly>;
|
||||
}
|
||||
|
||||
export type ClusterWorkerIngressApplicationResult =
|
||||
| { readonly status: 'disabled'; stop(): Promise<'stopped'> }
|
||||
| {
|
||||
readonly status: 'active';
|
||||
readonly protocol: 'https';
|
||||
readonly transport: 'mutual-tls';
|
||||
readonly address: ClusterControlHttpAddress;
|
||||
readonly evidence: ClusterControlReadinessEvidence;
|
||||
reloadTransport(options: ClusterControlMutualTlsOptions): number;
|
||||
stop(): Promise<'stopped'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Separate Worker-facing composition root. It owns a dedicated listener and a
|
||||
* worker-ingress database resource. Storage readiness and repositories are
|
||||
* supplied by the outer composition root, so this transport layer cannot
|
||||
* acquire Project Policy, dispatch, recovery-claim or DDL authority itself.
|
||||
*/
|
||||
export async function startClusterWorkerIngressApplication(
|
||||
options: ClusterWorkerIngressApplicationOptions,
|
||||
): Promise<ClusterWorkerIngressApplicationResult> {
|
||||
if (!(options.enabled ?? false)) {
|
||||
return Object.freeze({
|
||||
status: 'disabled',
|
||||
async stop() {
|
||||
return 'stopped' as const;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (options.profile !== 'cluster-control') {
|
||||
throw new TypeError('Worker ingress requires cluster-control profile');
|
||||
}
|
||||
if (typeof options.create !== 'function') {
|
||||
throw new TypeError('Worker ingress assembly factory is required');
|
||||
}
|
||||
if (!options.http?.mutualTls) {
|
||||
throw new TypeError('Worker ingress requires mutual TLS');
|
||||
}
|
||||
assertWorkerCredentialPepper(options.workerCredentialPepper ?? '');
|
||||
const bodyLimit = options.http.maxBodyBytes ?? 64 * 1024;
|
||||
if (
|
||||
!Number.isSafeInteger(bodyLimit) ||
|
||||
bodyLimit < 1024 ||
|
||||
bodyLimit > 64 * 1024
|
||||
) {
|
||||
throw new RangeError(
|
||||
'Worker ingress body limit must be between 1 KiB and 64 KiB',
|
||||
);
|
||||
}
|
||||
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
const http = await startClusterControlHttpSurface({
|
||||
...options.http,
|
||||
maxBodyBytes: bodyLimit,
|
||||
maxResponseBytes: Math.min(
|
||||
options.http.maxResponseBytes ?? MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES,
|
||||
MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES,
|
||||
),
|
||||
maxInFlightRequests: Math.min(options.http.maxInFlightRequests ?? 64, 256),
|
||||
});
|
||||
let disposeAdmission: ClusterControlAdmissionDisposer | undefined;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const assembly = await options.create({
|
||||
database,
|
||||
workerCredentialPepper: options.workerCredentialPepper!,
|
||||
});
|
||||
disposeAdmission = http.installAdmission(
|
||||
assembly.evidence,
|
||||
assembly.pipeline,
|
||||
);
|
||||
let stopPromise: Promise<'stopped'> | undefined;
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
protocol: 'https' as const,
|
||||
transport: 'mutual-tls' as const,
|
||||
address: http.address,
|
||||
evidence: assembly.evidence,
|
||||
reloadTransport(mutualTls: ClusterControlMutualTlsOptions) {
|
||||
return http.reloadMutualTls(mutualTls);
|
||||
},
|
||||
stop() {
|
||||
stopPromise ??= (async () => {
|
||||
let primary: unknown;
|
||||
try {
|
||||
await disposeAdmission?.();
|
||||
} catch (error) {
|
||||
primary = error;
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch (error) {
|
||||
primary ??= error;
|
||||
}
|
||||
try {
|
||||
await http.close();
|
||||
} catch (error) {
|
||||
primary ??= error;
|
||||
}
|
||||
if (primary) throw primary;
|
||||
return 'stopped' as const;
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await disposeAdmission?.();
|
||||
} catch {
|
||||
/* preserve root */
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch {
|
||||
/* preserve root */
|
||||
}
|
||||
try {
|
||||
await http.close();
|
||||
} catch {
|
||||
/* preserve root */
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './workerCredentialAuthenticator';
|
||||
export * from './workerIngressPipeline';
|
||||
export * from '../remote-execution/remoteRunActivationService';
|
||||
export * from '../remote-execution/remoteWorkerSecretDeliveryService';
|
||||
export * from '../remote-execution/remoteWorkerCompletionService';
|
||||
export * from '../remote-execution/remoteWorkerLeaseControlService';
|
||||
@@ -0,0 +1,864 @@
|
||||
// Cluster Control Worker Ingress boundary; keep fail-closed deployment configuration explicit.
|
||||
import {
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
timingSafeEqual,
|
||||
X509Certificate,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import type {
|
||||
DeploymentProfile,
|
||||
OpenPostgresDatabase,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresConnectionEnvironment,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/runtime';
|
||||
import type {
|
||||
ClusterControlHttpSurfaceOptions,
|
||||
ClusterControlMutualTlsOptions,
|
||||
} from '../transport/httpSurface';
|
||||
|
||||
export type ClusterWorkerIngressEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export interface DisabledClusterWorkerIngressConfig {
|
||||
readonly enabled: false;
|
||||
readonly profile: DeploymentProfile;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerArtifactS3Config {
|
||||
readonly bucket: string;
|
||||
readonly region: string;
|
||||
readonly prefix?: string;
|
||||
readonly expectedBucketOwner?: string;
|
||||
readonly endpoint?: string;
|
||||
readonly forcePathStyle: boolean;
|
||||
readonly encryption:
|
||||
| Readonly<{ readonly mode: 's3' }>
|
||||
| Readonly<{ readonly mode: 'kms'; readonly keyId: string }>;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerMountedSecretConfig {
|
||||
readonly provider: 'mounted-files';
|
||||
readonly rootDirectory: string;
|
||||
}
|
||||
|
||||
export interface EnabledClusterWorkerIngressConfig {
|
||||
readonly enabled: true;
|
||||
readonly profile: 'cluster-control';
|
||||
readonly http: Omit<ClusterControlHttpSurfaceOptions, 'mutualTls'>;
|
||||
readonly transport: Readonly<{
|
||||
readonly privateKeyFile: string;
|
||||
readonly certificateFile: string;
|
||||
readonly clientCertificateAuthorityFile: string;
|
||||
readonly clientCertificateRevocationListFile?: string;
|
||||
}>;
|
||||
readonly database: Readonly<{
|
||||
readonly connection: PostgresConnectionOptions;
|
||||
readonly pool: PostgresPoolOptions;
|
||||
}>;
|
||||
readonly security: Readonly<{
|
||||
readonly workerCredentialPepper: string;
|
||||
}>;
|
||||
readonly artifact: Readonly<ClusterWorkerArtifactS3Config>;
|
||||
readonly secret?: Readonly<ClusterWorkerMountedSecretConfig>;
|
||||
}
|
||||
|
||||
export type ClusterWorkerIngressConfig =
|
||||
| DisabledClusterWorkerIngressConfig
|
||||
| EnabledClusterWorkerIngressConfig;
|
||||
|
||||
export class ClusterWorkerIngressConfigError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Worker ingress configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterWorkerIngressConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
const PROFILES = new Set<DeploymentProfile>([
|
||||
'edge',
|
||||
'standalone',
|
||||
'cluster-control',
|
||||
'worker',
|
||||
]);
|
||||
const MAX_TLS_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
name: string,
|
||||
defaultValue: boolean,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw new ClusterWorkerIngressConfigError(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
name: string,
|
||||
defaultValue: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new ClusterWorkerIngressConfigError(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') {
|
||||
if (required) {
|
||||
throw new ClusterWorkerIngressConfigError(`${name} is required`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > maximumLength || /[\0\r\n]/.test(value)) {
|
||||
throw new ClusterWorkerIngressConfigError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function deploymentProfile(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
): DeploymentProfile {
|
||||
const value = environment.QL_DEPLOYMENT_PROFILE ?? 'standalone';
|
||||
if (!PROFILES.has(value as DeploymentProfile)) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL_DEPLOYMENT_PROFILE is invalid',
|
||||
);
|
||||
}
|
||||
return value as DeploymentProfile;
|
||||
}
|
||||
|
||||
function absoluteFile(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
const value = boundedValue(environment, name, 4096, true)!;
|
||||
if (!isAbsolute(value)) {
|
||||
throw new ClusterWorkerIngressConfigError(`${name} must be absolute`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalAbsoluteFile(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = boundedValue(environment, name, 4096);
|
||||
if (value === undefined) return undefined;
|
||||
if (!isAbsolute(value)) {
|
||||
throw new ClusterWorkerIngressConfigError(`${name} must be absolute`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function workerCredentialPepper(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
): string {
|
||||
const value = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_PEPPER',
|
||||
64,
|
||||
true,
|
||||
)!;
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(value)) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_CREDENTIAL_PEPPER must be canonical base64url for 32 bytes',
|
||||
);
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
const canonical =
|
||||
decoded.byteLength === 32 && decoded.toString('base64url') === value;
|
||||
decoded.fill(0);
|
||||
if (!canonical) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_CREDENTIAL_PEPPER must be canonical base64url for 32 bytes',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function databaseConnection(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
): PostgresConnectionOptions {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_WORKER_INGRESS_URL',
|
||||
host: 'QL3_POSTGRES_WORKER_INGRESS_HOST',
|
||||
port: 'QL3_POSTGRES_WORKER_INGRESS_PORT',
|
||||
database: 'QL3_POSTGRES_WORKER_INGRESS_DATABASE',
|
||||
user: 'QL3_POSTGRES_WORKER_INGRESS_USER',
|
||||
password: 'QL3_POSTGRES_WORKER_INGRESS_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Worker ingress connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_WORKER_INGRESS_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_INGRESS_POSTGRES_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_POSTGRES_ALLOW_INSECURE',
|
||||
false,
|
||||
)
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'disabling PostgreSQL TLS requires QL3_WORKER_INGRESS_POSTGRES_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_POSTGRES_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_INGRESS_POSTGRES_TLS_SERVERNAME must be an explicit DNS name for verify-full',
|
||||
);
|
||||
}
|
||||
const certificateAuthorityFile = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_POSTGRES_TLS_CA_FILE',
|
||||
4096,
|
||||
);
|
||||
if (mode === 'disable' && certificateAuthorityFile !== undefined) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_INGRESS_POSTGRES_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let certificateAuthority: string | undefined;
|
||||
if (certificateAuthorityFile !== undefined) {
|
||||
try {
|
||||
certificateAuthority = loadPostgresCertificateAuthorityFile(
|
||||
certificateAuthorityFile,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_INGRESS_POSTGRES_TLS_CA_FILE must contain a bounded trusted CA bundle',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
...(certificateAuthority === undefined
|
||||
? {}
|
||||
: { ca: certificateAuthority }),
|
||||
servername: servername!,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function workerArtifactS3(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
): Readonly<ClusterWorkerArtifactS3Config> {
|
||||
const bucket = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_BUCKET',
|
||||
63,
|
||||
true,
|
||||
)!;
|
||||
if (
|
||||
!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) ||
|
||||
bucket.includes('..') ||
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bucket)
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_BUCKET is invalid',
|
||||
);
|
||||
}
|
||||
const region = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_REGION',
|
||||
63,
|
||||
true,
|
||||
)!;
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(region)) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_REGION is invalid',
|
||||
);
|
||||
}
|
||||
const prefix = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_PREFIX',
|
||||
255,
|
||||
);
|
||||
if (
|
||||
prefix !== undefined &&
|
||||
(
|
||||
!/^[A-Za-z0-9][A-Za-z0-9/_=-]{0,254}$/.test(prefix) ||
|
||||
prefix.startsWith('/') ||
|
||||
prefix.endsWith('/') ||
|
||||
prefix.includes('//') ||
|
||||
prefix.split('/').some((segment) => segment === '.' || segment === '..')
|
||||
)
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_PREFIX is invalid',
|
||||
);
|
||||
}
|
||||
const expectedBucketOwner = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_EXPECTED_BUCKET_OWNER',
|
||||
12,
|
||||
);
|
||||
if (
|
||||
expectedBucketOwner !== undefined &&
|
||||
!/^\d{12}$/.test(expectedBucketOwner)
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_EXPECTED_BUCKET_OWNER must be 12 digits',
|
||||
);
|
||||
}
|
||||
const endpointValue = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_ENDPOINT',
|
||||
2048,
|
||||
);
|
||||
let endpoint: string | undefined;
|
||||
if (endpointValue !== undefined) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(endpointValue);
|
||||
} catch {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_ENDPOINT is invalid',
|
||||
);
|
||||
}
|
||||
const allowInsecure = booleanValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_ALLOW_INSECURE',
|
||||
false,
|
||||
);
|
||||
if (
|
||||
(parsed.protocol !== 'https:' &&
|
||||
!(parsed.protocol === 'http:' && allowInsecure)) ||
|
||||
parsed.username !== '' ||
|
||||
parsed.password !== '' ||
|
||||
parsed.search !== '' ||
|
||||
parsed.hash !== '' ||
|
||||
parsed.pathname !== '/'
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_ENDPOINT must be an origin URL; HTTP requires explicit insecure opt-in',
|
||||
);
|
||||
}
|
||||
endpoint = parsed.origin;
|
||||
}
|
||||
const encryptionMode =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_ENCRYPTION',
|
||||
3,
|
||||
) ?? 's3';
|
||||
if (encryptionMode !== 's3' && encryptionMode !== 'kms') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_ENCRYPTION must be s3 or kms',
|
||||
);
|
||||
}
|
||||
const keyId = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_KMS_KEY_ID',
|
||||
2048,
|
||||
);
|
||||
if (
|
||||
(encryptionMode === 'kms' && keyId === undefined) ||
|
||||
(encryptionMode === 's3' && keyId !== undefined)
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_KMS_KEY_ID must be present exactly for kms encryption',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
bucket,
|
||||
region,
|
||||
...(prefix === undefined ? {} : { prefix }),
|
||||
...(expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { expectedBucketOwner }),
|
||||
...(endpoint === undefined ? {} : { endpoint }),
|
||||
forcePathStyle: booleanValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_FORCE_PATH_STYLE',
|
||||
false,
|
||||
),
|
||||
encryption:
|
||||
encryptionMode === 's3'
|
||||
? Object.freeze({ mode: 's3' as const })
|
||||
: Object.freeze({ mode: 'kms' as const, keyId: keyId! }),
|
||||
});
|
||||
}
|
||||
|
||||
function workerSecret(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
): Readonly<ClusterWorkerMountedSecretConfig> | undefined {
|
||||
const provider = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_PROVIDER',
|
||||
32,
|
||||
);
|
||||
if (provider === undefined || provider === 'disabled') return undefined;
|
||||
if (provider !== 'mounted-files') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_SECRET_PROVIDER must be disabled or mounted-files',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
provider,
|
||||
rootDirectory: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_ROOT_DIRECTORY',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the Profile gate before reading database, Worker secret or TLS file
|
||||
* configuration. Disabled edge/standalone installs therefore remain free of
|
||||
* Worker ingress credential and filesystem requirements.
|
||||
*/
|
||||
export function loadClusterWorkerIngressConfig(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
): ClusterWorkerIngressConfig {
|
||||
if (
|
||||
!environment ||
|
||||
typeof environment !== 'object' ||
|
||||
Array.isArray(environment)
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError('environment must be an object');
|
||||
}
|
||||
const profile = deploymentProfile(environment);
|
||||
const enabled = booleanValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_ENABLED',
|
||||
false,
|
||||
);
|
||||
if (!enabled) return Object.freeze({ enabled: false, profile });
|
||||
if (profile !== 'cluster-control') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'enabled ingress requires QL_DEPLOYMENT_PROFILE=cluster-control',
|
||||
);
|
||||
}
|
||||
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_POSTGRES_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong-worker-ingress';
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(applicationName)) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_INGRESS_POSTGRES_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
boundedValue(environment, 'QL3_WORKER_INGRESS_HOST', 253) ?? '0.0.0.0';
|
||||
const secret = workerSecret(environment);
|
||||
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
http: Object.freeze({
|
||||
host,
|
||||
port: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_PORT',
|
||||
5801,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
maxBodyBytes: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_MAX_BODY_BYTES',
|
||||
64 * 1024,
|
||||
1024,
|
||||
64 * 1024,
|
||||
),
|
||||
maxResponseBytes: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_MAX_RESPONSE_BYTES',
|
||||
64 * 1024,
|
||||
1024,
|
||||
64 * 1024,
|
||||
),
|
||||
maxInFlightRequests: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_MAX_IN_FLIGHT',
|
||||
64,
|
||||
1,
|
||||
256,
|
||||
),
|
||||
authenticationRateWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_AUTH_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
60 * 60_000,
|
||||
),
|
||||
authenticationRatePerPeer: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_AUTH_RATE_PER_PEER',
|
||||
120,
|
||||
1,
|
||||
1_000_000,
|
||||
),
|
||||
authenticationRateGlobal: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_AUTH_RATE_GLOBAL',
|
||||
1_200,
|
||||
1,
|
||||
1_000_000,
|
||||
),
|
||||
authenticationRateMaxPeers: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_AUTH_RATE_MAX_PEERS',
|
||||
4_096,
|
||||
1,
|
||||
65_536,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_REQUEST_TIMEOUT_MS',
|
||||
15_000,
|
||||
100,
|
||||
120_000,
|
||||
),
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_DRAIN_TIMEOUT_MS',
|
||||
10_000,
|
||||
100,
|
||||
120_000,
|
||||
),
|
||||
}),
|
||||
transport: Object.freeze({
|
||||
privateKeyFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_TLS_PRIVATE_KEY_FILE',
|
||||
),
|
||||
certificateFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_TLS_CERTIFICATE_FILE',
|
||||
),
|
||||
clientCertificateAuthorityFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_TLS_CLIENT_CA_FILE',
|
||||
),
|
||||
...(() => {
|
||||
const clientCertificateRevocationListFile = optionalAbsoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_TLS_CLIENT_CRL_FILE',
|
||||
);
|
||||
return clientCertificateRevocationListFile === undefined
|
||||
? {}
|
||||
: { clientCertificateRevocationListFile };
|
||||
})(),
|
||||
}),
|
||||
database: Object.freeze({
|
||||
connection: databaseConnection(environment),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_POSTGRES_MAX_CONNECTIONS',
|
||||
4,
|
||||
1,
|
||||
16,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_INGRESS_POSTGRES_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
security: Object.freeze({
|
||||
workerCredentialPepper: workerCredentialPepper(environment),
|
||||
}),
|
||||
artifact: workerArtifactS3(environment),
|
||||
...(secret === undefined ? {} : { secret }),
|
||||
});
|
||||
}
|
||||
|
||||
async function readTlsFile(
|
||||
path: string,
|
||||
privateMaterial: boolean,
|
||||
): Promise<Buffer> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(path, constants.O_RDONLY);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_TLS_FILE_BYTES ||
|
||||
(privateMaterial && (stat.mode & 0o022) !== 0)
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError('TLS file metadata is unsafe');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > MAX_TLS_FILE_BYTES) {
|
||||
bytes.fill(0);
|
||||
throw new ClusterWorkerIngressConfigError('TLS file size is unsafe');
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterWorkerIngressConfigError) throw error;
|
||||
throw new ClusterWorkerIngressConfigError('TLS material is unavailable');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function activeCertificate(
|
||||
name: string,
|
||||
bytes: Buffer,
|
||||
now: number,
|
||||
): X509Certificate {
|
||||
let certificate: X509Certificate;
|
||||
try {
|
||||
certificate = new X509Certificate(bytes);
|
||||
} catch {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
`${name} is not an X.509 certificate`,
|
||||
);
|
||||
}
|
||||
const validFrom = Date.parse(certificate.validFrom);
|
||||
const validTo = Date.parse(certificate.validTo);
|
||||
if (
|
||||
!Number.isFinite(validFrom) ||
|
||||
!Number.isFinite(validTo) ||
|
||||
now < validFrom ||
|
||||
now >= validTo
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(`${name} is not currently valid`);
|
||||
}
|
||||
return certificate;
|
||||
}
|
||||
|
||||
function activeCertificateAuthorities(
|
||||
bytes: Buffer,
|
||||
now: number,
|
||||
): readonly Buffer[] {
|
||||
const pem = bytes.toString('utf8');
|
||||
const matches = pem.match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
|
||||
);
|
||||
if (!matches || matches.length < 1 || matches.length > 16) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'TLS client certificate authority bundle must contain 1 to 16 PEM certificates',
|
||||
);
|
||||
}
|
||||
const remainder = matches.reduce(
|
||||
(value, certificate) => value.replace(certificate, ''),
|
||||
pem,
|
||||
);
|
||||
if (remainder.trim() !== '') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'TLS client certificate authority bundle contains unsupported data',
|
||||
);
|
||||
}
|
||||
const authorities: Buffer[] = [];
|
||||
try {
|
||||
for (const match of matches) {
|
||||
const authorityBytes = Buffer.from(`${match}\n`, 'utf8');
|
||||
const authority = activeCertificate(
|
||||
'TLS client certificate authority',
|
||||
authorityBytes,
|
||||
now,
|
||||
);
|
||||
if (!authority.ca) {
|
||||
authorityBytes.fill(0);
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'TLS client certificate authority is not a CA',
|
||||
);
|
||||
}
|
||||
authorities.push(authorityBytes);
|
||||
}
|
||||
return Object.freeze(authorities);
|
||||
} catch (error) {
|
||||
for (const authority of authorities) authority.fill(0);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function certificateRevocationList(bytes: Buffer): Buffer {
|
||||
const value = bytes.toString('utf8').trim();
|
||||
if (
|
||||
!value.startsWith('-----BEGIN X509 CRL-----') ||
|
||||
!value.endsWith('-----END X509 CRL-----')
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'TLS client certificate revocation list is not a PEM CRL',
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function matchingPrivateKey(
|
||||
privateKey: KeyObject,
|
||||
certificate: X509Certificate,
|
||||
): boolean {
|
||||
const key = createPublicKey(privateKey).export({
|
||||
type: 'spki',
|
||||
format: 'der',
|
||||
});
|
||||
const certificateKey = certificate.publicKey.export({
|
||||
type: 'spki',
|
||||
format: 'der',
|
||||
});
|
||||
return (
|
||||
key.byteLength === certificateKey.byteLength &&
|
||||
timingSafeEqual(key, certificateKey)
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadClusterWorkerIngressMutualTls(
|
||||
config: EnabledClusterWorkerIngressConfig,
|
||||
now: number = Date.now(),
|
||||
): Promise<ClusterControlMutualTlsOptions> {
|
||||
if (!config?.enabled || config.profile !== 'cluster-control') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'TLS material requires an enabled Worker ingress config',
|
||||
);
|
||||
}
|
||||
if (!Number.isSafeInteger(now) || now < 0) {
|
||||
throw new ClusterWorkerIngressConfigError('observation time is invalid');
|
||||
}
|
||||
const keyBytes = await readTlsFile(config.transport.privateKeyFile, true);
|
||||
let certificateBytes: Buffer | undefined;
|
||||
let clientAuthorityBundleBytes: Buffer | undefined;
|
||||
let certificateRevocationListBytes: Buffer | undefined;
|
||||
let clientCertificateAuthorities: readonly Buffer[] = Object.freeze([]);
|
||||
try {
|
||||
let privateKey: KeyObject;
|
||||
try {
|
||||
privateKey = createPrivateKey(keyBytes);
|
||||
} catch {
|
||||
throw new ClusterWorkerIngressConfigError('TLS private key is invalid');
|
||||
}
|
||||
certificateBytes = await readTlsFile(
|
||||
config.transport.certificateFile,
|
||||
false,
|
||||
);
|
||||
clientAuthorityBundleBytes = await readTlsFile(
|
||||
config.transport.clientCertificateAuthorityFile,
|
||||
false,
|
||||
);
|
||||
const certificate = activeCertificate(
|
||||
'TLS server certificate',
|
||||
certificateBytes,
|
||||
now,
|
||||
);
|
||||
clientCertificateAuthorities = activeCertificateAuthorities(
|
||||
clientAuthorityBundleBytes,
|
||||
now,
|
||||
);
|
||||
if (!matchingPrivateKey(privateKey, certificate)) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'TLS private key does not match the server certificate',
|
||||
);
|
||||
}
|
||||
if (config.transport.clientCertificateRevocationListFile !== undefined) {
|
||||
certificateRevocationListBytes = certificateRevocationList(
|
||||
await readTlsFile(
|
||||
config.transport.clientCertificateRevocationListFile,
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
const mutualTls: ClusterControlMutualTlsOptions = Object.freeze({
|
||||
privateKey: keyBytes,
|
||||
certificateChain: certificateBytes,
|
||||
clientCertificateAuthorities,
|
||||
...(certificateRevocationListBytes === undefined
|
||||
? {}
|
||||
: {
|
||||
certificateRevocationLists: Object.freeze([
|
||||
certificateRevocationListBytes,
|
||||
]),
|
||||
}),
|
||||
});
|
||||
clientAuthorityBundleBytes.fill(0);
|
||||
return mutualTls;
|
||||
} catch (error) {
|
||||
keyBytes.fill(0);
|
||||
certificateBytes?.fill(0);
|
||||
clientAuthorityBundleBytes?.fill(0);
|
||||
certificateRevocationListBytes?.fill(0);
|
||||
for (const authority of clientCertificateAuthorities) authority.fill(0);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createClusterWorkerIngressHttpOptions(
|
||||
config: EnabledClusterWorkerIngressConfig,
|
||||
now: number = Date.now(),
|
||||
): Promise<ClusterControlHttpSurfaceOptions> {
|
||||
const mutualTls = await loadClusterWorkerIngressMutualTls(config, now);
|
||||
return Object.freeze({ ...config.http, mutualTls });
|
||||
}
|
||||
|
||||
export function createClusterWorkerIngressDatabaseOpener(
|
||||
config: EnabledClusterWorkerIngressConfig,
|
||||
onPoolError: (error: Error) => void,
|
||||
): OpenPostgresDatabase {
|
||||
if (!config?.enabled || config.profile !== 'cluster-control') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'database opener requires an enabled Worker ingress config',
|
||||
);
|
||||
}
|
||||
if (typeof onPoolError !== 'function') {
|
||||
throw new ClusterWorkerIngressConfigError('onPoolError must be a function');
|
||||
}
|
||||
return createPostgresDatabaseOpener({
|
||||
role: 'worker-ingress',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
// Cluster Control Worker Ingress boundary; keep authenticated admission routing explicit.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
WorkerSessionConflictError,
|
||||
WorkerSessionFenceRejectedError,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type {
|
||||
AuthenticatedWorkerSessionRepository,
|
||||
} from '@qinglong/runtime-core/worker-credential-delivery';
|
||||
import {
|
||||
WorkerCredentialDeliveryConflictError,
|
||||
WorkerCredentialDeliveryUnavailableError,
|
||||
} from '@qinglong/runtime-core/worker-credential-delivery';
|
||||
import {
|
||||
InvalidWorkerSessionTransportError,
|
||||
createWorkerSessionHeartbeatResponseBody,
|
||||
createWorkerSessionRegisterResponseBody,
|
||||
createWorkerSessionTransitionResponseBody,
|
||||
parseWorkerSessionHeartbeatRequestBody,
|
||||
parseWorkerSessionRegisterRequestBody,
|
||||
parseWorkerSessionTransitionRequestBody,
|
||||
} from '@qinglong/runtime-core/worker-session-transport';
|
||||
import {
|
||||
WorkerExecutionAttestationFenceRejectedError,
|
||||
WorkerExecutionAttestationUnavailableError,
|
||||
type WorkerExecutionAttestationRepository,
|
||||
} from '@qinglong/runtime-core/worker-attestation';
|
||||
import {
|
||||
WorkerCredentialUnavailableError,
|
||||
} from '@qinglong/runtime-core/worker-credential';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import type {
|
||||
ClusterControlAdmissionMetadata,
|
||||
ClusterControlAdmissionPipeline,
|
||||
ClusterControlAdmissionResponse,
|
||||
ClusterControlStreamingAdmissionBody,
|
||||
} from '../transport/httpSurface';
|
||||
import type {
|
||||
AuthenticatedWorkerPrincipal,
|
||||
WorkerCredentialAuthenticator,
|
||||
} from './workerCredentialAuthenticator';
|
||||
import {
|
||||
ClusterRemoteWorkerOfferFenceRejectedError,
|
||||
type ClusterRemoteWorkerOfferClaimService,
|
||||
} from '../remote-execution/remoteWorkerDispatcher';
|
||||
import {
|
||||
RemoteRunActivationFenceRejectedError,
|
||||
RemoteRunActivationUnavailableError,
|
||||
} from '@qinglong/runtime-core/remote-activation';
|
||||
import {
|
||||
createRemoteRunActivationResponseBody,
|
||||
InvalidRemoteRunActivationDeliveryError,
|
||||
} from '@qinglong/runtime-core/remote-activation-delivery';
|
||||
import type { ClusterRemoteRunActivationService } from '../remote-execution/remoteRunActivationService';
|
||||
import {
|
||||
createRemoteExecutionOfferPullBody,
|
||||
InvalidRemoteExecutionOfferDeliveryError,
|
||||
} from '@qinglong/runtime-core/remote-offer-delivery';
|
||||
import {
|
||||
createRemoteWorkerSecretDeliveryResponseBody,
|
||||
InvalidRemoteWorkerSecretDeliveryError,
|
||||
REMOTE_SECRET_DELIVERY_SCHEMA,
|
||||
RemoteWorkerSecretDeliveryFenceRejectedError,
|
||||
RemoteWorkerSecretDeliveryUnavailableError,
|
||||
} from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import type { ClusterRemoteWorkerSecretDeliveryService } from '../remote-execution/remoteWorkerSecretDeliveryService';
|
||||
import {
|
||||
InvalidRemoteWorkerCompletionError,
|
||||
MAX_REMOTE_WORKER_ARTIFACT_BYTES,
|
||||
MAX_REMOTE_WORKER_ARTIFACT_HEADER_BYTES,
|
||||
REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
|
||||
RemoteWorkerCompletionFenceRejectedError,
|
||||
RemoteWorkerCompletionUnavailableError,
|
||||
createRemoteWorkerArtifactUploadResponseBody,
|
||||
createRemoteWorkerCompletionResponseBody,
|
||||
parseRemoteWorkerCompletionRequestBody,
|
||||
} from '@qinglong/runtime-core/remote-worker-completion';
|
||||
import type {
|
||||
ClusterRemoteWorkerArtifactService,
|
||||
ClusterRemoteWorkerCompletionService,
|
||||
} from '../remote-execution/remoteWorkerCompletionService';
|
||||
import {
|
||||
InvalidRemoteWorkerLeaseControlError,
|
||||
RemoteWorkerLeaseControlFenceRejectedError,
|
||||
RemoteWorkerLeaseControlUnavailableError,
|
||||
createRemoteWorkerLeaseControlResponseBody,
|
||||
parseRemoteWorkerLeaseControlRequestBody,
|
||||
} from '@qinglong/runtime-core/remote-worker-lease-control';
|
||||
import type { ClusterRemoteWorkerLeaseControlService } from '../remote-execution/remoteWorkerLeaseControlService';
|
||||
|
||||
export interface WorkerIngressPipelineOptions {
|
||||
readonly authenticator: WorkerCredentialAuthenticator;
|
||||
readonly workers: AuthenticatedWorkerSessionRepository;
|
||||
readonly attestations: WorkerExecutionAttestationRepository;
|
||||
readonly audit: SecurityAuditSink;
|
||||
readonly offers?: Pick<ClusterRemoteWorkerOfferClaimService, 'claimNext'>;
|
||||
readonly activation?: Pick<
|
||||
ClusterRemoteRunActivationService,
|
||||
'acknowledgeStarting' | 'acknowledgeRunning' | 'failStart'
|
||||
>;
|
||||
readonly secrets?: Pick<ClusterRemoteWorkerSecretDeliveryService, 'deliver'>;
|
||||
readonly artifacts?: Pick<ClusterRemoteWorkerArtifactService, 'upload'>;
|
||||
readonly completion?: Pick<ClusterRemoteWorkerCompletionService, 'complete'>;
|
||||
readonly leaseControl?: Pick<ClusterRemoteWorkerLeaseControlService, 'control'>;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
type Operation =
|
||||
| 'register'
|
||||
| 'heartbeat'
|
||||
| 'transition'
|
||||
| 'attestations'
|
||||
| 'offers'
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'start-failure'
|
||||
| 'secrets'
|
||||
| 'artifacts'
|
||||
| 'completion'
|
||||
| 'lease-control';
|
||||
|
||||
interface ResolvedRoute {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly operation: Operation;
|
||||
}
|
||||
|
||||
const ROUTE = /^\/api\/v3\/worker-ingress\/workers\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/sessions\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/(register|heartbeat|transition|attestations|offers|starting|running|start-failure|secrets|artifacts|completion|lease-control)$/;
|
||||
|
||||
function failure(statusCode: number, code: string): Error {
|
||||
return Object.assign(new Error(code), { statusCode, code });
|
||||
}
|
||||
|
||||
function route(metadata: ClusterControlAdmissionMetadata): ResolvedRoute {
|
||||
if (metadata.method !== 'POST' || Object.keys(metadata.query).length !== 0) {
|
||||
throw failure(404, 'worker_route_not_found');
|
||||
}
|
||||
const match = ROUTE.exec(metadata.path);
|
||||
if (!match) throw failure(404, 'worker_route_not_found');
|
||||
return Object.freeze({
|
||||
workerId: match[1]!,
|
||||
sessionId: match[2]!,
|
||||
operation: match[3]! as Operation,
|
||||
});
|
||||
}
|
||||
|
||||
function objectBody(body: unknown | null, keys: readonly string[]): Record<string, unknown> {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw failure(400, 'invalid_worker_request');
|
||||
}
|
||||
const actual = Object.keys(body).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) throw failure(400, 'invalid_worker_request');
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function audit(
|
||||
sink: SecurityAuditSink,
|
||||
metadata: ClusterControlAdmissionMetadata,
|
||||
operation: Operation,
|
||||
principal: Readonly<AuthenticatedWorkerPrincipal> | null,
|
||||
outcome: 'authentication_rejected' | 'authentication_unavailable' | 'allowed',
|
||||
now: () => number,
|
||||
): Promise<void> {
|
||||
await sink.record(normalizeSecurityAuditRecord({
|
||||
eventId: randomUUID(),
|
||||
requestId: metadata.requestId,
|
||||
operationId: `worker.${operation}`,
|
||||
projectId: null,
|
||||
subject: principal ? { type: 'worker', id: principal.workerId } : null,
|
||||
authenticationId: principal?.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: [outcome === 'allowed' ? 'worker_credential' : outcome],
|
||||
fence: null,
|
||||
occurredAtMs: now(),
|
||||
}));
|
||||
}
|
||||
|
||||
function response(statusCode: number, body: unknown): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body });
|
||||
}
|
||||
|
||||
function mapIngressFailure(error: unknown): never {
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
|
||||
if (
|
||||
error instanceof WorkerSessionConflictError ||
|
||||
error instanceof WorkerSessionFenceRejectedError ||
|
||||
error instanceof WorkerCredentialDeliveryConflictError
|
||||
) throw failure(409, 'worker_session_fenced');
|
||||
if (error instanceof WorkerExecutionAttestationFenceRejectedError) {
|
||||
throw failure(409, 'worker_attestation_fenced');
|
||||
}
|
||||
if (error instanceof ClusterRemoteWorkerOfferFenceRejectedError) {
|
||||
throw failure(409, 'worker_offer_fenced');
|
||||
}
|
||||
if (error instanceof RemoteRunActivationFenceRejectedError) {
|
||||
throw failure(409, 'worker_activation_fenced');
|
||||
}
|
||||
if (error instanceof RemoteWorkerSecretDeliveryFenceRejectedError) {
|
||||
throw failure(409, 'worker_secret_delivery_fenced');
|
||||
}
|
||||
if (error instanceof RemoteWorkerCompletionFenceRejectedError) {
|
||||
throw failure(409, 'worker_completion_fenced');
|
||||
}
|
||||
if (error instanceof RemoteWorkerLeaseControlFenceRejectedError) {
|
||||
throw failure(409, 'worker_lease_control_fenced');
|
||||
}
|
||||
if (error instanceof InvalidRemoteWorkerCompletionError) {
|
||||
throw failure(400, 'invalid_worker_request');
|
||||
}
|
||||
if (error instanceof InvalidRemoteWorkerLeaseControlError) {
|
||||
throw failure(400, 'invalid_worker_request');
|
||||
}
|
||||
if (error instanceof InvalidWorkerSessionTransportError) {
|
||||
throw failure(400, 'invalid_worker_request');
|
||||
}
|
||||
if (
|
||||
error instanceof InvalidRemoteExecutionOfferDeliveryError ||
|
||||
error instanceof InvalidRemoteRunActivationDeliveryError ||
|
||||
error instanceof InvalidRemoteWorkerSecretDeliveryError
|
||||
) throw failure(503, 'worker_ingress_unavailable');
|
||||
if (
|
||||
error instanceof WorkerExecutionAttestationUnavailableError ||
|
||||
error instanceof WorkerCredentialUnavailableError ||
|
||||
error instanceof WorkerCredentialDeliveryUnavailableError ||
|
||||
error instanceof RemoteRunActivationUnavailableError ||
|
||||
error instanceof RemoteWorkerSecretDeliveryUnavailableError ||
|
||||
error instanceof RemoteWorkerCompletionUnavailableError ||
|
||||
error instanceof RemoteWorkerLeaseControlUnavailableError
|
||||
) throw failure(503, 'worker_ingress_unavailable');
|
||||
if (error instanceof TypeError || error instanceof RangeError) {
|
||||
throw failure(400, 'invalid_worker_request');
|
||||
}
|
||||
throw failure(503, 'worker_ingress_unavailable');
|
||||
}
|
||||
|
||||
export function createWorkerIngressAdmissionPipeline(
|
||||
options: WorkerIngressPipelineOptions,
|
||||
): ClusterControlAdmissionPipeline {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.authenticator?.authenticate !== 'function' ||
|
||||
typeof options.workers?.register !== 'function' ||
|
||||
typeof options.workers?.heartbeatAuthenticated !== 'function' ||
|
||||
typeof options.workers?.transitionAuthenticated !== 'function' ||
|
||||
typeof options.attestations?.submit !== 'function' ||
|
||||
typeof options.audit?.record !== 'function' ||
|
||||
(options.offers !== undefined &&
|
||||
typeof options.offers.claimNext !== 'function') ||
|
||||
(options.activation !== undefined &&
|
||||
(typeof options.activation.acknowledgeStarting !== 'function' ||
|
||||
typeof options.activation.acknowledgeRunning !== 'function' ||
|
||||
typeof options.activation.failStart !== 'function')) ||
|
||||
(options.secrets !== undefined &&
|
||||
typeof options.secrets.deliver !== 'function') ||
|
||||
(options.artifacts !== undefined &&
|
||||
typeof options.artifacts.upload !== 'function') ||
|
||||
(options.completion !== undefined &&
|
||||
typeof options.completion.complete !== 'function') ||
|
||||
(options.leaseControl !== undefined &&
|
||||
typeof options.leaseControl.control !== 'function')
|
||||
) throw new TypeError('Worker ingress pipeline options are invalid');
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return Object.freeze({
|
||||
async prepare(metadata: ClusterControlAdmissionMetadata) {
|
||||
const resolved = route(metadata);
|
||||
let principal: Readonly<AuthenticatedWorkerPrincipal> | null;
|
||||
try {
|
||||
principal = await options.authenticator.authenticate(metadata);
|
||||
} catch {
|
||||
try { await audit(options.audit, metadata, resolved.operation, null, 'authentication_unavailable', now); } catch { /* fail below */ }
|
||||
throw failure(503, 'worker_authentication_unavailable');
|
||||
}
|
||||
if (!principal || principal.workerId !== resolved.workerId) {
|
||||
try { await audit(options.audit, metadata, resolved.operation, null, 'authentication_rejected', now); } catch { throw failure(503, 'worker_audit_unavailable'); }
|
||||
throw failure(401, 'worker_authentication_required');
|
||||
}
|
||||
try {
|
||||
await audit(options.audit, metadata, resolved.operation, principal, 'allowed', now);
|
||||
} catch {
|
||||
throw failure(503, 'worker_audit_unavailable');
|
||||
}
|
||||
|
||||
if (resolved.operation === 'artifacts') {
|
||||
return Object.freeze({
|
||||
bodyMode: 'stream' as const,
|
||||
contentType: REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
|
||||
maximumBodyBytes:
|
||||
4 + MAX_REMOTE_WORKER_ARTIFACT_HEADER_BYTES +
|
||||
MAX_REMOTE_WORKER_ARTIFACT_BYTES,
|
||||
async handleStream(body: ClusterControlStreamingAdmissionBody) {
|
||||
try {
|
||||
if (!options.artifacts) {
|
||||
throw failure(503, 'worker_artifact_unavailable');
|
||||
}
|
||||
const receipt = await options.artifacts.upload({
|
||||
workerId: resolved.workerId,
|
||||
workerSessionId: resolved.sessionId,
|
||||
contentLength: body.contentLength,
|
||||
chunks: body.chunks,
|
||||
signal: metadata.signal,
|
||||
});
|
||||
return response(
|
||||
200,
|
||||
createRemoteWorkerArtifactUploadResponseBody(receipt),
|
||||
);
|
||||
} catch (error) {
|
||||
return mapIngressFailure(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async handle(body: unknown | null) {
|
||||
try {
|
||||
if (resolved.operation === 'register') {
|
||||
const command = parseWorkerSessionRegisterRequestBody(body, {
|
||||
workerId: resolved.workerId,
|
||||
sessionId: resolved.sessionId,
|
||||
});
|
||||
const result = await options.workers.register(command);
|
||||
return response(
|
||||
200,
|
||||
createWorkerSessionRegisterResponseBody(result),
|
||||
);
|
||||
}
|
||||
if (resolved.operation === 'heartbeat') {
|
||||
const command = parseWorkerSessionHeartbeatRequestBody(body, {
|
||||
workerId: resolved.workerId,
|
||||
sessionId: resolved.sessionId,
|
||||
});
|
||||
const worker = await options.workers.heartbeatAuthenticated(
|
||||
command,
|
||||
{
|
||||
workerId: principal.workerId,
|
||||
credentialId: principal.credentialId,
|
||||
credentialVersion: principal.credentialVersion,
|
||||
},
|
||||
);
|
||||
return response(
|
||||
200,
|
||||
createWorkerSessionHeartbeatResponseBody(worker),
|
||||
);
|
||||
}
|
||||
if (resolved.operation === 'transition') {
|
||||
const command = parseWorkerSessionTransitionRequestBody(body, {
|
||||
workerId: resolved.workerId,
|
||||
sessionId: resolved.sessionId,
|
||||
});
|
||||
const worker = await options.workers.transitionAuthenticated(
|
||||
command,
|
||||
{
|
||||
workerId: principal.workerId,
|
||||
credentialId: principal.credentialId,
|
||||
credentialVersion: principal.credentialVersion,
|
||||
},
|
||||
);
|
||||
return response(
|
||||
200,
|
||||
createWorkerSessionTransitionResponseBody(worker),
|
||||
);
|
||||
}
|
||||
if (resolved.operation === 'offers') {
|
||||
if (!options.offers) {
|
||||
throw failure(503, 'worker_offer_unavailable');
|
||||
}
|
||||
const value = objectBody(body, [
|
||||
'workerGeneration', 'offerId', 'leaseToken',
|
||||
]);
|
||||
const result = await options.offers.claimNext(
|
||||
{ workerId: resolved.workerId },
|
||||
{
|
||||
workerSessionId: resolved.sessionId,
|
||||
workerGeneration: value.workerGeneration as number,
|
||||
offerId: value.offerId as string,
|
||||
leaseToken: value.leaseToken as string,
|
||||
},
|
||||
);
|
||||
return response(200, createRemoteExecutionOfferPullBody(result));
|
||||
}
|
||||
if (
|
||||
resolved.operation === 'starting' ||
|
||||
resolved.operation === 'start-failure'
|
||||
) {
|
||||
if (!options.activation) {
|
||||
throw failure(503, 'worker_activation_unavailable');
|
||||
}
|
||||
const value = objectBody(body, [
|
||||
'runId', 'attemptId', 'workerGeneration', 'offerId',
|
||||
'leaseGeneration', 'leaseToken', 'expectedLeaseVersion',
|
||||
]);
|
||||
const command = {
|
||||
runId: value.runId as string,
|
||||
attemptId: value.attemptId as string,
|
||||
workerSessionId: resolved.sessionId,
|
||||
workerGeneration: value.workerGeneration as number,
|
||||
offerId: value.offerId as string,
|
||||
leaseGeneration: value.leaseGeneration as number,
|
||||
leaseToken: value.leaseToken as string,
|
||||
expectedLeaseVersion: value.expectedLeaseVersion as number,
|
||||
};
|
||||
const activation = resolved.operation === 'starting'
|
||||
? await options.activation.acknowledgeStarting(
|
||||
{ workerId: resolved.workerId }, command,
|
||||
)
|
||||
: await options.activation.failStart(
|
||||
{ workerId: resolved.workerId }, command,
|
||||
);
|
||||
return response(
|
||||
200,
|
||||
createRemoteRunActivationResponseBody(activation),
|
||||
);
|
||||
}
|
||||
if (resolved.operation === 'running') {
|
||||
if (!options.activation) {
|
||||
throw failure(503, 'worker_activation_unavailable');
|
||||
}
|
||||
const value = objectBody(body, [
|
||||
'runId', 'attemptId', 'workerGeneration', 'offerId',
|
||||
'leaseGeneration', 'leaseToken', 'expectedLeaseVersion',
|
||||
'executorHandle', 'logArtifactId', 'callbackSequence',
|
||||
'callbackTokenDigest',
|
||||
]);
|
||||
if (
|
||||
value.logArtifactId !== null &&
|
||||
typeof value.logArtifactId !== 'string'
|
||||
) throw failure(400, 'invalid_worker_request');
|
||||
const activation = await options.activation.acknowledgeRunning(
|
||||
{ workerId: resolved.workerId },
|
||||
{
|
||||
runId: value.runId as string,
|
||||
attemptId: value.attemptId as string,
|
||||
workerSessionId: resolved.sessionId,
|
||||
workerGeneration: value.workerGeneration as number,
|
||||
offerId: value.offerId as string,
|
||||
leaseGeneration: value.leaseGeneration as number,
|
||||
leaseToken: value.leaseToken as string,
|
||||
expectedLeaseVersion: value.expectedLeaseVersion as number,
|
||||
executorHandle: value.executorHandle as string,
|
||||
callbackSequence: value.callbackSequence as number,
|
||||
callbackTokenDigest: value.callbackTokenDigest as string,
|
||||
...(value.logArtifactId === null
|
||||
? {}
|
||||
: { logArtifactId: value.logArtifactId }),
|
||||
},
|
||||
);
|
||||
return response(
|
||||
200,
|
||||
createRemoteRunActivationResponseBody(activation),
|
||||
);
|
||||
}
|
||||
if (resolved.operation === 'secrets') {
|
||||
if (!options.secrets) {
|
||||
throw failure(503, 'worker_secret_delivery_unavailable');
|
||||
}
|
||||
const value = objectBody(body, [
|
||||
'schema', 'runId', 'attemptId', 'projectId', 'taskId',
|
||||
'taskRevision', 'executionDigest', 'workerGeneration',
|
||||
'offerId', 'leaseGeneration', 'leaseToken',
|
||||
'expectedLeaseVersion', 'secretRefs',
|
||||
]);
|
||||
if (value.schema !== REMOTE_SECRET_DELIVERY_SCHEMA) {
|
||||
throw failure(400, 'invalid_worker_request');
|
||||
}
|
||||
const delivered = await options.secrets.deliver(
|
||||
{ workerId: resolved.workerId },
|
||||
{
|
||||
workerSessionId: resolved.sessionId,
|
||||
workerGeneration: value.workerGeneration as number,
|
||||
runId: value.runId as string,
|
||||
attemptId: value.attemptId as string,
|
||||
projectId: value.projectId as string,
|
||||
taskId: value.taskId as string,
|
||||
taskRevision: value.taskRevision as string,
|
||||
executionDigest: value.executionDigest as string,
|
||||
offerId: value.offerId as string,
|
||||
leaseGeneration: value.leaseGeneration as number,
|
||||
leaseToken: value.leaseToken as string,
|
||||
expectedLeaseVersion: value.expectedLeaseVersion as number,
|
||||
secretRefs: value.secretRefs as string[],
|
||||
},
|
||||
);
|
||||
try {
|
||||
const responseBody = createRemoteWorkerSecretDeliveryResponseBody(
|
||||
delivered,
|
||||
value.secretRefs as string[],
|
||||
);
|
||||
if (
|
||||
responseBody.runId !== value.runId ||
|
||||
responseBody.attemptId !== value.attemptId ||
|
||||
responseBody.offerId !== value.offerId ||
|
||||
responseBody.executionDigest !== value.executionDigest
|
||||
) throw new InvalidRemoteWorkerSecretDeliveryError(
|
||||
'service response authority does not match request',
|
||||
);
|
||||
return response(
|
||||
200,
|
||||
responseBody,
|
||||
);
|
||||
} finally {
|
||||
try { await delivered.dispose?.(); } catch { /* response remains valid */ }
|
||||
}
|
||||
}
|
||||
if (resolved.operation === 'completion') {
|
||||
if (!options.completion) {
|
||||
throw failure(503, 'worker_completion_unavailable');
|
||||
}
|
||||
const command = parseRemoteWorkerCompletionRequestBody(body, {
|
||||
workerId: resolved.workerId,
|
||||
workerSessionId: resolved.sessionId,
|
||||
});
|
||||
const completed = await options.completion.complete(
|
||||
command,
|
||||
metadata.signal,
|
||||
);
|
||||
return response(
|
||||
200,
|
||||
createRemoteWorkerCompletionResponseBody(completed),
|
||||
);
|
||||
}
|
||||
if (resolved.operation === 'lease-control') {
|
||||
if (!options.leaseControl) {
|
||||
throw failure(503, 'worker_lease_control_unavailable');
|
||||
}
|
||||
const command = parseRemoteWorkerLeaseControlRequestBody(body, {
|
||||
workerId: resolved.workerId,
|
||||
workerSessionId: resolved.sessionId,
|
||||
});
|
||||
return response(
|
||||
200,
|
||||
createRemoteWorkerLeaseControlResponseBody(
|
||||
await options.leaseControl.control(command),
|
||||
),
|
||||
);
|
||||
}
|
||||
const value = objectBody(body, [
|
||||
'attestationId', 'runId', 'attemptId', 'sequence', 'state',
|
||||
'workerGeneration', 'leaseTokenDigest', 'leaseGeneration',
|
||||
'leaseVersion', 'offerId', 'callbackSequence', 'executorHandle',
|
||||
'journalRevision',
|
||||
]);
|
||||
if (value.workerGeneration === undefined) {
|
||||
throw failure(400, 'invalid_worker_request');
|
||||
}
|
||||
const result = await options.attestations.submit({
|
||||
attestationId: value.attestationId as string,
|
||||
runId: value.runId as string,
|
||||
attemptId: value.attemptId as string,
|
||||
sequence: value.sequence as number,
|
||||
state: value.state as 'running' | 'stopped',
|
||||
workerId: resolved.workerId,
|
||||
workerSessionId: resolved.sessionId,
|
||||
workerGeneration: value.workerGeneration as number,
|
||||
leaseTokenDigest: value.leaseTokenDigest as string,
|
||||
leaseGeneration: value.leaseGeneration as number,
|
||||
leaseVersion: value.leaseVersion as number,
|
||||
offerId: value.offerId as string,
|
||||
callbackSequence: value.callbackSequence as number,
|
||||
executorHandle: value.executorHandle as string,
|
||||
journalRevision: value.journalRevision as number,
|
||||
});
|
||||
return response(result.status === 'created' ? 201 : 200, {
|
||||
attestationId: result.attestation.attestationId,
|
||||
sequence: result.attestation.sequence,
|
||||
state: result.attestation.state,
|
||||
receivedAtMs: result.attestation.receivedAtMs,
|
||||
replay: result.status === 'existing',
|
||||
});
|
||||
} catch (error) {
|
||||
return mapIngressFailure(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlAdmissionSecurityError,
|
||||
createClusterControlAdmissionPipeline,
|
||||
createClusterControlProjectPolicyAuthorizer,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
|
||||
const NOW = 10_000;
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'usr_primary' }),
|
||||
authenticationId: 'session:abc123',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'multi_factor',
|
||||
});
|
||||
const METADATA = Object.freeze({
|
||||
requestId: 'request-1',
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/prj_default/runs',
|
||||
query: Object.freeze({}),
|
||||
headers: Object.freeze({ authorization: 'Bearer opaque' }),
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
function options(overrides = {}) {
|
||||
const events = overrides.events ?? [];
|
||||
return {
|
||||
routes: createClusterControlRouteRegistry([
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/{projectId}/runs',
|
||||
operationId: 'run.create',
|
||||
permission: 'run.start',
|
||||
projectParameter: 'projectId',
|
||||
handle(input, parameters) {
|
||||
events.push(
|
||||
`handle:${parameters.projectId}:${input.request.body.taskId}`,
|
||||
);
|
||||
return { statusCode: 202, body: { accepted: true } };
|
||||
},
|
||||
},
|
||||
]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
events.push('authenticate');
|
||||
return PRINCIPAL;
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
authorize(request) {
|
||||
events.push(`authorize:${request.permission}`);
|
||||
return {
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
};
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
record(record) {
|
||||
events.push(`audit:${record.outcome}`);
|
||||
},
|
||||
},
|
||||
now: () => NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('authenticates, authorizes and audits before accepting a body', async () => {
|
||||
const events = [];
|
||||
const pipeline = createClusterControlAdmissionPipeline(options({ events }));
|
||||
const prepared = await pipeline.prepare(METADATA);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.start',
|
||||
'audit:allowed',
|
||||
]);
|
||||
assert.deepEqual(await prepared.handle({ taskId: 'task-1' }), {
|
||||
statusCode: 202,
|
||||
body: { accepted: true },
|
||||
});
|
||||
assert.deepEqual(events.slice(-1), ['handle:prj_default:task-1']);
|
||||
});
|
||||
|
||||
test('requires a reviewed route registry and rejects unknown routes before authentication', async () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createClusterControlAdmissionPipeline(
|
||||
options({
|
||||
routes: {
|
||||
contractVersion: 1,
|
||||
size: 1,
|
||||
resolve() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
/options are invalid/,
|
||||
);
|
||||
|
||||
const events = [];
|
||||
const pipeline = createClusterControlAdmissionPipeline(options({ events }));
|
||||
await assert.rejects(
|
||||
pipeline.prepare({
|
||||
...METADATA,
|
||||
path: '/api/v3/projects/prj_default/tasks',
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof ClusterControlAdmissionSecurityError &&
|
||||
error.statusCode === 404 &&
|
||||
error.code === 'route_not_found',
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('rejects missing authentication before policy and handler execution', async () => {
|
||||
const events = [];
|
||||
const pipeline = createClusterControlAdmissionPipeline(
|
||||
options({
|
||||
events,
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
events.push('authenticate');
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
pipeline.prepare(METADATA),
|
||||
(error) =>
|
||||
error instanceof ClusterControlAdmissionSecurityError &&
|
||||
error.statusCode === 401 &&
|
||||
error.code === 'authentication_required',
|
||||
);
|
||||
assert.equal(events.includes('authorize:run.start'), false);
|
||||
assert.equal(
|
||||
events.some((event) => event.startsWith('handle:')),
|
||||
false,
|
||||
);
|
||||
assert.equal(events.includes('audit:authentication_rejected'), true);
|
||||
});
|
||||
|
||||
test('maps policy decisions to low-sensitive deny and approval responses', async () => {
|
||||
for (const [effect, code, outcome] of [
|
||||
['deny', 'forbidden', 'denied'],
|
||||
['require_approval', 'approval_required', 'approval_required'],
|
||||
]) {
|
||||
const events = [];
|
||||
const pipeline = createClusterControlAdmissionPipeline(
|
||||
options({
|
||||
events,
|
||||
policy: {
|
||||
authorize() {
|
||||
return { effect, reasons: ['policy_decision'], fence: null };
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
pipeline.prepare(METADATA),
|
||||
(error) =>
|
||||
error instanceof ClusterControlAdmissionSecurityError &&
|
||||
error.statusCode === 403 &&
|
||||
error.code === code &&
|
||||
!error.message.includes('policy_decision'),
|
||||
);
|
||||
assert.equal(events.includes(`audit:${outcome}`), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when authentication, policy or security audit is unavailable', async () => {
|
||||
const scenarios = [
|
||||
{
|
||||
override: {
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
throw new Error('identity database detail');
|
||||
},
|
||||
},
|
||||
},
|
||||
code: 'authentication_unavailable',
|
||||
},
|
||||
{
|
||||
override: {
|
||||
policy: {
|
||||
authorize() {
|
||||
throw new Error('policy database detail');
|
||||
},
|
||||
},
|
||||
},
|
||||
code: 'authorization_unavailable',
|
||||
},
|
||||
{
|
||||
override: {
|
||||
audit: {
|
||||
record() {
|
||||
throw new Error('audit store detail');
|
||||
},
|
||||
},
|
||||
},
|
||||
code: 'security_audit_unavailable',
|
||||
},
|
||||
];
|
||||
for (const scenario of scenarios) {
|
||||
const pipeline = createClusterControlAdmissionPipeline(
|
||||
options(scenario.override),
|
||||
);
|
||||
await assert.rejects(
|
||||
pipeline.prepare(METADATA),
|
||||
(error) =>
|
||||
error instanceof ClusterControlAdmissionSecurityError &&
|
||||
error.statusCode === 503 &&
|
||||
error.code === scenario.code &&
|
||||
!error.message.includes('database detail') &&
|
||||
!error.message.includes('store detail'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('adapts the shared fenced Project Policy engine without an allow-all seam', async () => {
|
||||
const policy = createClusterControlProjectPolicyAuthorizer({
|
||||
async resolve(projectId, subject) {
|
||||
assert.equal(projectId, 'prj_default');
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 4,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 7,
|
||||
state: 'active',
|
||||
role: 'operator',
|
||||
mutationId: 'grant-7',
|
||||
changedBy: { type: 'user', id: 'usr_owner' },
|
||||
createdAtMs: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await policy.authorize({
|
||||
principal: PRINCIPAL,
|
||||
operationId: 'run.create',
|
||||
permission: 'run.start',
|
||||
projectId: 'prj_default',
|
||||
signal: METADATA.signal,
|
||||
}),
|
||||
{
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await policy.authorize({
|
||||
principal: PRINCIPAL,
|
||||
operationId: 'project.update',
|
||||
permission: 'project.manage',
|
||||
projectId: null,
|
||||
signal: METADATA.signal,
|
||||
})
|
||||
).effect,
|
||||
'deny',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { chmod, mkdtemp, rm, writeFile } = require('node:fs/promises');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ProductionClusterAiConfigError,
|
||||
loadProductionClusterAiConfig,
|
||||
startProductionClusterAiControlApplication,
|
||||
} = require('@qinglong/cluster-control/ai-production');
|
||||
const {
|
||||
canonicalPluginPackagePromptOutputKeyringManifest,
|
||||
PLUGIN_PACKAGE_PROMPT_OUTPUT_KEYRING_MANIFEST_SCHEMA,
|
||||
} = require('@qinglong/ai/plugin-package-prompt-output-keyring-manifest');
|
||||
|
||||
function enabledEnvironment(overrides = {}) {
|
||||
return {
|
||||
QL3_CLUSTER_AI_ENABLED: 'true',
|
||||
QL3_CLUSTER_AI_PROVIDER_AUTHORITY_FILE: '/var/run/qinglong/ai/providers.json',
|
||||
QL3_CLUSTER_AI_SECRET_ROOT: '/var/run/qinglong/ai/provider-secrets',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function controlConfig() {
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
http: { host: '127.0.0.1', port: 5800 },
|
||||
database: {
|
||||
connection: {
|
||||
host: '127.0.0.1',
|
||||
port: 5432,
|
||||
database: 'qinglong',
|
||||
user: 'ql3_runtime',
|
||||
password: 'test-only',
|
||||
tls: { mode: 'disable' },
|
||||
},
|
||||
pool: { maxConnections: 8 },
|
||||
},
|
||||
security: { apiCredentialPepper: 'test-only' },
|
||||
};
|
||||
}
|
||||
|
||||
test('AI config is fail-closed and bounded behind the explicit process flag', () => {
|
||||
assert.throws(
|
||||
() => loadProductionClusterAiConfig({}),
|
||||
(error) =>
|
||||
error instanceof ProductionClusterAiConfigError &&
|
||||
error.code === 'QL3_CLUSTER_AI_CONFIG_INVALID',
|
||||
);
|
||||
assert.deepEqual(loadProductionClusterAiConfig(enabledEnvironment()), {
|
||||
enabled: true,
|
||||
providerAuthorityFile: '/var/run/qinglong/ai/providers.json',
|
||||
secretRootDirectory: '/var/run/qinglong/ai/provider-secrets',
|
||||
maxConcurrent: 4,
|
||||
recoveryLimit: 32,
|
||||
databaseMaxConnections: 4,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
loadProductionClusterAiConfig(
|
||||
enabledEnvironment({ QL3_CLUSTER_AI_MAX_CONCURRENT: '65' }),
|
||||
),
|
||||
/between 1 and 64/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
loadProductionClusterAiConfig(
|
||||
enabledEnvironment({
|
||||
QL3_CLUSTER_AI_PROMPT_OUTPUT_ENABLED: 'true',
|
||||
}),
|
||||
),
|
||||
/QL3_CLUSTER_AI_PROMPT_OUTPUT_KEYRING_ROOT is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit AI composition injects one reviewed Prompt capability and drains it after HTTP control', async () => {
|
||||
const secretRoot = await mkdtemp(join(tmpdir(), 'ql3-cluster-ai-secret-'));
|
||||
const events = [];
|
||||
const promptCatalog = Object.freeze({ inspect() {} });
|
||||
const promptExecutions = Object.freeze({ execute() {} });
|
||||
const promptExecutionInspections = Object.freeze({ inspectAuthorized() {} });
|
||||
let promptOptions;
|
||||
let controlOptions;
|
||||
const neverUnavailable = new Promise(() => {});
|
||||
try {
|
||||
const application = await startProductionClusterAiControlApplication({
|
||||
control: { config: controlConfig() },
|
||||
ai: {
|
||||
enabled: true,
|
||||
providerAuthorityFile: '/unused/providers.json',
|
||||
secretRootDirectory: secretRoot,
|
||||
maxConcurrent: 3,
|
||||
recoveryLimit: 11,
|
||||
databaseMaxConnections: 2,
|
||||
},
|
||||
audit() {},
|
||||
async bootstrapPrompt(options) {
|
||||
promptOptions = options;
|
||||
return {
|
||||
status: 'active',
|
||||
profile: 'cluster',
|
||||
readiness: {},
|
||||
capability: {},
|
||||
prompts: {},
|
||||
promptCatalog,
|
||||
promptExecutions,
|
||||
promptExecutionInspections,
|
||||
async stop() {
|
||||
events.push('stop-prompt');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
async startControl(options) {
|
||||
controlOptions = options;
|
||||
return {
|
||||
status: 'active',
|
||||
address: { host: '127.0.0.1', port: 5800 },
|
||||
evidence: {},
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
unavailable: neverUnavailable,
|
||||
availabilityStatus() {
|
||||
return 'ready';
|
||||
},
|
||||
async stop() {
|
||||
events.push('stop-control');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(application.status, 'active');
|
||||
assert.equal(application.availabilityStatus(), 'ready');
|
||||
assert.equal(promptOptions.enabled, true);
|
||||
assert.equal(promptOptions.maxConcurrent, 3);
|
||||
assert.equal(promptOptions.recoveryLimit, 11);
|
||||
assert.equal(controlOptions.promptCatalog.capability, promptCatalog);
|
||||
assert.equal(controlOptions.promptExecution.capability, promptExecutions);
|
||||
assert.equal(
|
||||
controlOptions.promptExecutionInspection.capability,
|
||||
promptExecutionInspections,
|
||||
);
|
||||
assert.equal('promptOutputRead' in controlOptions, false);
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.deepEqual(events, ['stop-control', 'stop-prompt']);
|
||||
} finally {
|
||||
await rm(secretRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('output-enabled AI composition wires exact and request-keyed protected reads', async () => {
|
||||
const secretRoot = await mkdtemp(join(tmpdir(), 'ql3-cluster-ai-secret-'));
|
||||
const outputRoot = await mkdtemp(join(tmpdir(), 'ql3-cluster-ai-output-'));
|
||||
const manifest = canonicalPluginPackagePromptOutputKeyringManifest({
|
||||
schema: PLUGIN_PACKAGE_PROMPT_OUTPUT_KEYRING_MANIFEST_SCHEMA,
|
||||
generation: 1,
|
||||
activeKeyId: 'prompt-key-1',
|
||||
keys: { 'prompt-key-1': Buffer.alloc(32, 7).toString('base64url') },
|
||||
retirements: {},
|
||||
});
|
||||
await writeFile(join(outputRoot, 'keyring.json'), manifest, { mode: 0o440 });
|
||||
await chmod(join(outputRoot, 'keyring.json'), 0o440);
|
||||
let promptOptions;
|
||||
let controlOptions;
|
||||
const promptOutputs = Object.freeze({ read() {} });
|
||||
const promptExecutionOutputs = Object.freeze({ read() {} });
|
||||
try {
|
||||
const application = await startProductionClusterAiControlApplication({
|
||||
control: { config: controlConfig() },
|
||||
ai: {
|
||||
enabled: true,
|
||||
providerAuthorityFile: '/unused/providers.json',
|
||||
secretRootDirectory: secretRoot,
|
||||
promptOutputKeyringRootDirectory: outputRoot,
|
||||
maxConcurrent: 1,
|
||||
recoveryLimit: 1,
|
||||
databaseMaxConnections: 1,
|
||||
},
|
||||
audit() {},
|
||||
async bootstrapPrompt(options) {
|
||||
promptOptions = options;
|
||||
return {
|
||||
status: 'active',
|
||||
profile: 'cluster',
|
||||
readiness: {},
|
||||
capability: {},
|
||||
prompts: {},
|
||||
promptCatalog: { inspect() {} },
|
||||
promptExecutions: { execute() {} },
|
||||
promptExecutionInspections: { inspectAuthorized() {} },
|
||||
promptOutputs,
|
||||
promptExecutionOutputs,
|
||||
async stop() { return 'stopped'; },
|
||||
};
|
||||
},
|
||||
async startControl(options) {
|
||||
controlOptions = options;
|
||||
return {
|
||||
status: 'active',
|
||||
address: { host: '127.0.0.1', port: 5800 },
|
||||
evidence: {},
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
unavailable: new Promise(() => {}),
|
||||
availabilityStatus() { return 'ready'; },
|
||||
async stop() { return 'stopped'; },
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(typeof promptOptions.promptOutputKeys.resolve, 'function');
|
||||
assert.equal(
|
||||
typeof promptOptions.promptOutputRead.authorizer.authorize,
|
||||
'function',
|
||||
);
|
||||
assert.equal(controlOptions.promptOutputRead.capability, promptOutputs);
|
||||
assert.equal(
|
||||
controlOptions.promptExecutionOutputRead.capability,
|
||||
promptExecutionOutputs,
|
||||
);
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
} finally {
|
||||
manifest.fill(0);
|
||||
await rm(secretRoot, { recursive: true, force: true });
|
||||
await rm(outputRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('AI composition fails closed and drains a non-active Prompt bootstrap', async () => {
|
||||
const secretRoot = await mkdtemp(join(tmpdir(), 'ql3-cluster-ai-secret-'));
|
||||
let stops = 0;
|
||||
try {
|
||||
await assert.rejects(
|
||||
startProductionClusterAiControlApplication({
|
||||
control: { config: controlConfig() },
|
||||
ai: {
|
||||
enabled: true,
|
||||
providerAuthorityFile: '/unused/providers.json',
|
||||
secretRootDirectory: secretRoot,
|
||||
maxConcurrent: 1,
|
||||
recoveryLimit: 1,
|
||||
databaseMaxConnections: 1,
|
||||
},
|
||||
audit() {},
|
||||
async bootstrapPrompt() {
|
||||
return {
|
||||
status: 'disabled',
|
||||
profile: 'cluster',
|
||||
async stop() {
|
||||
stops += 1;
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
async startControl() {
|
||||
throw new Error('control must not start');
|
||||
},
|
||||
}),
|
||||
/Prompt application did not activate/,
|
||||
);
|
||||
assert.equal(stops, 1);
|
||||
} finally {
|
||||
await rm(secretRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHmac } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ApiCredentialUnavailableError,
|
||||
} = require('@qinglong/runtime-core/api-credential');
|
||||
const {
|
||||
ClusterControlApiCredentialConfigurationError,
|
||||
ClusterControlApiCredentialUnavailableError,
|
||||
apiCredentialSecretDigest,
|
||||
createClusterControlApiCredentialAuthenticator,
|
||||
} = require('@qinglong/cluster-control/api-credential');
|
||||
|
||||
const NOW = 10_000;
|
||||
const PEPPER = Buffer.alloc(32, 1).toString('base64url');
|
||||
const SECRET = Buffer.alloc(32, 2).toString('base64url');
|
||||
const CREDENTIAL_ID = 'app_primary';
|
||||
|
||||
function metadata(authorization = `Bearer ql3c_${CREDENTIAL_ID}_${SECRET}`) {
|
||||
return {
|
||||
requestId: 'request-1',
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/default/runs',
|
||||
query: Object.freeze({}),
|
||||
headers: Object.freeze({ authorization }),
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function credential(overrides = {}) {
|
||||
return {
|
||||
credentialId: CREDENTIAL_ID,
|
||||
version: 2,
|
||||
pepperKeyId: 'legacy-v1',
|
||||
state: 'active',
|
||||
subject: { type: 'api_app', id: 'app_primary' },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
||||
createdAtMs: 1,
|
||||
notBeforeAtMs: 1,
|
||||
expiresAtMs: 100_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authenticator(value = credential(), overrides = {}) {
|
||||
return createClusterControlApiCredentialAuthenticator(
|
||||
{
|
||||
async resolve(credentialId) {
|
||||
assert.equal(credentialId, CREDENTIAL_ID);
|
||||
return value;
|
||||
},
|
||||
},
|
||||
PEPPER,
|
||||
{ now: () => NOW, ...overrides },
|
||||
);
|
||||
}
|
||||
|
||||
test('authenticates a high-entropy service bearer as a short-lived principal', async () => {
|
||||
const principal = await authenticator().authenticate(metadata());
|
||||
assert.deepEqual(principal, {
|
||||
subject: { type: 'api_app', id: 'app_primary' },
|
||||
authenticationId: 'api_credential:app_primary:2',
|
||||
authenticatedAtMs: NOW,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'service',
|
||||
});
|
||||
assert.equal(Object.isFrozen(principal), true);
|
||||
assert.equal(JSON.stringify(principal).includes(SECRET), false);
|
||||
});
|
||||
|
||||
test('derives a domain-separated HMAC digest and user assurance', async () => {
|
||||
const expected = createHmac('sha256', Buffer.from(PEPPER, 'base64url'))
|
||||
.update(Buffer.from('qinglong-api-credential-v1\0', 'utf8'))
|
||||
.update(CREDENTIAL_ID, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(Buffer.from(SECRET, 'base64url'))
|
||||
.digest('hex');
|
||||
assert.equal(
|
||||
apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
||||
expected,
|
||||
);
|
||||
const principal = await authenticator(
|
||||
credential({ subject: { type: 'user', id: 'usr_primary' } }),
|
||||
).authenticate(metadata());
|
||||
assert.equal(principal.assurance, 'single_factor');
|
||||
});
|
||||
|
||||
test('rejects missing, malformed, wrong, inactive and disabled credentials', async () => {
|
||||
let repositoryCalls = 0;
|
||||
const strict = createClusterControlApiCredentialAuthenticator(
|
||||
{
|
||||
async resolve() {
|
||||
repositoryCalls += 1;
|
||||
return credential();
|
||||
},
|
||||
},
|
||||
PEPPER,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
for (const header of [
|
||||
undefined,
|
||||
'bearer token',
|
||||
`Bearer ql3c_${CREDENTIAL_ID}_short`,
|
||||
`Bearer ql3c_${CREDENTIAL_ID}_${Buffer.alloc(32, 3).toString('base64url')}`,
|
||||
]) {
|
||||
const request = metadata();
|
||||
request.headers = Object.freeze(
|
||||
header === undefined ? {} : { authorization: header },
|
||||
);
|
||||
assert.equal(await strict.authenticate(request), null);
|
||||
}
|
||||
assert.equal(
|
||||
repositoryCalls,
|
||||
1,
|
||||
'only a structurally valid token reaches SQL',
|
||||
);
|
||||
|
||||
for (const value of [
|
||||
credential({ state: 'revoked' }),
|
||||
credential({ subjectStatus: 'disabled' }),
|
||||
credential({ notBeforeAtMs: NOW + 1 }),
|
||||
credential({ expiresAtMs: NOW }),
|
||||
null,
|
||||
]) {
|
||||
assert.equal(await authenticator(value).authenticate(metadata()), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('maps storage, corrupt record and cancellation failures to unavailable', async () => {
|
||||
const unavailable = createClusterControlApiCredentialAuthenticator(
|
||||
{
|
||||
async resolve() {
|
||||
throw new ApiCredentialUnavailableError();
|
||||
},
|
||||
},
|
||||
PEPPER,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(
|
||||
unavailable.authenticate(metadata()),
|
||||
ClusterControlApiCredentialUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
authenticator(credential({ secretDigest: 'corrupt' })).authenticate(
|
||||
metadata(),
|
||||
),
|
||||
ClusterControlApiCredentialUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
authenticator(credential({ pepperKeyId: 'other-v1' })).authenticate(
|
||||
metadata(),
|
||||
),
|
||||
ClusterControlApiCredentialUnavailableError,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
authenticator().authenticate({ ...metadata(), signal: controller.signal }),
|
||||
ClusterControlApiCredentialUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects weak pepper and unbounded principal lifetime at construction', () => {
|
||||
const repository = { async resolve() {} };
|
||||
assert.throws(
|
||||
() => createClusterControlApiCredentialAuthenticator(repository, 'weak'),
|
||||
ClusterControlApiCredentialConfigurationError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createClusterControlApiCredentialAuthenticator(repository, PEPPER, {
|
||||
principalTtlMs: 300_001,
|
||||
}),
|
||||
ClusterControlApiCredentialConfigurationError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,787 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const net = require('node:net');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
startClusterControlApplication,
|
||||
} = require('@qinglong/cluster-control/application');
|
||||
const {
|
||||
ClusterControlAvailabilityFence,
|
||||
} = require('@qinglong/cluster-control/availability');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
} = require('@qinglong/cluster-control/api-credential');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
createClusterControlProjectPolicyAuthorizer,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
const {
|
||||
PostgresSchemaReadinessError,
|
||||
postgresqlMainMigrationManifest,
|
||||
} = require('@qinglong/cluster-postgres/runtime');
|
||||
const {
|
||||
postgresqlControlSchemaContract,
|
||||
} = require('@qinglong/cluster-postgres');
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(address.port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function request(port, path, options = {}) {
|
||||
const body =
|
||||
options.body === undefined
|
||||
? undefined
|
||||
: Buffer.from(JSON.stringify(options.body));
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
connection: 'close',
|
||||
...(body
|
||||
? {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(body.byteLength),
|
||||
}
|
||||
: {}),
|
||||
...options.headers,
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
resolve({
|
||||
statusCode: response.statusCode,
|
||||
body: text.length === 0 ? null : JSON.parse(text),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
outgoing.once('error', reject);
|
||||
if (body) outgoing.write(body);
|
||||
outgoing.end();
|
||||
});
|
||||
}
|
||||
|
||||
function migrationHistory() {
|
||||
return postgresqlMainMigrationManifest.migrations.map((migration, index) => ({
|
||||
streamId: postgresqlMainMigrationManifest.id,
|
||||
dialect: postgresqlMainMigrationManifest.dialect,
|
||||
migrationId: migration.id,
|
||||
checksum: migration.checksum,
|
||||
appliedAtMs: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function admission(handler) {
|
||||
return {
|
||||
async prepare(metadata) {
|
||||
return {
|
||||
handle(body) {
|
||||
return handler({ ...metadata, body });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runtimePrivileges() {
|
||||
const privileges = {
|
||||
schema_migrations: [true, false, false, false],
|
||||
schema_capabilities: [true, false, false, false],
|
||||
projects: [true, true, true, false],
|
||||
task_definitions: [true, false, false, false],
|
||||
task_definition_revisions: [true, false, false, false],
|
||||
task_execution_revisions: [true, false, false, false],
|
||||
triggers: [true, false, false, false],
|
||||
trigger_revisions: [true, false, false, false],
|
||||
trigger_schedules: [true, false, true, false],
|
||||
project_role_bindings: [true, true, false, false],
|
||||
identity_subjects: [true, false, false, false],
|
||||
api_credentials: [true, false, false, false],
|
||||
security_audit_events: [false, true, false, false],
|
||||
identity_subject_mutations: [false, false, false, false],
|
||||
api_credential_mutations: [false, false, false, false],
|
||||
runs: [true, true, true, false],
|
||||
step_runs: [true, true, true, false],
|
||||
step_run_mutations: [true, true, false, false],
|
||||
tool_execution_trace_anchors: [true, true, false, false],
|
||||
tool_execution_audit_receipts: [true, true, false, false],
|
||||
tool_execution_start_barriers: [true, true, false, false],
|
||||
tool_execution_start_artifact_bindings: [true, true, false, false],
|
||||
tool_execution_completions: [true, true, false, false],
|
||||
tool_execution_failure_completions: [true, true, false, false],
|
||||
tool_result_key_catalog_generations: [true, false, false, false],
|
||||
tool_execution_result_key_bindings: [true, true, false, false],
|
||||
tool_execution_result_rekey_overlays: [true, false, false, false],
|
||||
tool_execution_result_rekey_heads: [true, false, false, false],
|
||||
tool_result_key_retirement_receipts: [false, false, false, false],
|
||||
tool_invocation_input_artifacts: [true, true, false, false],
|
||||
tool_invocation_preview_artifacts: [true, true, false, false],
|
||||
run_attempts: [true, true, true, false],
|
||||
run_recovery_controls: [true, true, true, false],
|
||||
worker_sessions: [true, true, true, false],
|
||||
run_dispatch_leases: [true, true, true, false],
|
||||
worker_credentials: [false, false, false, false],
|
||||
worker_credential_mutations: [false, false, false, false],
|
||||
worker_credential_deliveries: [false, false, false, false],
|
||||
worker_credential_stage_discards: [false, false, false, false],
|
||||
worker_credential_management_plans: [false, false, false, false],
|
||||
worker_credential_management_quota_buckets: [false, false, false, false],
|
||||
plugin_package_installs: [false, false, false, false],
|
||||
plugin_package_install_heads: [false, false, false, false],
|
||||
plugin_package_install_mutations: [false, false, false, false],
|
||||
approval_requests: [false, false, false, false],
|
||||
approved_action_dispatches: [false, false, false, false],
|
||||
approved_action_executions: [false, false, false, false],
|
||||
plugin_package_install_proposals: [false, false, false, false],
|
||||
plugin_package_admission_receipts: [false, false, false, false],
|
||||
plugin_package_management_quota_buckets: [false, false, false, false],
|
||||
plugin_package_identity_keyset_ledger: [false, false, false, false],
|
||||
plugin_package_materialized_revisions: [false, false, false, false],
|
||||
plugin_package_task_ownerships: [false, false, false, false],
|
||||
plugin_package_task_reconciliations: [false, false, false, false],
|
||||
plugin_package_task_reconciliation_items: [false, false, false, false],
|
||||
project_tool_definition_snapshots: [false, false, false, false],
|
||||
project_tool_definition_snapshot_sources: [false, false, false, false],
|
||||
plugin_package_publisher_provenance: [false, false, false, false],
|
||||
plugin_package_publisher_revocation_impact_items: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_publisher_revocation_impacts: [false, false, false, false],
|
||||
plugin_package_publisher_revocation_proposals: [false, false, false, false],
|
||||
plugin_package_publisher_revocation_receipts: [false, false, false, false],
|
||||
plugin_package_publisher_trust_heads: [false, false, false, false],
|
||||
plugin_package_publisher_trust_snapshots: [false, false, false, false],
|
||||
plugin_package_publisher_trust_transition_proposals: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_publisher_trust_transition_receipts: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_quarantine_events: [false, false, false, false],
|
||||
plugin_package_withdrawal_receipts: [false, false, false, false],
|
||||
plugin_package_withdrawal_tasks: [false, false, false, false],
|
||||
plugin_package_lifecycle_events: [false, false, false, false],
|
||||
plugin_package_lifecycle_heads: [false, false, false, false],
|
||||
plugin_package_lifecycle_receipts: [false, false, false, false],
|
||||
plugin_package_lifecycle_tasks: [false, false, false, false],
|
||||
plugin_package_lifecycle_plans: [false, false, false, false],
|
||||
plugin_package_automation_publications: [true, false, false, false],
|
||||
plugin_package_automation_publication_heads: [true, false, false, false],
|
||||
plugin_package_workflow_admissions: [true, true, false, false],
|
||||
plugin_package_workflow_admission_steps: [true, true, false, false],
|
||||
plugin_package_workflow_task_attempt_admissions: [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
worker_execution_attestations: [true, false, false, false],
|
||||
run_events: [true, true, false, false],
|
||||
run_retry_policies: [true, true, true, false],
|
||||
};
|
||||
return Object.entries(privileges).map(
|
||||
([
|
||||
tableName,
|
||||
[selectAllowed, insertAllowed, updateAllowed, deleteAllowed],
|
||||
]) => ({
|
||||
tableName,
|
||||
selectAllowed,
|
||||
insertAllowed,
|
||||
updateAllowed,
|
||||
deleteAllowed,
|
||||
isOwner: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function databaseResource(events, options = {}) {
|
||||
const contract = postgresqlControlSchemaContract;
|
||||
let firstQuery = true;
|
||||
const pool = {
|
||||
async query(text, values) {
|
||||
events.push('query');
|
||||
if (firstQuery && options.readinessGate) {
|
||||
firstQuery = false;
|
||||
options.onReadinessQuery?.();
|
||||
await options.readinessGate;
|
||||
}
|
||||
if (text.includes("current_setting('server_version_num')")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
serverVersionNum: options.serverVersionNum ?? '160014',
|
||||
currentUser: 'ql3_runtime',
|
||||
inRecovery: false,
|
||||
transactionReadOnly: 'off',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM "ql3"."schema_migrations"')) {
|
||||
return { rows: migrationHistory() };
|
||||
}
|
||||
if (text.includes('FROM "ql3"."schema_capabilities"')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
contractName: contract.contractName,
|
||||
contractVersion: contract.contractVersion,
|
||||
migrationId: contract.migrationId,
|
||||
capabilities: contract.capabilities,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_class tables')) {
|
||||
return {
|
||||
rows: contract.tables.flatMap((table) =>
|
||||
table.columns.map((columnName) => ({
|
||||
tableName: table.name,
|
||||
columnName,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_indexes')) {
|
||||
return { rows: contract.indexes.map((indexName) => ({ indexName })) };
|
||||
}
|
||||
if (text.includes('FROM pg_constraint')) {
|
||||
return {
|
||||
rows: [
|
||||
...contract.checks.map((constraintName) => ({
|
||||
constraintName,
|
||||
constraintType: 'check',
|
||||
})),
|
||||
...contract.foreignKeys.map((constraintName) => ({
|
||||
constraintName,
|
||||
constraintType: 'foreign_key',
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_proc routines')) {
|
||||
return {
|
||||
rows: contract.functions.map((definition) => ({
|
||||
functionName: definition.name,
|
||||
identityArguments: definition.identityArguments,
|
||||
owner: definition.owner,
|
||||
securityDefiner: definition.securityDefiner,
|
||||
volatility: definition.volatility,
|
||||
configuration: definition.configuration,
|
||||
publicExecute: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_catalog.pg_roles')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
canLogin: true,
|
||||
superuser: false,
|
||||
createDatabase: false,
|
||||
createRole: false,
|
||||
replication: false,
|
||||
bypassRowLevelSecurity: false,
|
||||
databaseConnect: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('has_schema_privilege')) {
|
||||
return { rows: [{ schemaUsage: true, schemaCreate: false }] };
|
||||
}
|
||||
if (text.includes('has_table_privilege')) {
|
||||
return { rows: runtimePrivileges() };
|
||||
}
|
||||
if (text.includes('has_function_privilege')) {
|
||||
return {
|
||||
rows: contract.functions.map(({ name: functionName }) => ({
|
||||
functionName,
|
||||
executeAllowed: [
|
||||
'plugin_package_automation_start_allowed',
|
||||
'plugin_package_workflow_admission_snapshot',
|
||||
'plugin_package_workflow_task_attempt_snapshot',
|
||||
'plugin_package_run_start_allowed',
|
||||
'plugin_package_tool_start_allowed',
|
||||
].includes(functionName),
|
||||
isOwner: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (text.includes('WITH observation AS')) {
|
||||
return {
|
||||
rows: options.recoveryRows ?? [
|
||||
{
|
||||
observedAtMs: '1',
|
||||
kind: null,
|
||||
id: null,
|
||||
runId: null,
|
||||
status: null,
|
||||
createdAtMs: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (options.query) return options.query(text, values);
|
||||
throw new Error(`unexpected query: ${text}`);
|
||||
},
|
||||
async connect() {
|
||||
throw new Error('Repository connections are not used by this fixture');
|
||||
},
|
||||
};
|
||||
return {
|
||||
pool,
|
||||
async close() {
|
||||
events.push('close-database');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function baseOptions(events, port, overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
apiCredentialPepper: 'A'.repeat(43),
|
||||
recovery: { ownerId: 'test-replica', providers: [] },
|
||||
availability: new ClusterControlAvailabilityFence(),
|
||||
http: { host: '127.0.0.1', port, drainTimeoutMs: 1000 },
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events);
|
||||
},
|
||||
create({
|
||||
evidence,
|
||||
authenticator,
|
||||
policies,
|
||||
runs,
|
||||
taskDefinitions,
|
||||
taskExecutionRevisions,
|
||||
triggers,
|
||||
schedules,
|
||||
securityAudit,
|
||||
}) {
|
||||
events.push('create-stack');
|
||||
assert.equal(
|
||||
evidence.contractVersion,
|
||||
postgresqlControlSchemaContract.contractVersion,
|
||||
);
|
||||
assert.equal(typeof authenticator.authenticate, 'function');
|
||||
assert.equal(typeof policies.resolve, 'function');
|
||||
assert.equal(typeof runs.transaction, 'function');
|
||||
assert.equal(
|
||||
typeof taskDefinitions.findCurrentTaskDefinition,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof taskExecutionRevisions.resolveClusterTaskExecutionRevision,
|
||||
'function',
|
||||
);
|
||||
assert.equal(typeof triggers.findCurrentTrigger, 'function');
|
||||
assert.equal(typeof schedules.claimNextClusterSchedule, 'function');
|
||||
assert.equal(typeof schedules.commitClusterScheduleDecision, 'function');
|
||||
assert.equal(typeof securityAudit.record, 'function');
|
||||
return {
|
||||
async reconcile() {
|
||||
events.push('reconcile');
|
||||
return { safe: true, remaining: 0, failed: 0 };
|
||||
},
|
||||
async startLifecycles() {
|
||||
events.push('start-lifecycles');
|
||||
return true;
|
||||
},
|
||||
admission: admission(async (incoming) => {
|
||||
events.push(`handle:${incoming.path}`);
|
||||
return { statusCode: 202, body: { accepted: true } };
|
||||
}),
|
||||
async stop() {
|
||||
events.push('stop-stack');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
audit(record) {
|
||||
events.push(`audit:${record.state}`);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('disabled and wrong-profile applications never bind or open PostgreSQL', async () => {
|
||||
const disabledEvents = [];
|
||||
const disabled = await startClusterControlApplication(
|
||||
baseOptions(disabledEvents, 0, { enabled: false }),
|
||||
);
|
||||
assert.equal(disabled.status, 'disabled');
|
||||
assert.deepEqual(disabledEvents, ['audit:disabled']);
|
||||
|
||||
const wrongProfileEvents = [];
|
||||
await assert.rejects(
|
||||
startClusterControlApplication(
|
||||
baseOptions(wrongProfileEvents, 0, { profile: 'standalone' }),
|
||||
),
|
||||
/cannot activate cluster-control/,
|
||||
);
|
||||
assert.deepEqual(wrongProfileEvents, []);
|
||||
});
|
||||
|
||||
test('rejects an invalid credential pepper before binding or opening PostgreSQL', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
startClusterControlApplication(
|
||||
baseOptions(events, 0, { apiCredentialPepper: undefined }),
|
||||
),
|
||||
/API credential configuration is invalid/,
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('rejects an enabled application without an availability source', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
startClusterControlApplication(
|
||||
baseOptions(events, 0, { availability: undefined }),
|
||||
),
|
||||
/availability source is invalid/,
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('composes bearer authentication, fenced Policy and durable audit on one Pool', async (t) => {
|
||||
const pepper = 'A'.repeat(43);
|
||||
const secret = Buffer.alloc(32, 2).toString('base64url');
|
||||
const digest = apiCredentialSecretDigest(pepper, 'app_primary', secret);
|
||||
const auditWrites = [];
|
||||
const events = [];
|
||||
const port = await freePort();
|
||||
const result = await startClusterControlApplication(
|
||||
baseOptions(events, port, {
|
||||
apiCredentialPepper: pepper,
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events, {
|
||||
async query(text, values) {
|
||||
if (text.includes('FROM "ql3"."api_credentials"')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
credentialId: 'app_primary',
|
||||
version: '1',
|
||||
state: 'active',
|
||||
subjectType: 'api_app',
|
||||
subjectId: 'app_primary',
|
||||
subjectStatus: 'active',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
secretDigest: digest,
|
||||
createdAtMs: '1',
|
||||
notBeforeAtMs: '1',
|
||||
expiresAtMs: String(Date.now() + 60_000),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM "ql3"."projects" AS project')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
projectId: 'default',
|
||||
projectName: 'Default',
|
||||
projectSlug: 'default',
|
||||
projectStatus: 'active',
|
||||
projectVersion: '2',
|
||||
projectCreatedAtMs: '0',
|
||||
projectUpdatedAtMs: '1',
|
||||
bindingProjectId: 'default',
|
||||
bindingSubjectType: 'api_app',
|
||||
bindingSubjectId: 'app_primary',
|
||||
bindingVersion: '3',
|
||||
bindingState: 'active',
|
||||
bindingRole: 'operator',
|
||||
bindingMutationId: 'grant-app',
|
||||
bindingChangedByType: 'user',
|
||||
bindingChangedById: 'usr_owner',
|
||||
bindingCreatedAtMs: '1',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.startsWith('INSERT INTO "ql3"."security_audit_events"')) {
|
||||
auditWrites.push(values);
|
||||
return { rows: [] };
|
||||
}
|
||||
throw new Error(`unexpected repository query: ${text}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
create({ authenticator, policies, securityAudit }) {
|
||||
events.push('create-stack');
|
||||
return {
|
||||
async reconcile() {
|
||||
return { safe: true, remaining: 0, failed: 0 };
|
||||
},
|
||||
async startLifecycles() {
|
||||
return true;
|
||||
},
|
||||
admission: createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/{projectId}/runs',
|
||||
operationId: 'run.create',
|
||||
permission: 'run.start',
|
||||
projectParameter: 'projectId',
|
||||
handle(input) {
|
||||
return {
|
||||
statusCode: 202,
|
||||
body: {
|
||||
accepted: true,
|
||||
subject: input.principal.subject.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
]),
|
||||
authenticator,
|
||||
policy: createClusterControlProjectPolicyAuthorizer(policies),
|
||||
audit: securityAudit,
|
||||
}),
|
||||
async stop() {
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
t.after(() => result.stop());
|
||||
|
||||
const rejected = await request(port, '/api/v3/projects/default/runs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(1024 * 1024),
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.statusCode, 401);
|
||||
assert.equal(auditWrites.length, 1);
|
||||
assert.equal(auditWrites[0][7], 'authentication_rejected');
|
||||
|
||||
const accepted = await request(port, '/api/v3/projects/default/runs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ql3c_app_primary_${secret}`,
|
||||
},
|
||||
body: { taskId: 'task-1' },
|
||||
});
|
||||
assert.equal(accepted.statusCode, 202);
|
||||
assert.deepEqual(accepted.body, {
|
||||
accepted: true,
|
||||
subject: 'app_primary',
|
||||
});
|
||||
assert.equal(auditWrites.length, 2);
|
||||
assert.equal(auditWrites[1][7], 'allowed');
|
||||
assert.equal(auditWrites[1][9], 2);
|
||||
assert.equal(auditWrites[1][10], 3);
|
||||
assert.equal(JSON.stringify(auditWrites).includes(secret), false);
|
||||
});
|
||||
|
||||
test('serves not-ready while auditing PostgreSQL, then opens admission', async () => {
|
||||
const events = [];
|
||||
const port = await freePort();
|
||||
let releaseReadiness;
|
||||
const readinessGate = new Promise((resolve) => {
|
||||
releaseReadiness = resolve;
|
||||
});
|
||||
let readinessStarted;
|
||||
const readinessStartedPromise = new Promise((resolve) => {
|
||||
readinessStarted = resolve;
|
||||
});
|
||||
const starting = startClusterControlApplication(
|
||||
baseOptions(events, port, {
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events, {
|
||||
readinessGate,
|
||||
onReadinessQuery: readinessStarted,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
await readinessStartedPromise;
|
||||
assert.equal((await request(port, '/livez')).statusCode, 200);
|
||||
assert.deepEqual(await request(port, '/readyz'), {
|
||||
statusCode: 503,
|
||||
body: { status: 'not_ready' },
|
||||
});
|
||||
assert.equal(events.includes('create-stack'), false);
|
||||
|
||||
releaseReadiness();
|
||||
const application = await starting;
|
||||
assert.equal(application.status, 'active');
|
||||
assert.deepEqual(await request(port, '/readyz'), {
|
||||
statusCode: 200,
|
||||
body: { status: 'ready' },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await request(port, '/api/v3/runs', {
|
||||
method: 'POST',
|
||||
body: { taskId: 'task-1' },
|
||||
}),
|
||||
{ statusCode: 202, body: { accepted: true } },
|
||||
);
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.deepEqual(events.slice(-3), [
|
||||
'stop-stack',
|
||||
'audit:stopped',
|
||||
'close-database',
|
||||
]);
|
||||
await assert.rejects(request(port, '/livez'));
|
||||
});
|
||||
|
||||
test('withdraws and drains application admission before stack and Pool stop', async () => {
|
||||
const events = [];
|
||||
const port = await freePort();
|
||||
let entered;
|
||||
const enteredPromise = new Promise((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release;
|
||||
const handlerGate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const application = await startClusterControlApplication(
|
||||
baseOptions(events, port, {
|
||||
create(input) {
|
||||
const stack = baseOptions([], port).create(input);
|
||||
return {
|
||||
...stack,
|
||||
admission: admission(async () => {
|
||||
events.push('handle:slow');
|
||||
entered();
|
||||
await handlerGate;
|
||||
events.push('handler-finished');
|
||||
return { statusCode: 200, body: { completed: true } };
|
||||
}),
|
||||
async stop() {
|
||||
events.push('stop-stack');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
const admitted = request(port, '/api/v3/slow');
|
||||
await enteredPromise;
|
||||
const stopping = application.stop();
|
||||
assert.deepEqual(await request(port, '/readyz'), {
|
||||
statusCode: 503,
|
||||
body: { status: 'not_ready' },
|
||||
});
|
||||
assert.equal(events.includes('stop-stack'), false);
|
||||
release();
|
||||
assert.deepEqual(await admitted, {
|
||||
statusCode: 503,
|
||||
body: { code: 'admission_draining' },
|
||||
});
|
||||
assert.equal(await stopping, 'stopped');
|
||||
assert.ok(events.indexOf('handler-finished') < events.indexOf('stop-stack'));
|
||||
assert.ok(events.indexOf('stop-stack') < events.indexOf('close-database'));
|
||||
});
|
||||
|
||||
test('a database availability signal withdraws admission but keeps liveness', async () => {
|
||||
const events = [];
|
||||
const port = await freePort();
|
||||
const availability = new ClusterControlAvailabilityFence();
|
||||
const application = await startClusterControlApplication(
|
||||
baseOptions(events, port, { availability }),
|
||||
);
|
||||
assert.equal(application.availabilityStatus(), 'ready');
|
||||
assert.equal((await request(port, '/readyz')).statusCode, 200);
|
||||
|
||||
const unavailable = new Error('PostgreSQL connection lost');
|
||||
assert.equal(await availability.signal(unavailable), 'signaled');
|
||||
assert.equal(await application.unavailable, unavailable);
|
||||
assert.equal(application.availabilityStatus(), 'unavailable');
|
||||
assert.deepEqual(await request(port, '/readyz'), {
|
||||
statusCode: 503,
|
||||
body: { status: 'not_ready' },
|
||||
});
|
||||
assert.equal((await request(port, '/livez')).statusCode, 200);
|
||||
assert.deepEqual(
|
||||
await request(port, '/api/v3/runs', {
|
||||
method: 'POST',
|
||||
body: { taskId: 'task-1' },
|
||||
}),
|
||||
{ statusCode: 503, body: { code: 'not_ready' } },
|
||||
);
|
||||
assert.ok(events.indexOf('stop-stack') < events.indexOf('close-database'));
|
||||
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(application.availabilityStatus(), 'stopped');
|
||||
await assert.rejects(request(port, '/livez'));
|
||||
});
|
||||
|
||||
test('an availability signal raised before startup cannot open admission', async () => {
|
||||
const events = [];
|
||||
const port = await freePort();
|
||||
const availability = new ClusterControlAvailabilityFence();
|
||||
assert.equal(
|
||||
await availability.signal(new Error('PostgreSQL unavailable at startup')),
|
||||
'signaled',
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
startClusterControlApplication(baseOptions(events, port, { availability })),
|
||||
(error) => error?.code === 'CLUSTER_CONTROL_DATABASE_UNAVAILABLE',
|
||||
);
|
||||
assert.ok(events.indexOf('stop-stack') < events.indexOf('close-database'));
|
||||
await assert.rejects(request(port, '/livez'));
|
||||
});
|
||||
|
||||
test('readiness failure closes both PostgreSQL and the probe listener', async () => {
|
||||
const events = [];
|
||||
const port = await freePort();
|
||||
await assert.rejects(
|
||||
startClusterControlApplication(
|
||||
baseOptions(events, port, {
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events, { serverVersionNum: '150018' });
|
||||
},
|
||||
}),
|
||||
),
|
||||
(error) =>
|
||||
error instanceof PostgresSchemaReadinessError &&
|
||||
error.code === 'server_version_unsupported',
|
||||
);
|
||||
assert.equal(events.includes('create-stack'), false);
|
||||
assert.deepEqual(events.slice(-2), ['audit:failed', 'close-database']);
|
||||
await assert.rejects(request(port, '/livez'));
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterControlAuthenticationShield,
|
||||
} = require('../dist/authentication/authenticationShield.js');
|
||||
|
||||
function shield(overrides = {}) {
|
||||
let currentTime = 1000;
|
||||
const instance = createClusterControlAuthenticationShield({
|
||||
windowMs: 1000,
|
||||
maxRequestsPerPeer: 1,
|
||||
maxRequestsGlobal: 3,
|
||||
maxTrackedPeers: 2,
|
||||
now: () => currentTime,
|
||||
...overrides,
|
||||
});
|
||||
return {
|
||||
instance,
|
||||
advance(milliseconds) {
|
||||
currentTime += milliseconds;
|
||||
},
|
||||
setTime(value) {
|
||||
currentTime = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertAllowed(result) {
|
||||
assert.equal(result.allowed, true);
|
||||
assert.equal(typeof result.refund, 'function');
|
||||
return result;
|
||||
}
|
||||
|
||||
test('bounds per-peer and aggregate attempts in a process-local window', () => {
|
||||
const { instance } = shield();
|
||||
|
||||
assertAllowed(instance.consume('192.0.2.10'));
|
||||
assert.deepEqual(instance.consume('192.0.2.10'), {
|
||||
allowed: false,
|
||||
reason: 'peer',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
assertAllowed(instance.consume('192.0.2.11'));
|
||||
assert.deepEqual(instance.consume('192.0.2.12'), {
|
||||
allowed: false,
|
||||
reason: 'global',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
instance.close();
|
||||
});
|
||||
|
||||
test('keeps peer state bounded and reclaims expired windows lazily', () => {
|
||||
const clock = shield({ maxRequestsGlobal: 20 });
|
||||
|
||||
assertAllowed(clock.instance.consume('192.0.2.10'));
|
||||
assertAllowed(clock.instance.consume('192.0.2.11'));
|
||||
assert.deepEqual(clock.instance.consume('192.0.2.12'), {
|
||||
allowed: false,
|
||||
reason: 'capacity',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
clock.advance(1000);
|
||||
assertAllowed(clock.instance.consume('192.0.2.12'));
|
||||
clock.instance.close();
|
||||
});
|
||||
|
||||
test('fails closed on a broken monotonic clock and after disposal', () => {
|
||||
const clock = shield({ maxRequestsPerPeer: 20, maxRequestsGlobal: 20 });
|
||||
assertAllowed(clock.instance.consume(undefined));
|
||||
clock.setTime(999);
|
||||
assert.deepEqual(clock.instance.consume(undefined), {
|
||||
allowed: false,
|
||||
reason: 'clock',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
clock.instance.close();
|
||||
assert.deepEqual(clock.instance.consume('192.0.2.10'), {
|
||||
allowed: false,
|
||||
reason: 'clock',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
test('refunds only the exact successful authentication attempt once', () => {
|
||||
const { instance } = shield({
|
||||
maxRequestsPerPeer: 1,
|
||||
maxRequestsGlobal: 1,
|
||||
});
|
||||
const first = assertAllowed(instance.consume('192.0.2.10'));
|
||||
assert.deepEqual(instance.consume('192.0.2.10'), {
|
||||
allowed: false,
|
||||
reason: 'global',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
|
||||
first.refund();
|
||||
first.refund();
|
||||
const second = assertAllowed(instance.consume('192.0.2.10'));
|
||||
second.refund();
|
||||
assertAllowed(instance.consume('192.0.2.11'));
|
||||
instance.close();
|
||||
});
|
||||
|
||||
test('a delayed refund cannot alter a replacement window', () => {
|
||||
const clock = shield({
|
||||
maxRequestsPerPeer: 1,
|
||||
maxRequestsGlobal: 1,
|
||||
});
|
||||
const expired = assertAllowed(clock.instance.consume('192.0.2.10'));
|
||||
clock.advance(1000);
|
||||
assertAllowed(clock.instance.consume('192.0.2.10'));
|
||||
|
||||
expired.refund();
|
||||
assert.deepEqual(clock.instance.consume('192.0.2.11'), {
|
||||
allowed: false,
|
||||
reason: 'global',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
clock.instance.close();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlAvailabilityFence,
|
||||
} = require('@qinglong/cluster-control/availability');
|
||||
|
||||
test('signals one bound listener exactly once without a retry path', async () => {
|
||||
const fence = new ClusterControlAvailabilityFence();
|
||||
const reasons = [];
|
||||
const unsubscribe = fence.subscribe(async (error) => {
|
||||
reasons.push(error.message);
|
||||
});
|
||||
|
||||
assert.equal(fence.status, 'available');
|
||||
assert.equal(await fence.signal(new Error('connection lost')), 'signaled');
|
||||
assert.equal(fence.status, 'unavailable');
|
||||
assert.equal(
|
||||
await fence.signal(new Error('second connection lost')),
|
||||
'already_unavailable',
|
||||
);
|
||||
assert.deepEqual(reasons, ['connection lost']);
|
||||
unsubscribe();
|
||||
unsubscribe();
|
||||
fence.dispose();
|
||||
assert.equal(fence.status, 'disposed');
|
||||
assert.equal(await fence.signal(new Error('after dispose')), 'disposed');
|
||||
});
|
||||
|
||||
test('delivers an early signal when the single application listener binds', async () => {
|
||||
const fence = new ClusterControlAvailabilityFence();
|
||||
assert.equal(await fence.signal(new Error('early failure')), 'signaled');
|
||||
let reason;
|
||||
fence.subscribe((error) => {
|
||||
reason = error.message;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(reason, 'early failure');
|
||||
assert.throws(() => fence.subscribe(() => {}), /already bound/);
|
||||
await assert.rejects(() => fence.signal('invalid'), /error is invalid/);
|
||||
});
|
||||
@@ -0,0 +1,753 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
PostgresSchemaReadinessError,
|
||||
postgresqlControlSchemaContract,
|
||||
postgresqlMainMigrationStream,
|
||||
} = require('@qinglong/cluster-postgres');
|
||||
const { bootstrapClusterControlRuntime } = require('@qinglong/cluster-control');
|
||||
|
||||
function migrationHistory() {
|
||||
return postgresqlMainMigrationStream.migrations.map((migration, index) => ({
|
||||
streamId: 'postgresql-main',
|
||||
dialect: 'postgresql',
|
||||
migrationId: migration.id,
|
||||
checksum: migration.checksum,
|
||||
appliedAtMs: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function runtimePrivileges() {
|
||||
const privileges = {
|
||||
schema_migrations: [true, false, false, false],
|
||||
schema_capabilities: [true, false, false, false],
|
||||
projects: [true, true, true, false],
|
||||
task_definitions: [true, false, false, false],
|
||||
task_definition_revisions: [true, false, false, false],
|
||||
task_execution_revisions: [true, false, false, false],
|
||||
triggers: [true, false, false, false],
|
||||
trigger_revisions: [true, false, false, false],
|
||||
trigger_schedules: [true, false, true, false],
|
||||
project_role_bindings: [true, true, false, false],
|
||||
identity_subjects: [true, false, false, false],
|
||||
api_credentials: [true, false, false, false],
|
||||
security_audit_events: [false, true, false, false],
|
||||
identity_subject_mutations: [false, false, false, false],
|
||||
api_credential_mutations: [false, false, false, false],
|
||||
runs: [true, true, true, false],
|
||||
step_runs: [true, true, true, false],
|
||||
step_run_mutations: [true, true, false, false],
|
||||
tool_execution_trace_anchors: [true, true, false, false],
|
||||
tool_execution_audit_receipts: [true, true, false, false],
|
||||
tool_execution_start_barriers: [true, true, false, false],
|
||||
tool_execution_start_artifact_bindings: [true, true, false, false],
|
||||
tool_execution_completions: [true, true, false, false],
|
||||
tool_execution_failure_completions: [true, true, false, false],
|
||||
tool_result_key_catalog_generations: [true, false, false, false],
|
||||
tool_execution_result_key_bindings: [true, true, false, false],
|
||||
tool_execution_result_rekey_overlays: [true, false, false, false],
|
||||
tool_execution_result_rekey_heads: [true, false, false, false],
|
||||
tool_result_key_retirement_receipts: [false, false, false, false],
|
||||
tool_invocation_input_artifacts: [true, true, false, false],
|
||||
tool_invocation_preview_artifacts: [true, true, false, false],
|
||||
run_attempts: [true, true, true, false],
|
||||
run_recovery_controls: [true, true, true, false],
|
||||
worker_sessions: [true, true, true, false],
|
||||
run_dispatch_leases: [true, true, true, false],
|
||||
worker_credentials: [false, false, false, false],
|
||||
worker_credential_mutations: [false, false, false, false],
|
||||
worker_credential_deliveries: [false, false, false, false],
|
||||
worker_credential_stage_discards: [false, false, false, false],
|
||||
worker_credential_management_plans: [false, false, false, false],
|
||||
worker_credential_management_quota_buckets: [false, false, false, false],
|
||||
plugin_package_installs: [false, false, false, false],
|
||||
plugin_package_install_heads: [false, false, false, false],
|
||||
plugin_package_install_mutations: [false, false, false, false],
|
||||
approval_requests: [false, false, false, false],
|
||||
approved_action_dispatches: [false, false, false, false],
|
||||
approved_action_executions: [false, false, false, false],
|
||||
plugin_package_install_proposals: [false, false, false, false],
|
||||
plugin_package_admission_receipts: [false, false, false, false],
|
||||
plugin_package_management_quota_buckets: [false, false, false, false],
|
||||
plugin_package_identity_keyset_ledger: [false, false, false, false],
|
||||
plugin_package_materialized_revisions: [false, false, false, false],
|
||||
plugin_package_task_ownerships: [false, false, false, false],
|
||||
plugin_package_task_reconciliations: [false, false, false, false],
|
||||
plugin_package_task_reconciliation_items: [false, false, false, false],
|
||||
project_tool_definition_snapshots: [false, false, false, false],
|
||||
project_tool_definition_snapshot_sources: [false, false, false, false],
|
||||
plugin_package_publisher_provenance: [false, false, false, false],
|
||||
plugin_package_publisher_revocation_impact_items: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_publisher_revocation_impacts: [false, false, false, false],
|
||||
plugin_package_publisher_revocation_proposals: [false, false, false, false],
|
||||
plugin_package_publisher_revocation_receipts: [false, false, false, false],
|
||||
plugin_package_publisher_trust_heads: [false, false, false, false],
|
||||
plugin_package_publisher_trust_snapshots: [false, false, false, false],
|
||||
plugin_package_publisher_trust_transition_proposals: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_publisher_trust_transition_receipts: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_quarantine_events: [false, false, false, false],
|
||||
plugin_package_withdrawal_receipts: [false, false, false, false],
|
||||
plugin_package_withdrawal_tasks: [false, false, false, false],
|
||||
plugin_package_lifecycle_events: [false, false, false, false],
|
||||
plugin_package_lifecycle_heads: [false, false, false, false],
|
||||
plugin_package_lifecycle_receipts: [false, false, false, false],
|
||||
plugin_package_lifecycle_tasks: [false, false, false, false],
|
||||
plugin_package_lifecycle_plans: [false, false, false, false],
|
||||
plugin_package_automation_publications: [true, false, false, false],
|
||||
plugin_package_automation_publication_heads: [true, false, false, false],
|
||||
plugin_package_workflow_admissions: [true, true, false, false],
|
||||
plugin_package_workflow_admission_steps: [true, true, false, false],
|
||||
plugin_package_workflow_task_attempt_admissions: [true, true, false, false],
|
||||
worker_execution_attestations: [true, false, false, false],
|
||||
run_events: [true, true, false, false],
|
||||
run_retry_policies: [true, true, true, false],
|
||||
};
|
||||
return Object.entries(privileges).map(
|
||||
([
|
||||
tableName,
|
||||
[selectAllowed, insertAllowed, updateAllowed, deleteAllowed],
|
||||
]) => ({
|
||||
tableName,
|
||||
selectAllowed,
|
||||
insertAllowed,
|
||||
updateAllowed,
|
||||
deleteAllowed,
|
||||
isOwner: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function databaseResource(events, overrides = {}) {
|
||||
const contract = postgresqlControlSchemaContract;
|
||||
const pool = {
|
||||
async query(text) {
|
||||
events.push(
|
||||
`query:${
|
||||
events.filter((event) => event.startsWith('query:')).length + 1
|
||||
}`,
|
||||
);
|
||||
if (text.includes("current_setting('server_version_num')")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
serverVersionNum: overrides.serverVersionNum ?? '160014',
|
||||
currentUser: 'ql3_runtime',
|
||||
inRecovery: false,
|
||||
transactionReadOnly: 'off',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM "ql3"."schema_migrations"')) {
|
||||
return { rows: migrationHistory() };
|
||||
}
|
||||
if (text.includes('FROM "ql3"."schema_capabilities"')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
contractName: contract.contractName,
|
||||
contractVersion: contract.contractVersion,
|
||||
migrationId: contract.migrationId,
|
||||
capabilities: contract.capabilities,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_class tables')) {
|
||||
return {
|
||||
rows: contract.tables.flatMap((table) =>
|
||||
table.columns.map((columnName) => ({
|
||||
tableName: table.name,
|
||||
columnName,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_indexes')) {
|
||||
return {
|
||||
rows: contract.indexes.map((indexName) => ({ indexName })),
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_constraint')) {
|
||||
return {
|
||||
rows: [
|
||||
...contract.checks.map((constraintName) => ({
|
||||
constraintName,
|
||||
constraintType: 'check',
|
||||
})),
|
||||
...contract.foreignKeys.map((constraintName) => ({
|
||||
constraintName,
|
||||
constraintType: 'foreign_key',
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_proc routines')) {
|
||||
return {
|
||||
rows: contract.functions.map((definition) => ({
|
||||
functionName: definition.name,
|
||||
identityArguments: definition.identityArguments,
|
||||
owner: definition.owner,
|
||||
securityDefiner: definition.securityDefiner,
|
||||
volatility: definition.volatility,
|
||||
configuration: definition.configuration,
|
||||
publicExecute: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM pg_catalog.pg_roles')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
canLogin: true,
|
||||
superuser: false,
|
||||
createDatabase: false,
|
||||
createRole: false,
|
||||
replication: false,
|
||||
bypassRowLevelSecurity: false,
|
||||
databaseConnect: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.includes('has_schema_privilege')) {
|
||||
return { rows: [{ schemaUsage: true, schemaCreate: false }] };
|
||||
}
|
||||
if (text.includes('has_table_privilege')) {
|
||||
return { rows: runtimePrivileges() };
|
||||
}
|
||||
if (text.includes('has_function_privilege')) {
|
||||
return {
|
||||
rows: contract.functions.map(({ name: functionName }) => ({
|
||||
functionName,
|
||||
executeAllowed: [
|
||||
'plugin_package_automation_start_allowed',
|
||||
'plugin_package_workflow_admission_snapshot',
|
||||
'plugin_package_workflow_task_attempt_snapshot',
|
||||
'plugin_package_run_start_allowed',
|
||||
'plugin_package_tool_start_allowed',
|
||||
].includes(functionName),
|
||||
isOwner: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (
|
||||
overrides.runtimeScans &&
|
||||
text.includes('JOIN LATERAL') &&
|
||||
text.includes('"ql3"."run_retry_policies"')
|
||||
) {
|
||||
events.push('scan:lost-retry');
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (
|
||||
text.includes('WITH observation AS') &&
|
||||
!text.includes('FROM "ql3"."trigger_schedules"')
|
||||
) {
|
||||
return {
|
||||
rows: overrides.recoveryRows ?? [
|
||||
{
|
||||
observedAtMs: '1',
|
||||
kind: null,
|
||||
id: null,
|
||||
runId: null,
|
||||
status: null,
|
||||
createdAtMs: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (
|
||||
overrides.runtimeScans &&
|
||||
text.includes('FROM "ql3"."trigger_schedules"')
|
||||
) {
|
||||
events.push('scan:schedules');
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (
|
||||
overrides.runtimeScans &&
|
||||
text.includes(
|
||||
'FROM "ql3"."plugin_package_workflow_admissions" AS admission',
|
||||
)
|
||||
) {
|
||||
events.push('scan:workflow-frontier');
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
throw new Error(`unexpected query: ${text}`);
|
||||
},
|
||||
async connect() {
|
||||
if (overrides.runtimeScans) {
|
||||
return {
|
||||
async query(text) {
|
||||
if (
|
||||
text === 'BEGIN ISOLATION LEVEL READ COMMITTED' ||
|
||||
text.startsWith('SET LOCAL ') ||
|
||||
text === 'COMMIT' ||
|
||||
text === 'ROLLBACK'
|
||||
) {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (
|
||||
text.includes('attempt.lease_expires_at_ms') &&
|
||||
text.includes('LEFT JOIN candidates AS candidate')
|
||||
) {
|
||||
events.push('scan:runtime-recovery');
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
observedAtMs: '1',
|
||||
attemptId: null,
|
||||
runId: null,
|
||||
status: null,
|
||||
createdAtMs: null,
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
};
|
||||
}
|
||||
if (
|
||||
text.includes(
|
||||
'"ql3"."plugin_package_workflow_task_attempt_admissions"',
|
||||
)
|
||||
) {
|
||||
events.push('scan:workflow-task-attempts');
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
throw new Error(`unexpected repository query: ${text}`);
|
||||
},
|
||||
release() {
|
||||
events.push('release:workflow-task-attempts');
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Repository connections are not used during bootstrap');
|
||||
},
|
||||
};
|
||||
return {
|
||||
pool,
|
||||
async close() {
|
||||
events.push('close-database');
|
||||
if (overrides.closeError) throw overrides.closeError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function activationStack(
|
||||
events,
|
||||
recovery = { safe: true, remaining: 0, failed: 0 },
|
||||
) {
|
||||
return {
|
||||
async reconcile() {
|
||||
events.push('reconcile');
|
||||
return recovery;
|
||||
},
|
||||
async startLifecycles() {
|
||||
events.push('start-lifecycles');
|
||||
return true;
|
||||
},
|
||||
installAdmission() {
|
||||
events.push('install-admission');
|
||||
return () => events.push('dispose-admission');
|
||||
},
|
||||
async stop() {
|
||||
events.push('stop-stack');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapOptions(events, overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
apiCredentialPepper: 'A'.repeat(43),
|
||||
recovery: { ownerId: 'test-replica', providers: [] },
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events);
|
||||
},
|
||||
create(input) {
|
||||
const {
|
||||
evidence,
|
||||
authenticator,
|
||||
policies,
|
||||
runs,
|
||||
runCancellation,
|
||||
taskDefinitions,
|
||||
taskExecutionRevisions,
|
||||
triggers,
|
||||
schedules,
|
||||
trustedToolStorage,
|
||||
securityAudit,
|
||||
} = input;
|
||||
events.push('create-stack');
|
||||
assert.equal('pool' in input, false);
|
||||
assert.equal('recovery' in input, false);
|
||||
assert.equal('recoveryClaims' in input, false);
|
||||
assert.equal('recoveryTransitions' in input, false);
|
||||
assert.equal(
|
||||
evidence.contractVersion,
|
||||
postgresqlControlSchemaContract.contractVersion,
|
||||
);
|
||||
assert.equal(typeof authenticator.authenticate, 'function');
|
||||
assert.equal(typeof policies.resolve, 'function');
|
||||
assert.equal(typeof runs.transaction, 'function');
|
||||
assert.equal(typeof runCancellation.requestUserCancellation, 'function');
|
||||
assert.equal(
|
||||
typeof taskDefinitions.findCurrentTaskDefinition,
|
||||
'function',
|
||||
);
|
||||
assert.equal('appendTaskDefinitionRevision' in taskDefinitions, false);
|
||||
assert.equal(
|
||||
typeof taskExecutionRevisions.resolveClusterTaskExecutionRevision,
|
||||
'function',
|
||||
);
|
||||
assert.equal(typeof triggers.findCurrentTrigger, 'function');
|
||||
assert.equal('appendTriggerRevision' in triggers, false);
|
||||
assert.equal(typeof schedules.claimNextClusterSchedule, 'function');
|
||||
assert.equal(typeof schedules.commitClusterScheduleDecision, 'function');
|
||||
assert.equal(Object.isFrozen(trustedToolStorage), true);
|
||||
assert.equal(
|
||||
typeof trustedToolStorage.invocationArtifacts.findInput,
|
||||
'function',
|
||||
);
|
||||
assert.equal(typeof trustedToolStorage.stepRuns.findById, 'function');
|
||||
assert.equal(
|
||||
typeof trustedToolStorage.startBarriers.findByStartId,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof trustedToolStorage.completions.findByStartId,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof trustedToolStorage.failureCompletions.findByStartId,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof trustedToolStorage.resultKeyCatalog.findCurrent,
|
||||
'function',
|
||||
);
|
||||
assert.equal(trustedToolStorage.resultKeyCatalog.append, undefined);
|
||||
assert.equal(
|
||||
typeof trustedToolStorage.resultRekeys.findHeadByArtifactId,
|
||||
'function',
|
||||
);
|
||||
assert.equal(trustedToolStorage.resultRekeys.append, undefined);
|
||||
assert.equal(
|
||||
typeof trustedToolStorage.toolDefinitionSnapshots.findCurrent,
|
||||
'function',
|
||||
);
|
||||
assert.equal(typeof securityAudit.record, 'function');
|
||||
return activationStack(events);
|
||||
},
|
||||
audit(record) {
|
||||
events.push(`audit:${record.state}`);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('disabled and wrong-profile bootstrap never opens PostgreSQL', async () => {
|
||||
const disabledEvents = [];
|
||||
const disabled = await bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(disabledEvents, { enabled: false }),
|
||||
);
|
||||
assert.equal(disabled.status, 'disabled');
|
||||
assert.deepEqual(disabledEvents, ['audit:disabled']);
|
||||
|
||||
const wrongProfileEvents = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(wrongProfileEvents, { profile: 'edge' }),
|
||||
),
|
||||
/cannot activate cluster-control/,
|
||||
);
|
||||
assert.deepEqual(wrongProfileEvents, []);
|
||||
});
|
||||
|
||||
test('rejects a missing credential pepper before opening PostgreSQL', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(events, { apiCredentialPepper: undefined }),
|
||||
),
|
||||
/API credential configuration is invalid/,
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('rejects missing or unbounded recovery configuration before opening PostgreSQL', async () => {
|
||||
const missingEvents = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(missingEvents, { recovery: undefined }),
|
||||
),
|
||||
/requires bounded recovery configuration/,
|
||||
);
|
||||
assert.deepEqual(missingEvents, []);
|
||||
|
||||
const timeoutEvents = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(timeoutEvents, {
|
||||
recovery: {
|
||||
ownerId: 'test-replica',
|
||||
claimLeaseMs: 1_000,
|
||||
providerTimeoutMs: 900,
|
||||
},
|
||||
}),
|
||||
),
|
||||
/250ms for fenced settlement/,
|
||||
);
|
||||
assert.deepEqual(timeoutEvents, []);
|
||||
|
||||
const localClockEvents = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(localClockEvents, {
|
||||
scheduler: { clock: () => 1 },
|
||||
}),
|
||||
),
|
||||
/scheduler configuration is invalid/,
|
||||
);
|
||||
assert.deepEqual(localClockEvents, []);
|
||||
});
|
||||
|
||||
test('readiness failure closes the database before returning the root error', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(events, {
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events, { serverVersionNum: '150018' });
|
||||
},
|
||||
}),
|
||||
),
|
||||
(error) =>
|
||||
error instanceof PostgresSchemaReadinessError &&
|
||||
error.code === 'server_version_unsupported',
|
||||
);
|
||||
assert.equal(events.includes('create-stack'), false);
|
||||
assert.deepEqual(events.slice(-2), ['audit:failed', 'close-database']);
|
||||
});
|
||||
|
||||
test('opens once, assembles after readiness, and closes after stack shutdown', async () => {
|
||||
const events = [];
|
||||
const result = await bootstrapClusterControlRuntime(bootstrapOptions(events));
|
||||
assert.equal(result.status, 'active');
|
||||
assert.equal(events.filter((event) => event === 'open-database').length, 1);
|
||||
assert.ok(events.indexOf('create-stack') > events.lastIndexOf('query:8'));
|
||||
const first = result.stop();
|
||||
assert.equal(first, result.stop());
|
||||
assert.equal(await first, 'stopped');
|
||||
assert.deepEqual(events.slice(-4), [
|
||||
'dispose-admission',
|
||||
'stop-stack',
|
||||
'audit:stopped',
|
||||
'close-database',
|
||||
]);
|
||||
});
|
||||
|
||||
test('injects reviewed Worker operations without exposing the runtime Pool', async () => {
|
||||
const events = [];
|
||||
const artifactStore = {
|
||||
async put() {
|
||||
throw new Error('not invoked during assembly');
|
||||
},
|
||||
async inspect() {
|
||||
throw new Error('not invoked during assembly');
|
||||
},
|
||||
};
|
||||
const result = await bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(events, {
|
||||
workerRuntime: { artifactStore },
|
||||
create(input) {
|
||||
events.push('create-stack');
|
||||
assert.equal('pool' in input, false);
|
||||
assert.equal(Object.isFrozen(input.workerRuntime), true);
|
||||
assert.equal(typeof input.workerRuntime.offers.claimNext, 'function');
|
||||
assert.equal(
|
||||
typeof input.workerRuntime.activation.acknowledgeStarting,
|
||||
'function',
|
||||
);
|
||||
assert.equal(input.workerRuntime.secrets, undefined);
|
||||
assert.equal(typeof input.workerRuntime.artifacts.upload, 'function');
|
||||
assert.equal(
|
||||
typeof input.workerRuntime.completion.complete,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof input.workerRuntime.leaseControl.control,
|
||||
'function',
|
||||
);
|
||||
return activationStack(events);
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(result.status, 'active');
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
});
|
||||
|
||||
test('production scheduler cadence scans schedules, Workflow frontier, and Task Attempt admission', async () => {
|
||||
const events = [];
|
||||
let resolveCycle;
|
||||
const cycle = new Promise((resolve) => {
|
||||
resolveCycle = resolve;
|
||||
});
|
||||
const result = await bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(events, {
|
||||
scheduler: {
|
||||
intervalMs: 250,
|
||||
onDiagnostic(error, summary) {
|
||||
resolveCycle({ error, summary });
|
||||
},
|
||||
},
|
||||
cancellationConvergence: { intervalMs: 60 * 60_000 },
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events, { runtimeScans: true });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const observed = await Promise.race([
|
||||
cycle,
|
||||
new Promise((_, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('production scheduler cycle did not run')),
|
||||
2_000,
|
||||
);
|
||||
timeout.unref?.();
|
||||
}),
|
||||
]);
|
||||
assert.equal(observed.error, undefined);
|
||||
assert.deepEqual(observed.summary, {
|
||||
firstClaimAcquiredAtMs: null,
|
||||
lastClaimAcquiredAtMs: null,
|
||||
claimed: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
saturated: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.startsWith('scan:')),
|
||||
[
|
||||
'scan:runtime-recovery',
|
||||
'scan:lost-retry',
|
||||
'scan:schedules',
|
||||
'scan:workflow-frontier',
|
||||
'scan:workflow-task-attempts',
|
||||
],
|
||||
);
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
assert.ok(events.includes('release:workflow-task-attempts'));
|
||||
});
|
||||
|
||||
test('unsafe recovery stops the stack and closes the database', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(events, {
|
||||
create({ runs }) {
|
||||
events.push('create-stack');
|
||||
assert.equal(typeof runs.findRunById, 'function');
|
||||
return activationStack(events, {
|
||||
safe: false,
|
||||
remaining: 1,
|
||||
failed: 0,
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
/did not converge safely/,
|
||||
);
|
||||
assert.equal(events.includes('install-admission'), false);
|
||||
assert.deepEqual(events.slice(-3), [
|
||||
'stop-stack',
|
||||
'audit:failed',
|
||||
'close-database',
|
||||
]);
|
||||
});
|
||||
|
||||
test('bootstrap-owned recovery blocks a false-safe stack when the claim store is unavailable', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(events, {
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events, {
|
||||
recoveryRows: [
|
||||
{
|
||||
observedAtMs: '1',
|
||||
kind: 'run',
|
||||
id: 'run-1',
|
||||
runId: 'run-1',
|
||||
status: 'running',
|
||||
createdAtMs: '1',
|
||||
},
|
||||
{
|
||||
observedAtMs: '1',
|
||||
kind: 'attempt',
|
||||
id: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'running',
|
||||
createdAtMs: '2',
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
(error) =>
|
||||
error?.name === 'ClusterControlRecoveryStoreError' &&
|
||||
error.retryable === true,
|
||||
);
|
||||
assert.equal(events.includes('install-admission'), false);
|
||||
assert.equal(events.includes('reconcile'), false);
|
||||
assert.deepEqual(events.slice(-3), [
|
||||
'stop-stack',
|
||||
'audit:failed',
|
||||
'close-database',
|
||||
]);
|
||||
});
|
||||
|
||||
test('database close failure does not skip stack shutdown and remains idempotent', async () => {
|
||||
const events = [];
|
||||
const closeError = new Error('database close failed');
|
||||
const result = await bootstrapClusterControlRuntime(
|
||||
bootstrapOptions(events, {
|
||||
async openDatabase() {
|
||||
events.push('open-database');
|
||||
return databaseResource(events, { closeError });
|
||||
},
|
||||
}),
|
||||
);
|
||||
const first = result.stop();
|
||||
assert.equal(first, result.stop());
|
||||
await assert.rejects(first, (error) => error === closeError);
|
||||
assert.deepEqual(events.slice(-4), [
|
||||
'dispose-admission',
|
||||
'stop-stack',
|
||||
'audit:stopped',
|
||||
'close-database',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
} = require('@qinglong/cluster-postgres/runtime');
|
||||
const {
|
||||
ClusterControlConfigError,
|
||||
createClusterControlDatabaseBinding,
|
||||
loadClusterControlConfig,
|
||||
} = require('@qinglong/cluster-control/config');
|
||||
|
||||
const CA_FILE = path.join(__dirname, 'fixtures', 'mtls', 'ca-cert.pem');
|
||||
const CA_BUNDLE = loadPostgresCertificateAuthorityFile(CA_FILE);
|
||||
|
||||
const BASE_ENV = Object.freeze({
|
||||
QL3_CLUSTER_CONTROL_ENABLED: 'true',
|
||||
QL_DEPLOYMENT_PROFILE: 'cluster-control',
|
||||
QL3_POSTGRES_RUNTIME_URL:
|
||||
'postgresql://ql3_runtime:secret@database.internal:5432/qinglong',
|
||||
QL3_POSTGRES_TLS_SERVERNAME: 'database.internal',
|
||||
QL3_API_CREDENTIAL_PEPPER: 'A'.repeat(43),
|
||||
});
|
||||
|
||||
test('disabled configuration does not read PostgreSQL credentials', () => {
|
||||
const reads = [];
|
||||
const environment = new Proxy(
|
||||
{
|
||||
QL3_CLUSTER_CONTROL_ENABLED: 'false',
|
||||
QL_DEPLOYMENT_PROFILE: 'standalone',
|
||||
},
|
||||
{
|
||||
get(target, property, receiver) {
|
||||
reads.push(property);
|
||||
if (
|
||||
property === 'QL3_POSTGRES_RUNTIME_URL' ||
|
||||
property === 'QL3_API_CREDENTIAL_PEPPER'
|
||||
) {
|
||||
throw new Error('credential must not be read');
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(loadClusterControlConfig(environment), {
|
||||
enabled: false,
|
||||
profile: 'standalone',
|
||||
});
|
||||
assert.equal(reads.includes('QL3_POSTGRES_RUNTIME_URL'), false);
|
||||
assert.equal(reads.includes('QL3_API_CREDENTIAL_PEPPER'), false);
|
||||
});
|
||||
|
||||
test('enabled configuration requires the exact cluster-control profile', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
loadClusterControlConfig({
|
||||
...BASE_ENV,
|
||||
QL_DEPLOYMENT_PROFILE: 'standalone',
|
||||
}),
|
||||
ClusterControlConfigError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
loadClusterControlConfig({
|
||||
...BASE_ENV,
|
||||
QL3_CLUSTER_CONTROL_ENABLED: 'yes',
|
||||
}),
|
||||
ClusterControlConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds an exact runtime-only TLS-verified Pool configuration', async () => {
|
||||
const config = loadClusterControlConfig({
|
||||
...BASE_ENV,
|
||||
QL3_CLUSTER_HTTP_HOST: '127.0.0.1',
|
||||
QL3_CLUSTER_HTTP_PORT: '5900',
|
||||
QL3_CLUSTER_HTTP_MAX_IN_FLIGHT: '32',
|
||||
QL3_CLUSTER_AUTH_RATE_WINDOW_MS: '30000',
|
||||
QL3_CLUSTER_AUTH_RATE_PER_PEER: '20',
|
||||
QL3_CLUSTER_AUTH_RATE_GLOBAL: '200',
|
||||
QL3_CLUSTER_AUTH_RATE_MAX_PEERS: '512',
|
||||
QL3_POSTGRES_MAX_CONNECTIONS: '12',
|
||||
QL3_POSTGRES_TLS_SERVERNAME: 'database.internal',
|
||||
QL3_POSTGRES_TLS_CA_FILE: CA_FILE,
|
||||
});
|
||||
assert.equal(config.enabled, true);
|
||||
assert.deepEqual(config.http, {
|
||||
host: '127.0.0.1',
|
||||
port: 5900,
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
maxInFlightRequests: 32,
|
||||
authenticationRateWindowMs: 30_000,
|
||||
authenticationRatePerPeer: 20,
|
||||
authenticationRateGlobal: 200,
|
||||
authenticationRateMaxPeers: 512,
|
||||
requestTimeoutMs: 15_000,
|
||||
drainTimeoutMs: 10_000,
|
||||
});
|
||||
assert.deepEqual(config.database, {
|
||||
connection: {
|
||||
connectionString: BASE_ENV.QL3_POSTGRES_RUNTIME_URL,
|
||||
tls: {
|
||||
mode: 'verify-full',
|
||||
ca: CA_BUNDLE,
|
||||
servername: 'database.internal',
|
||||
},
|
||||
},
|
||||
pool: {
|
||||
applicationName: 'qinglong-cluster-runtime',
|
||||
maxConnections: 12,
|
||||
connectionTimeoutMs: 5_000,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(config.security, {
|
||||
apiCredentialPepper: BASE_ENV.QL3_API_CREDENTIAL_PEPPER,
|
||||
});
|
||||
|
||||
const binding = createClusterControlDatabaseBinding(config);
|
||||
assert.equal(binding.availability.status, 'available');
|
||||
const database = await binding.openDatabase();
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('loads discrete operator-managed runtime credentials without a DSN copy', () => {
|
||||
const {
|
||||
QL3_POSTGRES_RUNTIME_URL: _connectionString,
|
||||
...withoutConnectionString
|
||||
} = BASE_ENV;
|
||||
const config = loadClusterControlConfig({
|
||||
...withoutConnectionString,
|
||||
QL3_POSTGRES_RUNTIME_HOST: 'ql3-postgres-rw.qinglong3-system.svc',
|
||||
QL3_POSTGRES_RUNTIME_PORT: '5432',
|
||||
QL3_POSTGRES_RUNTIME_DATABASE: 'qinglong',
|
||||
QL3_POSTGRES_RUNTIME_USER: 'ql3_runtime',
|
||||
QL3_POSTGRES_RUNTIME_PASSWORD: 'operator-secret',
|
||||
});
|
||||
assert.deepEqual(config.database.connection, {
|
||||
host: 'ql3-postgres-rw.qinglong3-system.svc',
|
||||
port: 5432,
|
||||
database: 'qinglong',
|
||||
user: 'ql3_runtime',
|
||||
password: 'operator-secret',
|
||||
tls: {
|
||||
mode: 'verify-full',
|
||||
servername: 'database.internal',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('requires a second explicit gate before disabling PostgreSQL TLS', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
loadClusterControlConfig({
|
||||
...BASE_ENV,
|
||||
QL3_POSTGRES_TLS_MODE: 'disable',
|
||||
}),
|
||||
/requires QL3_POSTGRES_ALLOW_INSECURE=true/,
|
||||
);
|
||||
const config = loadClusterControlConfig({
|
||||
...BASE_ENV,
|
||||
QL3_POSTGRES_TLS_MODE: 'disable',
|
||||
QL3_POSTGRES_ALLOW_INSECURE: 'true',
|
||||
});
|
||||
assert.deepEqual(config.database.connection.tls, { mode: 'disable' });
|
||||
});
|
||||
|
||||
test('rejects TLS query overrides, missing credentials and unbounded values', () => {
|
||||
for (const environment of [
|
||||
{
|
||||
...BASE_ENV,
|
||||
QL3_POSTGRES_RUNTIME_URL:
|
||||
'postgresql://database.internal/qinglong?sslmode=disable',
|
||||
},
|
||||
{ ...BASE_ENV, QL3_POSTGRES_TLS_SERVERNAME: undefined },
|
||||
{ ...BASE_ENV, QL3_POSTGRES_TLS_SERVERNAME: '127.0.0.1' },
|
||||
{ ...BASE_ENV, QL3_POSTGRES_TLS_CA_FILE: 'relative-ca.pem' },
|
||||
{
|
||||
...BASE_ENV,
|
||||
QL3_POSTGRES_TLS_MODE: 'disable',
|
||||
QL3_POSTGRES_ALLOW_INSECURE: 'true',
|
||||
QL3_POSTGRES_TLS_CA_FILE: CA_FILE,
|
||||
},
|
||||
{ ...BASE_ENV, QL3_POSTGRES_RUNTIME_URL: '' },
|
||||
{
|
||||
...BASE_ENV,
|
||||
QL3_POSTGRES_RUNTIME_HOST: 'database.internal',
|
||||
},
|
||||
{ ...BASE_ENV, QL3_POSTGRES_MAX_CONNECTIONS: '65' },
|
||||
{ ...BASE_ENV, QL3_CLUSTER_HTTP_MAX_BODY_BYTES: '99999999' },
|
||||
{ ...BASE_ENV, QL3_CLUSTER_AUTH_RATE_WINDOW_MS: '99999999' },
|
||||
{ ...BASE_ENV, QL3_CLUSTER_AUTH_RATE_PER_PEER: '0' },
|
||||
{ ...BASE_ENV, QL3_CLUSTER_AUTH_RATE_GLOBAL: '1000001' },
|
||||
{ ...BASE_ENV, QL3_CLUSTER_AUTH_RATE_MAX_PEERS: '65537' },
|
||||
{ ...BASE_ENV, QL3_API_CREDENTIAL_PEPPER: 'weak' },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => loadClusterControlConfig(environment),
|
||||
ClusterControlConfigError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
test('public application, config, admission and route exports exclude migration and legacy modules', () => {
|
||||
const packageDirectory = path.resolve(__dirname, '..');
|
||||
const script = `
|
||||
const application = require('@qinglong/cluster-control/application');
|
||||
const config = require('@qinglong/cluster-control/config');
|
||||
const admission = require('@qinglong/cluster-control/admission');
|
||||
const routes = require('@qinglong/cluster-control/routes');
|
||||
const runRoutes = require('@qinglong/cluster-control/run-routes');
|
||||
const apiCredential = require('@qinglong/cluster-control/api-credential');
|
||||
const loaded = Object.keys(require.cache).map((file) => file.replaceAll('\\\\', '/'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
hasStart: typeof application.startClusterControlApplication === 'function',
|
||||
hasConfig: typeof config.loadClusterControlConfig === 'function',
|
||||
hasAdmission: typeof admission.createClusterControlAdmissionPipeline === 'function',
|
||||
hasRoutes: typeof routes.createClusterControlRouteRegistry === 'function',
|
||||
hasRunReadRoute: typeof runRoutes.createClusterControlRunReadRoute === 'function',
|
||||
hasApiCredential: typeof apiCredential.createClusterControlApiCredentialAuthenticator === 'function',
|
||||
loaded,
|
||||
}));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
cwd: packageDirectory,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.hasStart, true);
|
||||
assert.equal(report.hasConfig, true);
|
||||
assert.equal(report.hasAdmission, true);
|
||||
assert.equal(report.hasRoutes, true);
|
||||
assert.equal(report.hasRunReadRoute, true);
|
||||
assert.equal(report.hasApiCredential, true);
|
||||
assert.equal(
|
||||
report.loaded.some(
|
||||
(file) =>
|
||||
file.includes('/back/') ||
|
||||
/\/ql3-cluster-postgres\/dist\/migrations\/pg-\d/.test(file) ||
|
||||
file.endsWith('/ql3-cluster-postgres/dist/migration/migrate.js') ||
|
||||
file.endsWith('/ql3-cluster-postgres/dist/migration/migration.js') ||
|
||||
file.endsWith('/ql3-cluster-postgres/dist/schema/schema.js'),
|
||||
),
|
||||
false,
|
||||
report.loaded.join('\n'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDMTCCAhmgAwIBAgIUT09qZ5rsuZyfzb0ngF3gc/MLVT0wDQYJKoZIhvcNAQEL
|
||||
BQAwIDEeMBwGA1UEAwwVUWluZ0xvbmctMy1UZXN0LUNBLXYyMB4XDTI2MDcxOTE1
|
||||
MDU0M1oXDTQ2MDcxNDE1MDU0M1owIDEeMBwGA1UEAwwVUWluZ0xvbmctMy1UZXN0
|
||||
LUNBLXYyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3ZjZrBMqBu5U
|
||||
fGgnp4j9ohhwq2iCW9Y1U8tkFvxpca9SJawQAZE8U0D0fy1JPUOrUHP1j5PzDMk3
|
||||
CC3WfrP/e26Xx/BwXp45QfZS24vHPu8+0KCo3FfNoQPcXra58+XpNh6AhLdk1cMl
|
||||
/kSFDC/fwo4elETiETdmCD7g/JwvM0U4l+5yGN4lW0Wb7P4S+6Red1xxt9QWT6b7
|
||||
FREoXtL/Ego69T8kabLH38WlUVm+Mfhb18yMTlDDpOI83xbxDUinfivGREr41QUe
|
||||
RGE2+I1XKjztXqowdkIb3T3JVDx/WMI9+4m2vOX1ZJNjS0ZpaEeMzlNLSJKNQaMR
|
||||
uOmDqBPJJwIDAQABo2MwYTAdBgNVHQ4EFgQUZw224kl/JxHPu4khthe4bwJw5JIw
|
||||
HwYDVR0jBBgwFoAUZw224kl/JxHPu4khthe4bwJw5JIwDwYDVR0TAQH/BAUwAwEB
|
||||
/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAF6yWGbSYV7PT5+G
|
||||
6Am4M7n2xpy6EjtKN6jaeh46K6xTReooTJKj3Yix/AYYYG/Bh24CCjg7zxIq7CzL
|
||||
gPoJjGiSJVZBguHwSmzo1HajJJ7ncumT5cZwXbxHQ521CqQbskrltPJNf3P5tKq5
|
||||
AzCJ2KrTmi30LNbH1G1v9bkwsVv+T4aWYN+o3cIpMPklLvWKq381dT7eN0/O/rwK
|
||||
wUUXOUYFClHqm7M5jOQNrt7dneHU/R9GEYGWCw/T9FwNe7QovhcI22Q5xtbJZ9HR
|
||||
B/8/OZ+yB6ZmkPnw1STJ1YOckorSRNQLFOchdZP7CeXFOTRmU9KHoIka+zG9aOab
|
||||
/NVGuLU=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDKzCCAhOgAwIBAgICEAEwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UEAwwVUWlu
|
||||
Z0xvbmctMy1UZXN0LUNBLXYyMB4XDTI2MDcxOTE1MDYzOFoXDTQ2MDcxNDE1MDYz
|
||||
OFowGjEYMBYGA1UEAwwPcWwzLXRlc3Qtd29ya2VyMIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEArmm2syXGg03ZHQgoMknVv+QC9qVGA46Xnr8K/lgppZID
|
||||
H/7obm8P0oiAbgOQbKhEzoY+9fjoW2VWBKft1PkVfzjxBvsU/76KIrluKBn+wwtr
|
||||
LEkPxHaawYTtMUm6Urvxq2VlzU7pdZVJ1PLWovBxyhwe8OUagkvtqYkLN3t6YHZo
|
||||
kMgIjHKx8SKWUZRQ846V6mm2/8e+lO+BTd/wb7cEvZDYbqoKaqlm91sO6NDuvAJl
|
||||
DrLLQprdBg4kn6rI1/T2vztuz7TkAgAlkJyDJ2rz+VGSTZ6NxFKaV4hbF8b5RA4S
|
||||
SKDwqaNtFYoS+p5gJsyNSGvMOKUzMCiyapd8fB6tBQIDAQABo3UwczAMBgNVHRMB
|
||||
Af8EAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNV
|
||||
HQ4EFgQUj2XZPOHX/LbNTC2Uolt6HAkofbUwHwYDVR0jBBgwFoAUZw224kl/JxHP
|
||||
u4khthe4bwJw5JIwDQYJKoZIhvcNAQELBQADggEBAGsbsLjPTnev7JCpfC88HmgW
|
||||
pL22q93WDQ407O2+t/5dOc5AyZZx9kR6cUKfKT/JlseDN6x8V0yVDeq6OtKKYmDg
|
||||
xB8P1n/e/GCjx3w3cYWhP5bNw9yNNTIReG3DnKGr4Q6Xe8YA415o7GQSp24oNa0R
|
||||
Kx3xCr+y3VLOu6eeotsSb7tezcEvGTFXAd61wNMPIjyN1ZevIoNcMnYUbjAkH2py
|
||||
sDVP/8XjQCcU9vXTZPVtxGKkC+w6oiYpG96kixe//N8IgruiJHL/74/RxiOR0Zt/
|
||||
MDnrYFyS3Mp9hMHGbhJ41NhaUUDr7IAQQqsY9su6CMS50DtgSwZQ+kkzf+NBo4I=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCuabazJcaDTdkd
|
||||
CCgySdW/5AL2pUYDjpeevwr+WCmlkgMf/uhubw/SiIBuA5BsqETOhj71+OhbZVYE
|
||||
p+3U+RV/OPEG+xT/vooiuW4oGf7DC2ssSQ/EdprBhO0xSbpSu/GrZWXNTul1lUnU
|
||||
8tai8HHKHB7w5RqCS+2piQs3e3pgdmiQyAiMcrHxIpZRlFDzjpXqabb/x76U74FN
|
||||
3/BvtwS9kNhuqgpqqWb3Ww7o0O68AmUOsstCmt0GDiSfqsjX9Pa/O27PtOQCACWQ
|
||||
nIMnavP5UZJNno3EUppXiFsXxvlEDhJIoPCpo20VihL6nmAmzI1Ia8w4pTMwKLJq
|
||||
l3x8Hq0FAgMBAAECggEADWf/zJ5rN12NAj/FwzPMs87FcYr7qjtUKTYbFLjXHqxu
|
||||
BBAWcqKjzzWB1bL9b3SxsUQ9Y6/JwHi2F26HB/dLcTeBw10aXJwbRxcEwh1ZaUmf
|
||||
58wH47yQIa7jgoXdxg0/QsLtA858gHbEsZMm72hW/seRk0ew9XH7pyvrCk05918S
|
||||
IvtN49gZdffw0O/V89Mys1ZA/w/hsi6NuZI0zEIjOMWN1hMPKYHbVbaYaIX0mC6P
|
||||
Yg26UXqf8g6SgW/dYLt28r69yGvKBwyztYw47Ieo2NyWToZsHHg5ypdjUXnt++wn
|
||||
sFPNo28KVu/NYTGY6Y2TphamdZTUetQ+0AfeITTiYwKBgQDaa6gmDHqHEPwdgLmu
|
||||
fwrKNiU/NwNx8ZwEMYYvlyCmmXRLbTFux3AE1xHUUQac1PAhmelZwulm3tInQ94F
|
||||
pDTkjfcZt/K83gLkNVLbNepbVKfrvcAHbPcoBC3k161RcrYW0LxR/P8z/YXyzkUI
|
||||
04/g90INoKKITc8hoI1Ttmyn3wKBgQDMa72ICOa/HFmoT/G8xWkqU71F1rKGanJ3
|
||||
sTPZXjGEyx0atSWB0XfmBd0hiIAx1DWuUo4EZ9Yul1B97gmnJSxbXGcKTfp8+1kS
|
||||
YySIx1GE0qG23n/xppeT0xPUCyJl/UuAKEKi7l3ZtT4RiaDP/KRo2LuKmCXuxSSa
|
||||
6d5YYsAXmwKBgQC8fEjXxM77vZmDMDGMNs+t3nnYCnZrns8/AynD6cvgWO51pJ4S
|
||||
9gJh+uLE8MMfFda/eF1Z+4yFHGYIQlXXUnPeg/An/oh3mbKvEqEU0HsUI4LhOZb8
|
||||
EXlby/d60vAbKD2ghLLuob/tMquj84K5cjBoN5eFwQhhTZwgbbdn9TXgywKBgHxG
|
||||
6BQi7T20m6Fi8OFF1xi/jis+SozDfFHeLlpxFIPaBBivllzlHxJ98CEp3l3s2LHh
|
||||
SKvPAPyUS4AzzjSKufvVImO2YpnHKTCvi4+INbwdXelSPdCI6lAZnE1mc4QzyMni
|
||||
MBjj97SapmB9HoIz4zRGx0WMGEugGRABLIbpikUPAoGAbdUAdEwQIIcOZEvl5spC
|
||||
CNX2AMXCIQk4qkauJo+c/fQJXL/nHaO3reOXSJc1QjB1ceoZ+s0WuN7avkwy+jJ/
|
||||
ZXGxdo/ewFNYTqELnnN2/Kr6GDvpwrlwGhdNpp2Sxjnyq7DAxnfbaTdhUo+WGV70
|
||||
+VIzVYYa+dhmUFNEvWtwt4Y=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,10 @@
|
||||
-----BEGIN X509 CRL-----
|
||||
MIIBeTBjAgEBMA0GCSqGSIb3DQEBCwUAMCAxHjAcBgNVBAMMFVFpbmdMb25nLTMt
|
||||
VGVzdC1DQS12MhcNMjYwNzE5MTUwNjQ0WhcNNDYwNzE0MTUwNjQ0WqAPMA0wCwYD
|
||||
VR0UBAQCAhAAMA0GCSqGSIb3DQEBCwUAA4IBAQA3HS0UP59J+d0bRTTjFzLRHaaX
|
||||
BUr/2FBK7YcKr6wiSa8EVhiiRZ6zcPShIfKPDhNn/rozMJST0SyO4aH6A+BOuAsl
|
||||
jK37Cz1gdDcdnNTo87/QTPVSe1wQqNHu8fJSQgQMIOqjUigB8crIhcNBJO7wSVxl
|
||||
AELA5XV6QiVKT3vQZjfY6o99y1790x+BjcIi/zWSFRY5dFFLVCuPgxj+fR6+S04V
|
||||
cAVAWNt9z+eRf0uYbV+VseT2Xb5HieR/PZCN2oc3xyEQsNs5m2txCAy+ld3sUl/z
|
||||
2FDP+JBla05W+0xP8Ql5sCd1NZahrrxVvbRFJcJG25sRuYOGZVNHbtqMGD5U
|
||||
-----END X509 CRL-----
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN X509 CRL-----
|
||||
MIIBkDB6AgEBMA0GCSqGSIb3DQEBCwUAMCAxHjAcBgNVBAMMFVFpbmdMb25nLTMt
|
||||
VGVzdC1DQS12MhcNMjYwNzE5MTUwNjUzWhcNNDYwNzE0MTUwNjUzWjAVMBMCAhAB
|
||||
Fw0yNjA3MTkxNTA2NDlaoA8wDTALBgNVHRQEBAICEAEwDQYJKoZIhvcNAQELBQAD
|
||||
ggEBALG0R1hPya3WzAeLkGqcGoVz3HLDn1igESMSK34532NWSJwq161sZXLogy+K
|
||||
J1+NL7g8C9FbMqP9ZkA36rKt4TDRpdoLO1uLKCUVEQ3O87iUVixyKTWU2lUDOk5P
|
||||
ivJNf6y/TypKK5dOxkrOUH6eB4qmGPLZu1w8JWSbYlYLXueQ6NXR1mXeN4ZPaLi5
|
||||
5HGxMdAjRRWkiRm4LFbeJEiIcDEAxOwfKObdH5eC+VSl9acECQfLOkJ38ku18gtH
|
||||
Wha1Na5cQ6G6TwMbtQ60RXkdLmLonD3b/mRBoVwuIMbXq7tXOigc08+JANUjnair
|
||||
Df9nrEWjhB8V4NoDsAYZddG/cEc=
|
||||
-----END X509 CRL-----
|
||||
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDQzCCAiugAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UEAwwVUWlu
|
||||
Z0xvbmctMy1UZXN0LUNBLXYyMB4XDTI2MDcxOTE1MDYyMloXDTQ2MDcxNDE1MDYy
|
||||
MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
||||
MIIBCgKCAQEAwzxMyomJ5BxMMlRpZgmUd1t5SnEYxN7YLjOW5fdaI0tyg6r5R7Rm
|
||||
Ykui0ArxInaICXecgXk9O7MydAT8+W+aU8VLOaBA/lY6waFMrMGinCrmIXjuaB/H
|
||||
POeslX4hvx6i1teOGA1HmF0ELyDbuUNlIQjJ12sKJ3+FLo9vZf+JjRW3DP0Yt1tB
|
||||
mxWURb51VSnLzBTCp/INlm25/DZ7M+Kr2gqXtHoegO6aqTRWIRgCXlVKpCoaA2lv
|
||||
1RY1LwPY1KhTM/fUMYz9/pwwDPmKFRQbvH6lBRl3tayP0uR1jIheOAeC5/F2A1TA
|
||||
G5LMusSQ0vrn1+wolkB4qf6bBgTK5y6R4wIDAQABo4GSMIGPMAwGA1UdEwEB/wQC
|
||||
MAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBoGA1UdEQQT
|
||||
MBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUDB+Pt69+4JOSkxeky0fLzgVN
|
||||
9UIwHwYDVR0jBBgwFoAUZw224kl/JxHPu4khthe4bwJw5JIwDQYJKoZIhvcNAQEL
|
||||
BQADggEBAJpLx9FSXd426hE8q7QbhAyaK27/li7J/X93Zzkf7FV7mHFpl22SZ5No
|
||||
airtCM41hZXBY9BTb0OGzHOb4L7Yef5c6zjKrsDNlWixAunJg7kMJ+So8DhLugXo
|
||||
OIjWV1qX3dkx7oPg9I9s62rZGdQPFhmz+YAz/7WYAeNBpnwC0bCBH2ksOo0SArEA
|
||||
W4S0SlX+3g+vTk9f1JM90ytqDKU7fCcHFCuI+kM5IcWIjhvejpxmmywFYBXvng2+
|
||||
dL2xOmhyTWey+Q057JFqG8UNYF75OJ/Q090Lq/QVCMX7715wG8Uwe6InMll/otgl
|
||||
u6GSeoX8wp5aM6+IBynaRhdpJoSK/Ik=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDDPEzKiYnkHEwy
|
||||
VGlmCZR3W3lKcRjE3tguM5bl91ojS3KDqvlHtGZiS6LQCvEidogJd5yBeT07szJ0
|
||||
BPz5b5pTxUs5oED+VjrBoUyswaKcKuYheO5oH8c856yVfiG/HqLW144YDUeYXQQv
|
||||
INu5Q2UhCMnXawonf4Uuj29l/4mNFbcM/Ri3W0GbFZRFvnVVKcvMFMKn8g2Wbbn8
|
||||
Nnsz4qvaCpe0eh6A7pqpNFYhGAJeVUqkKhoDaW/VFjUvA9jUqFMz99QxjP3+nDAM
|
||||
+YoVFBu8fqUFGXe1rI/S5HWMiF44B4Ln8XYDVMAbksy6xJDS+ufX7CiWQHip/psG
|
||||
BMrnLpHjAgMBAAECggEAK7fvjDUjWyDZETYkyfQgOmAR95D+1mr+Zs4nnsR2e0vU
|
||||
T1a0bpGfkahVQ5gHqkt3qAMQWU/gXeOBS0ioq2SJ3vNhpnxTwYBmtOBdTpwnwSe8
|
||||
E8fEPy3KjEFmvK0benQuS8TGLW2f2CcQHkuR2FkWpsCUjjgKdJDSLnxxGhqQXzaW
|
||||
3sVFH6m6tYtOqcGPJscNkKiPh2fHIfKgiKLCVZSM4zw1FRXFk9c9dfnu1yNKeKZE
|
||||
fDn7p5+bDHQJio/w0AbOO90satqHYJEirPEuhrTQ6xo3jkJ+u0XWZ0n5fkxyHaiU
|
||||
oJcQ1BZ2VCMK2uD3MnlW1Kea8HeVWFAAaoNMjt278QKBgQDrVSlqRXdtSVaGu9WT
|
||||
20jVttUrcZl1D8G5CEO+PfMmMtJ8HyanlZPIiOzbSIWw94EXW6qpMeq2nxIn6xJI
|
||||
rJKWDkkYkAjScVfH6nNxyg4FDpbzMbD6ZN+KBkdbj7QyjYqo3PbSjFdDu5BDtKa1
|
||||
J5xaIWc8jB2R57yOekm3+QvPswKBgQDUYajbr+owoN9G8ij9PbpnacnrK8l+MvZO
|
||||
kwYlKB/IFXa+Zp9GA1W0ViyndTd8Jjq+JYXn4XpZyoPznLmxKUlXrlUBLgml2+nX
|
||||
gUYKerW2h3k1xd7mWalG2Iwqykkm27qSjeJteQsjI2lb+d2F7J4+wufWOXzfT+FP
|
||||
ISTYOmedEQKBgQC/lAyrHvIiXdm6xZ8RRyr8EGNpgolS6wjiAQBlFzmily0TLqjh
|
||||
NLuiPRXVTizH7DWDNnSwWJqjIMw/AvdXgmWzEt4eyOLlrq+vaAWjVMIlC8OHJ6TT
|
||||
stroGkRtHYesv91pqriknutLkJ3ZAG/WefCmzxqkB8zqwqSVuKfaxTwAUQKBgA99
|
||||
f/9l+ULKuP5Hs02lLu9T+/I3I18dAHICrzQOlSLWze5dNci5fYrFrkQYfI+FiktM
|
||||
GVQsUewypSUjjTeqtzq58GFBplp+i7O4Lg+dm5tQjjqpz/QlHf+Y08BbOLKo3BbM
|
||||
WnN5nKJXdtvKgam+9FvHg3lQgBcZkGlhFXrf56bBAoGARWjH3qFhePIIR/o/YyU4
|
||||
fPhLYtiIgIvr5Is4hkGcMIOu2bnmYUCHa/zBmLqKV4K3F4u4RWmieAiLmvwi+9YD
|
||||
3xZ/RVIi5yK5CKCk7hOp4AVBF9bdLIxXiRSkIO6QMithomoYlc+UkfXHMGyZFQ6h
|
||||
bxKAymile6IWwd4ALAsPb3U=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,710 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const https = require('node:https');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlAdmissionDrainTimeoutError,
|
||||
ClusterControlHttpConfigurationError,
|
||||
startClusterControlHttpSurface,
|
||||
} = require('@qinglong/cluster-control/http');
|
||||
|
||||
const EVIDENCE = Object.freeze({
|
||||
contractName: 'control-core',
|
||||
contractVersion: 2,
|
||||
serverMajor: 16,
|
||||
migrationIds: Object.freeze([
|
||||
'pg-0001-schema-capability',
|
||||
'pg-0002-run-core',
|
||||
'pg-0003-run-retry-policy',
|
||||
]),
|
||||
});
|
||||
const MTLS_FIXTURES = path.join(__dirname, 'fixtures', 'mtls');
|
||||
const MTLS = Object.freeze({
|
||||
privateKey: readFileSync(path.join(MTLS_FIXTURES, 'server-key.pem')),
|
||||
certificateChain: readFileSync(path.join(MTLS_FIXTURES, 'server-cert.pem')),
|
||||
clientCertificateAuthorities: Object.freeze([
|
||||
readFileSync(path.join(MTLS_FIXTURES, 'ca-cert.pem')),
|
||||
]),
|
||||
});
|
||||
|
||||
function pipeline(handler) {
|
||||
return {
|
||||
async prepare(metadata) {
|
||||
return {
|
||||
handle(body) {
|
||||
return handler({ ...metadata, body });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(address, options = {}) {
|
||||
const rawBody = options.rawBody;
|
||||
const body =
|
||||
rawBody === undefined
|
||||
? options.body === undefined
|
||||
? undefined
|
||||
: Buffer.from(JSON.stringify(options.body))
|
||||
: Buffer.from(rawBody);
|
||||
const headers = { connection: 'close', ...options.headers };
|
||||
if (body && headers['content-length'] === undefined) {
|
||||
headers['content-length'] = String(body.byteLength);
|
||||
}
|
||||
if (options.body !== undefined && headers['content-type'] === undefined) {
|
||||
headers['content-type'] = 'application/json';
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = http.request(
|
||||
{
|
||||
host: address.host,
|
||||
port: address.port,
|
||||
method: options.method ?? 'GET',
|
||||
path: options.path ?? '/',
|
||||
headers,
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
resolve({
|
||||
statusCode: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: text.length === 0 ? null : JSON.parse(text),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
outgoing.on('error', reject);
|
||||
if (body) outgoing.write(body);
|
||||
outgoing.end();
|
||||
});
|
||||
}
|
||||
|
||||
function secureRequest(address, withClientCertificate, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = https.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
servername: 'localhost',
|
||||
port: address.port,
|
||||
path: options.path ?? '/livez',
|
||||
method: options.method ?? 'GET',
|
||||
agent: options.agent,
|
||||
ca: readFileSync(path.join(MTLS_FIXTURES, 'ca-cert.pem')),
|
||||
...(withClientCertificate
|
||||
? {
|
||||
key: readFileSync(path.join(MTLS_FIXTURES, 'client-key.pem')),
|
||||
cert: readFileSync(path.join(MTLS_FIXTURES, 'client-cert.pem')),
|
||||
}
|
||||
: {}),
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
},
|
||||
(response) => {
|
||||
const tlsProtocol = response.socket.getProtocol();
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode,
|
||||
tlsProtocol,
|
||||
headers: response.headers,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
outgoing.once('error', reject);
|
||||
outgoing.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('requires a trusted client certificate on the TLS 1.3 surface', async (t) => {
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
mutualTls: MTLS,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
|
||||
await assert.rejects(secureRequest(surface.address, false));
|
||||
const trusted = await secureRequest(surface.address, true);
|
||||
assert.equal(trusted.statusCode, 200);
|
||||
assert.equal(trusted.tlsProtocol, 'TLSv1.3');
|
||||
assert.deepEqual(trusted.body, { status: 'live' });
|
||||
});
|
||||
|
||||
test('reloads mTLS trust and CRLs without rebinding the listener', async (t) => {
|
||||
const emptyCrl = readFileSync(path.join(MTLS_FIXTURES, 'empty-crl.pem'));
|
||||
const revokedClientCrl = readFileSync(
|
||||
path.join(MTLS_FIXTURES, 'revoked-client-crl.pem'),
|
||||
);
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
mutualTls: {
|
||||
...MTLS,
|
||||
certificateRevocationLists: [emptyCrl],
|
||||
},
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
const address = { ...surface.address };
|
||||
|
||||
assert.equal((await secureRequest(address, true)).statusCode, 200);
|
||||
assert.equal(
|
||||
surface.reloadMutualTls({
|
||||
...MTLS,
|
||||
certificateRevocationLists: [revokedClientCrl],
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(surface.address, address);
|
||||
await assert.rejects(secureRequest(address, true));
|
||||
|
||||
assert.equal(
|
||||
surface.reloadMutualTls({
|
||||
...MTLS,
|
||||
certificateRevocationLists: [emptyCrl],
|
||||
}),
|
||||
3,
|
||||
);
|
||||
assert.equal((await secureRequest(address, true)).statusCode, 200);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
surface.reloadMutualTls({
|
||||
...MTLS,
|
||||
certificateChain: 'not a certificate',
|
||||
certificateRevocationLists: [emptyCrl],
|
||||
}),
|
||||
ClusterControlHttpConfigurationError,
|
||||
);
|
||||
assert.equal((await secureRequest(address, true)).statusCode, 200);
|
||||
});
|
||||
|
||||
test('forces pre-reload keep-alive sockets to reconnect before routing', async (t) => {
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
mutualTls: MTLS,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
const agent = new https.Agent({ keepAlive: true, maxSockets: 1 });
|
||||
t.after(() => agent.destroy());
|
||||
let entered;
|
||||
let release;
|
||||
const enteredPromise = new Promise((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const releasePromise = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const dispose = surface.installAdmission(
|
||||
EVIDENCE,
|
||||
pipeline(async () => {
|
||||
entered();
|
||||
await releasePromise;
|
||||
return { statusCode: 200, body: { status: 'completed' } };
|
||||
}),
|
||||
);
|
||||
t.after(() => dispose());
|
||||
|
||||
const active = secureRequest(surface.address, true, {
|
||||
agent,
|
||||
path: '/api/v3/hold',
|
||||
});
|
||||
await enteredPromise;
|
||||
assert.equal(surface.reloadMutualTls(MTLS), 2);
|
||||
release();
|
||||
assert.equal((await active).statusCode, 200);
|
||||
|
||||
const stale = await secureRequest(surface.address, true, { agent });
|
||||
assert.equal(stale.statusCode, 503);
|
||||
assert.deepEqual(stale.body, { code: 'tls_context_reloaded' });
|
||||
assert.equal(stale.headers.connection, 'close');
|
||||
assert.equal(
|
||||
(await secureRequest(surface.address, true, { agent })).statusCode,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('exposes probes but rejects API work until admission is installed', async (t) => {
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
|
||||
const live = await request(surface.address, { path: '/livez' });
|
||||
assert.equal(live.statusCode, 200);
|
||||
assert.deepEqual(live.body, { status: 'live' });
|
||||
assert.equal(live.headers['cache-control'], 'no-store');
|
||||
const invalidProbeMethod = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/livez',
|
||||
});
|
||||
assert.equal(invalidProbeMethod.statusCode, 405);
|
||||
assert.deepEqual(invalidProbeMethod.body, { code: 'method_not_allowed' });
|
||||
const waiting = await request(surface.address, { path: '/readyz' });
|
||||
assert.equal(waiting.statusCode, 503);
|
||||
assert.deepEqual(waiting.body, { status: 'not_ready' });
|
||||
const rejected = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/runs',
|
||||
headers: { connection: 'keep-alive' },
|
||||
body: { mustNotBeRead: true },
|
||||
});
|
||||
assert.equal(rejected.statusCode, 503);
|
||||
assert.equal(rejected.headers.connection, 'close');
|
||||
|
||||
let observed;
|
||||
const dispose = surface.installAdmission(
|
||||
EVIDENCE,
|
||||
pipeline(async (incoming) => {
|
||||
observed = incoming;
|
||||
return { statusCode: 201, body: { accepted: true } };
|
||||
}),
|
||||
);
|
||||
const ready = await request(surface.address, { path: '/readyz' });
|
||||
assert.equal(ready.statusCode, 200);
|
||||
assert.deepEqual(ready.body, { status: 'ready' });
|
||||
const admitted = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/runs?tag=a&tag=b',
|
||||
headers: { 'x-request-id': 'request-123' },
|
||||
body: { taskId: 'task-1' },
|
||||
});
|
||||
assert.equal(admitted.statusCode, 201);
|
||||
assert.deepEqual(admitted.body, { accepted: true });
|
||||
assert.equal(admitted.headers['x-request-id'], 'request-123');
|
||||
assert.equal(observed.requestId, 'request-123');
|
||||
assert.equal(observed.method, 'POST');
|
||||
assert.equal(observed.path, '/api/v3/runs');
|
||||
assert.deepEqual(observed.query.tag, ['a', 'b']);
|
||||
assert.deepEqual(observed.body, { taskId: 'task-1' });
|
||||
|
||||
await dispose();
|
||||
assert.equal(
|
||||
(await request(surface.address, { path: '/readyz' })).statusCode,
|
||||
503,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/runs',
|
||||
body: { ignored: true },
|
||||
})
|
||||
).statusCode,
|
||||
503,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces bounded JSON requests and responses without leaking failures', async (t) => {
|
||||
const diagnostics = [];
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
maxBodyBytes: 1024,
|
||||
maxResponseBytes: 1024,
|
||||
onError(diagnostic) {
|
||||
diagnostics.push(diagnostic);
|
||||
},
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
const dispose = surface.installAdmission(
|
||||
EVIDENCE,
|
||||
pipeline(async (incoming) => {
|
||||
if (incoming.path.endsWith('/large')) {
|
||||
return { statusCode: 200, body: { value: 'x'.repeat(2048) } };
|
||||
}
|
||||
if (incoming.path.endsWith('/throw')) {
|
||||
throw new Error('secret driver detail');
|
||||
}
|
||||
return { statusCode: 200, body: incoming.body };
|
||||
}),
|
||||
);
|
||||
t.after(() => dispose());
|
||||
|
||||
const invalidJson = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/test',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
rawBody: '{',
|
||||
});
|
||||
assert.equal(invalidJson.statusCode, 400);
|
||||
assert.deepEqual(invalidJson.body, { code: 'invalid_json' });
|
||||
|
||||
const unsupported = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/test',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
rawBody: 'hello',
|
||||
});
|
||||
assert.equal(unsupported.statusCode, 415);
|
||||
assert.deepEqual(unsupported.body, { code: 'unsupported_content_type' });
|
||||
|
||||
const oversized = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/test',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
rawBody: JSON.stringify({ value: 'x'.repeat(1024) }),
|
||||
});
|
||||
assert.equal(oversized.statusCode, 413);
|
||||
assert.deepEqual(oversized.body, { code: 'request_too_large' });
|
||||
|
||||
const largeResponse = await request(surface.address, {
|
||||
path: '/api/v3/large',
|
||||
});
|
||||
assert.equal(largeResponse.statusCode, 500);
|
||||
assert.deepEqual(largeResponse.body, { code: 'response_too_large' });
|
||||
|
||||
const internal = await request(surface.address, {
|
||||
path: '/api/v3/throw',
|
||||
});
|
||||
assert.equal(internal.statusCode, 500);
|
||||
assert.deepEqual(internal.body, { code: 'internal_error' });
|
||||
assert.equal(
|
||||
JSON.stringify(internal).includes('secret driver detail'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
diagnostics.some(
|
||||
(diagnostic) =>
|
||||
diagnostic.phase === 'request' && diagnostic.path === '/api/v3/throw',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('completes admission preflight before reading an untrusted body', async (t) => {
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
const dispose = surface.installAdmission(EVIDENCE, {
|
||||
async prepare() {
|
||||
throw Object.assign(new Error('credential rejected'), {
|
||||
statusCode: 401,
|
||||
code: 'authentication_required',
|
||||
});
|
||||
},
|
||||
});
|
||||
t.after(() => dispose());
|
||||
|
||||
const rejected = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/runs',
|
||||
headers: {
|
||||
connection: 'keep-alive',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(1024 * 1024),
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.statusCode, 401);
|
||||
assert.deepEqual(rejected.body, { code: 'authentication_required' });
|
||||
assert.equal(rejected.headers.connection, 'close');
|
||||
});
|
||||
|
||||
test('streams one route-bounded body without widening the JSON body cap', async (t) => {
|
||||
const observations = [];
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
maxBodyBytes: 1024,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
const dispose = surface.installAdmission(EVIDENCE, {
|
||||
async prepare(metadata) {
|
||||
observations.push(`prepare:${metadata.path}`);
|
||||
return {
|
||||
bodyMode: 'stream',
|
||||
contentType: 'application/vnd.qinglong.worker-artifact',
|
||||
maximumBodyBytes: 8 * 1024,
|
||||
async handleStream(body) {
|
||||
observations.push(`handle:${body.contentLength}`);
|
||||
let total = 0;
|
||||
let chunks = 0;
|
||||
for await (const chunk of body.chunks) {
|
||||
total += chunk.byteLength;
|
||||
chunks += 1;
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { total, chunks, contentType: body.contentType },
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => dispose());
|
||||
|
||||
const bytes = Buffer.alloc(4 * 1024, 7);
|
||||
const result = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/worker-ingress/artifacts',
|
||||
headers: {
|
||||
'content-type': 'application/vnd.qinglong.worker-artifact',
|
||||
},
|
||||
rawBody: bytes,
|
||||
});
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.deepEqual(result.body, {
|
||||
total: bytes.byteLength,
|
||||
chunks: 1,
|
||||
contentType: 'application/vnd.qinglong.worker-artifact',
|
||||
});
|
||||
assert.deepEqual(observations, [
|
||||
'prepare:/api/v3/worker-ingress/artifacts',
|
||||
`handle:${bytes.byteLength}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects invalid stream envelopes and incomplete consumption', async (t) => {
|
||||
let handles = 0;
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
const dispose = surface.installAdmission(EVIDENCE, {
|
||||
async prepare() {
|
||||
return {
|
||||
bodyMode: 'stream',
|
||||
contentType: 'application/vnd.qinglong.worker-artifact',
|
||||
maximumBodyBytes: 1024,
|
||||
async handleStream() {
|
||||
handles += 1;
|
||||
return { statusCode: 200, body: { accepted: true } };
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => dispose());
|
||||
|
||||
const oversized = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/worker-ingress/artifacts',
|
||||
headers: {
|
||||
'content-type': 'application/vnd.qinglong.worker-artifact',
|
||||
'content-length': '1025',
|
||||
},
|
||||
});
|
||||
assert.equal(oversized.statusCode, 413);
|
||||
assert.deepEqual(oversized.body, { code: 'request_too_large' });
|
||||
assert.equal(handles, 0);
|
||||
|
||||
const unsupported = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/worker-ingress/artifacts',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
rawBody: 'log',
|
||||
});
|
||||
assert.equal(unsupported.statusCode, 415);
|
||||
assert.deepEqual(unsupported.body, { code: 'unsupported_content_type' });
|
||||
assert.equal(handles, 0);
|
||||
|
||||
const incomplete = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/worker-ingress/artifacts',
|
||||
headers: {
|
||||
'content-type': 'application/vnd.qinglong.worker-artifact',
|
||||
},
|
||||
rawBody: 'log',
|
||||
});
|
||||
assert.equal(incomplete.statusCode, 500);
|
||||
assert.deepEqual(incomplete.body, { code: 'internal_error' });
|
||||
assert.equal(incomplete.headers.connection, 'close');
|
||||
assert.equal(handles, 1);
|
||||
});
|
||||
|
||||
test('refunds successful admission and limits failed preflight before body reads', async (t) => {
|
||||
const events = [];
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
authenticationRatePerPeer: 1,
|
||||
authenticationRateGlobal: 10,
|
||||
authenticationRateMaxPeers: 4,
|
||||
onAuthenticationShieldEvent(event) {
|
||||
events.push(event);
|
||||
},
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
|
||||
const beforeRead = await request(surface.address, {
|
||||
path: '/api/v3/test',
|
||||
headers: { 'x-forwarded-for': '198.51.100.10' },
|
||||
});
|
||||
assert.equal(beforeRead.statusCode, 503);
|
||||
assert.deepEqual(beforeRead.body, { code: 'not_ready' });
|
||||
|
||||
let prepares = 0;
|
||||
const dispose = surface.installAdmission(EVIDENCE, {
|
||||
async prepare(metadata) {
|
||||
prepares += 1;
|
||||
if (metadata.path === '/api/v3/rejected') {
|
||||
throw Object.assign(new Error('authentication failed'), {
|
||||
statusCode: 401,
|
||||
code: 'authentication_required',
|
||||
});
|
||||
}
|
||||
return { handle: () => ({ statusCode: 204 }) };
|
||||
},
|
||||
});
|
||||
t.after(() => dispose());
|
||||
|
||||
const admitted = await request(surface.address, {
|
||||
path: '/api/v3/test',
|
||||
headers: { 'x-forwarded-for': '198.51.100.11' },
|
||||
});
|
||||
assert.equal(admitted.statusCode, 204);
|
||||
assert.equal(prepares, 1);
|
||||
|
||||
const rejected = await request(surface.address, {
|
||||
path: '/api/v3/rejected',
|
||||
});
|
||||
assert.equal(rejected.statusCode, 401);
|
||||
assert.deepEqual(rejected.body, { code: 'authentication_required' });
|
||||
assert.equal(prepares, 2);
|
||||
|
||||
const limited = await request(surface.address, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/rejected',
|
||||
headers: {
|
||||
connection: 'keep-alive',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(1024 * 1024),
|
||||
'x-forwarded-for': '203.0.113.99',
|
||||
},
|
||||
});
|
||||
assert.equal(limited.statusCode, 429);
|
||||
assert.deepEqual(limited.body, { code: 'authentication_rate_limited' });
|
||||
assert.equal(limited.headers['retry-after'], '60');
|
||||
assert.equal(limited.headers.connection, 'close');
|
||||
assert.equal(prepares, 2);
|
||||
assert.deepEqual(events, [{ outcome: 'rate_limited', reason: 'peer' }]);
|
||||
|
||||
const probe = await request(surface.address, { path: '/livez' });
|
||||
assert.equal(probe.statusCode, 200);
|
||||
});
|
||||
|
||||
test('withdraws admission immediately and drains in-flight requests', async (t) => {
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
maxInFlightRequests: 1,
|
||||
drainTimeoutMs: 1000,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
let entered;
|
||||
const enteredPromise = new Promise((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const dispose = surface.installAdmission(
|
||||
EVIDENCE,
|
||||
pipeline(async () => {
|
||||
entered();
|
||||
await gate;
|
||||
return { statusCode: 200, body: { completed: true } };
|
||||
}),
|
||||
);
|
||||
|
||||
const first = request(surface.address, { path: '/api/v3/slow' });
|
||||
await enteredPromise;
|
||||
const capacity = await request(surface.address, { path: '/api/v3/second' });
|
||||
assert.equal(capacity.statusCode, 503);
|
||||
assert.deepEqual(capacity.body, { code: 'admission_capacity_exhausted' });
|
||||
|
||||
let drained = false;
|
||||
const draining = dispose().then(() => {
|
||||
drained = true;
|
||||
});
|
||||
assert.equal(
|
||||
(await request(surface.address, { path: '/readyz' })).statusCode,
|
||||
503,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(drained, false);
|
||||
release();
|
||||
const withdrawn = await first;
|
||||
assert.equal(withdrawn.statusCode, 503);
|
||||
assert.deepEqual(withdrawn.body, { code: 'admission_draining' });
|
||||
await draining;
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
test('reports a drain timeout when a handler ignores cancellation', async (t) => {
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
drainTimeoutMs: 100,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
let entered;
|
||||
const enteredPromise = new Promise((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const dispose = surface.installAdmission(
|
||||
EVIDENCE,
|
||||
pipeline(async () => {
|
||||
entered();
|
||||
await new Promise(() => {});
|
||||
return { statusCode: 200 };
|
||||
}),
|
||||
);
|
||||
const response = request(surface.address, { path: '/api/v3/stuck' });
|
||||
await enteredPromise;
|
||||
await assert.rejects(dispose(), ClusterControlAdmissionDrainTimeoutError);
|
||||
const timedOut = await response;
|
||||
assert.equal(timedOut.statusCode, 503);
|
||||
assert.deepEqual(timedOut.body, { code: 'admission_draining' });
|
||||
});
|
||||
|
||||
test('rejects unsafe listener and resource configurations before binding', async () => {
|
||||
const plaintext = await startClusterControlHttpSurface({ port: 0 });
|
||||
await assert.rejects(
|
||||
Promise.resolve().then(() => plaintext.reloadMutualTls(MTLS)),
|
||||
/does not use mutual TLS/,
|
||||
);
|
||||
await plaintext.close();
|
||||
await assert.rejects(
|
||||
startClusterControlHttpSurface({
|
||||
port: 0,
|
||||
mutualTls: {
|
||||
...MTLS,
|
||||
certificateRevocationLists: [
|
||||
'-----BEGIN X509 CRL-----\ninvalid\n-----END X509 CRL-----',
|
||||
],
|
||||
},
|
||||
}),
|
||||
ClusterControlHttpConfigurationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
startClusterControlHttpSurface({ host: '0.0.0.0;bad', port: 0 }),
|
||||
ClusterControlHttpConfigurationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
startClusterControlHttpSurface({
|
||||
port: 0,
|
||||
maxInFlightRequests: 1025,
|
||||
}),
|
||||
ClusterControlHttpConfigurationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
startClusterControlHttpSurface({
|
||||
port: 0,
|
||||
authenticationRateMaxPeers: 65_537,
|
||||
}),
|
||||
ClusterControlHttpConfigurationError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
ClusterMountedSecretProvider,
|
||||
ClusterMountedSecretProviderError,
|
||||
clusterMountedSecretFileName,
|
||||
createClusterMountedSecretProvider,
|
||||
} = require('@qinglong/cluster-control/mounted-secret-provider');
|
||||
|
||||
const SECRET_REF = createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'api-token',
|
||||
});
|
||||
const VERSIONED_SECRET_REF = createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'certificate',
|
||||
version: 3,
|
||||
});
|
||||
|
||||
function authority(secretRefs = [SECRET_REF]) {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
workerSessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
workerGeneration: 1,
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
executionDigest: 'a'.repeat(64),
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 1,
|
||||
leaseVersion: 1,
|
||||
secretRefs,
|
||||
};
|
||||
}
|
||||
|
||||
async function privateFile(file, value, mode = 0o400) {
|
||||
await writeFile(file, value);
|
||||
await chmod(file, mode);
|
||||
}
|
||||
|
||||
test('maps canonical SecretRef to a stable path-free Kubernetes key', () => {
|
||||
const first = clusterMountedSecretFileName(SECRET_REF);
|
||||
assert.match(first, /^[0-9a-f]{64}$/);
|
||||
assert.equal(clusterMountedSecretFileName(SECRET_REF), first);
|
||||
assert.notEqual(
|
||||
clusterMountedSecretFileName(VERSIONED_SECRET_REF),
|
||||
first,
|
||||
);
|
||||
assert.throws(
|
||||
() => clusterMountedSecretFileName('not-a-secret-ref'),
|
||||
ClusterMountedSecretProviderError,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves every request again and observes atomic material rotation', async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-mounted-secret-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await chmod(root, 0o700);
|
||||
const file = path.join(root, clusterMountedSecretFileName(SECRET_REF));
|
||||
await privateFile(file, 'generation-one');
|
||||
|
||||
const provider = await createClusterMountedSecretProvider({
|
||||
rootDirectory: root,
|
||||
});
|
||||
const first = await provider.resolve(authority());
|
||||
assert.deepEqual(first.values, [
|
||||
{ secretRef: SECRET_REF, value: 'generation-one' },
|
||||
]);
|
||||
await first.dispose();
|
||||
|
||||
const replacement = `${file}.replacement`;
|
||||
await privateFile(replacement, 'generation-two');
|
||||
await rename(replacement, file);
|
||||
const second = await provider.resolve(authority());
|
||||
assert.deepEqual(second.values, [
|
||||
{ secretRef: SECRET_REF, value: 'generation-two' },
|
||||
]);
|
||||
await second.dispose();
|
||||
await second.dispose();
|
||||
});
|
||||
|
||||
test('accepts an in-root projected-volume symlink and rejects escapes or unsafe bytes', async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-projected-secret-'));
|
||||
const outside = await mkdtemp(path.join(os.tmpdir(), 'ql3-outside-secret-'));
|
||||
t.after(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
await chmod(root, 0o700);
|
||||
const generation = path.join(root, '..data-generation-1');
|
||||
await mkdir(generation, { mode: 0o700 });
|
||||
const name = clusterMountedSecretFileName(SECRET_REF);
|
||||
await privateFile(path.join(generation, name), 'projected-value', 0o440);
|
||||
await symlink(path.join('..data-generation-1', name), path.join(root, name));
|
||||
|
||||
const provider = new ClusterMountedSecretProvider({ rootDirectory: root });
|
||||
const projected = await provider.resolve(authority());
|
||||
assert.equal(projected.values[0].value, 'projected-value');
|
||||
await projected.dispose();
|
||||
|
||||
await rm(path.join(root, name));
|
||||
await privateFile(path.join(outside, name), 'escaped-value');
|
||||
await symlink(path.join(outside, name), path.join(root, name));
|
||||
await assert.rejects(
|
||||
provider.resolve(authority()),
|
||||
ClusterMountedSecretProviderError,
|
||||
);
|
||||
|
||||
await rm(path.join(root, name));
|
||||
await privateFile(path.join(root, name), 'world-readable', 0o444);
|
||||
await assert.rejects(
|
||||
provider.resolve(authority()),
|
||||
ClusterMountedSecretProviderError,
|
||||
);
|
||||
|
||||
await rm(path.join(root, name));
|
||||
await privateFile(path.join(root, name), Buffer.from([0xff]), 0o400);
|
||||
await assert.rejects(
|
||||
provider.resolve(authority()),
|
||||
ClusterMountedSecretProviderError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails readiness for a missing or symlinked provider root', async (t) => {
|
||||
const parent = await mkdtemp(path.join(os.tmpdir(), 'ql3-secret-root-'));
|
||||
t.after(() => rm(parent, { recursive: true, force: true }));
|
||||
const target = path.join(parent, 'target');
|
||||
const link = path.join(parent, 'link');
|
||||
await mkdir(target, { mode: 0o700 });
|
||||
await symlink(target, link);
|
||||
await assert.rejects(
|
||||
createClusterMountedSecretProvider({ rootDirectory: link }),
|
||||
ClusterMountedSecretProviderError,
|
||||
);
|
||||
await assert.rejects(
|
||||
createClusterMountedSecretProvider({
|
||||
rootDirectory: path.join(parent, 'missing'),
|
||||
}),
|
||||
ClusterMountedSecretProviderError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_CATALOG_RESPONSE_SCHEMA,
|
||||
createClusterControlPluginPackagePromptCatalogRoute,
|
||||
} = require('@qinglong/cluster-control/prompt-routes');
|
||||
|
||||
function authorized(body = null) {
|
||||
return {
|
||||
request: {
|
||||
requestId: '00000000-0000-4000-8000-000000000001',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/project-1/packages/example/prompts',
|
||||
query: {},
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
body,
|
||||
},
|
||||
principal: {
|
||||
subject: { type: 'api_app', id: 'app-1' },
|
||||
authenticationId: 'credential-1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'service',
|
||||
},
|
||||
operationId: 'prompt.read',
|
||||
permission: 'model.invoke',
|
||||
projectId: 'project-1',
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
test('returns a bounded content-free Prompt catalog', async () => {
|
||||
let target;
|
||||
const route = createClusterControlPluginPackagePromptCatalogRoute({
|
||||
async inspect(projectId, packageName) {
|
||||
target = { projectId, packageName };
|
||||
return {
|
||||
schema: CLUSTER_PLUGIN_PACKAGE_PROMPT_CATALOG_RESPONSE_SCHEMA,
|
||||
projectId,
|
||||
packageName,
|
||||
found: true,
|
||||
publicationState: 'active',
|
||||
prompts: [{
|
||||
id: 'summary',
|
||||
name: 'Summary',
|
||||
description: null,
|
||||
parameters: [{ name: 'subject', description: null, required: true }],
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(route.operationId, 'prompt.read');
|
||||
assert.equal(route.permission, 'model.invoke');
|
||||
const result = await route.handle(authorized(), {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
});
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.deepEqual(target, { projectId: 'project-1', packageName: 'example' });
|
||||
assert.equal(JSON.stringify(result).includes('template'), false);
|
||||
});
|
||||
|
||||
test('rejects bodies and masks malformed or unavailable catalog state', async () => {
|
||||
let calls = 0;
|
||||
const route = createClusterControlPluginPackagePromptCatalogRoute({
|
||||
async inspect(projectId, packageName) {
|
||||
calls += 1;
|
||||
return {
|
||||
schema: CLUSTER_PLUGIN_PACKAGE_PROMPT_CATALOG_RESPONSE_SCHEMA,
|
||||
projectId: 'another-project',
|
||||
packageName,
|
||||
found: false,
|
||||
publicationState: null,
|
||||
prompts: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle(authorized({ unexpected: true }), {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
}),
|
||||
{ statusCode: 400, body: { code: 'invalid_prompt_catalog_request' } },
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(
|
||||
await route.handle(authorized(), {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
}),
|
||||
{ statusCode: 503, body: { code: 'prompt_catalog_unavailable' } },
|
||||
);
|
||||
});
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createClusterControlPluginPackagePromptExecutionInspectionRoute,
|
||||
} = require('@qinglong/cluster-control/prompt-routes');
|
||||
|
||||
function authorized(body = null) {
|
||||
return {
|
||||
request: {
|
||||
requestId: '00000000-0000-4000-8000-000000000001',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/project-1/packages/example/prompts/summary/executions/execution-request-1',
|
||||
query: {},
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
body,
|
||||
},
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-1' },
|
||||
authenticationId: 'api_credential:credential-1:1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'multi_factor',
|
||||
},
|
||||
operationId: 'prompt.execution.read',
|
||||
permission: 'run.read',
|
||||
projectId: 'project-1',
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
const parameters = {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
promptId: 'summary',
|
||||
executionRequestId: 'execution-request-1',
|
||||
};
|
||||
|
||||
function found(command) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-execution-inspection@v1',
|
||||
found: true,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
promptId: command.promptId,
|
||||
executionRequestId: command.executionRequestId,
|
||||
execution: {
|
||||
invocationId: 'invocation-1',
|
||||
runId: '00000000-0000-4000-8000-000000000010',
|
||||
stepRunId: 'step-1',
|
||||
runStatus: 'succeeded',
|
||||
runVersion: 5,
|
||||
eventSequence: 5,
|
||||
stepStatus: 'succeeded',
|
||||
stepVersion: 3,
|
||||
admittedAtMs: 1_000,
|
||||
startedAtMs: 1_000,
|
||||
finishedAtMs: 1_500,
|
||||
finalizedAtMs: 1_500,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('reads one content-free Prompt execution by caller-known requestId', async () => {
|
||||
let command;
|
||||
const route = createClusterControlPluginPackagePromptExecutionInspectionRoute(
|
||||
{
|
||||
async inspectAuthorized(value) {
|
||||
command = value;
|
||||
return found(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
now: () => 2_000,
|
||||
createEventId: () => '00000000-0000-4000-8000-000000000002',
|
||||
},
|
||||
);
|
||||
assert.equal(route.operationId, 'prompt.execution.read');
|
||||
assert.equal(route.permission, 'run.read');
|
||||
const result = await route.handle(authorized(), parameters);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(command.executionRequestId, 'execution-request-1');
|
||||
assert.equal(command.audit.operationId, 'prompt.execution.read');
|
||||
assert.equal(JSON.stringify(result).includes('template'), false);
|
||||
assert.equal(JSON.stringify(result).includes('parameter'), false);
|
||||
});
|
||||
|
||||
test('masks cross-target absence and maps an authorization fence race', async () => {
|
||||
let calls = 0;
|
||||
const missing = createClusterControlPluginPackagePromptExecutionInspectionRoute(
|
||||
{
|
||||
async inspectAuthorized(command) {
|
||||
calls += 1;
|
||||
return { ...found(command), found: false, execution: null };
|
||||
},
|
||||
},
|
||||
{
|
||||
now: () => 2_000,
|
||||
createEventId: () => '00000000-0000-4000-8000-000000000002',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await missing.handle(authorized({ widened: true }), parameters), {
|
||||
statusCode: 400,
|
||||
body: { code: 'invalid_request_body' },
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(await missing.handle(authorized(), parameters), {
|
||||
statusCode: 404,
|
||||
body: { code: 'prompt_execution_not_found' },
|
||||
});
|
||||
|
||||
const conflict = createClusterControlPluginPackagePromptExecutionInspectionRoute(
|
||||
{
|
||||
async inspectAuthorized() {
|
||||
throw Object.assign(new Error('drift'), {
|
||||
code: 'PLUGIN_PACKAGE_PROMPT_EXECUTION_INSPECTION_AUTHORIZATION_FENCE_CONFLICT',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
now: () => 2_000,
|
||||
createEventId: () => '00000000-0000-4000-8000-000000000003',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await conflict.handle(authorized(), parameters), {
|
||||
statusCode: 409,
|
||||
body: { code: 'authorization_fence_conflict' },
|
||||
});
|
||||
});
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createClusterControlPluginPackagePromptExecutionOutputReadRoute,
|
||||
} = require('@qinglong/cluster-control/prompt-routes');
|
||||
|
||||
const parameters = Object.freeze({
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
promptId: 'summary',
|
||||
executionRequestId: 'execution-request-1',
|
||||
});
|
||||
|
||||
function authorized(body = null) {
|
||||
return {
|
||||
request: {
|
||||
requestId: 'request-1',
|
||||
method: 'GET',
|
||||
path: '/unused',
|
||||
query: {},
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
body,
|
||||
},
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'owner-1' },
|
||||
authenticationId: 'authentication-1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'multi_factor',
|
||||
},
|
||||
operationId: 'prompt.execution.output.read',
|
||||
permission: 'artifact.read',
|
||||
projectId: 'project-1',
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
function available(command, overrides = {}) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-execution-output-read-result@v1',
|
||||
status: 'available',
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
promptId: command.promptId,
|
||||
executionRequestId: command.executionRequestId,
|
||||
reference: {
|
||||
schema: 'qinglong/plugin-package-prompt-output-artifact-reference@v1',
|
||||
artifactId: 'pao:0123456789abcdef0123456789abcdef',
|
||||
projectId: command.projectId,
|
||||
runId: '00000000-0000-4000-8000-000000000010',
|
||||
stepRunId: 'step-1',
|
||||
invocationId: 'invocation-1',
|
||||
contentDigest: 'b'.repeat(64),
|
||||
outputBytes: 14,
|
||||
retentionPolicyDigest: 'c'.repeat(64),
|
||||
retentionEligibleAtMs: 10_000,
|
||||
keyId: 'key-1',
|
||||
algorithm: 'aes-256-gcm',
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
},
|
||||
result: {
|
||||
provider: 'provider-1',
|
||||
model: 'model-1',
|
||||
text: 'private output',
|
||||
finishReason: 'stop',
|
||||
usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('reads durable Prompt output by caller-known execution requestId', async () => {
|
||||
let command;
|
||||
const route = createClusterControlPluginPackagePromptExecutionOutputReadRoute({
|
||||
async read(value) {
|
||||
command = value;
|
||||
return available(value);
|
||||
},
|
||||
});
|
||||
assert.equal(route.operationId, 'prompt.execution.output.read');
|
||||
assert.equal(route.permission, 'artifact.read');
|
||||
const result = await route.handle(authorized(), parameters);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(
|
||||
result.body.schema,
|
||||
'qinglong/cluster-plugin-package-prompt-execution-output-read-response@v1',
|
||||
);
|
||||
assert.equal(result.body.result.text, 'private output');
|
||||
assert.equal(command.executionRequestId, parameters.executionRequestId);
|
||||
assert.equal(command.principal.subject.id, 'owner-1');
|
||||
});
|
||||
|
||||
test('masks missing output and rejects malformed requests before capability', async () => {
|
||||
let calls = 0;
|
||||
const route = createClusterControlPluginPackagePromptExecutionOutputReadRoute({
|
||||
async read(command) {
|
||||
calls += 1;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-execution-output-read-result@v1',
|
||||
status: 'not_found',
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
promptId: command.promptId,
|
||||
executionRequestId: command.executionRequestId,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle(authorized(), parameters), {
|
||||
statusCode: 404,
|
||||
body: { code: 'prompt_execution_output_not_found' },
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(
|
||||
(await route.handle(authorized({ widened: true }), parameters)).statusCode,
|
||||
400,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('fails closed when capability widens or drifts from the exact target', async () => {
|
||||
const route = createClusterControlPluginPackagePromptExecutionOutputReadRoute({
|
||||
async read(command) {
|
||||
return available(command, { packageName: 'another-package' });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle(authorized(), parameters), {
|
||||
statusCode: 503,
|
||||
body: { code: 'prompt_execution_output_read_unavailable' },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_REQUEST_SCHEMA,
|
||||
CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_RESPONSE_SCHEMA,
|
||||
createClusterControlPluginPackagePromptExecutionRoute,
|
||||
} = require('@qinglong/cluster-control/prompt-routes');
|
||||
|
||||
function authorized(body, overrides = {}) {
|
||||
return {
|
||||
request: {
|
||||
requestId: '00000000-0000-4000-8000-000000000001',
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/project-1/packages/example/prompts/summary/executions',
|
||||
query: {},
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
body,
|
||||
},
|
||||
principal: {
|
||||
subject: { type: 'api_app', id: 'app-1' },
|
||||
authenticationId: 'credential-1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'service',
|
||||
},
|
||||
operationId: 'prompt.execute',
|
||||
permission: 'model.invoke',
|
||||
projectId: 'project-1',
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function body(overrides = {}) {
|
||||
return {
|
||||
schema: CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_REQUEST_SCHEMA,
|
||||
requestId: 'prompt-request-1',
|
||||
traceId: 'trace-1',
|
||||
parameters: { subject: 'private input' },
|
||||
provider: 'openai-compatible',
|
||||
model: 'model-a',
|
||||
maxOutputTokens: 512,
|
||||
temperature: 0.2,
|
||||
timeoutMs: 5_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function result(status = 'executed', liveResult = { text: 'private output' }) {
|
||||
return {
|
||||
status,
|
||||
admission: {
|
||||
requestId: 'prompt-request-1',
|
||||
invocationId: 'ppi:1',
|
||||
runId: 'ppr:1',
|
||||
stepRunId: 'pps:1',
|
||||
},
|
||||
finalization: { runStatus: 'succeeded' },
|
||||
result: liveResult,
|
||||
};
|
||||
}
|
||||
|
||||
test('builds a bounded subject- and policy-fenced execution command', async () => {
|
||||
let command;
|
||||
const route = createClusterControlPluginPackagePromptExecutionRoute(
|
||||
{
|
||||
async execute(value) {
|
||||
command = value;
|
||||
return result();
|
||||
},
|
||||
},
|
||||
{
|
||||
now: () => 2_000,
|
||||
maxExecutionMs: 10_000,
|
||||
createEventId: () => '00000000-0000-4000-8000-000000000002',
|
||||
},
|
||||
);
|
||||
const request = authorized(body());
|
||||
const response = await route.handle(request, {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
promptId: 'summary',
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.schema, CLUSTER_PLUGIN_PACKAGE_PROMPT_EXECUTION_RESPONSE_SCHEMA);
|
||||
assert.equal(response.body.replayed, false);
|
||||
assert.equal(response.body.result.text, 'private output');
|
||||
assert.deepEqual(command.principal, request.principal);
|
||||
assert.equal(command.auditEventId, '00000000-0000-4000-8000-000000000002');
|
||||
assert.deepEqual(command.policyFence, { projectVersion: 3, bindingVersion: 7 });
|
||||
assert.equal(command.plannedAtMs, 2_000);
|
||||
assert.equal(command.deadlineAtMs, 7_000);
|
||||
assert.equal(command.signal, request.request.signal);
|
||||
assert.equal('publication' in command, false);
|
||||
assert.equal('publicationDigest' in command, false);
|
||||
});
|
||||
|
||||
test('returns an explicit content-free replay receipt', async () => {
|
||||
const route = createClusterControlPluginPackagePromptExecutionRoute({
|
||||
async execute() {
|
||||
return result('existing', null);
|
||||
},
|
||||
});
|
||||
const response = await route.handle(authorized(body()), {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
promptId: 'summary',
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.replayed, true);
|
||||
assert.equal(response.body.result, null);
|
||||
});
|
||||
|
||||
test('strictly carries durable output intent and returns only its reference on replay', async () => {
|
||||
let command;
|
||||
const outputArtifact = {
|
||||
schema: 'qinglong/plugin-package-prompt-output-artifact-reference@v1',
|
||||
artifactId: 'pao:artifact-1',
|
||||
artifactDigest: 'b'.repeat(64),
|
||||
};
|
||||
const route = createClusterControlPluginPackagePromptExecutionRoute({
|
||||
async execute(value) {
|
||||
command = value;
|
||||
return { ...result('existing', null), outputArtifact };
|
||||
},
|
||||
});
|
||||
const output = {
|
||||
mode: 'durable_artifact',
|
||||
retentionPolicy: {
|
||||
revision: 'cluster-prompt-output-v1',
|
||||
retentionMs: 86_400_000,
|
||||
},
|
||||
};
|
||||
const response = await route.handle(authorized(body({ output })), {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
promptId: 'summary',
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(command.output, output);
|
||||
assert.equal(response.body.result, null);
|
||||
assert.deepEqual(response.body.outputArtifact, outputArtifact);
|
||||
});
|
||||
|
||||
test('rejects malformed or over-timeout bodies before the capability', async () => {
|
||||
let calls = 0;
|
||||
const route = createClusterControlPluginPackagePromptExecutionRoute(
|
||||
{ async execute() { calls += 1; return result(); } },
|
||||
{ maxExecutionMs: 1_000 },
|
||||
);
|
||||
for (const invalid of [
|
||||
body({ timeoutMs: 1_001 }),
|
||||
body({ publication: {} }),
|
||||
body({ publicationDigest: 'a'.repeat(64) }),
|
||||
body({ parameters: { bad: 7 } }),
|
||||
body({ output: { mode: 'durable_artifact', retentionPolicy: {
|
||||
revision: 'cluster-prompt-output-v1', retentionMs: 1,
|
||||
} } }),
|
||||
body({ output: { mode: 'live_only', unexpected: true } }),
|
||||
]) {
|
||||
const response = await route.handle(authorized(invalid), {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
promptId: 'summary',
|
||||
});
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.deepEqual(response.body, { code: 'invalid_prompt_execution_request' });
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('maps internal errors to low-sensitive stable transport codes', async () => {
|
||||
for (const [internal, statusCode, external] of [
|
||||
['PLUGIN_PACKAGE_PROMPT_ADMISSION_NOT_ALLOWED', 409, 'prompt_execution_conflict'],
|
||||
['MODEL_GATEWAY_BUSY', 429, 'prompt_execution_capacity_exceeded'],
|
||||
['MODEL_POLICY_DENIED', 422, 'prompt_execution_policy_rejected'],
|
||||
['MODEL_INVOCATION_DEADLINE_EXCEEDED', 504, 'prompt_execution_deadline_exceeded'],
|
||||
['MODEL_PROVIDER_UNAVAILABLE', 503, 'prompt_execution_unavailable'],
|
||||
]) {
|
||||
const route = createClusterControlPluginPackagePromptExecutionRoute({
|
||||
async execute() {
|
||||
throw Object.assign(new Error('private provider detail'), { code: internal });
|
||||
},
|
||||
});
|
||||
const response = await route.handle(authorized(body()), {
|
||||
projectId: 'project-1',
|
||||
packageName: 'example',
|
||||
promptId: 'summary',
|
||||
});
|
||||
assert.equal(response.statusCode, statusCode, internal);
|
||||
assert.deepEqual(response.body, { code: external });
|
||||
assert.equal(JSON.stringify(response).includes('private provider detail'), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
createClusterControlPluginPackagePromptOutputReadRoute,
|
||||
} = require('@qinglong/cluster-control/prompt-routes');
|
||||
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
const RETENTION_DIGEST = 'c'.repeat(64);
|
||||
const ARTIFACT_ID = 'pao:1234';
|
||||
|
||||
function authorized(query = { artifact_digest: [DIGEST] }, body = null) {
|
||||
return {
|
||||
request: {
|
||||
requestId: 'request-1',
|
||||
method: 'GET',
|
||||
path: '/unused',
|
||||
query,
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
body,
|
||||
},
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'user-1' },
|
||||
authenticationId: 'auth-1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'multi_factor',
|
||||
},
|
||||
operationId: 'prompt.output.read',
|
||||
permission: 'artifact.read',
|
||||
projectId: 'project-1',
|
||||
policyFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function available(command, overrides = {}) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-output-read-result@v1',
|
||||
status: 'available',
|
||||
reference: {
|
||||
schema: 'qinglong/plugin-package-prompt-output-artifact-reference@v1',
|
||||
artifactId: command.artifactId,
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
stepRunId: 'step-1',
|
||||
invocationId: 'invocation-1',
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
outputBytes: 14,
|
||||
retentionPolicyDigest: RETENTION_DIGEST,
|
||||
retentionEligibleAtMs: 10_000,
|
||||
keyId: 'key-1',
|
||||
algorithm: 'aes-256-gcm',
|
||||
artifactDigest: command.artifactDigest,
|
||||
},
|
||||
result: {
|
||||
provider: 'openai-compatible',
|
||||
model: 'model-1',
|
||||
text: 'durable output',
|
||||
finishReason: 'stop',
|
||||
usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('returns one bounded Prompt output after the reviewed read capability', async () => {
|
||||
let command;
|
||||
const route = createClusterControlPluginPackagePromptOutputReadRoute({
|
||||
async read(value) {
|
||||
command = value;
|
||||
return available(value);
|
||||
},
|
||||
});
|
||||
const result = await route.handle(authorized(), {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
artifactId: ARTIFACT_ID,
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(result.body.schema,
|
||||
'qinglong/cluster-plugin-package-prompt-output-read-response@v1');
|
||||
assert.equal(result.body.result.text, 'durable output');
|
||||
assert.equal(command.principal.subject.id, 'user-1');
|
||||
assert.equal(command.artifactDigest, DIGEST);
|
||||
});
|
||||
|
||||
test('masks product not-found and rejects malformed requests before capability', async () => {
|
||||
let calls = 0;
|
||||
const route = createClusterControlPluginPackagePromptOutputReadRoute({
|
||||
async read() {
|
||||
calls += 1;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-output-read-result@v1',
|
||||
status: 'not_found',
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal((await route.handle(authorized(), {
|
||||
projectId: 'project-1', runId: 'run-1', artifactId: ARTIFACT_ID,
|
||||
})).statusCode, 404);
|
||||
assert.equal(calls, 1);
|
||||
|
||||
assert.equal((await route.handle(authorized({ artifact_digest: ['bad'] }), {
|
||||
projectId: 'project-1', runId: 'run-1', artifactId: ARTIFACT_ID,
|
||||
})).statusCode, 400);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('fails closed when capability widens or drifts from the requested identity', async () => {
|
||||
const route = createClusterControlPluginPackagePromptOutputReadRoute({
|
||||
async read(command) {
|
||||
return available(command, {
|
||||
reference: {
|
||||
...available(command).reference,
|
||||
projectId: 'project-other',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await route.handle(authorized(), {
|
||||
projectId: 'project-1', runId: 'run-1', artifactId: ARTIFACT_ID,
|
||||
});
|
||||
assert.equal(result.statusCode, 503);
|
||||
assert.deepEqual(result.body, { code: 'prompt_output_read_unavailable' });
|
||||
});
|
||||
@@ -0,0 +1,819 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
createPluginPackageWorkflowAdmissionBundle,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
createClusterPluginPackageWorkflowAdministrationCapability,
|
||||
} = require('@qinglong/cluster-control/workflow-administration');
|
||||
const {
|
||||
createClusterControlPluginPackageWorkflowRoutes,
|
||||
} = require('@qinglong/cluster-control/workflow-routes');
|
||||
|
||||
const IDS = Object.freeze({
|
||||
planId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
runId: '123e4567-e89b-42d3-a456-426614174001',
|
||||
collect: '123e4567-e89b-42d3-a456-426614174002',
|
||||
summarize: '123e4567-e89b-42d3-a456-426614174003',
|
||||
});
|
||||
|
||||
function productFixture() {
|
||||
const value = pluginPackageTaskReconciliationFixture('cluster-product', {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
...value,
|
||||
publication: createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function principal(now = 3_000) {
|
||||
return Object.freeze({
|
||||
subject: { type: 'api_app', id: 'workflow-operator' },
|
||||
authenticationId: 'api_credential:workflow-product:1',
|
||||
authenticatedAtMs: now - 1,
|
||||
expiresAtMs: now + 60_000,
|
||||
assurance: 'service',
|
||||
});
|
||||
}
|
||||
|
||||
test('derives the cluster Workflow plan from current server publications and exactly replays it', async () => {
|
||||
const value = productFixture();
|
||||
let storedPlan = null;
|
||||
let cancellationCommand;
|
||||
let inspectionCommand;
|
||||
let runListCommand;
|
||||
let stepListCommand;
|
||||
let runEventListCommand;
|
||||
const admissions = [];
|
||||
const capability = createClusterPluginPackageWorkflowAdministrationCapability(
|
||||
{
|
||||
async findCurrent(projectId, packageName) {
|
||||
assert.equal(projectId, value.projectId);
|
||||
assert.equal(packageName, value.packageName);
|
||||
return value.publication;
|
||||
},
|
||||
},
|
||||
{
|
||||
async find(generationDigest) {
|
||||
assert.equal(
|
||||
generationDigest,
|
||||
value.revision.generation.generationDigest,
|
||||
);
|
||||
return value.revision;
|
||||
},
|
||||
},
|
||||
{
|
||||
async findPlanByPlanId(planId) {
|
||||
assert.equal(planId, IDS.planId);
|
||||
return storedPlan;
|
||||
},
|
||||
async admitAuthorized(admission) {
|
||||
admissions.push(admission);
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(
|
||||
admission.plan,
|
||||
);
|
||||
const status = storedPlan ? 'existing' : 'created';
|
||||
storedPlan = admission.plan;
|
||||
return { status, receipt: bundle.receipt };
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspectRunAuthorized(command) {
|
||||
inspectionCommand = command;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-inspection@v1',
|
||||
found: false,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
run: null,
|
||||
stepCount: null,
|
||||
stepStatusCounts: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async listRunsAuthorized(command) {
|
||||
runListCommand = command;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-list@v1',
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
after: command.after,
|
||||
runs: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async listStepRunsAuthorized(command) {
|
||||
stepListCommand = command;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-step-run-list@v1',
|
||||
found: false,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
stepRuns: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async listRunEventsAuthorized(command) {
|
||||
runEventListCommand = command;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-event-list@v1',
|
||||
found: false,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
afterSequence: command.afterSequence,
|
||||
headSequence: null,
|
||||
events: [],
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async requestUserCancellation(command) {
|
||||
cancellationCommand = command;
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
runStatus: 'running',
|
||||
runVersion: 4,
|
||||
eventSequence: 4,
|
||||
cancelRequestedAtMs: 3_100,
|
||||
cancelReason: 'user',
|
||||
};
|
||||
},
|
||||
},
|
||||
value.registry,
|
||||
);
|
||||
|
||||
const listed = await capability.inspect(value.projectId, value.packageName);
|
||||
assert.equal(listed.found, true);
|
||||
assert.deepEqual(listed.workflows[0].steps, [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
]);
|
||||
|
||||
const command = {
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
workflowId: 'daily',
|
||||
planId: IDS.planId,
|
||||
runId: IDS.runId,
|
||||
stepRunIds: { collect: IDS.collect, summarize: IDS.summarize },
|
||||
principal: principal(),
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
plannedAtMs: 3_000,
|
||||
};
|
||||
const created = await capability.start(command);
|
||||
const replay = await capability.start({ ...command, plannedAtMs: 9_000 });
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.plan.planDigest, created.plan.planDigest);
|
||||
assert.equal(
|
||||
created.plan.target.publicationDigest,
|
||||
value.publication.publicationDigest,
|
||||
);
|
||||
assert.equal(admissions[0].audit.eventId, IDS.planId);
|
||||
assert.equal(admissions[0].audit.requestId, IDS.planId);
|
||||
assert.equal(
|
||||
admissions[0].audit.authenticationId,
|
||||
principal().authenticationId,
|
||||
);
|
||||
assert.equal(admissions[1].audit.occurredAtMs, created.plan.plannedAtMs);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await capability.cancel({
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
mutationId: 'workflow-cancel-mutation-1',
|
||||
eventId: '018f0000-0000-7000-8000-000000000091',
|
||||
principal: principal(),
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
})
|
||||
).status,
|
||||
'accepted',
|
||||
);
|
||||
assert.deepEqual(cancellationCommand.workflowTarget, {
|
||||
packageName: value.packageName,
|
||||
workflowId: 'daily',
|
||||
});
|
||||
const inspected = await capability.inspectRun({
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
requestId: 'workflow-run-read-request-1',
|
||||
auditEventId: '123e4567-e89b-42d3-a456-426614174093',
|
||||
principal: principal(),
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
observedAtMs: 3_200,
|
||||
});
|
||||
assert.equal(inspected.found, false);
|
||||
assert.equal(inspectionCommand.audit.operationId, 'workflow.run.read');
|
||||
assert.equal(
|
||||
inspectionCommand.audit.requestId,
|
||||
'workflow-run-read-request-1',
|
||||
);
|
||||
assert.deepEqual(inspectionCommand.fence, {
|
||||
projectVersion: 3,
|
||||
bindingVersion: 7,
|
||||
});
|
||||
const runPage = await capability.listRuns({
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
workflowId: 'daily',
|
||||
limit: 16,
|
||||
after: { admittedAtMs: 3_100, runId: IDS.runId },
|
||||
requestId: 'workflow-run-list-request-1',
|
||||
auditEventId: '123e4567-e89b-42d3-a456-426614174096',
|
||||
principal: principal(),
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
observedAtMs: 3_250,
|
||||
});
|
||||
assert.deepEqual(runPage.runs, []);
|
||||
assert.equal(runListCommand.audit.operationId, 'workflow.run.list');
|
||||
assert.equal(runListCommand.limit, 16);
|
||||
assert.deepEqual(runListCommand.after, {
|
||||
admittedAtMs: 3_100,
|
||||
runId: IDS.runId,
|
||||
});
|
||||
const stepPage = await capability.listStepRuns({
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
limit: 16,
|
||||
after: { stepKey: 'collect', id: IDS.collect },
|
||||
requestId: 'workflow-step-list-request-1',
|
||||
auditEventId: '123e4567-e89b-42d3-a456-426614174094',
|
||||
principal: principal(),
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
observedAtMs: 3_300,
|
||||
});
|
||||
assert.equal(stepPage.found, false);
|
||||
assert.equal(stepListCommand.audit.operationId, 'workflow.step.list');
|
||||
assert.equal(stepListCommand.limit, 16);
|
||||
assert.deepEqual(stepListCommand.after, {
|
||||
stepKey: 'collect',
|
||||
id: IDS.collect,
|
||||
});
|
||||
const eventPage = await capability.listRunEvents({
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
limit: 16,
|
||||
afterSequence: 2,
|
||||
requestId: 'workflow-event-list-request-1',
|
||||
auditEventId: '123e4567-e89b-42d3-a456-426614174095',
|
||||
principal: principal(),
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
observedAtMs: 3_400,
|
||||
});
|
||||
assert.equal(eventPage.found, false);
|
||||
assert.equal(runEventListCommand.audit.operationId, 'workflow.event.list');
|
||||
assert.equal(runEventListCommand.limit, 16);
|
||||
assert.equal(runEventListCommand.afterSequence, 2);
|
||||
});
|
||||
|
||||
test('publishes strict authenticated list and content-free start routes', async () => {
|
||||
let startCommand;
|
||||
let cancellationError;
|
||||
const routes = createClusterControlPluginPackageWorkflowRoutes(
|
||||
{
|
||||
async inspect() {
|
||||
return { found: false, publicationState: null, workflows: [] };
|
||||
},
|
||||
async inspectRun(command) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-inspection@v1',
|
||||
found: true,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
run: {
|
||||
status: 'running',
|
||||
version: 4,
|
||||
eventSequence: 4,
|
||||
createdAtMs: 3_000,
|
||||
queuedAtMs: 3_010,
|
||||
startedAtMs: 3_020,
|
||||
finishedAtMs: null,
|
||||
cancelRequestedAtMs: null,
|
||||
cancelReason: null,
|
||||
},
|
||||
stepCount: 1,
|
||||
stepStatusCounts: {
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
waiting_approval: 0,
|
||||
running: 1,
|
||||
lost: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
cancelled: 0,
|
||||
timed_out: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async listRuns(command) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-list@v1',
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
after: command.after,
|
||||
runs: [
|
||||
{
|
||||
runId: IDS.runId,
|
||||
status: 'running',
|
||||
version: 4,
|
||||
eventSequence: 4,
|
||||
stepCount: 2,
|
||||
admittedAtMs: 3_000,
|
||||
queuedAtMs: 3_010,
|
||||
startedAtMs: 3_020,
|
||||
finishedAtMs: null,
|
||||
cancelRequestedAtMs: null,
|
||||
cancelReason: null,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { admittedAtMs: 3_000, runId: IDS.runId },
|
||||
};
|
||||
},
|
||||
async listStepRuns(command) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-step-run-list@v1',
|
||||
found: true,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
stepRuns: [
|
||||
{
|
||||
id: IDS.collect,
|
||||
parentStepRunId: null,
|
||||
stepKey: 'collect',
|
||||
kind: 'task',
|
||||
required: true,
|
||||
status: 'running',
|
||||
version: 2,
|
||||
attemptCount: 1,
|
||||
readyAtMs: 3_010,
|
||||
startedAtMs: 3_020,
|
||||
finishedAtMs: null,
|
||||
resultCode: null,
|
||||
createdAtMs: 3_000,
|
||||
updatedAtMs: 3_020,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { stepKey: 'collect', id: IDS.collect },
|
||||
};
|
||||
},
|
||||
async listRunEvents(command) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-event-list@v1',
|
||||
found: true,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
afterSequence: command.afterSequence,
|
||||
headSequence: 4,
|
||||
events: [
|
||||
{
|
||||
id: '123e4567-e89b-42d3-a456-426614174004',
|
||||
sequence: 3,
|
||||
type: 'workflow.task_attempt.running',
|
||||
stepRunId: IDS.collect,
|
||||
createdAtMs: 3_020,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
nextAfterSequence: 3,
|
||||
};
|
||||
},
|
||||
async start(command) {
|
||||
startCommand = command;
|
||||
return {
|
||||
status: 'created',
|
||||
plan: { planId: command.planId, runId: command.runId },
|
||||
receipt: { receiptDigest: 'a'.repeat(64) },
|
||||
};
|
||||
},
|
||||
async cancel(command) {
|
||||
if (cancellationError) {
|
||||
throw cancellationError;
|
||||
}
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
runStatus: 'running',
|
||||
runVersion: 4,
|
||||
eventSequence: 4,
|
||||
cancelRequestedAtMs: 3_100,
|
||||
cancelReason: 'user',
|
||||
};
|
||||
},
|
||||
},
|
||||
() => 3_000,
|
||||
() => '123e4567-e89b-42d3-a456-426614174092',
|
||||
);
|
||||
const start = routes.find(
|
||||
({ operationId }) => operationId === 'workflow.start',
|
||||
);
|
||||
assert.ok(start);
|
||||
const authorized = {
|
||||
request: {
|
||||
requestId: 'transport-request-1',
|
||||
body: {
|
||||
schema: 'qinglong/cluster-plugin-package-workflow-start-request@v1',
|
||||
planId: IDS.planId,
|
||||
runId: IDS.runId,
|
||||
stepRunIds: { collect: IDS.collect },
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
principal: principal(),
|
||||
operationId: 'workflow.start',
|
||||
permission: 'run.start',
|
||||
projectId: 'project-1',
|
||||
policyFence: { projectVersion: 3, bindingVersion: 7 },
|
||||
};
|
||||
const result = await start.handle(authorized, {
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
});
|
||||
assert.equal(result.statusCode, 201);
|
||||
assert.deepEqual(Object.keys(result.body).sort(), [
|
||||
'planId',
|
||||
'receiptDigest',
|
||||
'replayed',
|
||||
'runId',
|
||||
'schema',
|
||||
'status',
|
||||
]);
|
||||
assert.equal(startCommand.principal.subject.id, 'workflow-operator');
|
||||
assert.equal(startCommand.plannedAtMs, 3_000);
|
||||
|
||||
const inspectRun = routes.find(
|
||||
({ operationId }) => operationId === 'workflow.run.read',
|
||||
);
|
||||
assert.ok(inspectRun);
|
||||
assert.equal(inspectRun.permission, 'run.read');
|
||||
const inspected = await inspectRun.handle(
|
||||
{
|
||||
...authorized,
|
||||
operationId: 'workflow.run.read',
|
||||
permission: 'run.read',
|
||||
request: { ...authorized.request, body: null },
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(inspected.statusCode, 200);
|
||||
assert.equal(
|
||||
inspected.body.schema,
|
||||
'qinglong/plugin-package-workflow-run-inspection@v1',
|
||||
);
|
||||
assert.deepEqual(Object.keys(inspected.body.run).sort(), [
|
||||
'cancelReason',
|
||||
'cancelRequestedAtMs',
|
||||
'createdAtMs',
|
||||
'eventSequence',
|
||||
'finishedAtMs',
|
||||
'queuedAtMs',
|
||||
'startedAtMs',
|
||||
'status',
|
||||
'version',
|
||||
]);
|
||||
|
||||
const listRuns = routes.find(
|
||||
({ operationId }) => operationId === 'workflow.run.list',
|
||||
);
|
||||
assert.ok(listRuns);
|
||||
assert.equal(listRuns.permission, 'run.read');
|
||||
assert.deepEqual(listRuns.allowedQuery, [
|
||||
'after_admitted_at_ms',
|
||||
'after_run_id',
|
||||
'limit',
|
||||
]);
|
||||
const listedRuns = await listRuns.handle(
|
||||
{
|
||||
...authorized,
|
||||
operationId: 'workflow.run.list',
|
||||
permission: 'run.read',
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: null,
|
||||
query: {
|
||||
limit: ['1'],
|
||||
after_admitted_at_ms: ['3100'],
|
||||
after_run_id: [IDS.runId],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ packageName: 'example', workflowId: 'daily' },
|
||||
);
|
||||
assert.equal(listedRuns.statusCode, 200);
|
||||
assert.equal(
|
||||
listedRuns.body.schema,
|
||||
'qinglong/plugin-package-workflow-run-list@v1',
|
||||
);
|
||||
assert.deepEqual(Object.keys(listedRuns.body.runs[0]).sort(), [
|
||||
'admittedAtMs',
|
||||
'cancelReason',
|
||||
'cancelRequestedAtMs',
|
||||
'eventSequence',
|
||||
'finishedAtMs',
|
||||
'queuedAtMs',
|
||||
'runId',
|
||||
'startedAtMs',
|
||||
'status',
|
||||
'stepCount',
|
||||
'version',
|
||||
]);
|
||||
const invalidRunCursor = await listRuns.handle(
|
||||
{
|
||||
...authorized,
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: null,
|
||||
query: { after_admitted_at_ms: ['3100'] },
|
||||
},
|
||||
},
|
||||
{ packageName: 'example', workflowId: 'daily' },
|
||||
);
|
||||
assert.equal(invalidRunCursor.statusCode, 400);
|
||||
|
||||
const listSteps = routes.find(
|
||||
({ operationId }) => operationId === 'workflow.step.list',
|
||||
);
|
||||
assert.ok(listSteps);
|
||||
assert.equal(listSteps.permission, 'run.read');
|
||||
assert.deepEqual(listSteps.allowedQuery, [
|
||||
'after_step_key',
|
||||
'after_step_run_id',
|
||||
'limit',
|
||||
]);
|
||||
const listedSteps = await listSteps.handle(
|
||||
{
|
||||
...authorized,
|
||||
operationId: 'workflow.step.list',
|
||||
permission: 'run.read',
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: null,
|
||||
query: {
|
||||
limit: ['1'],
|
||||
after_step_key: ['collect'],
|
||||
after_step_run_id: [IDS.collect],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(listedSteps.statusCode, 200);
|
||||
assert.equal(
|
||||
listedSteps.body.schema,
|
||||
'qinglong/plugin-package-workflow-step-run-list@v1',
|
||||
);
|
||||
assert.deepEqual(Object.keys(listedSteps.body.stepRuns[0]).sort(), [
|
||||
'attemptCount',
|
||||
'createdAtMs',
|
||||
'finishedAtMs',
|
||||
'id',
|
||||
'kind',
|
||||
'parentStepRunId',
|
||||
'readyAtMs',
|
||||
'required',
|
||||
'resultCode',
|
||||
'startedAtMs',
|
||||
'status',
|
||||
'stepKey',
|
||||
'updatedAtMs',
|
||||
'version',
|
||||
]);
|
||||
const invalidStepCursor = await listSteps.handle(
|
||||
{
|
||||
...authorized,
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: null,
|
||||
query: { after_step_key: ['collect'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(invalidStepCursor.statusCode, 400);
|
||||
|
||||
const listEvents = routes.find(
|
||||
({ operationId }) => operationId === 'workflow.event.list',
|
||||
);
|
||||
assert.ok(listEvents);
|
||||
assert.equal(listEvents.permission, 'run.read');
|
||||
assert.deepEqual(listEvents.allowedQuery, ['after_sequence', 'limit']);
|
||||
const listedEvents = await listEvents.handle(
|
||||
{
|
||||
...authorized,
|
||||
operationId: 'workflow.event.list',
|
||||
permission: 'run.read',
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: null,
|
||||
query: { limit: ['1'], after_sequence: ['2'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(listedEvents.statusCode, 200);
|
||||
assert.equal(
|
||||
listedEvents.body.schema,
|
||||
'qinglong/plugin-package-workflow-run-event-list@v1',
|
||||
);
|
||||
assert.deepEqual(Object.keys(listedEvents.body.events[0]).sort(), [
|
||||
'createdAtMs',
|
||||
'id',
|
||||
'sequence',
|
||||
'stepRunId',
|
||||
'type',
|
||||
]);
|
||||
const invalidEventCursor = await listEvents.handle(
|
||||
{
|
||||
...authorized,
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: null,
|
||||
query: { after_sequence: ['02'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(invalidEventCursor.statusCode, 400);
|
||||
|
||||
const invalid = await start.handle(
|
||||
{
|
||||
...authorized,
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: { ...authorized.request.body, extra: true },
|
||||
},
|
||||
},
|
||||
{ packageName: 'example', workflowId: 'daily' },
|
||||
);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
|
||||
const cancellation = routes.find(
|
||||
({ operationId }) => operationId === 'workflow.cancel',
|
||||
);
|
||||
assert.ok(cancellation);
|
||||
const cancelled = await cancellation.handle(
|
||||
{
|
||||
...authorized,
|
||||
operationId: 'workflow.cancel',
|
||||
permission: 'run.stop',
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: {
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'workflow-cancel-mutation-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(cancelled.statusCode, 202);
|
||||
assert.equal(cancelled.body.status, 'accepted');
|
||||
assert.deepEqual(
|
||||
Object.keys(cancelled.body).sort(),
|
||||
[
|
||||
'cancelReason',
|
||||
'cancelRequestedAtMs',
|
||||
'eventSequence',
|
||||
'projectId',
|
||||
'runId',
|
||||
'runStatus',
|
||||
'runVersion',
|
||||
'schema',
|
||||
'status',
|
||||
].sort(),
|
||||
);
|
||||
const invalidCancellation = await cancellation.handle(
|
||||
{
|
||||
...authorized,
|
||||
operationId: 'workflow.cancel',
|
||||
permission: 'run.stop',
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: {
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'workflow-cancel-mutation-1',
|
||||
reason: 'shutdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(invalidCancellation.statusCode, 400);
|
||||
|
||||
cancellationError = Object.assign(new Error('private adapter detail'), {
|
||||
code: 'CLUSTER_RUN_CANCELLATION_FENCE_REJECTED',
|
||||
reason: 'private_adapter_detail',
|
||||
});
|
||||
const closedCancellationFailure = await cancellation.handle(
|
||||
{
|
||||
...authorized,
|
||||
operationId: 'workflow.cancel',
|
||||
permission: 'run.stop',
|
||||
request: {
|
||||
...authorized.request,
|
||||
body: {
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'workflow-cancel-mutation-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
packageName: 'example',
|
||||
workflowId: 'daily',
|
||||
runId: IDS.runId,
|
||||
},
|
||||
);
|
||||
assert.equal(closedCancellationFailure.statusCode, 409);
|
||||
assert.deepEqual(closedCancellationFailure.body, {
|
||||
code: 'workflow_cancellation_fence_rejected',
|
||||
reason: 'state_mismatch',
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterControlProcessError,
|
||||
runProductionClusterControlProcess,
|
||||
} = require('@qinglong/cluster-control/process');
|
||||
|
||||
const BASE_ENV = Object.freeze({
|
||||
QL3_CLUSTER_CONTROL_ENABLED: 'true',
|
||||
QL_DEPLOYMENT_PROFILE: 'cluster-control',
|
||||
QL3_CLUSTER_REPLICA_ID: 'cluster-control-0',
|
||||
QL3_POSTGRES_RUNTIME_URL:
|
||||
'postgresql://ql3_runtime:do-not-log@postgres-rw.internal:5432/qinglong',
|
||||
QL3_POSTGRES_TLS_SERVERNAME: 'postgres-rw.internal',
|
||||
QL3_API_CREDENTIAL_PEPPER: 'A'.repeat(43),
|
||||
});
|
||||
const NEVER_UNAVAILABLE = new Promise(() => {});
|
||||
|
||||
function signalSource(events, signal = 'SIGTERM') {
|
||||
return {
|
||||
subscribe(listener) {
|
||||
events.push('subscribe');
|
||||
queueMicrotask(() => listener(signal));
|
||||
return () => events.push('unsubscribe');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('runs one production replica and drains it on the first signal', async () => {
|
||||
const events = [];
|
||||
const facts = [];
|
||||
const result = await runProductionClusterControlProcess({
|
||||
environment: BASE_ENV,
|
||||
signals: signalSource(events),
|
||||
emit(record) {
|
||||
facts.push(record);
|
||||
},
|
||||
async start(options) {
|
||||
events.push('start');
|
||||
assert.equal(options.config.enabled, true);
|
||||
assert.equal(options.config.profile, 'cluster-control');
|
||||
assert.equal(options.recovery.ownerId, 'cluster-control-0');
|
||||
assert.equal(options.scheduler.ownerId, 'cluster-control-0');
|
||||
await options.audit({
|
||||
state: 'active',
|
||||
contractName: 'qinglong-cluster-control',
|
||||
contractVersion: 16,
|
||||
serverMajor: 18,
|
||||
migrationCount: 16,
|
||||
});
|
||||
options.scheduler.onDiagnostic(
|
||||
Object.assign(new Error('must-not-be-logged'), {
|
||||
code: 'ECONNRESET',
|
||||
}),
|
||||
);
|
||||
return {
|
||||
status: 'active',
|
||||
address: { host: '0.0.0.0', port: 5800 },
|
||||
evidence: {
|
||||
contractName: 'qinglong-cluster-control',
|
||||
contractVersion: 16,
|
||||
serverMajor: 18,
|
||||
migrationIds: [],
|
||||
},
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
unavailable: NEVER_UNAVAILABLE,
|
||||
availabilityStatus: () => 'ready',
|
||||
async stop() {
|
||||
events.push('stop');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 'stopped');
|
||||
assert.deepEqual(events, ['subscribe', 'start', 'stop', 'unsubscribe']);
|
||||
assert.equal(facts.some((fact) => fact.event === 'activation'), true);
|
||||
assert.equal(facts.some((fact) => fact.event === 'listening'), true);
|
||||
assert.equal(
|
||||
facts.some(
|
||||
(fact) =>
|
||||
fact.event === 'shutdown_requested' && fact.signal === 'SIGTERM',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(facts.at(-1).event, 'stopped');
|
||||
assert.equal(facts.at(-1).stopResult, 'stopped');
|
||||
const serialized = JSON.stringify(facts);
|
||||
assert.equal(serialized.includes('do-not-log'), false);
|
||||
assert.equal(serialized.includes(BASE_ENV.QL3_API_CREDENTIAL_PEPPER), false);
|
||||
assert.equal(serialized.includes('must-not-be-logged'), false);
|
||||
assert.equal(serialized.includes('ECONNRESET'), true);
|
||||
});
|
||||
|
||||
test('fails closed before startup for a disabled profile or invalid replica id', async () => {
|
||||
let starts = 0;
|
||||
for (const environment of [
|
||||
{
|
||||
QL3_CLUSTER_CONTROL_ENABLED: 'false',
|
||||
QL_DEPLOYMENT_PROFILE: 'standalone',
|
||||
},
|
||||
{ ...BASE_ENV, QL3_CLUSTER_REPLICA_ID: 'unsafe replica' },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
runProductionClusterControlProcess({
|
||||
environment,
|
||||
signals: { subscribe() { return () => {}; } },
|
||||
emit() {},
|
||||
async start() {
|
||||
starts += 1;
|
||||
throw new Error('must not start');
|
||||
},
|
||||
}),
|
||||
ClusterControlProcessError,
|
||||
);
|
||||
}
|
||||
assert.equal(starts, 0);
|
||||
});
|
||||
|
||||
test('propagates timed-out drain and always releases signal ownership', async () => {
|
||||
const events = [];
|
||||
const result = await runProductionClusterControlProcess({
|
||||
environment: BASE_ENV,
|
||||
signals: signalSource(events, 'SIGINT'),
|
||||
emit() {},
|
||||
async start() {
|
||||
return {
|
||||
status: 'active',
|
||||
address: { host: '127.0.0.1', port: 5800 },
|
||||
evidence: {
|
||||
contractName: 'qinglong-cluster-control',
|
||||
contractVersion: 16,
|
||||
serverMajor: 18,
|
||||
migrationIds: [],
|
||||
},
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
unavailable: NEVER_UNAVAILABLE,
|
||||
availabilityStatus: () => 'ready',
|
||||
async stop() {
|
||||
events.push('stop');
|
||||
return 'timed_out';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(result, 'timed_out');
|
||||
assert.deepEqual(events, ['subscribe', 'stop', 'unsubscribe']);
|
||||
});
|
||||
|
||||
test('starts the optional Worker listener and closes its lazy Artifact binding', async () => {
|
||||
const events = [];
|
||||
const facts = [];
|
||||
const artifactStore = {
|
||||
async put() {},
|
||||
async inspect() {},
|
||||
};
|
||||
const environment = {
|
||||
...BASE_ENV,
|
||||
QL3_WORKER_INGRESS_ENABLED: 'true',
|
||||
QL3_POSTGRES_WORKER_INGRESS_URL:
|
||||
'postgresql://ql3_worker_ingress:secret@postgres-rw.internal:5432/qinglong',
|
||||
QL3_WORKER_INGRESS_POSTGRES_TLS_SERVERNAME: 'postgres-rw.internal',
|
||||
QL3_WORKER_CREDENTIAL_PEPPER: 'A'.repeat(43),
|
||||
QL3_WORKER_INGRESS_TLS_PRIVATE_KEY_FILE: '/run/worker/tls.key',
|
||||
QL3_WORKER_INGRESS_TLS_CERTIFICATE_FILE: '/run/worker/tls.crt',
|
||||
QL3_WORKER_INGRESS_TLS_CLIENT_CA_FILE: '/run/worker/client-ca.crt',
|
||||
QL3_WORKER_ARTIFACT_S3_BUCKET: 'qinglong-worker-artifacts',
|
||||
QL3_WORKER_ARTIFACT_S3_REGION: 'us-east-1',
|
||||
};
|
||||
const result = await runProductionClusterControlProcess({
|
||||
environment,
|
||||
signals: signalSource(events),
|
||||
emit(record) {
|
||||
facts.push(record);
|
||||
},
|
||||
async createWorkerArtifactBinding(config) {
|
||||
events.push(`artifact:${config.bucket}:${config.region}`);
|
||||
return {
|
||||
store: artifactStore,
|
||||
async close() {
|
||||
events.push('close-artifact');
|
||||
},
|
||||
};
|
||||
},
|
||||
async start(options) {
|
||||
events.push('start');
|
||||
assert.equal(options.workerIngress.config.enabled, true);
|
||||
assert.equal(options.workerIngress.artifactStore, artifactStore);
|
||||
return {
|
||||
status: 'active',
|
||||
address: { host: '0.0.0.0', port: 5800 },
|
||||
evidence: {
|
||||
contractName: 'qinglong-cluster-control',
|
||||
contractVersion: 16,
|
||||
serverMajor: 18,
|
||||
migrationIds: [],
|
||||
},
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
unavailable: NEVER_UNAVAILABLE,
|
||||
availabilityStatus: () => 'ready',
|
||||
async stop() {
|
||||
events.push('stop');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(result, 'stopped');
|
||||
assert.deepEqual(events, [
|
||||
'subscribe',
|
||||
'artifact:qinglong-worker-artifacts:us-east-1',
|
||||
'start',
|
||||
'stop',
|
||||
'unsubscribe',
|
||||
'close-artifact',
|
||||
]);
|
||||
assert.equal(
|
||||
facts.some((fact) => fact.event === 'worker_ingress_listening'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates the configured mounted Secret provider before Worker activation', async () => {
|
||||
const events = [];
|
||||
const artifactStore = {
|
||||
async put() {},
|
||||
async inspect() {},
|
||||
};
|
||||
const provider = { async resolve() {} };
|
||||
const result = await runProductionClusterControlProcess({
|
||||
environment: {
|
||||
...BASE_ENV,
|
||||
QL3_WORKER_INGRESS_ENABLED: 'true',
|
||||
QL3_POSTGRES_WORKER_INGRESS_URL:
|
||||
'postgresql://ql3_worker_ingress:secret@postgres-rw.internal:5432/qinglong',
|
||||
QL3_WORKER_INGRESS_POSTGRES_TLS_SERVERNAME: 'postgres-rw.internal',
|
||||
QL3_WORKER_CREDENTIAL_PEPPER: 'A'.repeat(43),
|
||||
QL3_WORKER_INGRESS_TLS_PRIVATE_KEY_FILE: '/run/worker/tls.key',
|
||||
QL3_WORKER_INGRESS_TLS_CERTIFICATE_FILE: '/run/worker/tls.crt',
|
||||
QL3_WORKER_INGRESS_TLS_CLIENT_CA_FILE: '/run/worker/client-ca.crt',
|
||||
QL3_WORKER_ARTIFACT_S3_BUCKET: 'qinglong-worker-artifacts',
|
||||
QL3_WORKER_ARTIFACT_S3_REGION: 'us-east-1',
|
||||
QL3_WORKER_SECRET_PROVIDER: 'mounted-files',
|
||||
QL3_WORKER_SECRET_ROOT_DIRECTORY: '/run/worker/values',
|
||||
},
|
||||
signals: signalSource(events),
|
||||
emit() {},
|
||||
async createWorkerArtifactBinding() {
|
||||
events.push('artifact');
|
||||
return {
|
||||
store: artifactStore,
|
||||
async close() {
|
||||
events.push('close-artifact');
|
||||
},
|
||||
};
|
||||
},
|
||||
async createWorkerSecretProvider(config) {
|
||||
events.push(`secret:${config.provider}:${config.rootDirectory}`);
|
||||
return provider;
|
||||
},
|
||||
async start(options) {
|
||||
events.push('start');
|
||||
assert.equal(options.workerIngress.secretProvider, provider);
|
||||
return {
|
||||
status: 'active',
|
||||
address: { host: '0.0.0.0', port: 5800 },
|
||||
evidence: {
|
||||
contractName: 'qinglong-cluster-control',
|
||||
contractVersion: 16,
|
||||
serverMajor: 18,
|
||||
migrationIds: [],
|
||||
},
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
unavailable: NEVER_UNAVAILABLE,
|
||||
availabilityStatus: () => 'ready',
|
||||
async stop() {
|
||||
events.push('stop');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(result, 'stopped');
|
||||
assert.deepEqual(events, [
|
||||
'subscribe',
|
||||
'artifact',
|
||||
'secret:mounted-files:/run/worker/values',
|
||||
'start',
|
||||
'stop',
|
||||
'unsubscribe',
|
||||
'close-artifact',
|
||||
]);
|
||||
});
|
||||
|
||||
test('fails the process after a database fence drains the active application', async () => {
|
||||
const events = [];
|
||||
const facts = [];
|
||||
let reportUnavailable;
|
||||
const unavailable = new Promise((resolve) => {
|
||||
reportUnavailable = resolve;
|
||||
});
|
||||
const running = runProductionClusterControlProcess({
|
||||
environment: BASE_ENV,
|
||||
signals: {
|
||||
subscribe() {
|
||||
events.push('subscribe');
|
||||
return () => events.push('unsubscribe');
|
||||
},
|
||||
},
|
||||
emit(record) {
|
||||
facts.push(record);
|
||||
},
|
||||
async start() {
|
||||
queueMicrotask(() =>
|
||||
reportUnavailable(
|
||||
Object.assign(new Error('must-not-escape-database-detail'), {
|
||||
code: '57P01',
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
status: 'active',
|
||||
address: { host: '127.0.0.1', port: 5800 },
|
||||
evidence: {
|
||||
contractName: 'qinglong-cluster-control',
|
||||
contractVersion: 52,
|
||||
serverMajor: 18,
|
||||
migrationIds: [],
|
||||
},
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
unavailable,
|
||||
availabilityStatus: () => 'unavailable',
|
||||
async stop() {
|
||||
events.push('stop');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
running,
|
||||
(error) => error?.code === 'CLUSTER_CONTROL_DATABASE_UNAVAILABLE',
|
||||
);
|
||||
assert.deepEqual(events, ['subscribe', 'stop', 'unsubscribe']);
|
||||
assert.equal(
|
||||
facts.some(
|
||||
({ event, diagnostic }) =>
|
||||
event === 'database_unavailable' &&
|
||||
diagnostic?.scope === 'database' &&
|
||||
diagnostic?.code === '57P01',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(facts.some(({ event }) => event === 'shutdown_requested'), false);
|
||||
assert.equal(facts.at(-1).event, 'stopped');
|
||||
assert.equal(
|
||||
JSON.stringify(facts).includes('must-not-escape-database-detail'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,896 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
PRODUCTION_CLUSTER_CONTROL_ROUTE_OPERATIONS,
|
||||
PRODUCTION_CLUSTER_CONTROL_OPTIONAL_ROUTE_OPERATIONS,
|
||||
createProductionClusterControlApplicationStack,
|
||||
startProductionClusterControlApplication,
|
||||
} = require('@qinglong/cluster-control/production');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
|
||||
const EVENT_ID = '123e4567-e89b-42d3-a456-426614174092';
|
||||
|
||||
function metadata(path, method = 'GET', body = null, query = {}) {
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
headers: Object.freeze({ authorization: 'Bearer test' }),
|
||||
remoteAddress: '127.0.0.1',
|
||||
requestId: 'request-production-1',
|
||||
signal: new AbortController().signal,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const events = [];
|
||||
const currentTask = createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174093',
|
||||
name: 'Task 1',
|
||||
description: 'private',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: { command: { kind: 'shell', command: 'private' } },
|
||||
},
|
||||
labels: { private: 'value' },
|
||||
enabled: true,
|
||||
occurredAtMs: 20,
|
||||
},
|
||||
10,
|
||||
);
|
||||
const input = {
|
||||
evidence: {
|
||||
contractName: 'qinglong-cluster-control',
|
||||
contractVersion: 14,
|
||||
serverMajor: 16,
|
||||
migrationIds: [],
|
||||
},
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
events.push('authenticate');
|
||||
const now = Date.now();
|
||||
return {
|
||||
subject: { type: 'api_app', id: 'app-production' },
|
||||
authenticationId: 'credential-production',
|
||||
authenticatedAtMs: now - 1_000,
|
||||
expiresAtMs: now + 60_000,
|
||||
assurance: 'service',
|
||||
};
|
||||
},
|
||||
},
|
||||
policies: {
|
||||
async resolve() {
|
||||
events.push('authorize');
|
||||
return {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
name: 'Production Project',
|
||||
slug: 'production-project',
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
},
|
||||
binding: {
|
||||
projectId: 'project-1',
|
||||
subject: { type: 'api_app', id: 'app-production' },
|
||||
state: 'active',
|
||||
role: 'owner',
|
||||
version: 7,
|
||||
mutationId: 'binding-production-1',
|
||||
changedBy: { type: 'system', id: 'bootstrap' },
|
||||
createdAtMs: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
async findRunById(runId) {
|
||||
events.push(`read:${runId}`);
|
||||
return {
|
||||
id: runId,
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'queued',
|
||||
version: 2,
|
||||
eventSequence: 1,
|
||||
priority: 0,
|
||||
executionOrigin: 'scheduled_system',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 1,
|
||||
queuedAtMs: 2,
|
||||
};
|
||||
},
|
||||
async listRunsByProject(query) {
|
||||
events.push(`list:${query.projectId}:${query.limit}`);
|
||||
return [
|
||||
{
|
||||
id: 'run-1',
|
||||
projectId: query.projectId,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'queued',
|
||||
version: 2,
|
||||
eventSequence: 1,
|
||||
priority: 0,
|
||||
executionOrigin: 'scheduled_system',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 1,
|
||||
queuedAtMs: 2,
|
||||
},
|
||||
];
|
||||
},
|
||||
async listEvents(runId, input) {
|
||||
events.push(`events:${runId}:${input.afterSequence}:${input.limit}`);
|
||||
return [
|
||||
{
|
||||
id: 'event-1',
|
||||
runId,
|
||||
sequence: 1,
|
||||
type: 'run.created',
|
||||
actorType: 'system',
|
||||
payload: { secret: 'must-not-cross-projection' },
|
||||
createdAtMs: 2,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
trustedToolStorage: {
|
||||
stepRuns: {
|
||||
async listByRun() {
|
||||
events.push('steps');
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
},
|
||||
},
|
||||
runCancellation: {
|
||||
async requestUserCancellation(command) {
|
||||
events.push(`cancel:${command.runId}:${command.eventId}`);
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
cancelReason: 'user',
|
||||
cancelRequestedAtMs: 20,
|
||||
runStatus: 'queued',
|
||||
runVersion: 3,
|
||||
eventSequence: 2,
|
||||
};
|
||||
},
|
||||
},
|
||||
taskStart: {
|
||||
async startTask(command) {
|
||||
events.push(`task-start:${command.taskId}:${command.mutationId}`);
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: command.projectId,
|
||||
taskId: command.taskId,
|
||||
taskRevision: command.expectedRevision,
|
||||
taskContentDigest: command.expectedContentDigest,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
runStatus: 'queued',
|
||||
runVersion: 2,
|
||||
eventSequence: 2,
|
||||
executorType: 'remote_worker',
|
||||
executionRevisionDigest: 'f'.repeat(64),
|
||||
createdAtMs: 20,
|
||||
};
|
||||
},
|
||||
},
|
||||
taskDefinitions: {
|
||||
async findCurrentTaskDefinition(projectId, taskId) {
|
||||
events.push(`task-get:${projectId}:${taskId}`);
|
||||
return projectId === currentTask.projectId && taskId === currentTask.taskId
|
||||
? currentTask
|
||||
: null;
|
||||
},
|
||||
async listTaskDefinitions(query) {
|
||||
events.push(`task-list:${query.projectId}:${query.limit}`);
|
||||
return {
|
||||
definitions: [currentTask],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
taskExecutionRevisions: {},
|
||||
triggers: {},
|
||||
schedules: {},
|
||||
securityAudit: {
|
||||
record(record) {
|
||||
events.push(`audit:${record.operationId}:${record.outcome}`);
|
||||
},
|
||||
},
|
||||
workflowAdministration: {
|
||||
async inspect(projectId, packageName) {
|
||||
events.push(`workflow-read:${projectId}:${packageName}`);
|
||||
return { found: false, publicationState: null, workflows: [] };
|
||||
},
|
||||
async inspectRun(command) {
|
||||
events.push(
|
||||
`workflow-run-read:${command.packageName}:${command.workflowId}:${command.runId}`,
|
||||
);
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-inspection@v1',
|
||||
found: false,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
run: null,
|
||||
stepCount: null,
|
||||
stepStatusCounts: null,
|
||||
};
|
||||
},
|
||||
async listRuns(command) {
|
||||
events.push(
|
||||
`workflow-run-list:${command.packageName}:${command.workflowId}:${command.limit}`,
|
||||
);
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-list@v1',
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
after: command.after,
|
||||
runs: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
};
|
||||
},
|
||||
async listStepRuns(command) {
|
||||
events.push(
|
||||
`workflow-step-list:${command.packageName}:${command.workflowId}:${command.runId}:${command.limit}`,
|
||||
);
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-step-run-list@v1',
|
||||
found: false,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
stepRuns: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
};
|
||||
},
|
||||
async listRunEvents(command) {
|
||||
events.push(
|
||||
`workflow-event-list:${command.packageName}:${command.workflowId}:${command.runId}:${command.limit}:${command.afterSequence}`,
|
||||
);
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-workflow-run-event-list@v1',
|
||||
found: false,
|
||||
projectId: command.projectId,
|
||||
packageName: command.packageName,
|
||||
workflowId: command.workflowId,
|
||||
runId: command.runId,
|
||||
afterSequence: command.afterSequence,
|
||||
headSequence: null,
|
||||
events: [],
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
};
|
||||
},
|
||||
async start(command) {
|
||||
events.push(`workflow-start:${command.workflowId}`);
|
||||
return {
|
||||
status: 'created',
|
||||
plan: { planId: command.planId, runId: command.runId },
|
||||
receipt: { receiptDigest: 'f'.repeat(64) },
|
||||
};
|
||||
},
|
||||
async cancel(command) {
|
||||
events.push(
|
||||
`workflow-cancel:${command.packageName}:${command.workflowId}:${command.runId}:${command.eventId}`,
|
||||
);
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
runStatus: 'running',
|
||||
runVersion: 4,
|
||||
eventSequence: 4,
|
||||
cancelRequestedAtMs: 20,
|
||||
cancelReason: 'user',
|
||||
};
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { events, input };
|
||||
}
|
||||
|
||||
async function invoke(stack, request) {
|
||||
const prepared = await stack.admission.prepare(request);
|
||||
return prepared.handle(request.body);
|
||||
}
|
||||
|
||||
test('production composition exposes the reviewed Run and Workflow routes', async () => {
|
||||
const { events, input } = fixture();
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
createEventId: () => EVENT_ID,
|
||||
});
|
||||
|
||||
assert.deepEqual(PRODUCTION_CLUSTER_CONTROL_ROUTE_OPERATIONS, [
|
||||
'task.get',
|
||||
'task.list',
|
||||
'task.start',
|
||||
'run.get',
|
||||
'run.list',
|
||||
'run.events.list',
|
||||
'run.steps.list',
|
||||
'run.cancel',
|
||||
'workflow.read',
|
||||
'workflow.run.read',
|
||||
'workflow.run.list',
|
||||
'workflow.step.list',
|
||||
'workflow.event.list',
|
||||
'workflow.start',
|
||||
'workflow.cancel',
|
||||
]);
|
||||
assert.deepEqual(await stack.reconcile(), {
|
||||
safe: true,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
});
|
||||
assert.equal(await stack.startLifecycles(), true);
|
||||
|
||||
const tasks = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/tasks', 'GET', null, {
|
||||
limit: ['8'],
|
||||
}),
|
||||
);
|
||||
assert.equal(tasks.statusCode, 200);
|
||||
assert.equal(tasks.body.tasks[0].taskId, 'task-1');
|
||||
assert.equal(JSON.stringify(tasks).includes('private'), false);
|
||||
|
||||
const task = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/tasks/task-1'),
|
||||
);
|
||||
assert.equal(task.statusCode, 200);
|
||||
assert.equal(task.body.task.taskId, 'task-1');
|
||||
assert.match(task.body.task.contentDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(JSON.stringify(task).includes('private'), false);
|
||||
|
||||
const read = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/runs/run-1'),
|
||||
);
|
||||
assert.equal(read.statusCode, 200);
|
||||
assert.equal(read.body.run.id, 'run-1');
|
||||
|
||||
const listed = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/runs', 'GET', null, {
|
||||
limit: ['8'],
|
||||
}),
|
||||
);
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.body.runs[0].id, 'run-1');
|
||||
|
||||
const timeline = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/runs/run-1/events', 'GET', null, {
|
||||
after_sequence: ['0'],
|
||||
limit: ['8'],
|
||||
}),
|
||||
);
|
||||
assert.equal(timeline.statusCode, 200);
|
||||
assert.equal(timeline.body.events[0].type, 'run.created');
|
||||
assert.equal(JSON.stringify(timeline).includes('secret'), false);
|
||||
|
||||
const steps = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/runs/run-1/steps', 'GET', null, {
|
||||
limit: ['8'],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(steps, {
|
||||
statusCode: 200,
|
||||
body: { steps: [], hasMore: false, next: null },
|
||||
});
|
||||
|
||||
const cancellation = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/runs/run-1/cancellation', 'POST', {
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'mutation-production-1',
|
||||
}),
|
||||
);
|
||||
assert.equal(cancellation.statusCode, 202);
|
||||
assert.equal(cancellation.body.status, 'accepted');
|
||||
assert.equal(events.includes(`cancel:run-1:${EVENT_ID}`), true);
|
||||
assert.equal(events.includes('audit:run.get:allowed'), true);
|
||||
assert.equal(events.includes('audit:run.events.list:allowed'), true);
|
||||
assert.equal(events.includes('audit:run.steps.list:allowed'), true);
|
||||
assert.equal(events.includes('audit:run.cancel:allowed'), true);
|
||||
|
||||
const workflows = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/packages/example/workflows'),
|
||||
);
|
||||
assert.equal(workflows.statusCode, 200);
|
||||
assert.equal(workflows.body.found, false);
|
||||
|
||||
const workflowStart = await invoke(
|
||||
stack,
|
||||
metadata(
|
||||
'/api/v3/projects/project-1/packages/example/workflows/daily/runs',
|
||||
'POST',
|
||||
{
|
||||
schema: 'qinglong/cluster-plugin-package-workflow-start-request@v1',
|
||||
planId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
runId: '123e4567-e89b-42d3-a456-426614174001',
|
||||
stepRunIds: {
|
||||
run: '123e4567-e89b-42d3-a456-426614174002',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.equal(workflowStart.statusCode, 201);
|
||||
assert.equal(workflowStart.body.replayed, false);
|
||||
assert.equal(events.includes('audit:workflow.read:allowed'), true);
|
||||
assert.equal(events.includes('audit:workflow.start:allowed'), true);
|
||||
|
||||
const workflowRunRead = await invoke(
|
||||
stack,
|
||||
metadata(
|
||||
'/api/v3/projects/project-1/packages/example/workflows/daily/runs/123e4567-e89b-42d3-a456-426614174001',
|
||||
),
|
||||
);
|
||||
assert.equal(workflowRunRead.statusCode, 404);
|
||||
assert.deepEqual(workflowRunRead.body, { code: 'workflow_run_not_found' });
|
||||
assert.equal(
|
||||
events.includes(
|
||||
'workflow-run-read:example:daily:123e4567-e89b-42d3-a456-426614174001',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(events.includes('audit:workflow.run.read:allowed'), true);
|
||||
|
||||
const workflowRunListRequest = metadata(
|
||||
'/api/v3/projects/project-1/packages/example/workflows/daily/runs',
|
||||
);
|
||||
const workflowRunList = await invoke(stack, {
|
||||
...workflowRunListRequest,
|
||||
query: { limit: ['16'] },
|
||||
});
|
||||
assert.equal(workflowRunList.statusCode, 200);
|
||||
assert.deepEqual(workflowRunList.body.runs, []);
|
||||
assert.equal(events.includes('workflow-run-list:example:daily:16'), true);
|
||||
assert.equal(events.includes('audit:workflow.run.list:allowed'), true);
|
||||
|
||||
const workflowStepListRequest = metadata(
|
||||
'/api/v3/projects/project-1/packages/example/workflows/daily/runs/123e4567-e89b-42d3-a456-426614174001/steps',
|
||||
);
|
||||
const workflowStepList = await invoke(stack, {
|
||||
...workflowStepListRequest,
|
||||
query: { limit: ['16'] },
|
||||
});
|
||||
assert.equal(workflowStepList.statusCode, 404);
|
||||
assert.deepEqual(workflowStepList.body, { code: 'workflow_run_not_found' });
|
||||
assert.equal(
|
||||
events.includes(
|
||||
'workflow-step-list:example:daily:123e4567-e89b-42d3-a456-426614174001:16',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(events.includes('audit:workflow.step.list:allowed'), true);
|
||||
|
||||
const workflowEventListRequest = metadata(
|
||||
'/api/v3/projects/project-1/packages/example/workflows/daily/runs/123e4567-e89b-42d3-a456-426614174001/events',
|
||||
);
|
||||
const workflowEventList = await invoke(stack, {
|
||||
...workflowEventListRequest,
|
||||
query: { limit: ['16'], after_sequence: ['2'] },
|
||||
});
|
||||
assert.equal(workflowEventList.statusCode, 404);
|
||||
assert.deepEqual(workflowEventList.body, { code: 'workflow_run_not_found' });
|
||||
assert.equal(
|
||||
events.includes(
|
||||
'workflow-event-list:example:daily:123e4567-e89b-42d3-a456-426614174001:16:2',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(events.includes('audit:workflow.event.list:allowed'), true);
|
||||
|
||||
const workflowCancellation = await invoke(
|
||||
stack,
|
||||
metadata(
|
||||
'/api/v3/projects/project-1/packages/example/workflows/daily/runs/123e4567-e89b-42d3-a456-426614174001/cancellation',
|
||||
'POST',
|
||||
{
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'workflow-cancel-production-1',
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.equal(workflowCancellation.statusCode, 202);
|
||||
assert.equal(workflowCancellation.body.status, 'accepted');
|
||||
assert.equal(
|
||||
events.includes(
|
||||
`workflow-cancel:example:daily:123e4567-e89b-42d3-a456-426614174001:${EVENT_ID}`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(events.includes('audit:workflow.cancel:allowed'), true);
|
||||
assert.equal(await stack.stop(), 'stopped');
|
||||
});
|
||||
|
||||
test('production composition fails closed for an unreviewed route', async () => {
|
||||
const { input } = fixture();
|
||||
const stack = createProductionClusterControlApplicationStack(input);
|
||||
await assert.rejects(
|
||||
stack.admission.prepare(
|
||||
metadata('/api/v3/projects/project-1/runs/run-1/retry', 'POST'),
|
||||
),
|
||||
(error) => error?.statusCode === 404 && error?.code === 'route_not_found',
|
||||
);
|
||||
});
|
||||
|
||||
test('optionally exposes Prompt execution behind shared admission and policy', async () => {
|
||||
const { events, input } = fixture();
|
||||
let command;
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
promptExecution: {
|
||||
now: () => 2_000,
|
||||
maxExecutionMs: 10_000,
|
||||
capability: {
|
||||
async execute(value) {
|
||||
command = value;
|
||||
return {
|
||||
status: 'executed',
|
||||
admission: {
|
||||
requestId: value.requestId,
|
||||
invocationId: 'ppi:1',
|
||||
runId: 'ppr:1',
|
||||
stepRunId: 'pps:1',
|
||||
},
|
||||
finalization: { runStatus: 'succeeded' },
|
||||
result: { text: 'live output' },
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(PRODUCTION_CLUSTER_CONTROL_OPTIONAL_ROUTE_OPERATIONS, [
|
||||
'prompt.read',
|
||||
'prompt.execute',
|
||||
'prompt.execution.read',
|
||||
'prompt.execution.output.read',
|
||||
'prompt.output.read',
|
||||
]);
|
||||
const response = await invoke(
|
||||
stack,
|
||||
metadata(
|
||||
'/api/v3/projects/project-1/packages/example/prompts/summary/executions',
|
||||
'POST',
|
||||
{
|
||||
schema: 'qinglong/cluster-plugin-package-prompt-execution-request@v2',
|
||||
requestId: 'prompt-request-1',
|
||||
traceId: 'trace-1',
|
||||
parameters: { subject: 'private input' },
|
||||
provider: 'openai-compatible',
|
||||
model: 'model-a',
|
||||
maxOutputTokens: 256,
|
||||
timeoutMs: 5_000,
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(command.projectId, 'project-1');
|
||||
assert.deepEqual(command.policyFence, {
|
||||
projectVersion: 3,
|
||||
bindingVersion: 7,
|
||||
});
|
||||
assert.equal(events.includes('audit:prompt.execute:allowed'), true);
|
||||
});
|
||||
|
||||
test('optionally exposes the redacted Prompt catalog behind shared admission and policy', async () => {
|
||||
const { events, input } = fixture();
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
promptCatalog: {
|
||||
capability: {
|
||||
async inspect(projectId, packageName) {
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-catalog@v1',
|
||||
projectId,
|
||||
packageName,
|
||||
found: true,
|
||||
publicationState: 'active',
|
||||
prompts: [
|
||||
{
|
||||
id: 'summary',
|
||||
name: 'Summary',
|
||||
description: null,
|
||||
parameters: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await invoke(
|
||||
stack,
|
||||
metadata('/api/v3/projects/project-1/packages/example/prompts'),
|
||||
);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(result.body.prompts[0].id, 'summary');
|
||||
assert.equal(JSON.stringify(result).includes('template'), false);
|
||||
assert.equal(events.includes('audit:prompt.read:allowed'), true);
|
||||
});
|
||||
|
||||
test('optionally inspects one Prompt execution behind shared admission and policy', async () => {
|
||||
const { events, input } = fixture();
|
||||
let command;
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
createEventId: () => '00000000-0000-4000-8000-000000000011',
|
||||
promptExecutionInspection: {
|
||||
now: () => 2_000,
|
||||
capability: {
|
||||
async inspectAuthorized(value) {
|
||||
command = value;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-execution-inspection@v1',
|
||||
found: true,
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
promptId: value.promptId,
|
||||
executionRequestId: value.executionRequestId,
|
||||
execution: {
|
||||
invocationId: 'invocation-1',
|
||||
runId: '00000000-0000-4000-8000-000000000010',
|
||||
stepRunId: 'step-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 2,
|
||||
eventSequence: 2,
|
||||
stepStatus: 'running',
|
||||
stepVersion: 2,
|
||||
admittedAtMs: 1_000,
|
||||
startedAtMs: 1_000,
|
||||
finishedAtMs: null,
|
||||
finalizedAtMs: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await invoke(
|
||||
stack,
|
||||
metadata(
|
||||
'/api/v3/projects/project-1/packages/example/prompts/summary/executions/execution-request-1',
|
||||
),
|
||||
);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(command.executionRequestId, 'execution-request-1');
|
||||
assert.equal(command.audit.operationId, 'prompt.execution.read');
|
||||
assert.equal(events.includes('audit:prompt.execution.read:allowed'), true);
|
||||
});
|
||||
|
||||
test('optionally exposes Prompt output read behind shared admission and policy', async () => {
|
||||
const { events, input } = fixture();
|
||||
let command;
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
promptOutputRead: {
|
||||
capability: {
|
||||
async read(value) {
|
||||
command = value;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-prompt-output-read-result@v1',
|
||||
status: 'not_found',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = metadata(
|
||||
'/api/v3/projects/project-1/runs/run-1/prompt-output-artifacts/pao:1',
|
||||
);
|
||||
const result = await invoke(stack, {
|
||||
...request,
|
||||
query: { artifact_digest: ['a'.repeat(64)] },
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 404);
|
||||
assert.equal(command.projectId, 'project-1');
|
||||
assert.equal(command.runId, 'run-1');
|
||||
assert.equal(command.artifactId, 'pao:1');
|
||||
assert.equal(command.principal.subject.id, 'app-production');
|
||||
assert.equal(events.includes('audit:prompt.output.read:allowed'), true);
|
||||
});
|
||||
|
||||
test('optionally recovers Prompt output by execution requestId behind artifact.read', async () => {
|
||||
const { events, input } = fixture();
|
||||
let command;
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
promptExecutionOutputRead: {
|
||||
capability: {
|
||||
async read(value) {
|
||||
command = value;
|
||||
return {
|
||||
schema:
|
||||
'qinglong/plugin-package-prompt-execution-output-read-result@v1',
|
||||
status: 'not_found',
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
promptId: value.promptId,
|
||||
executionRequestId: value.executionRequestId,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await invoke(
|
||||
stack,
|
||||
metadata(
|
||||
'/api/v3/projects/project-1/packages/example/prompts/summary/executions/execution-request-1/output',
|
||||
),
|
||||
);
|
||||
assert.equal(result.statusCode, 404);
|
||||
assert.equal(command.projectId, 'project-1');
|
||||
assert.equal(command.packageName, 'example');
|
||||
assert.equal(command.promptId, 'summary');
|
||||
assert.equal(command.executionRequestId, 'execution-request-1');
|
||||
assert.equal(command.principal.subject.id, 'app-production');
|
||||
assert.equal(
|
||||
events.includes('audit:prompt.execution.output.read:allowed'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('production start requires an enabled configuration before owning resources', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
startProductionClusterControlApplication({
|
||||
config: { enabled: false, profile: 'standalone' },
|
||||
recovery: { ownerId: 'production-test' },
|
||||
audit() {},
|
||||
}),
|
||||
/database binding requires an enabled cluster-control config/,
|
||||
);
|
||||
});
|
||||
test('production composition rejects an invalid event ID factory', () => {
|
||||
const { input } = fixture();
|
||||
assert.throws(
|
||||
() =>
|
||||
createProductionClusterControlApplicationStack(input, {
|
||||
createEventId: 'invalid',
|
||||
}),
|
||||
/event ID factory is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('starts and stops Worker ingress through the injected runtime port', async () => {
|
||||
const events = [];
|
||||
const runtime = Object.freeze({
|
||||
offers: { claimNext() {} },
|
||||
activation: {
|
||||
acknowledgeStarting() {},
|
||||
acknowledgeRunning() {},
|
||||
failStart() {},
|
||||
},
|
||||
artifacts: { upload() {} },
|
||||
completion: { complete() {} },
|
||||
leaseControl: { control() {} },
|
||||
});
|
||||
const { input } = fixture({ workerRuntime: runtime });
|
||||
const config = {
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
http: { host: '127.0.0.1', port: 5801 },
|
||||
transport: {},
|
||||
database: {},
|
||||
security: { workerCredentialPepper: 'A'.repeat(43) },
|
||||
artifact: {},
|
||||
};
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
workerIngress: { config },
|
||||
async startWorkerIngress(options) {
|
||||
events.push('start-worker-ingress');
|
||||
assert.equal(options.config, config);
|
||||
assert.equal(options.runtime, runtime);
|
||||
return {
|
||||
status: 'active',
|
||||
protocol: 'https',
|
||||
transport: 'mutual-tls',
|
||||
address: { host: '127.0.0.1', port: 5801 },
|
||||
evidence: input.evidence,
|
||||
reloadTransport() {
|
||||
return 1;
|
||||
},
|
||||
async stop() {
|
||||
events.push('stop-worker-ingress');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await stack.startLifecycles(), true);
|
||||
assert.equal(await stack.startLifecycles(), true);
|
||||
assert.equal(await stack.stop(), 'stopped');
|
||||
assert.equal(await stack.stop(), 'stopped');
|
||||
assert.deepEqual(events, ['start-worker-ingress', 'stop-worker-ingress']);
|
||||
});
|
||||
|
||||
test('fails closed when Worker ingress has no runtime service port', async () => {
|
||||
const { input } = fixture();
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
workerIngress: {
|
||||
config: {
|
||||
enabled: true,
|
||||
profile: 'cluster-control',
|
||||
},
|
||||
},
|
||||
async startWorkerIngress() {
|
||||
throw new Error('must not start');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
stack.startLifecycles(),
|
||||
/requires an injected runtime service port/,
|
||||
);
|
||||
});
|
||||
|
||||
test('drains a Worker listener when its Pool fails during activation', async () => {
|
||||
let stops = 0;
|
||||
const diagnostics = [];
|
||||
const { input } = fixture({
|
||||
workerRuntime: {
|
||||
offers: { claimNext() {} },
|
||||
activation: {
|
||||
acknowledgeStarting() {},
|
||||
acknowledgeRunning() {},
|
||||
failStart() {},
|
||||
},
|
||||
artifacts: { upload() {} },
|
||||
completion: { complete() {} },
|
||||
leaseControl: { control() {} },
|
||||
},
|
||||
});
|
||||
const stack = createProductionClusterControlApplicationStack(input, {
|
||||
workerIngress: {
|
||||
config: { enabled: true, profile: 'cluster-control' },
|
||||
onDiagnostic(error) {
|
||||
diagnostics.push(error);
|
||||
},
|
||||
},
|
||||
async startWorkerIngress(options) {
|
||||
options.onPoolError(new Error('worker database unavailable'));
|
||||
return {
|
||||
status: 'active',
|
||||
protocol: 'https',
|
||||
transport: 'mutual-tls',
|
||||
address: { host: '127.0.0.1', port: 5801 },
|
||||
evidence: input.evidence,
|
||||
reloadTransport() {
|
||||
return 1;
|
||||
},
|
||||
async stop() {
|
||||
stops += 1;
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
stack.startLifecycles(),
|
||||
/became unavailable during activation/,
|
||||
);
|
||||
assert.equal(stops, 1);
|
||||
assert.equal(diagnostics.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterRemoteRunActivationService,
|
||||
} = require('@qinglong/cluster-control/remote-activation');
|
||||
|
||||
const SESSION_ID = '018f5c64-9b9d-7f1a-8c2d-1234567890ac';
|
||||
const EVENT_IDS = [
|
||||
'018f5c64-9b9d-7f1a-8c2d-1234567890a1',
|
||||
'018f5c64-9b9d-7f1a-8c2d-1234567890a2',
|
||||
'018f5c64-9b9d-7f1a-8c2d-1234567890a3',
|
||||
'018f5c64-9b9d-7f1a-8c2d-1234567890a4',
|
||||
'018f5c64-9b9d-7f1a-8c2d-1234567890a5',
|
||||
];
|
||||
|
||||
function command() {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
expectedLeaseVersion: 4,
|
||||
};
|
||||
}
|
||||
|
||||
test('binds Worker principal and creates server-owned event IDs', async () => {
|
||||
const observed = [];
|
||||
const repository = {
|
||||
async acknowledgeStarting(value) {
|
||||
observed.push(['starting', value]);
|
||||
return { status: 'applied', snapshot: {} };
|
||||
},
|
||||
async acknowledgeRunning(value) {
|
||||
observed.push(['running', value]);
|
||||
return { status: 'applied', snapshot: {} };
|
||||
},
|
||||
async failStart(value) {
|
||||
observed.push(['failed', value]);
|
||||
return { status: 'applied', snapshot: {} };
|
||||
},
|
||||
};
|
||||
let sequence = 0;
|
||||
const service = new ClusterRemoteRunActivationService(repository, {
|
||||
createEventId: () => EVENT_IDS[sequence++],
|
||||
});
|
||||
const principal = { workerId: 'edge-1' };
|
||||
await service.acknowledgeStarting(principal, command());
|
||||
await service.acknowledgeRunning(principal, {
|
||||
...command(),
|
||||
executorHandle: 'remote:handle-1',
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: 'a'.repeat(64),
|
||||
});
|
||||
await service.failStart(principal, command());
|
||||
assert.deepEqual(observed, [
|
||||
['starting', { ...command(), workerId: 'edge-1', eventId: EVENT_IDS[0] }],
|
||||
['running', {
|
||||
...command(),
|
||||
executorHandle: 'remote:handle-1',
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: 'a'.repeat(64),
|
||||
workerId: 'edge-1',
|
||||
attemptEventId: EVENT_IDS[1],
|
||||
runEventId: EVENT_IDS[2],
|
||||
}],
|
||||
['failed', {
|
||||
...command(),
|
||||
workerId: 'edge-1',
|
||||
attemptEventId: EVENT_IDS[3],
|
||||
runEventId: EVENT_IDS[4],
|
||||
}],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
RemoteWorkerCompletionFenceRejectedError,
|
||||
RemoteWorkerCompletionUnavailableError,
|
||||
createRemoteWorkerArtifactUploadPreamble,
|
||||
} = require('@qinglong/runtime-core/remote-worker-completion');
|
||||
const {
|
||||
ClusterRemoteWorkerArtifactService,
|
||||
ClusterRemoteWorkerCompletionService,
|
||||
} = require('@qinglong/cluster-control/remote-completion');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const LOG_ARTIFACT_ID = `wlog-${'a'.repeat(30)}`;
|
||||
const CALLBACK_TOKEN_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function fence() {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: LEASE_TOKEN,
|
||||
expectedLeaseVersion: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function uploadCommand(content, overrides = {}) {
|
||||
return {
|
||||
...fence(),
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function completionCommand(content, overrides = {}) {
|
||||
return {
|
||||
...fence(),
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: CALLBACK_TOKEN_DIGEST,
|
||||
result: {
|
||||
outcome: 'succeeded',
|
||||
startedAtMs: 100,
|
||||
finishedAtMs: 200,
|
||||
exitCode: 0,
|
||||
},
|
||||
artifact: {
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
sha256: createHash('sha256').update(content).digest('hex'),
|
||||
truncated: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function split(bytes, sizes) {
|
||||
return (async function* () {
|
||||
let offset = 0;
|
||||
for (const size of sizes) {
|
||||
yield bytes.subarray(offset, offset + size);
|
||||
offset += size;
|
||||
}
|
||||
if (offset < bytes.byteLength) yield bytes.subarray(offset);
|
||||
})();
|
||||
}
|
||||
|
||||
test('authorizes a framed upload before storing exact capability-free bytes', async () => {
|
||||
const content = Buffer.from('worker-log');
|
||||
const command = uploadCommand(content);
|
||||
const preamble = createRemoteWorkerArtifactUploadPreamble(command);
|
||||
const envelope = Buffer.concat([preamble, content]);
|
||||
const observations = [];
|
||||
const service = new ClusterRemoteWorkerArtifactService(
|
||||
{
|
||||
async authorizeArtifactUpload(value) {
|
||||
observations.push({ kind: 'authority', value });
|
||||
},
|
||||
},
|
||||
{
|
||||
async put(target, chunks) {
|
||||
const stored = [];
|
||||
for await (const chunk of chunks) stored.push(Buffer.from(chunk));
|
||||
const bytes = Buffer.concat(stored);
|
||||
observations.push({ kind: 'store', target, bytes });
|
||||
return {
|
||||
status: 'stored',
|
||||
...target,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
};
|
||||
},
|
||||
async inspect() { return undefined; },
|
||||
},
|
||||
);
|
||||
|
||||
const receipt = await service.upload({
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
contentLength: envelope.byteLength,
|
||||
chunks: split(envelope, [1, 2, 1, 3, 5]),
|
||||
});
|
||||
assert.equal(receipt.sha256, createHash('sha256').update(content).digest('hex'));
|
||||
assert.deepEqual(observations.map(({ kind }) => kind), ['authority', 'store']);
|
||||
assert.deepEqual(observations[0].value, command);
|
||||
assert.equal('leaseToken' in observations[1].target, false);
|
||||
assert.equal('workerId' in observations[1].target, false);
|
||||
assert.deepEqual(observations[1].bytes, content);
|
||||
});
|
||||
|
||||
test('rejects envelope drift before authority or storage access', async () => {
|
||||
const content = Buffer.from('log');
|
||||
const command = uploadCommand(content);
|
||||
const preamble = createRemoteWorkerArtifactUploadPreamble(command);
|
||||
const envelope = Buffer.concat([preamble, content]);
|
||||
let authorityCalls = 0;
|
||||
let storeCalls = 0;
|
||||
const service = new ClusterRemoteWorkerArtifactService(
|
||||
{
|
||||
async authorizeArtifactUpload() { authorityCalls += 1; },
|
||||
},
|
||||
{
|
||||
async put() { storeCalls += 1; throw new Error('must not store'); },
|
||||
async inspect() { return undefined; },
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
service.upload({
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
contentLength: envelope.byteLength + 1,
|
||||
chunks: split(envelope, [4]),
|
||||
}),
|
||||
/envelope length does not match/,
|
||||
);
|
||||
assert.equal(authorityCalls, 0);
|
||||
assert.equal(storeCalls, 0);
|
||||
});
|
||||
|
||||
test('fails closed when a store does not consume or drifts from the command', async () => {
|
||||
const content = Buffer.from('log');
|
||||
const command = uploadCommand(content);
|
||||
const preamble = createRemoteWorkerArtifactUploadPreamble(command);
|
||||
const envelope = Buffer.concat([preamble, content]);
|
||||
const service = new ClusterRemoteWorkerArtifactService(
|
||||
{ async authorizeArtifactUpload() {} },
|
||||
{
|
||||
async put(target) {
|
||||
return { status: 'stored', ...target, sha256: 'c'.repeat(64) };
|
||||
},
|
||||
async inspect() { return undefined; },
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
service.upload({
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
contentLength: envelope.byteLength,
|
||||
chunks: split(envelope, [4]),
|
||||
}),
|
||||
RemoteWorkerCompletionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('inspects immutable Artifact evidence before one server-ID completion', async () => {
|
||||
const content = Buffer.from('worker-log');
|
||||
const command = completionCommand(content);
|
||||
const calls = [];
|
||||
const ids = [
|
||||
'018f0000-0000-7000-8000-000000000011',
|
||||
'018f0000-0000-7000-8000-000000000012',
|
||||
];
|
||||
const service = new ClusterRemoteWorkerCompletionService(
|
||||
{
|
||||
async complete(value) {
|
||||
calls.push({ kind: 'repository', value });
|
||||
return {
|
||||
status: 'applied',
|
||||
runId: value.runId,
|
||||
attemptId: value.attemptId,
|
||||
callbackSequence: value.callbackSequence,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect(lookup) {
|
||||
calls.push({ kind: 'store', lookup });
|
||||
return {
|
||||
status: 'already_stored',
|
||||
...lookup,
|
||||
byteLength: content.byteLength,
|
||||
sha256: command.artifact.sha256,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{ createEventId: () => ids.shift() },
|
||||
);
|
||||
assert.deepEqual(await service.complete(command), {
|
||||
status: 'applied',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
callbackSequence: 1,
|
||||
});
|
||||
assert.deepEqual(calls.map(({ kind }) => kind), ['store', 'repository']);
|
||||
assert.equal(calls[1].value.attemptEventId.endsWith('11'), true);
|
||||
assert.equal(calls[1].value.runEventId.endsWith('12'), true);
|
||||
});
|
||||
|
||||
test('fences missing or digest-drifted Artifact evidence before completion', async () => {
|
||||
const content = Buffer.from('worker-log');
|
||||
const command = completionCommand(content);
|
||||
let repositoryCalls = 0;
|
||||
const repository = {
|
||||
async complete() { repositoryCalls += 1; throw new Error('must not run'); },
|
||||
};
|
||||
const missing = new ClusterRemoteWorkerCompletionService(
|
||||
repository,
|
||||
{ async inspect() { return undefined; } },
|
||||
);
|
||||
await assert.rejects(
|
||||
missing.complete(command),
|
||||
(error) =>
|
||||
error instanceof RemoteWorkerCompletionFenceRejectedError &&
|
||||
error.reason === 'state_mismatch',
|
||||
);
|
||||
|
||||
const drifted = new ClusterRemoteWorkerCompletionService(
|
||||
repository,
|
||||
{
|
||||
async inspect(lookup) {
|
||||
return {
|
||||
status: 'stored',
|
||||
...lookup,
|
||||
byteLength: content.byteLength,
|
||||
sha256: 'd'.repeat(64),
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
drifted.complete(command),
|
||||
(error) =>
|
||||
error instanceof RemoteWorkerCompletionFenceRejectedError &&
|
||||
error.reason === 'replay_mismatch',
|
||||
);
|
||||
assert.equal(repositoryCalls, 0);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
canonicalRemoteWorkerCapabilities,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/cluster-execution-revision');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('@qinglong/runtime-core');
|
||||
const {
|
||||
ClusterRemoteWorkerOfferClaimService,
|
||||
ClusterRemoteWorkerOfferFenceRejectedError,
|
||||
} = require('../dist/remote-execution/remoteWorkerDispatcher');
|
||||
|
||||
const SESSION = '018f0000-0000-7000-8000-000000000001';
|
||||
const TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
|
||||
function candidate() {
|
||||
return {
|
||||
runId: 'run-1', attemptId: 'attempt-1', projectId: 'project-1',
|
||||
taskId: 'task-1', taskRevision: TASK_REVISION, priority: 2,
|
||||
queuedAtMs: 10, attemptCreatedAtMs: 11, attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
};
|
||||
}
|
||||
|
||||
function revision() {
|
||||
return createClusterTaskExecutionRevision({
|
||||
projectId: 'project-1', taskId: 'task-1', taskRevision: TASK_REVISION,
|
||||
sourceRevision: 1, sourceContentDigest: SOURCE_DIGEST,
|
||||
executorType: 'remote_worker', planSchema: 'qinglong/command-execution@v1',
|
||||
command: { kind: 'argv', file: '/usr/bin/node', args: ['job.js'] },
|
||||
environment: [],
|
||||
placement: { required: { architectures: ['arm64'] } },
|
||||
createdAtMs: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function worker() {
|
||||
const capabilities = canonicalRemoteWorkerCapabilities({
|
||||
architecture: 'arm64', executors: ['remote-worker'],
|
||||
});
|
||||
return {
|
||||
workerId: 'edge-1', sessionId: SESSION, generation: 2,
|
||||
status: 'online', version: 1,
|
||||
capabilitiesJson: capabilities.json, capabilitiesHash: capabilities.hash,
|
||||
maxConcurrentRuns: 1, availableSlots: 1,
|
||||
registeredAtMs: 1, lastHeartbeatAtMs: 10,
|
||||
leaseExpiresAtMs: 60_000, updatedAtMs: 10,
|
||||
};
|
||||
}
|
||||
|
||||
function lease() {
|
||||
return {
|
||||
attemptId: 'attempt-1', runId: 'run-1', status: 'leased', version: 0,
|
||||
leaseGeneration: 1, workerId: 'edge-1', workerSessionId: SESSION,
|
||||
workerGeneration: 2, leaseTokenDigest: digestRunDispatchLeaseToken(TOKEN),
|
||||
acquiredAtMs: 1000, renewedAtMs: 1000, expiresAtMs: 31_000,
|
||||
updatedAtMs: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function service(overrides = {}) {
|
||||
const calls = [];
|
||||
const source = overrides.source ?? {
|
||||
async findClusterDispatchRecovery() { return null; },
|
||||
async listClusterDispatchCandidates() {
|
||||
calls.push('candidates');
|
||||
return { observedAtMs: 1000, candidates: [candidate()], truncated: false };
|
||||
},
|
||||
};
|
||||
const value = new ClusterRemoteWorkerOfferClaimService(
|
||||
source,
|
||||
overrides.workers ?? { async findById() { calls.push('worker'); return worker(); } },
|
||||
overrides.revisions ?? {
|
||||
async resolveClusterTaskExecutionRevision() { calls.push('revision'); return revision(); },
|
||||
},
|
||||
overrides.leases ?? {
|
||||
async claim(command) { calls.push(`claim:${command.offerId}`); return { status: 'claimed', lease: lease() }; },
|
||||
},
|
||||
{ createEventId: () => 'event-1' },
|
||||
);
|
||||
return { value, calls };
|
||||
}
|
||||
|
||||
const command = {
|
||||
workerSessionId: SESSION,
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseToken: TOKEN,
|
||||
};
|
||||
|
||||
test('pulls one bounded candidate, applies Placement and atomically returns a fenced offer', async () => {
|
||||
const { value, calls } = service();
|
||||
const result = await value.claimNext({ workerId: 'edge-1' }, command);
|
||||
assert.equal(result.status, 'offered');
|
||||
assert.equal(result.offer.deliveryKind, 'new_claim');
|
||||
assert.equal(result.offer.worker.sessionId, SESSION);
|
||||
assert.equal(result.offer.executionRevision.placement.required.executors[0], 'remote-worker');
|
||||
assert.deepEqual(calls, ['candidates', 'worker', 'revision', 'claim:offer-1']);
|
||||
});
|
||||
|
||||
test('rebuilds a lost response only for the same Worker-provided offer capability', async () => {
|
||||
const { value } = service({
|
||||
source: {
|
||||
async findClusterDispatchRecovery() {
|
||||
return {
|
||||
observedAtMs: 2000,
|
||||
candidate: candidate(),
|
||||
lease: lease(),
|
||||
workerCurrent: true,
|
||||
};
|
||||
},
|
||||
async listClusterDispatchCandidates() { throw new Error('must not list'); },
|
||||
},
|
||||
});
|
||||
const result = await value.claimNext({ workerId: 'edge-1' }, command);
|
||||
assert.equal(result.status, 'offered');
|
||||
assert.equal(result.offer.deliveryKind, 'lease_recovery');
|
||||
await assert.rejects(
|
||||
value.claimNext({ workerId: 'edge-1' }, { ...command, leaseToken: `${TOKEN}x` }),
|
||||
ClusterRemoteWorkerOfferFenceRejectedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns low-cardinality idle evidence when the authenticated Worker does not match', async () => {
|
||||
const { value } = service({
|
||||
workers: { async findById() { return { ...worker(), sessionId: '018f0000-0000-7000-8000-000000000002' }; } },
|
||||
});
|
||||
const result = await value.claimNext({ workerId: 'edge-1' }, command);
|
||||
assert.deepEqual(
|
||||
{ status: result.status, reason: result.reason },
|
||||
{ status: 'idle', reason: 'worker_unavailable' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterRemoteWorkerLeaseControlService,
|
||||
} = require('@qinglong/cluster-control/lease-control');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
|
||||
function command() {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: LEASE_TOKEN,
|
||||
expectedLeaseVersion: 4,
|
||||
};
|
||||
}
|
||||
|
||||
test('adds server-owned renewal duration and timeout Event authority', async () => {
|
||||
let observed;
|
||||
const service = new ClusterRemoteWorkerLeaseControlService({
|
||||
async control(value) {
|
||||
observed = value;
|
||||
return {
|
||||
status: 'renewed',
|
||||
projectId: value.projectId,
|
||||
runId: value.runId,
|
||||
attemptId: value.attemptId,
|
||||
offerId: value.offerId,
|
||||
leaseGeneration: value.leaseGeneration,
|
||||
leaseVersion: value.expectedLeaseVersion + 1,
|
||||
renewedAtMs: 10_000,
|
||||
expiresAtMs: 55_000,
|
||||
};
|
||||
},
|
||||
}, {
|
||||
leaseDurationMs: 45_000,
|
||||
createEventId: () => '018f0000-0000-7000-8000-000000000011',
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.control(command()), {
|
||||
status: 'renewed',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseVersion: 5,
|
||||
renewedAtMs: 10_000,
|
||||
expiresAtMs: 55_000,
|
||||
});
|
||||
assert.deepEqual(observed, {
|
||||
...command(),
|
||||
leaseDurationMs: 45_000,
|
||||
timeoutEventId: '018f0000-0000-7000-8000-000000000011',
|
||||
});
|
||||
});
|
||||
|
||||
test('fails closed before repository access for invalid server Event IDs', async () => {
|
||||
let calls = 0;
|
||||
const service = new ClusterRemoteWorkerLeaseControlService({
|
||||
async control() { calls += 1; throw new Error('must not run'); },
|
||||
}, { createEventId: () => '' });
|
||||
await assert.rejects(service.control(command()), /unavailable/);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('rejects a repository response that drifts from the wire contract', async () => {
|
||||
const service = new ClusterRemoteWorkerLeaseControlService({
|
||||
async control(value) {
|
||||
return {
|
||||
status: 'renewed',
|
||||
projectId: value.projectId,
|
||||
runId: value.runId,
|
||||
attemptId: value.attemptId,
|
||||
offerId: value.offerId,
|
||||
leaseGeneration: value.leaseGeneration,
|
||||
leaseVersion: value.expectedLeaseVersion + 1,
|
||||
renewedAtMs: 10_000,
|
||||
expiresAtMs: 10_000,
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(service.control(command()), /invalid/);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
RemoteWorkerSecretDeliveryFenceRejectedError,
|
||||
} = require('@qinglong/runtime-core/remote-secret-delivery');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
ClusterRemoteWorkerSecretDeliveryService,
|
||||
} = require('../dist/remote-execution/remoteWorkerSecretDeliveryService');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const SECRET_REF = createSecretRef({ projectId: 'project-1', name: 'token' });
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
|
||||
function command() {
|
||||
return {
|
||||
workerSessionId: SESSION_ID, workerGeneration: 2,
|
||||
runId: 'run-1', attemptId: 'attempt-1', projectId: 'project-1',
|
||||
taskId: 'task-1', taskRevision: 'revision-1', executionDigest: DIGEST,
|
||||
offerId: 'offer-1', leaseGeneration: 3,
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
expectedLeaseVersion: 4, secretRefs: [SECRET_REF],
|
||||
};
|
||||
}
|
||||
|
||||
function authority(input) {
|
||||
const { leaseToken: _leaseToken, expectedLeaseVersion, ...rest } = input;
|
||||
return {
|
||||
workerId: 'edge-1', ...rest, leaseVersion: expectedLeaseVersion,
|
||||
};
|
||||
}
|
||||
|
||||
test('resolves plaintext only after repository authority succeeds', async () => {
|
||||
const events = [];
|
||||
let authorized;
|
||||
const service = new ClusterRemoteWorkerSecretDeliveryService({
|
||||
async authorize(input) {
|
||||
events.push('authorize');
|
||||
authorized = input;
|
||||
return authority(input);
|
||||
},
|
||||
}, {
|
||||
async resolve(input) {
|
||||
events.push('resolve');
|
||||
assert.equal('leaseToken' in input, false);
|
||||
return {
|
||||
values: [{ secretRef: SECRET_REF, value: 'resolved-value' }],
|
||||
dispose() { events.push('dispose'); },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await service.deliver({ workerId: 'edge-1' }, command());
|
||||
assert.equal(authorized.workerId, 'edge-1');
|
||||
assert.deepEqual(result.values, [
|
||||
{ secretRef: SECRET_REF, value: 'resolved-value' },
|
||||
]);
|
||||
assert.deepEqual(events, ['authorize', 'resolve']);
|
||||
await result.dispose();
|
||||
assert.deepEqual(events, ['authorize', 'resolve', 'dispose']);
|
||||
});
|
||||
|
||||
test('never calls the plaintext provider for fenced or replayed authority', async () => {
|
||||
let resolutions = 0;
|
||||
const service = new ClusterRemoteWorkerSecretDeliveryService({
|
||||
async authorize() {
|
||||
throw new RemoteWorkerSecretDeliveryFenceRejectedError('authority_mismatch');
|
||||
},
|
||||
}, {
|
||||
async resolve() { resolutions += 1; },
|
||||
});
|
||||
await assert.rejects(
|
||||
service.deliver({ workerId: 'edge-1' }, command()),
|
||||
/authority_mismatch/,
|
||||
);
|
||||
assert.equal(resolutions, 0);
|
||||
});
|
||||
|
||||
test('never calls plaintext provider when an injected repository widens authority', async () => {
|
||||
let resolutions = 0;
|
||||
const service = new ClusterRemoteWorkerSecretDeliveryService({
|
||||
async authorize(input) {
|
||||
return { ...authority(input), taskId: 'task-other' };
|
||||
},
|
||||
}, {
|
||||
async resolve() { resolutions += 1; },
|
||||
});
|
||||
await assert.rejects(
|
||||
service.deliver({ workerId: 'edge-1' }, command()),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(resolutions, 0);
|
||||
});
|
||||
|
||||
test('disposes malformed provider output and converts it to unavailable', async () => {
|
||||
let disposed = 0;
|
||||
const service = new ClusterRemoteWorkerSecretDeliveryService({
|
||||
async authorize(input) { return authority(input); },
|
||||
}, {
|
||||
async resolve() {
|
||||
return {
|
||||
values: [{ secretRef: SECRET_REF, value: 'x'.repeat(17 * 1024) }],
|
||||
dispose() { disposed += 1; },
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
service.deliver({ workerId: 'edge-1' }, command()),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(disposed, 1);
|
||||
});
|
||||
|
||||
test('rejects extensible provider output and still invokes valid cleanup', async () => {
|
||||
let disposed = 0;
|
||||
const service = new ClusterRemoteWorkerSecretDeliveryService({
|
||||
async authorize(input) { return authority(input); },
|
||||
}, {
|
||||
async resolve() {
|
||||
return {
|
||||
values: [{ secretRef: SECRET_REF, value: 'resolved-value' }],
|
||||
dispose() { disposed += 1; },
|
||||
diagnostic: 'must-not-cross-boundary',
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
service.deliver({ workerId: 'edge-1' }, command()),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(disposed, 1);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS,
|
||||
ClusterControlRouteRegistryConfigurationError,
|
||||
ClusterControlRouteResolutionError,
|
||||
createClusterControlRouteRegistry,
|
||||
isClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
|
||||
function metadata(overrides = {}) {
|
||||
return Object.freeze({
|
||||
requestId: 'request-1',
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/prj_default/runs',
|
||||
query: Object.freeze({}),
|
||||
headers: Object.freeze({}),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function definition(overrides = {}) {
|
||||
return {
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/{projectId}/runs',
|
||||
operationId: 'run.create',
|
||||
permission: 'run.start',
|
||||
projectParameter: 'projectId',
|
||||
handle(request, parameters) {
|
||||
return {
|
||||
statusCode: 202,
|
||||
body: {
|
||||
projectId: request.projectId,
|
||||
parameter: parameters.projectId,
|
||||
},
|
||||
};
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('compiles one immutable reviewed route and owns its Project scope', async () => {
|
||||
const source = definition({ allowedQuery: ['dry_run'] });
|
||||
const registry = createClusterControlRouteRegistry([source]);
|
||||
source.operationId = 'forged.operation';
|
||||
|
||||
assert.equal(isClusterControlRouteRegistry(registry), true);
|
||||
assert.equal(isClusterControlRouteRegistry({ ...registry }), false);
|
||||
assert.equal(Object.isFrozen(registry), true);
|
||||
assert.equal(registry.contractVersion, 1);
|
||||
assert.equal(registry.size, 1);
|
||||
|
||||
const route = registry.resolve(
|
||||
metadata({ query: Object.freeze({ dry_run: Object.freeze(['true']) }) }),
|
||||
);
|
||||
assert.equal(route.operationId, 'run.create');
|
||||
assert.equal(route.permission, 'run.start');
|
||||
assert.equal(route.projectId, 'prj_default');
|
||||
assert.equal(Object.isFrozen(route), true);
|
||||
assert.deepEqual(
|
||||
await route.handle({
|
||||
request: { ...metadata(), body: { taskId: 'task-1' } },
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'usr_primary' },
|
||||
authenticationId: 'session:1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
assurance: 'multi_factor',
|
||||
},
|
||||
operationId: route.operationId,
|
||||
permission: route.permission,
|
||||
projectId: route.projectId,
|
||||
policyFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
}),
|
||||
{
|
||||
statusCode: 202,
|
||||
body: { projectId: 'prj_default', parameter: 'prj_default' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened, ambiguous and unbounded route definitions at startup', () => {
|
||||
const invalidSets = [
|
||||
[definition({ extra: true })],
|
||||
[definition({ path: '/api/v3/projects/{projectId}/' })],
|
||||
[definition({ path: '/api/v3/projects/%7BprojectId%7D/runs' })],
|
||||
[definition({ projectParameter: 'missing' })],
|
||||
[definition(), definition({ operationId: 'run.create' })],
|
||||
[
|
||||
definition(),
|
||||
definition({
|
||||
path: '/api/v3/projects/fixed/runs',
|
||||
operationId: 'run.create.fixed',
|
||||
}),
|
||||
],
|
||||
];
|
||||
for (const definitions of invalidSets) {
|
||||
assert.throws(
|
||||
() => createClusterControlRouteRegistry(definitions),
|
||||
ClusterControlRouteRegistryConfigurationError,
|
||||
);
|
||||
}
|
||||
|
||||
const tooMany = Array.from(
|
||||
{ length: CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxRoutes + 1 },
|
||||
(_, index) =>
|
||||
definition({
|
||||
path: `/api/v3/routes/route-${index}`,
|
||||
operationId: `route.operation:${index}`,
|
||||
projectParameter: null,
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() => createClusterControlRouteRegistry(tooMany),
|
||||
ClusterControlRouteRegistryConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-canonical paths and unknown query before returning a route', () => {
|
||||
const registry = createClusterControlRouteRegistry([
|
||||
definition({ allowedQuery: ['dry_run'] }),
|
||||
]);
|
||||
for (const path of [
|
||||
'/api/v3/projects/prj_default/runs/',
|
||||
'/api/v3/projects//runs',
|
||||
'/api/v3/projects/%2e%2e/runs',
|
||||
'/api/v3/projects/prj_default\\runs',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => registry.resolve(metadata({ path })),
|
||||
(error) =>
|
||||
error instanceof ClusterControlRouteResolutionError &&
|
||||
error.statusCode === 400 &&
|
||||
error.code === 'invalid_route_path',
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.resolve(
|
||||
metadata({ query: Object.freeze({ debug: Object.freeze(['1']) }) }),
|
||||
),
|
||||
(error) =>
|
||||
error instanceof ClusterControlRouteResolutionError &&
|
||||
error.code === 'invalid_route_query',
|
||||
);
|
||||
assert.equal(
|
||||
registry.resolve(metadata({ method: 'GET' })),
|
||||
null,
|
||||
'method is part of the reviewed route identity',
|
||||
);
|
||||
assert.equal(
|
||||
registry.resolve(metadata({ path: '/api/v3/projects/prj_default/tasks' })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('bounds repeated query values and rejects control characters', () => {
|
||||
const registry = createClusterControlRouteRegistry([
|
||||
definition({ allowedQuery: ['cursor'] }),
|
||||
]);
|
||||
for (const values of [
|
||||
[],
|
||||
Array.from(
|
||||
{
|
||||
length:
|
||||
CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxQueryValuesPerParameter + 1,
|
||||
},
|
||||
() => 'value',
|
||||
),
|
||||
['unsafe\u0000value'],
|
||||
['x'.repeat(CLUSTER_CONTROL_ROUTE_REGISTRY_LIMITS.maxQueryValueBytes + 1)],
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.resolve(
|
||||
metadata({ query: Object.freeze({ cursor: Object.freeze(values) }) }),
|
||||
),
|
||||
(error) =>
|
||||
error instanceof ClusterControlRouteResolutionError &&
|
||||
error.code === 'invalid_route_query',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterRunCancellationConvergenceLifecycle,
|
||||
} = require('../dist/run/runCancellationLifecycle');
|
||||
|
||||
function summary() {
|
||||
return {
|
||||
pages: 1,
|
||||
scanned: 1,
|
||||
settledRuns: 1,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
remaining: false,
|
||||
stopReason: 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
test('coalesces one bounded cycle and drains without owning per-Run timers', async () => {
|
||||
let release;
|
||||
let calls = 0;
|
||||
const lifecycle = new ClusterRunCancellationConvergenceLifecycle({
|
||||
async reconcile() {
|
||||
calls += 1;
|
||||
await new Promise((resolve) => { release = resolve; });
|
||||
return summary();
|
||||
},
|
||||
}, { intervalMs: 10_000, stopTimeoutMs: 1_000 });
|
||||
assert.equal(lifecycle.start(), 'started');
|
||||
const first = lifecycle.runOnce();
|
||||
const second = lifecycle.runOnce();
|
||||
assert.equal(first, second);
|
||||
while (!release) await new Promise((resolve) => setImmediate(resolve));
|
||||
release();
|
||||
assert.deepEqual(await first, summary());
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(await lifecycle.stopAndDrain(), { status: 'stopped' });
|
||||
await assert.rejects(lifecycle.runOnce(), /stopping/);
|
||||
});
|
||||
|
||||
test('reports a bounded drain timeout without cancelling database authority', async () => {
|
||||
const lifecycle = new ClusterRunCancellationConvergenceLifecycle({
|
||||
reconcile: () => new Promise(() => {}),
|
||||
}, { intervalMs: 10_000, stopTimeoutMs: 100 });
|
||||
lifecycle.start();
|
||||
void lifecycle.runOnce();
|
||||
assert.deepEqual(await lifecycle.stopAndDrain(), { status: 'timed_out' });
|
||||
});
|
||||
|
||||
test('rejects an unbounded cadence configuration', () => {
|
||||
assert.throws(() => new ClusterRunCancellationConvergenceLifecycle({
|
||||
async reconcile() { return summary(); },
|
||||
}, { intervalMs: 249, stopTimeoutMs: 1_000 }));
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
ClusterRunCancellationFenceRejectedError,
|
||||
ClusterRunCancellationNotFoundError,
|
||||
ClusterRunCancellationUnavailableError,
|
||||
} = require('@qinglong/runtime-core/cluster-run-cancellation');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
const {
|
||||
CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE,
|
||||
createClusterControlRunCancellationRoute,
|
||||
} = require('@qinglong/cluster-control/run-routes');
|
||||
|
||||
const EVENT_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'user-1' }),
|
||||
authenticationId: 'session:user-1',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
const METADATA = Object.freeze({
|
||||
requestId: 'request-cancel-run',
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/project-1/runs/run-1/cancellation',
|
||||
query: Object.freeze({}),
|
||||
headers: Object.freeze({ authorization: 'Bearer opaque' }),
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
function accepted(overrides = {}) {
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
cancelRequestedAtMs: 10_000,
|
||||
cancelReason: 'user',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pipeline(repository, events = [], policyFence = {
|
||||
projectVersion: 2,
|
||||
bindingVersion: 3,
|
||||
}) {
|
||||
return createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([
|
||||
createClusterControlRunCancellationRoute(repository, () => EVENT_ID),
|
||||
]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
events.push('authenticate');
|
||||
return PRINCIPAL;
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
authorize(request) {
|
||||
events.push(`authorize:${request.permission}:${request.projectId}`);
|
||||
return { effect: 'allow', reasons: ['role_grant'], fence: policyFence };
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
record(record) {
|
||||
events.push(`audit:${record.outcome}:${record.operationId}`);
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
test('publishes one reviewed run.stop mutation route', () => {
|
||||
const route = createClusterControlRunCancellationRoute({
|
||||
async requestUserCancellation() { return accepted(); },
|
||||
}, () => EVENT_ID);
|
||||
assert.deepEqual(CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/cancellation',
|
||||
operationId: 'run.cancel',
|
||||
permission: 'run.stop',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
assert.equal(Object.isFrozen(route), true);
|
||||
});
|
||||
|
||||
test('authenticates, authorizes and audits before committing cancellation', async () => {
|
||||
const events = [];
|
||||
let observed;
|
||||
const prepared = await pipeline({
|
||||
async requestUserCancellation(command) {
|
||||
events.push('repository');
|
||||
observed = command;
|
||||
return accepted();
|
||||
},
|
||||
}, events).prepare(METADATA);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.stop:project-1',
|
||||
'audit:allowed:run.cancel',
|
||||
]);
|
||||
assert.deepEqual(await prepared.handle({
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
}), {
|
||||
statusCode: 202,
|
||||
body: {
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
...accepted(),
|
||||
},
|
||||
});
|
||||
assert.deepEqual(observed, {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
mutationId: 'mutation-1',
|
||||
eventId: EVENT_ID,
|
||||
subject: PRINCIPAL.subject,
|
||||
policyFence: { projectVersion: 2, bindingVersion: 3 },
|
||||
});
|
||||
assert.equal(events.at(-1), 'repository');
|
||||
});
|
||||
|
||||
test('rejects caller-selected reasons and missing policy fences', async () => {
|
||||
let calls = 0;
|
||||
const repository = {
|
||||
async requestUserCancellation() { calls += 1; return accepted(); },
|
||||
};
|
||||
const prepared = await pipeline(repository).prepare(METADATA);
|
||||
assert.deepEqual(await prepared.handle({
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
reason: 'shutdown',
|
||||
}), {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
code: 'invalid_run_cancellation_request',
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
},
|
||||
});
|
||||
const unfenced = await pipeline(repository, [], null).prepare(METADATA);
|
||||
assert.deepEqual(await unfenced.handle({
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
}), {
|
||||
statusCode: 503,
|
||||
body: { code: 'run_cancellation_unavailable' },
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('maps replay, terminal, missing, fenced and unavailable outcomes', async () => {
|
||||
for (const [outcome, expected] of [
|
||||
[accepted({ status: 'already_requested' }), 200],
|
||||
[{
|
||||
status: 'already_terminal',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'succeeded',
|
||||
runVersion: 6,
|
||||
eventSequence: 8,
|
||||
}, 200],
|
||||
]) {
|
||||
const prepared = await pipeline({
|
||||
async requestUserCancellation() { return outcome; },
|
||||
}).prepare(METADATA);
|
||||
const result = await prepared.handle({
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
});
|
||||
assert.equal(result.statusCode, expected);
|
||||
}
|
||||
|
||||
const errors = [
|
||||
[new ClusterRunCancellationNotFoundError(), 404, 'run_not_found'],
|
||||
[
|
||||
new ClusterRunCancellationFenceRejectedError('authorization_changed'),
|
||||
409,
|
||||
'run_cancellation_fence_rejected',
|
||||
],
|
||||
[
|
||||
new ClusterRunCancellationUnavailableError(),
|
||||
503,
|
||||
'run_cancellation_unavailable',
|
||||
],
|
||||
];
|
||||
for (const [error, statusCode, code] of errors) {
|
||||
const prepared = await pipeline({
|
||||
async requestUserCancellation() { throw error; },
|
||||
}).prepare(METADATA);
|
||||
const result = await prepared.handle({
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
mutationId: 'mutation-1',
|
||||
});
|
||||
assert.equal(result.statusCode, statusCode);
|
||||
assert.equal(result.body.code, code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CLUSTER_CONTROL_RUN_EVENT_LIST_ROUTE,
|
||||
createClusterControlRunEventListRoute,
|
||||
} = require('@qinglong/cluster-control/run-routes');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
|
||||
function run(projectId = 'prj_default') {
|
||||
return { id: 'run-1', projectId };
|
||||
}
|
||||
|
||||
function event(sequence, overrides = {}) {
|
||||
return {
|
||||
id: `event-${sequence}`,
|
||||
runId: 'run-1',
|
||||
sequence,
|
||||
type: `run.event.${sequence}`,
|
||||
actorType: 'system',
|
||||
actorId: 'private-actor',
|
||||
payload: { secret: 'must-not-cross-projection' },
|
||||
createdAtMs: 1_000 + sequence,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authorized(query = {}, body = null) {
|
||||
return {
|
||||
projectId: 'prj_default',
|
||||
request: { query, body },
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one reviewed bounded Run event list route', () => {
|
||||
assert.deepEqual(CLUSTER_CONTROL_RUN_EVENT_LIST_ROUTE, {
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/events',
|
||||
operationId: 'run.events.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: ['after_sequence', 'limit'],
|
||||
});
|
||||
assert.throws(() => createClusterControlRunEventListRoute({}), TypeError);
|
||||
});
|
||||
|
||||
test('returns the shared projection with exact sequence keyset input', async () => {
|
||||
const calls = [];
|
||||
const route = createClusterControlRunEventListRoute({
|
||||
async findRunById(runId) {
|
||||
calls.push(['run', runId]);
|
||||
return run();
|
||||
},
|
||||
async listEvents(runId, input) {
|
||||
calls.push(['events', runId, input]);
|
||||
return [event(3), event(4)];
|
||||
},
|
||||
});
|
||||
const result = await route.handle(
|
||||
authorized({ after_sequence: ['2'], limit: ['1'] }),
|
||||
{ runId: 'run-1' },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['run', 'run-1'],
|
||||
['events', 'run-1', { afterSequence: 2, limit: 2 }],
|
||||
]);
|
||||
assert.deepEqual(result, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: [
|
||||
{
|
||||
sequence: 3,
|
||||
type: 'run.event.3',
|
||||
actorType: 'system',
|
||||
createdAtMs: 1_003,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAfterSequence: 3,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('private'), false);
|
||||
assert.equal(JSON.stringify(result).includes('secret'), false);
|
||||
});
|
||||
|
||||
test('masks Project mismatch and rejects malformed query before authentication', async () => {
|
||||
const route = createClusterControlRunEventListRoute({
|
||||
async findRunById() {
|
||||
return run('prj_other');
|
||||
},
|
||||
async listEvents() {
|
||||
throw new Error('must not read');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle(authorized(), { runId: 'run-1' }), {
|
||||
statusCode: 404,
|
||||
body: { code: 'run_not_found' },
|
||||
});
|
||||
|
||||
let authentications = 0;
|
||||
const pipeline = createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([route]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
authentications += 1;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
authorize() {
|
||||
throw new Error('must not authorize');
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
record() {
|
||||
throw new Error('must not audit');
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
});
|
||||
for (const query of [
|
||||
{ after_sequence: ['02'] },
|
||||
{ after_sequence: ['-1'] },
|
||||
{ limit: ['65'] },
|
||||
{ limit: ['1', '2'] },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
pipeline.prepare({
|
||||
requestId: 'request-invalid-run-event-list',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/prj_default/runs/run-1/events',
|
||||
query,
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
(error) =>
|
||||
error.statusCode === 400 && error.code === 'invalid_route_query',
|
||||
);
|
||||
}
|
||||
assert.equal(authentications, 0);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CLUSTER_CONTROL_RUN_LIST_ROUTE,
|
||||
createClusterControlRunListRoute,
|
||||
} = require('@qinglong/cluster-control/run-routes');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
|
||||
function run(id, createdAtMs, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
projectId: 'prj_default',
|
||||
taskId: `task-${id}`,
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'running',
|
||||
version: 0,
|
||||
eventSequence: 1,
|
||||
priority: 0,
|
||||
createdAtMs,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authorized(query = {}, body = null) {
|
||||
return {
|
||||
projectId: 'prj_default',
|
||||
request: { query, body },
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one reviewed bounded Run list route', () => {
|
||||
assert.deepEqual(CLUSTER_CONTROL_RUN_LIST_ROUTE, {
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/{projectId}/runs',
|
||||
operationId: 'run.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: ['after_created_at_ms', 'after_run_id', 'limit'],
|
||||
});
|
||||
assert.throws(() => createClusterControlRunListRoute({}), TypeError);
|
||||
});
|
||||
|
||||
test('parses an exact keyset page and returns the shared projection', async () => {
|
||||
const calls = [];
|
||||
const route = createClusterControlRunListRoute({
|
||||
async listRunsByProject(query) {
|
||||
calls.push(query);
|
||||
return [run('run-a', 90)];
|
||||
},
|
||||
});
|
||||
const result = await route.handle(
|
||||
authorized({
|
||||
limit: ['8'],
|
||||
after_created_at_ms: ['100'],
|
||||
after_run_id: ['run-b'],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(result.body.runs[0].id, 'run-a');
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
projectId: 'prj_default',
|
||||
limit: 9,
|
||||
after: { createdAtMs: 100, runId: 'run-b' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects malformed query and body and masks repository failures', async () => {
|
||||
const route = createClusterControlRunListRoute({
|
||||
async listRunsByProject() { throw new Error('offline'); },
|
||||
});
|
||||
for (const query of [
|
||||
{ limit: ['08'] },
|
||||
{ limit: ['65'] },
|
||||
{ limit: ['8', '9'] },
|
||||
{ after_run_id: ['run-a'] },
|
||||
{ after_created_at_ms: ['-1'], after_run_id: ['run-a'] },
|
||||
]) {
|
||||
assert.deepEqual(await route.handle(authorized(query), {}), {
|
||||
statusCode: 400,
|
||||
body: { code: 'invalid_run_list_query' },
|
||||
});
|
||||
}
|
||||
assert.deepEqual(await route.handle(authorized({}, { value: true }), {}), {
|
||||
statusCode: 400,
|
||||
body: { code: 'invalid_request_body' },
|
||||
});
|
||||
assert.deepEqual(await route.handle(authorized({}), {}), {
|
||||
statusCode: 503,
|
||||
body: { code: 'run_list_unavailable' },
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects non-canonical pagination before authentication', async () => {
|
||||
let authentications = 0;
|
||||
const pipeline = createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([
|
||||
createClusterControlRunListRoute({
|
||||
async listRunsByProject() { return []; },
|
||||
}),
|
||||
]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
authentications += 1;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
policy: { authorize() { throw new Error('must not authorize'); } },
|
||||
audit: { record() { throw new Error('must not audit'); } },
|
||||
now: () => 10_000,
|
||||
});
|
||||
await assert.rejects(
|
||||
pipeline.prepare({
|
||||
requestId: 'request-invalid-run-list',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/prj_default/runs',
|
||||
query: { limit: ['08'] },
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
(error) => error.statusCode === 400 && error.code === 'invalid_route_query',
|
||||
);
|
||||
assert.equal(authentications, 0);
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
const {
|
||||
CLUSTER_CONTROL_RUN_READ_ROUTE,
|
||||
createClusterControlRunReadRoute,
|
||||
} = require('@qinglong/cluster-control/run-routes');
|
||||
const {
|
||||
startClusterControlHttpSurface,
|
||||
} = require('@qinglong/cluster-control/http');
|
||||
|
||||
const NOW = 10_000;
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'usr_viewer' }),
|
||||
authenticationId: 'session:viewer',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
const METADATA = Object.freeze({
|
||||
requestId: 'request-run-read',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/prj_default/runs/run_123',
|
||||
query: Object.freeze({}),
|
||||
headers: Object.freeze({ authorization: 'Bearer opaque' }),
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const EVIDENCE = Object.freeze({
|
||||
contractName: 'control-core',
|
||||
contractVersion: 5,
|
||||
serverMajor: 16,
|
||||
migrationIds: Object.freeze(['pg-0001', 'pg-0002']),
|
||||
});
|
||||
|
||||
function httpRequest(address, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = http.request(
|
||||
{
|
||||
host: address.host,
|
||||
port: address.port,
|
||||
path,
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
connection: 'close',
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
outgoing.once('error', reject);
|
||||
outgoing.end();
|
||||
});
|
||||
}
|
||||
|
||||
function run(overrides = {}) {
|
||||
return {
|
||||
id: 'run_123',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
taskRevision: 'revision_7',
|
||||
taskName: 'must not cross the wire',
|
||||
taskSnapshotRef: 'secret-adjacent-ref',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
triggeredBy: 'private-user-id',
|
||||
requestId: 'private-request-id',
|
||||
status: 'running',
|
||||
version: 4,
|
||||
eventSequence: 6,
|
||||
priority: 10,
|
||||
inputRef: 'private-input-ref',
|
||||
outputRef: 'private-output-ref',
|
||||
createdAtMs: 1_000,
|
||||
queuedAtMs: 2_000,
|
||||
startedAtMs: 3_000,
|
||||
errorCode: 'private-error-code',
|
||||
errorSummary: 'private error detail',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function admission(repository, events = [], overrides = {}) {
|
||||
return createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([
|
||||
createClusterControlRunReadRoute(repository),
|
||||
]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
events.push('authenticate');
|
||||
return PRINCIPAL;
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
authorize(request) {
|
||||
events.push(`authorize:${request.permission}:${request.projectId}`);
|
||||
return {
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
};
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
record(record) {
|
||||
events.push(`audit:${record.outcome}:${record.operationId}`);
|
||||
},
|
||||
},
|
||||
now: () => NOW,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('publishes one immutable reviewed Run read route', () => {
|
||||
const route = createClusterControlRunReadRoute({
|
||||
async findRunById() {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(CLUSTER_CONTROL_RUN_READ_ROUTE, {
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}',
|
||||
operationId: 'run.get',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
assert.equal(Object.isFrozen(route), true);
|
||||
assert.throws(() => createClusterControlRunReadRoute({}), TypeError);
|
||||
});
|
||||
|
||||
test('authenticates, authorizes and audits before one bounded Run lookup', async () => {
|
||||
const events = [];
|
||||
const pipeline = admission(
|
||||
{
|
||||
async findRunById(runId) {
|
||||
events.push(`repository:${runId}`);
|
||||
return run({ version: 0 });
|
||||
},
|
||||
},
|
||||
events,
|
||||
);
|
||||
|
||||
const prepared = await pipeline.prepare(METADATA);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.read:prj_default',
|
||||
'audit:allowed:run.get',
|
||||
]);
|
||||
assert.deepEqual(await prepared.handle(null), {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
run: {
|
||||
id: 'run_123',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
taskRevision: 'revision_7',
|
||||
status: 'running',
|
||||
version: 0,
|
||||
eventSequence: 6,
|
||||
priority: 10,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 1_000,
|
||||
queuedAtMs: 2_000,
|
||||
startedAtMs: 3_000,
|
||||
finishedAtMs: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events.slice(-1), ['repository:run_123']);
|
||||
assert.equal(
|
||||
JSON.stringify(await prepared.handle(null)).includes('private'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('serves the reviewed Run projection through the bounded HTTP surface', async (t) => {
|
||||
const events = [];
|
||||
const surface = await startClusterControlHttpSurface({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
});
|
||||
t.after(() => surface.close());
|
||||
const dispose = surface.installAdmission(
|
||||
EVIDENCE,
|
||||
admission(
|
||||
{
|
||||
async findRunById(runId) {
|
||||
events.push(`repository:${runId}`);
|
||||
return run({ status: 'succeeded', finishedAtMs: 4_000 });
|
||||
},
|
||||
},
|
||||
events,
|
||||
),
|
||||
);
|
||||
t.after(() => dispose());
|
||||
|
||||
const result = await httpRequest(
|
||||
surface.address,
|
||||
'/api/v3/projects/prj_default/runs/run_123',
|
||||
);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(result.body.run.id, 'run_123');
|
||||
assert.equal(result.body.run.projectId, 'prj_default');
|
||||
assert.equal(result.body.run.status, 'succeeded');
|
||||
assert.equal(result.body.run.finishedAtMs, 4_000);
|
||||
assert.equal(JSON.stringify(result).includes('private'), false);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.read:prj_default',
|
||||
'audit:allowed:run.get',
|
||||
'repository:run_123',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not query storage when authentication fails', async () => {
|
||||
let queries = 0;
|
||||
const pipeline = admission(
|
||||
{
|
||||
async findRunById() {
|
||||
queries += 1;
|
||||
return run();
|
||||
},
|
||||
},
|
||||
[],
|
||||
{ authenticator: { authenticate: () => null } },
|
||||
);
|
||||
await assert.rejects(
|
||||
pipeline.prepare(METADATA),
|
||||
(error) =>
|
||||
error.statusCode === 401 && error.code === 'authentication_required',
|
||||
);
|
||||
assert.equal(queries, 0);
|
||||
});
|
||||
|
||||
test('masks absent, cross-Project, corrupt and unavailable Run records', async () => {
|
||||
for (const [record, expected] of [
|
||||
[null, { statusCode: 404, body: { code: 'run_not_found' } }],
|
||||
[
|
||||
run({ projectId: 'prj_other' }),
|
||||
{ statusCode: 404, body: { code: 'run_not_found' } },
|
||||
],
|
||||
[
|
||||
run({ taskRevision: '\ncorrupt' }),
|
||||
{ statusCode: 503, body: { code: 'run_query_unavailable' } },
|
||||
],
|
||||
[
|
||||
{ id: 'run_123' },
|
||||
{ statusCode: 503, body: { code: 'run_query_unavailable' } },
|
||||
],
|
||||
]) {
|
||||
const prepared = await admission({
|
||||
async findRunById() {
|
||||
return record;
|
||||
},
|
||||
}).prepare(METADATA);
|
||||
assert.deepEqual(await prepared.handle(null), expected);
|
||||
}
|
||||
|
||||
const unavailable = await admission({
|
||||
async findRunById() {
|
||||
throw new Error('postgresql secret detail');
|
||||
},
|
||||
}).prepare(METADATA);
|
||||
const response = await unavailable.handle(null);
|
||||
assert.deepEqual(response, {
|
||||
statusCode: 503,
|
||||
body: { code: 'run_query_unavailable' },
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('postgresql'), false);
|
||||
});
|
||||
|
||||
test('rejects a GET body without touching the Run repository', async () => {
|
||||
let queries = 0;
|
||||
const prepared = await admission({
|
||||
async findRunById() {
|
||||
queries += 1;
|
||||
return run();
|
||||
},
|
||||
}).prepare(METADATA);
|
||||
assert.deepEqual(await prepared.handle({ unexpected: true }), {
|
||||
statusCode: 400,
|
||||
body: { code: 'invalid_request_body' },
|
||||
});
|
||||
assert.equal(queries, 0);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CLUSTER_CONTROL_RUN_STEP_LIST_ROUTE,
|
||||
createClusterControlRunStepListRoute,
|
||||
} = require('@qinglong/cluster-control/run-routes');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
const {
|
||||
createStepRunRecord,
|
||||
} = require('../../ql3-runtime-core/dist/run/stepRun.js');
|
||||
|
||||
function run(projectId = 'prj_default') {
|
||||
return { id: 'run-1', projectId };
|
||||
}
|
||||
|
||||
function step(id, stepKey) {
|
||||
return createStepRunRecord({
|
||||
id,
|
||||
runId: 'run-1',
|
||||
parentStepRunId: 'step-parent',
|
||||
stepKey,
|
||||
kind: 'tool',
|
||||
definitionRef: 'tool:private.internal@1.0.0',
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
inputRef: 'artifact:private-input',
|
||||
mutationId: `create-${id}`,
|
||||
createdAtMs: 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
function authorized(query = {}, body = null) {
|
||||
return { projectId: 'prj_default', request: { query, body } };
|
||||
}
|
||||
|
||||
test('publishes one reviewed bounded Run Step list route', () => {
|
||||
assert.deepEqual(CLUSTER_CONTROL_RUN_STEP_LIST_ROUTE, {
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/steps',
|
||||
operationId: 'run.steps.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: ['after_step_key', 'after_step_run_id', 'limit'],
|
||||
});
|
||||
assert.throws(() => createClusterControlRunStepListRoute({}, {}), TypeError);
|
||||
});
|
||||
|
||||
test('returns the shared projection with the paired Step keyset cursor', async () => {
|
||||
const calls = [];
|
||||
const first = step('step-1', 'build');
|
||||
const second = step('step-2', 'deploy');
|
||||
const route = createClusterControlRunStepListRoute(
|
||||
{
|
||||
async findRunById(runId) {
|
||||
calls.push(['run', runId]);
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun(query) {
|
||||
calls.push(['steps', query]);
|
||||
return {
|
||||
stepRuns: [first, second],
|
||||
truncated: true,
|
||||
next: { stepKey: second.stepKey, id: second.id },
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await route.handle(
|
||||
authorized({
|
||||
after_step_key: ['admit'],
|
||||
after_step_run_id: ['step-0'],
|
||||
limit: ['2'],
|
||||
}),
|
||||
{ runId: 'run-1' },
|
||||
);
|
||||
assert.deepEqual(calls[1], [
|
||||
'steps',
|
||||
{
|
||||
runId: 'run-1',
|
||||
limit: 2,
|
||||
after: { stepKey: 'admit', id: 'step-0' },
|
||||
},
|
||||
]);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(result.body.steps.length, 2);
|
||||
assert.deepEqual(result.body.next, {
|
||||
stepKey: 'deploy',
|
||||
stepRunId: 'step-2',
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('private'), false);
|
||||
});
|
||||
|
||||
test('rejects an unpaired or malformed cursor before authentication', async () => {
|
||||
const route = createClusterControlRunStepListRoute(
|
||||
{
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun() {
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
},
|
||||
);
|
||||
let authentications = 0;
|
||||
const pipeline = createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([route]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
authentications += 1;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
authorize() {
|
||||
throw new Error('must not authorize');
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
record() {
|
||||
throw new Error('must not audit');
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
});
|
||||
for (const query of [
|
||||
{ after_step_key: ['build'] },
|
||||
{ after_step_run_id: ['step-1'] },
|
||||
{ after_step_key: ['bad value'], after_step_run_id: ['step-1'] },
|
||||
{ after_step_key: ['build'], after_step_run_id: ['step-1'], limit: ['65'] },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
pipeline.prepare({
|
||||
requestId: 'request-invalid-run-step-list',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/prj_default/runs/run-1/steps',
|
||||
query,
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
(error) =>
|
||||
error.statusCode === 400 && error.code === 'invalid_route_query',
|
||||
);
|
||||
}
|
||||
assert.equal(authentications, 0);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ClusterRuntimeSchedulerCoordinator,
|
||||
} = require('../dist/scheduling/runtimeScheduler');
|
||||
|
||||
function schedulerSummary() {
|
||||
return Object.freeze({
|
||||
firstClaimAcquiredAtMs: null,
|
||||
lastClaimAcquiredAtMs: null,
|
||||
claimed: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
saturated: false,
|
||||
});
|
||||
}
|
||||
|
||||
test('orders recovery and lost retry before the existing scheduler cadence', async () => {
|
||||
const calls = [];
|
||||
const coordinator = new ClusterRuntimeSchedulerCoordinator(
|
||||
{
|
||||
async reconcile() {
|
||||
calls.push('recovery');
|
||||
return { safe: true, remaining: 0, failed: 0 };
|
||||
},
|
||||
},
|
||||
{
|
||||
async reconcile() {
|
||||
calls.push('lost-retry');
|
||||
return {
|
||||
scanned: 1,
|
||||
scheduled: 1,
|
||||
requeued: 0,
|
||||
failed: 0,
|
||||
raced: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async scheduleOnce() {
|
||||
calls.push('schedule');
|
||||
return schedulerSummary();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await coordinator.scheduleOnce(), schedulerSummary());
|
||||
assert.deepEqual(calls, ['recovery', 'lost-retry', 'schedule']);
|
||||
assert.deepEqual(coordinator.latestMaintenanceSummary(), {
|
||||
recovery: { safe: true, remaining: 0, failed: 0 },
|
||||
lostRetry: {
|
||||
scanned: 1,
|
||||
scheduled: 1,
|
||||
requeued: 0,
|
||||
failed: 0,
|
||||
raced: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('coalesces overlapping cadence calls and fails closed before scheduling', async () => {
|
||||
let release;
|
||||
let recoveryCalls = 0;
|
||||
let schedulerCalls = 0;
|
||||
const coordinator = new ClusterRuntimeSchedulerCoordinator(
|
||||
{
|
||||
async reconcile() {
|
||||
recoveryCalls += 1;
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
throw new Error('recovery unavailable');
|
||||
},
|
||||
},
|
||||
{
|
||||
async reconcile() {
|
||||
throw new Error('lost retry must not run');
|
||||
},
|
||||
},
|
||||
{
|
||||
async scheduleOnce() {
|
||||
schedulerCalls += 1;
|
||||
return schedulerSummary();
|
||||
},
|
||||
},
|
||||
);
|
||||
const first = coordinator.scheduleOnce();
|
||||
const second = coordinator.scheduleOnce();
|
||||
assert.equal(first, second);
|
||||
while (!release) await new Promise((resolve) => setImmediate(resolve));
|
||||
release();
|
||||
await assert.rejects(first, /recovery unavailable/);
|
||||
assert.equal(recoveryCalls, 1);
|
||||
assert.equal(schedulerCalls, 0);
|
||||
assert.equal(coordinator.latestMaintenanceSummary(), undefined);
|
||||
});
|
||||
|
||||
test('rejects incomplete maintenance capabilities', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
new ClusterRuntimeSchedulerCoordinator(
|
||||
{},
|
||||
{ reconcile() {} },
|
||||
{ scheduleOnce() {} },
|
||||
),
|
||||
/runtime scheduler coordinator is invalid/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const {
|
||||
S3ClusterRemoteWorkerArtifactStore,
|
||||
S3ClusterRemoteWorkerArtifactStoreError,
|
||||
} = require('@qinglong/cluster-control/s3-artifact-store');
|
||||
|
||||
const endpoint = process.env.QL3_TEST_S3_ENDPOINT;
|
||||
const accessKeyId = process.env.QL3_TEST_S3_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.QL3_TEST_S3_SECRET_ACCESS_KEY;
|
||||
|
||||
test('real S3-compatible service preserves immutable Artifact evidence', {
|
||||
skip: endpoint && accessKeyId && secretAccessKey
|
||||
? false
|
||||
: 'requires QL3_TEST_S3_ENDPOINT and credentials',
|
||||
}, async () => {
|
||||
const client = new S3Client({
|
||||
endpoint,
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
});
|
||||
const bucket = `ql3-artifact-${process.pid}-${Date.now()}`.slice(0, 63);
|
||||
const command = Object.freeze({
|
||||
projectId: 'project-s3-integration',
|
||||
runId: 'run-s3-integration',
|
||||
attemptId: 'attempt-s3-integration',
|
||||
logArtifactId: `wlog-${'c'.repeat(30)}`,
|
||||
byteLength: 17,
|
||||
truncated: true,
|
||||
});
|
||||
const content = Buffer.from('real object bytes');
|
||||
const body = (value) => Object.freeze({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield value.subarray(0, 4);
|
||||
yield value.subarray(4);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const store = new S3ClusterRemoteWorkerArtifactStore({
|
||||
client,
|
||||
bucket,
|
||||
prefix: 'qinglong/integration',
|
||||
encryption: { mode: 's3' },
|
||||
});
|
||||
const stored = await store.put(command, body(content));
|
||||
assert.equal(stored.status, 'stored');
|
||||
assert.equal(
|
||||
stored.sha256,
|
||||
createHash('sha256').update(content).digest('hex'),
|
||||
);
|
||||
const replay = await store.put(command, body(content));
|
||||
assert.equal(replay.status, 'already_stored');
|
||||
await assert.rejects(
|
||||
store.put(command, body(Buffer.from('REAL OBJECT BYTES'))),
|
||||
(error) =>
|
||||
error instanceof S3ClusterRemoteWorkerArtifactStoreError &&
|
||||
error.reason === 'integrity_mismatch',
|
||||
);
|
||||
const objects = await client.send(new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: 'qinglong/integration/',
|
||||
}));
|
||||
assert.equal(objects.KeyCount, 1);
|
||||
assert.match(objects.Contents[0].Key, /\/objects\//);
|
||||
} finally {
|
||||
try {
|
||||
const objects = await client.send(new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
}));
|
||||
if (objects.Contents?.length) {
|
||||
await client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: objects.Contents.map(({ Key }) => ({ Key })),
|
||||
Quiet: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
await client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
} catch {
|
||||
// Preserve the integration assertion; the ephemeral container is removed.
|
||||
}
|
||||
client.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const {
|
||||
S3ClusterRemoteWorkerArtifactStore,
|
||||
S3ClusterRemoteWorkerArtifactStoreError,
|
||||
} = require('@qinglong/cluster-control/s3-artifact-store');
|
||||
|
||||
const TEMPORARY_ID = '018f62f6-7b41-4e4f-8cf8-6f38888629a2';
|
||||
const COMMAND = Object.freeze({
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
logArtifactId: `wlog-${'a'.repeat(30)}`,
|
||||
byteLength: 11,
|
||||
truncated: false,
|
||||
});
|
||||
const CONTENT = Buffer.from('hello world');
|
||||
const CONTENT_SHA256 = createHash('sha256').update(CONTENT).digest('hex');
|
||||
const LOOKUP = Object.freeze({
|
||||
projectId: COMMAND.projectId,
|
||||
runId: COMMAND.runId,
|
||||
attemptId: COMMAND.attemptId,
|
||||
logArtifactId: COMMAND.logArtifactId,
|
||||
});
|
||||
|
||||
function checksum(content) {
|
||||
return createHash('sha256').update(content).digest('base64');
|
||||
}
|
||||
|
||||
function notFound() {
|
||||
const error = new Error('not found');
|
||||
error.name = 'NotFound';
|
||||
error.$metadata = { httpStatusCode: 404 };
|
||||
return error;
|
||||
}
|
||||
|
||||
class MemoryS3Client {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
this.objects = new Map();
|
||||
this.commands = [];
|
||||
}
|
||||
|
||||
async send(command) {
|
||||
this.commands.push(command);
|
||||
const input = command.input;
|
||||
if (command instanceof HeadObjectCommand) {
|
||||
const object = this.objects.get(input.Key);
|
||||
if (!object) throw notFound();
|
||||
const metadata = { ...object.metadata };
|
||||
if (this.options.corruptFinalMetadata && input.Key.includes('/objects/')) {
|
||||
metadata['ql3-content-sha256'] = '0'.repeat(64);
|
||||
}
|
||||
return {
|
||||
ContentLength: object.content.byteLength,
|
||||
ContentType: object.contentType,
|
||||
ChecksumSHA256: this.options.corruptFinalChecksum &&
|
||||
input.Key.includes('/objects/')
|
||||
? Buffer.alloc(32, 9).toString('base64')
|
||||
: checksum(object.content),
|
||||
Metadata: metadata,
|
||||
};
|
||||
}
|
||||
if (command instanceof PutObjectCommand) {
|
||||
assert.equal(input.IfNoneMatch, '*');
|
||||
assert.equal(input.ChecksumAlgorithm, 'SHA256');
|
||||
assert.equal(
|
||||
input.ServerSideEncryption,
|
||||
this.options.expectedEncryption ?? 'AES256',
|
||||
);
|
||||
if (this.options.expectedKmsKeyId) {
|
||||
assert.equal(input.SSEKMSKeyId, this.options.expectedKmsKeyId);
|
||||
}
|
||||
if (this.objects.has(input.Key)) {
|
||||
const error = new Error('precondition failed');
|
||||
error.$metadata = { httpStatusCode: 412 };
|
||||
throw error;
|
||||
}
|
||||
const chunks = [];
|
||||
for await (const chunk of input.Body) chunks.push(Buffer.from(chunk));
|
||||
const content = Buffer.concat(chunks);
|
||||
this.objects.set(input.Key, {
|
||||
content,
|
||||
contentType: input.ContentType,
|
||||
metadata: { ...input.Metadata },
|
||||
});
|
||||
if (this.options.throwAfterPut) throw new Error('lost put response');
|
||||
return { ChecksumSHA256: checksum(content) };
|
||||
}
|
||||
if (command instanceof CopyObjectCommand) {
|
||||
assert.equal(input.IfNoneMatch, '*');
|
||||
assert.equal(input.MetadataDirective, 'REPLACE');
|
||||
assert.equal(input.ChecksumAlgorithm, 'SHA256');
|
||||
const sourceKey = decodeURIComponent(input.CopySource)
|
||||
.split('/')
|
||||
.slice(1)
|
||||
.join('/');
|
||||
const source = this.objects.get(sourceKey);
|
||||
if (!source) throw notFound();
|
||||
const final = {
|
||||
content: Buffer.from(source.content),
|
||||
contentType: input.ContentType,
|
||||
metadata: { ...input.Metadata },
|
||||
};
|
||||
if (this.options.raceOnCopy && !this.objects.has(input.Key)) {
|
||||
this.objects.set(input.Key, final);
|
||||
const error = new Error('conditional request conflict');
|
||||
error.$metadata = { httpStatusCode: 409 };
|
||||
throw error;
|
||||
}
|
||||
if (this.objects.has(input.Key)) {
|
||||
const error = new Error('precondition failed');
|
||||
error.$metadata = { httpStatusCode: 412 };
|
||||
throw error;
|
||||
}
|
||||
this.objects.set(input.Key, final);
|
||||
if (this.options.throwAfterCopy) throw new Error('lost copy response');
|
||||
return { CopyObjectResult: { ChecksumSHA256: checksum(final.content) } };
|
||||
}
|
||||
if (command instanceof DeleteObjectCommand) {
|
||||
if (this.options.failDelete) throw new Error('delete unavailable');
|
||||
this.objects.delete(input.Key);
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected command: ${command.constructor.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function store(client, overrides = {}) {
|
||||
return new S3ClusterRemoteWorkerArtifactStore({
|
||||
client,
|
||||
bucket: 'ql3-artifacts-test',
|
||||
prefix: 'tenant-a/worker-artifacts',
|
||||
encryption: { mode: 's3' },
|
||||
createTemporaryId: () => TEMPORARY_ID,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function chunks(content = CONTENT, observed) {
|
||||
return Object.freeze({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield content.subarray(0, 3);
|
||||
yield content.subarray(3, 7);
|
||||
yield content.subarray(7);
|
||||
if (observed) observed.complete = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function permanentKey(client) {
|
||||
return [...client.objects.keys()].find((key) => key.includes('/objects/'));
|
||||
}
|
||||
|
||||
test('streams to a checksummed temporary object then conditionally promotes it', async () => {
|
||||
const client = new MemoryS3Client();
|
||||
const adapter = store(client);
|
||||
const receipt = await adapter.put(COMMAND, chunks());
|
||||
|
||||
assert.deepEqual(receipt, {
|
||||
status: 'stored',
|
||||
...COMMAND,
|
||||
sha256: CONTENT_SHA256,
|
||||
});
|
||||
assert.deepEqual(
|
||||
client.commands.map((command) => command.constructor.name),
|
||||
[
|
||||
'HeadObjectCommand',
|
||||
'PutObjectCommand',
|
||||
'HeadObjectCommand',
|
||||
'CopyObjectCommand',
|
||||
'HeadObjectCommand',
|
||||
'DeleteObjectCommand',
|
||||
],
|
||||
);
|
||||
const key = permanentKey(client);
|
||||
assert.match(key, /^tenant-a\/worker-artifacts\/objects\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
|
||||
assert.equal(key.includes(COMMAND.runId), false);
|
||||
assert.equal(client.objects.size, 1);
|
||||
|
||||
const copy = client.commands.find((command) => command instanceof CopyObjectCommand);
|
||||
assert.equal(copy.input.Metadata['ql3-content-sha256'], CONTENT_SHA256);
|
||||
assert.equal(JSON.stringify(copy.input.Metadata).includes(COMMAND.projectId), false);
|
||||
assert.equal(JSON.stringify(copy.input.Metadata).includes(COMMAND.runId), false);
|
||||
|
||||
const inspected = await adapter.inspect(LOOKUP);
|
||||
assert.deepEqual(inspected, { ...receipt, status: 'already_stored' });
|
||||
});
|
||||
|
||||
test('exact replay consumes and hashes the whole body without another write', async () => {
|
||||
const client = new MemoryS3Client();
|
||||
const adapter = store(client);
|
||||
await adapter.put(COMMAND, chunks());
|
||||
client.commands.length = 0;
|
||||
const observed = { complete: false };
|
||||
|
||||
const replay = await adapter.put(COMMAND, chunks(CONTENT, observed));
|
||||
assert.equal(replay.status, 'already_stored');
|
||||
assert.equal(observed.complete, true);
|
||||
assert.deepEqual(
|
||||
client.commands.map((command) => command.constructor.name),
|
||||
['HeadObjectCommand'],
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
adapter.put(COMMAND, chunks(Buffer.from('HELLO WORLD'))),
|
||||
(error) =>
|
||||
error instanceof S3ClusterRemoteWorkerArtifactStoreError &&
|
||||
error.reason === 'integrity_mismatch',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves a concurrent conditional-copy winner by immutable inspect', async () => {
|
||||
const client = new MemoryS3Client({ raceOnCopy: true });
|
||||
const receipt = await store(client).put(COMMAND, chunks());
|
||||
assert.equal(receipt.status, 'already_stored');
|
||||
assert.equal(receipt.sha256, CONTENT_SHA256);
|
||||
assert.equal(client.objects.size, 1);
|
||||
});
|
||||
|
||||
test('recovers lost put and copy responses only from checksum evidence', async () => {
|
||||
for (const [option, expectedStatus] of [
|
||||
['throwAfterPut', 'stored'],
|
||||
['throwAfterCopy', 'already_stored'],
|
||||
]) {
|
||||
const client = new MemoryS3Client({ [option]: true });
|
||||
const receipt = await store(client).put(COMMAND, chunks());
|
||||
assert.equal(receipt.status, expectedStatus);
|
||||
assert.equal(receipt.sha256, CONTENT_SHA256);
|
||||
assert.equal(client.objects.size, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects corrupt metadata, checksum, short and oversized content', async () => {
|
||||
for (const option of ['corruptFinalMetadata', 'corruptFinalChecksum']) {
|
||||
const client = new MemoryS3Client({ [option]: true });
|
||||
await assert.rejects(
|
||||
store(client).put(COMMAND, chunks()),
|
||||
(error) =>
|
||||
error instanceof S3ClusterRemoteWorkerArtifactStoreError &&
|
||||
error.reason === 'integrity_mismatch',
|
||||
);
|
||||
}
|
||||
|
||||
for (const content of [Buffer.from('short'), Buffer.from('hello world!')]) {
|
||||
const client = new MemoryS3Client();
|
||||
await assert.rejects(
|
||||
store(client).put(COMMAND, chunks(content)),
|
||||
(error) => error instanceof S3ClusterRemoteWorkerArtifactStoreError,
|
||||
);
|
||||
assert.equal(permanentKey(client), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('temporary cleanup failure is diagnostic and never reverses promotion', async () => {
|
||||
const diagnostics = [];
|
||||
const client = new MemoryS3Client({ failDelete: true });
|
||||
const receipt = await store(client, {
|
||||
onDiagnostic(error, context) {
|
||||
diagnostics.push([error.message, context.operation]);
|
||||
},
|
||||
}).put(COMMAND, chunks());
|
||||
assert.equal(receipt.status, 'stored');
|
||||
assert.deepEqual(diagnostics, [[
|
||||
'delete unavailable',
|
||||
'temporary_object_cleanup',
|
||||
]]);
|
||||
assert.equal(client.objects.size, 2);
|
||||
});
|
||||
|
||||
test('requires exact bucket, prefix, encryption and temporary ID configuration', async () => {
|
||||
const client = new MemoryS3Client();
|
||||
assert.throws(
|
||||
() => new S3ClusterRemoteWorkerArtifactStore({
|
||||
client,
|
||||
bucket: 'Invalid_Bucket',
|
||||
encryption: { mode: 's3' },
|
||||
}),
|
||||
/bucket is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => new S3ClusterRemoteWorkerArtifactStore({
|
||||
client,
|
||||
bucket: 'valid-bucket',
|
||||
prefix: '../escape',
|
||||
encryption: { mode: 's3' },
|
||||
}),
|
||||
/prefix is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => new S3ClusterRemoteWorkerArtifactStore({
|
||||
client,
|
||||
bucket: 'valid-bucket',
|
||||
encryption: { mode: 'kms' },
|
||||
}),
|
||||
/encryption is invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
store(client, { createTemporaryId: () => '../invalid' }).put(
|
||||
COMMAND,
|
||||
chunks(),
|
||||
),
|
||||
(error) =>
|
||||
error instanceof S3ClusterRemoteWorkerArtifactStoreError &&
|
||||
error.reason === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('supports an empty immutable Artifact without synthesizing a chunk', async () => {
|
||||
const client = new MemoryS3Client();
|
||||
const command = {
|
||||
...COMMAND,
|
||||
logArtifactId: `wlog-${'b'.repeat(30)}`,
|
||||
byteLength: 0,
|
||||
truncated: undefined,
|
||||
};
|
||||
delete command.truncated;
|
||||
const receipt = await store(client).put(command, {
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
});
|
||||
assert.equal(receipt.byteLength, 0);
|
||||
assert.equal(receipt.sha256, createHash('sha256').digest('hex'));
|
||||
assert.equal(Object.hasOwn(receipt, 'truncated'), false);
|
||||
});
|
||||
|
||||
test('propagates KMS and expected-owner fences to both sides of promotion', async () => {
|
||||
const keyId = 'arn:aws:kms:us-east-1:123456789012:key/test';
|
||||
const client = new MemoryS3Client({
|
||||
expectedEncryption: 'aws:kms',
|
||||
expectedKmsKeyId: keyId,
|
||||
});
|
||||
await store(client, {
|
||||
expectedBucketOwner: '123456789012',
|
||||
encryption: { mode: 'kms', keyId },
|
||||
}).put(COMMAND, chunks());
|
||||
for (const command of client.commands) {
|
||||
assert.equal(command.input.ExpectedBucketOwner, '123456789012');
|
||||
}
|
||||
const copy = client.commands.find(
|
||||
(command) => command instanceof CopyObjectCommand,
|
||||
);
|
||||
assert.equal(copy.input.CopySourceExpectedBucketOwner, '123456789012');
|
||||
assert.equal(copy.input.ServerSideEncryption, 'aws:kms');
|
||||
assert.equal(copy.input.SSEKMSKeyId, keyId);
|
||||
});
|
||||
|
||||
test('never deletes a colliding temporary object it cannot prove it owns', async () => {
|
||||
const client = new MemoryS3Client();
|
||||
const temporaryKey =
|
||||
`tenant-a/worker-artifacts/temporary/${TEMPORARY_ID}`;
|
||||
client.objects.set(temporaryKey, {
|
||||
content: Buffer.from('other operation'),
|
||||
contentType: 'application/octet-stream',
|
||||
metadata: { 'ql3-schema': 'other' },
|
||||
});
|
||||
await assert.rejects(
|
||||
store(client).put(COMMAND, chunks()),
|
||||
(error) =>
|
||||
error instanceof S3ClusterRemoteWorkerArtifactStoreError &&
|
||||
error.reason === 'unavailable',
|
||||
);
|
||||
assert.equal(client.objects.has(temporaryKey), true);
|
||||
assert.equal(
|
||||
client.commands.some((command) => command instanceof DeleteObjectCommand),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a pre-aborted request performs no object-store operation', async () => {
|
||||
const client = new MemoryS3Client();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('stopping');
|
||||
controller.abort(reason);
|
||||
await assert.rejects(
|
||||
store(client).put(COMMAND, chunks(), controller.signal),
|
||||
(error) => error === reason,
|
||||
);
|
||||
assert.equal(client.commands.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
function inspectEntrypoint(specifier) {
|
||||
const packageDirectory = path.resolve(__dirname, '..');
|
||||
const script = `
|
||||
const exported = require(${JSON.stringify(specifier)});
|
||||
const loaded = Object.keys(require.cache).map((file) => file.replaceAll('\\\\', '/'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
hasStore: typeof exported.S3ClusterRemoteWorkerArtifactStore === 'function',
|
||||
loadedAwsSdk: loaded.some((file) => file.includes('/@aws-sdk/')),
|
||||
}));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
cwd: packageDirectory,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
test('default and production entrypoints do not load the S3 client', () => {
|
||||
for (const specifier of [
|
||||
'@qinglong/cluster-control',
|
||||
'@qinglong/cluster-control/production',
|
||||
]) {
|
||||
const report = inspectEntrypoint(specifier);
|
||||
assert.equal(report.hasStore, false, specifier);
|
||||
assert.equal(report.loadedAwsSdk, false, specifier);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 artifact store is reachable only through its explicit subpath', () => {
|
||||
const report = inspectEntrypoint(
|
||||
'@qinglong/cluster-control/s3-artifact-store',
|
||||
);
|
||||
assert.equal(report.hasStore, true);
|
||||
assert.equal(report.loadedAwsSdk, true);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
ClusterSchedulerCoordinator,
|
||||
ClusterSchedulerLifecycle,
|
||||
} = require('../dist/scheduling/scheduler');
|
||||
|
||||
function nextMinute(schedule, afterMs) {
|
||||
if (schedule.expression !== '* * * * *' || schedule.timezone !== 'UTC') {
|
||||
throw new Error('unsupported test schedule');
|
||||
}
|
||||
return Math.floor(afterMs / 60_000 + 1) * 60_000;
|
||||
}
|
||||
|
||||
function claim(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
triggerRevision: 1,
|
||||
triggerContentDigest: 'a'.repeat(64),
|
||||
triggerUpdatedAtMs: 1,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: 'b'.repeat(64),
|
||||
expression: '* * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
stateVersion: 1,
|
||||
nextFireAtMs: 60_000,
|
||||
claimOwner: 'scheduler-a',
|
||||
claimToken: '019f7800-0000-7000-8000-000000000001',
|
||||
claimVersion: 1,
|
||||
claimAcquiredAtMs: 61_000,
|
||||
claimExpiresAtMs: 91_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function idFactory() {
|
||||
let value = 0;
|
||||
return () => `019f7800-0000-7000-8000-${String(++value).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
test('claims a bounded cycle and notifies only fenced admissions', async () => {
|
||||
const claims = [
|
||||
claim(),
|
||||
claim({
|
||||
triggerId: 'trigger-2',
|
||||
claimToken: '019f7800-0000-7000-8000-000000000002',
|
||||
claimAcquiredAtMs: 62_000,
|
||||
claimExpiresAtMs: 92_000,
|
||||
}),
|
||||
];
|
||||
const claimCommands = [];
|
||||
const commits = [];
|
||||
const notified = [];
|
||||
const coordinator = new ClusterSchedulerCoordinator(
|
||||
{
|
||||
async claimNextClusterSchedule(command) {
|
||||
claimCommands.push(command);
|
||||
const next = claims.shift();
|
||||
return next
|
||||
? {
|
||||
...next,
|
||||
claimOwner: command.ownerId,
|
||||
claimToken: command.claimToken,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
async commitClusterScheduleDecision(command) {
|
||||
commits.push(command);
|
||||
if (commits.length === 2) return { status: 'raced' };
|
||||
return {
|
||||
status: 'admitted',
|
||||
disposition: 'admit',
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
ownerId: 'scheduler-a',
|
||||
claimLeaseMs: 30_000,
|
||||
maxClaimsPerCycle: 4,
|
||||
misfireGraceMs: 5_000,
|
||||
nextOccurrence: nextMinute,
|
||||
createId: idFactory(),
|
||||
onAdmitted: (runId, attemptId) => notified.push([runId, attemptId]),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await coordinator.scheduleOnce(), {
|
||||
firstClaimAcquiredAtMs: 61_000,
|
||||
lastClaimAcquiredAtMs: 62_000,
|
||||
claimed: 2,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 1,
|
||||
raced: 1,
|
||||
saturated: false,
|
||||
});
|
||||
assert.equal(claimCommands.length, 3);
|
||||
assert.equal(
|
||||
claimCommands.every(({ ownerId }) => ownerId === 'scheduler-a'),
|
||||
true,
|
||||
);
|
||||
assert.equal(commits.length, 2);
|
||||
assert.deepEqual(notified, [[commits[0].runId, commits[0].attemptId]]);
|
||||
});
|
||||
|
||||
test('does not allocate Run identities for a skipped occurrence', async () => {
|
||||
let claimed = false;
|
||||
const commands = [];
|
||||
let allocations = 0;
|
||||
const skipped = claim({
|
||||
nextFireAtMs: 60_000,
|
||||
claimAcquiredAtMs: 900_000,
|
||||
claimExpiresAtMs: 930_000,
|
||||
});
|
||||
const coordinator = new ClusterSchedulerCoordinator(
|
||||
{
|
||||
async claimNextClusterSchedule(command) {
|
||||
if (claimed) return null;
|
||||
claimed = true;
|
||||
return {
|
||||
...skipped,
|
||||
claimOwner: command.ownerId,
|
||||
claimToken: command.claimToken,
|
||||
};
|
||||
},
|
||||
async commitClusterScheduleDecision(command) {
|
||||
commands.push(command);
|
||||
return { status: 'advanced', disposition: 'skip' };
|
||||
},
|
||||
},
|
||||
{
|
||||
ownerId: 'scheduler-a',
|
||||
misfireGraceMs: 0,
|
||||
nextOccurrence: nextMinute,
|
||||
createId() {
|
||||
allocations += 1;
|
||||
return `019f7800-0000-7000-8000-${String(allocations).padStart(
|
||||
12,
|
||||
'0',
|
||||
)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
const summary = await coordinator.scheduleOnce();
|
||||
assert.equal(summary.skipped, 1);
|
||||
assert.equal(allocations, 2);
|
||||
assert.equal(commands[0].runId, undefined);
|
||||
assert.equal(commands[0].attemptId, undefined);
|
||||
});
|
||||
|
||||
test('marks a cycle saturated at its hard claim budget', async () => {
|
||||
let version = 0;
|
||||
const coordinator = new ClusterSchedulerCoordinator(
|
||||
{
|
||||
async claimNextClusterSchedule(command) {
|
||||
version += 1;
|
||||
return claim({
|
||||
stateVersion: version,
|
||||
claimVersion: version,
|
||||
claimOwner: command.ownerId,
|
||||
claimToken: command.claimToken,
|
||||
});
|
||||
},
|
||||
async commitClusterScheduleDecision(command) {
|
||||
return {
|
||||
status: 'admitted',
|
||||
disposition: 'admit',
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
ownerId: 'scheduler-a',
|
||||
maxClaimsPerCycle: 2,
|
||||
nextOccurrence: nextMinute,
|
||||
createId: idFactory(),
|
||||
},
|
||||
);
|
||||
const summary = await coordinator.scheduleOnce();
|
||||
assert.equal(summary.claimed, 2);
|
||||
assert.equal(summary.saturated, true);
|
||||
});
|
||||
|
||||
test('rejects a node-local clock as scheduler authority', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
new ClusterSchedulerCoordinator(
|
||||
{
|
||||
async claimNextClusterSchedule() {
|
||||
return null;
|
||||
},
|
||||
async commitClusterScheduleDecision() {
|
||||
return { status: 'raced' };
|
||||
},
|
||||
},
|
||||
{
|
||||
ownerId: 'scheduler-a',
|
||||
clock: () => 1,
|
||||
},
|
||||
),
|
||||
/options are invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('lifecycle coalesces work and drains without overlapping cycles', async () => {
|
||||
let calls = 0;
|
||||
let release;
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const summary = {
|
||||
firstClaimAcquiredAtMs: null,
|
||||
lastClaimAcquiredAtMs: null,
|
||||
claimed: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
saturated: false,
|
||||
};
|
||||
const lifecycle = new ClusterSchedulerLifecycle(
|
||||
{
|
||||
async scheduleOnce() {
|
||||
calls += 1;
|
||||
await pending;
|
||||
return summary;
|
||||
},
|
||||
},
|
||||
{ intervalMs: 250, stopTimeoutMs: 1_000 },
|
||||
);
|
||||
assert.equal(lifecycle.start(), 'started');
|
||||
const first = lifecycle.runOnce();
|
||||
assert.equal(lifecycle.runOnce(), first);
|
||||
const stopping = lifecycle.stopAndDrain();
|
||||
release();
|
||||
assert.deepEqual(await first, summary);
|
||||
assert.deepEqual(await stopping, { status: 'stopped' });
|
||||
assert.equal(calls, 1);
|
||||
await assert.rejects(lifecycle.runOnce(), /stopping/);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CLUSTER_CONTROL_TASK_LIST_ROUTE,
|
||||
createClusterControlTaskListRoute,
|
||||
} = require('@qinglong/cluster-control/task-routes');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
|
||||
function task(taskId, overrides = {}) {
|
||||
return {
|
||||
projectId: 'prj_default',
|
||||
taskId,
|
||||
revision: 2,
|
||||
name: `Task ${taskId}`,
|
||||
description: 'secret-adjacent',
|
||||
kind: 'command',
|
||||
spec: { schema: 'qinglong/command@v1', config: { command: ['private'] } },
|
||||
labels: { private: 'value' },
|
||||
enabled: true,
|
||||
mutationId: 'mutation-private',
|
||||
contentDigest: 'digest-private',
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authorized(query = {}, body = null) {
|
||||
return {
|
||||
projectId: 'prj_default',
|
||||
request: { query, body },
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one reviewed bounded Task list route', () => {
|
||||
assert.deepEqual(CLUSTER_CONTROL_TASK_LIST_ROUTE, {
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/{projectId}/tasks',
|
||||
operationId: 'task.list',
|
||||
permission: 'task.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: ['after_task_id', 'limit'],
|
||||
});
|
||||
assert.throws(() => createClusterControlTaskListRoute({}), TypeError);
|
||||
});
|
||||
|
||||
test('parses an exact keyset page and returns the shared projection', async () => {
|
||||
const calls = [];
|
||||
const route = createClusterControlTaskListRoute({
|
||||
async listTaskDefinitions(query) {
|
||||
calls.push(query);
|
||||
return {
|
||||
definitions: [task('task-b', { enabled: false })],
|
||||
truncated: true,
|
||||
next: { taskId: 'task-b' },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await route.handle(
|
||||
authorized({ limit: ['8'], after_task_id: ['task-a'] }),
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
projectId: 'prj_default',
|
||||
limit: 8,
|
||||
after: { taskId: 'task-a' },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(result, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-b',
|
||||
revision: 2,
|
||||
name: 'Task task-b',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: false,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { taskId: 'task-b' },
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('secret-adjacent'), false);
|
||||
assert.equal(JSON.stringify(result).includes('private'), false);
|
||||
});
|
||||
|
||||
test('rejects malformed query and body and masks repository failures', async () => {
|
||||
const route = createClusterControlTaskListRoute({
|
||||
async listTaskDefinitions() { throw new Error('offline'); },
|
||||
});
|
||||
for (const query of [
|
||||
{ limit: ['08'] },
|
||||
{ limit: ['65'] },
|
||||
{ limit: ['8', '9'] },
|
||||
{ after_task_id: ['-bad'] },
|
||||
]) {
|
||||
assert.deepEqual(await route.handle(authorized(query), {}), {
|
||||
statusCode: 400,
|
||||
body: { code: 'invalid_task_list_query' },
|
||||
});
|
||||
}
|
||||
assert.deepEqual(await route.handle(authorized({}, { value: true }), {}), {
|
||||
statusCode: 400,
|
||||
body: { code: 'invalid_request_body' },
|
||||
});
|
||||
assert.deepEqual(await route.handle(authorized({}), {}), {
|
||||
statusCode: 503,
|
||||
body: { code: 'task_list_unavailable' },
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects non-canonical pagination before authentication', async () => {
|
||||
let authentications = 0;
|
||||
const pipeline = createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([
|
||||
createClusterControlTaskListRoute({
|
||||
async listTaskDefinitions() {
|
||||
return { definitions: [], truncated: false };
|
||||
},
|
||||
}),
|
||||
]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
authentications += 1;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
policy: { authorize() { throw new Error('must not authorize'); } },
|
||||
audit: { record() { throw new Error('must not audit'); } },
|
||||
now: () => 10_000,
|
||||
});
|
||||
await assert.rejects(
|
||||
pipeline.prepare({
|
||||
requestId: 'request-invalid-task-list',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/prj_default/tasks',
|
||||
query: { limit: ['08'] },
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
(error) => error.statusCode === 400 && error.code === 'invalid_route_query',
|
||||
);
|
||||
assert.equal(authentications, 0);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CLUSTER_CONTROL_TASK_READ_ROUTE,
|
||||
createClusterControlTaskReadRoute,
|
||||
} = require('@qinglong/cluster-control/task-routes');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
|
||||
function task(overrides = {}) {
|
||||
return createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task-a',
|
||||
expectedRevision: null,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174201',
|
||||
name: 'Task A',
|
||||
description: 'secret-adjacent',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: { command: { kind: 'shell', command: 'private' } },
|
||||
},
|
||||
labels: { private: 'value' },
|
||||
enabled: true,
|
||||
occurredAtMs: 20,
|
||||
...overrides,
|
||||
},
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
function authorized(body = null, query = {}) {
|
||||
return {
|
||||
projectId: 'prj_default',
|
||||
request: { body, query },
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one reviewed current Task route', () => {
|
||||
assert.deepEqual(CLUSTER_CONTROL_TASK_READ_ROUTE, {
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/{projectId}/tasks/{taskId}',
|
||||
operationId: 'task.get',
|
||||
permission: 'task.read',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
assert.throws(() => createClusterControlTaskReadRoute({}), TypeError);
|
||||
});
|
||||
|
||||
test('returns the shared projection and masks absent or cross-Project Tasks', async () => {
|
||||
const definition = task({ enabled: false });
|
||||
const calls = [];
|
||||
const route = createClusterControlTaskReadRoute({
|
||||
async findCurrentTaskDefinition(projectId, taskId) {
|
||||
calls.push([projectId, taskId]);
|
||||
return taskId === 'task-a' ? definition : null;
|
||||
},
|
||||
});
|
||||
const result = await route.handle(authorized(), { taskId: 'task-a' });
|
||||
assert.deepEqual(calls, [['prj_default', 'task-a']]);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.deepEqual(result.body.task, {
|
||||
taskId: 'task-a',
|
||||
revision: 1,
|
||||
name: 'Task A',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: false,
|
||||
contentDigest: definition.contentDigest,
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('private'), false);
|
||||
assert.deepEqual(
|
||||
await route.handle(authorized(), { taskId: 'task-absent' }),
|
||||
{ statusCode: 404, body: { code: 'task_not_found' } },
|
||||
);
|
||||
const crossProject = createClusterControlTaskReadRoute({
|
||||
async findCurrentTaskDefinition() { return task({ projectId: 'other' }); },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await crossProject.handle(authorized(), { taskId: 'task-a' }),
|
||||
{ statusCode: 404, body: { code: 'task_not_found' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects body and fails closed on corrupt or unavailable storage', async () => {
|
||||
const definition = task();
|
||||
const corrupt = createClusterControlTaskReadRoute({
|
||||
async findCurrentTaskDefinition() {
|
||||
return { ...definition, contentDigest: '0'.repeat(64) };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await corrupt.handle(authorized(), { taskId: 'task-a' }),
|
||||
{ statusCode: 503, body: { code: 'task_query_unavailable' } },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await corrupt.handle(authorized({ invalid: true }), { taskId: 'task-a' }),
|
||||
{ statusCode: 400, body: { code: 'invalid_request_body' } },
|
||||
);
|
||||
const unavailable = createClusterControlTaskReadRoute({
|
||||
async findCurrentTaskDefinition() { throw new Error('offline'); },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await unavailable.handle(authorized(), { taskId: 'task-a' }),
|
||||
{ statusCode: 503, body: { code: 'task_query_unavailable' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects query before authentication', async () => {
|
||||
let authentications = 0;
|
||||
const pipeline = createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([
|
||||
createClusterControlTaskReadRoute({
|
||||
async findCurrentTaskDefinition() { return null; },
|
||||
}),
|
||||
]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
authentications += 1;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
policy: { authorize() { throw new Error('must not authorize'); } },
|
||||
audit: { record() { throw new Error('must not audit'); } },
|
||||
now: () => 10_000,
|
||||
});
|
||||
await assert.rejects(
|
||||
pipeline.prepare({
|
||||
requestId: 'request-invalid-task-get',
|
||||
method: 'GET',
|
||||
path: '/api/v3/projects/prj_default/tasks/task-a',
|
||||
query: { expanded: ['true'] },
|
||||
headers: {},
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
(error) => error.statusCode === 400 && error.code === 'invalid_route_query',
|
||||
);
|
||||
assert.equal(authentications, 0);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
TASK_START_SCHEMA,
|
||||
TaskStartFenceRejectedError,
|
||||
TaskStartNotFoundError,
|
||||
TaskStartUnavailableError,
|
||||
} = require('@qinglong/runtime-core/task-start');
|
||||
const {
|
||||
createClusterControlAdmissionPipeline,
|
||||
} = require('@qinglong/cluster-control/admission');
|
||||
const {
|
||||
createClusterControlRouteRegistry,
|
||||
} = require('@qinglong/cluster-control/routes');
|
||||
const {
|
||||
CLUSTER_CONTROL_TASK_START_ROUTE,
|
||||
createClusterControlTaskStartRoute,
|
||||
} = require('@qinglong/cluster-control/task-routes');
|
||||
|
||||
const IDS = [
|
||||
'019f7300-0000-7000-8000-000000000601',
|
||||
'019f7300-0000-7000-8000-000000000602',
|
||||
'019f7300-0000-7000-8000-000000000603',
|
||||
'019f7300-0000-7000-8000-000000000604',
|
||||
];
|
||||
const MUTATION_ID = '019f7300-0000-7000-8000-000000000600';
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'user-1' }),
|
||||
authenticationId: 'session:user-1',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
const METADATA = Object.freeze({
|
||||
requestId: 'request-task-start',
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/project-1/tasks/task-1/runs',
|
||||
query: Object.freeze({}),
|
||||
headers: Object.freeze({ authorization: 'Bearer opaque' }),
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
function receipt(overrides = {}) {
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 3,
|
||||
taskContentDigest: DIGEST,
|
||||
runId: IDS[0],
|
||||
attemptId: IDS[1],
|
||||
runStatus: 'queued',
|
||||
runVersion: 2,
|
||||
eventSequence: 2,
|
||||
executorType: 'remote_worker',
|
||||
executionRevisionDigest: 'b'.repeat(64),
|
||||
createdAtMs: 10_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pipeline(repository, events = [], fence = {
|
||||
projectVersion: 2,
|
||||
bindingVersion: 3,
|
||||
}) {
|
||||
let index = 0;
|
||||
return createClusterControlAdmissionPipeline({
|
||||
routes: createClusterControlRouteRegistry([
|
||||
createClusterControlTaskStartRoute(repository, () => IDS[index++]),
|
||||
]),
|
||||
authenticator: {
|
||||
authenticate() {
|
||||
events.push('authenticate');
|
||||
return PRINCIPAL;
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
authorize(request) {
|
||||
events.push(`authorize:${request.permission}:${request.projectId}`);
|
||||
return { effect: 'allow', reasons: ['role_grant'], fence };
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
record(record) {
|
||||
events.push(`audit:${record.outcome}:${record.operationId}`);
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
function body(overrides = {}) {
|
||||
return {
|
||||
schema: TASK_START_SCHEMA,
|
||||
mutationId: MUTATION_ID,
|
||||
expectedRevision: 3,
|
||||
expectedContentDigest: DIGEST,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one reviewed run.start Task route', () => {
|
||||
assert.deepEqual(CLUSTER_CONTROL_TASK_START_ROUTE, {
|
||||
method: 'POST',
|
||||
path: '/api/v3/projects/{projectId}/tasks/{taskId}/runs',
|
||||
operationId: 'task.start',
|
||||
permission: 'run.start',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
});
|
||||
|
||||
test('authenticates, authorizes and audits before starting the exact Task', async () => {
|
||||
const events = [];
|
||||
let observed;
|
||||
const prepared = await pipeline({
|
||||
async startTask(command) {
|
||||
events.push('repository');
|
||||
observed = command;
|
||||
return receipt();
|
||||
},
|
||||
}, events).prepare(METADATA);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.start:project-1',
|
||||
'audit:allowed:task.start',
|
||||
]);
|
||||
assert.deepEqual(await prepared.handle(body()), {
|
||||
statusCode: 202,
|
||||
body: { schema: TASK_START_SCHEMA, ...receipt() },
|
||||
});
|
||||
assert.deepEqual(observed, {
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
mutationId: MUTATION_ID,
|
||||
expectedRevision: 3,
|
||||
expectedContentDigest: DIGEST,
|
||||
runId: IDS[0],
|
||||
attemptId: IDS[1],
|
||||
createdEventId: IDS[2],
|
||||
queuedEventId: IDS[3],
|
||||
subject: PRINCIPAL.subject,
|
||||
policyFence: { projectVersion: 2, bindingVersion: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects command injection and maps replay plus stable failures', async () => {
|
||||
let calls = 0;
|
||||
const invalid = await pipeline({
|
||||
async startTask() { calls += 1; return receipt(); },
|
||||
}).prepare(METADATA);
|
||||
assert.equal((await invalid.handle(body({ command: '/bin/sh' }))).statusCode, 400);
|
||||
assert.equal(calls, 0);
|
||||
|
||||
const replay = await pipeline({
|
||||
async startTask() { return receipt({ status: 'existing' }); },
|
||||
}).prepare(METADATA);
|
||||
assert.equal((await replay.handle(body())).statusCode, 200);
|
||||
|
||||
for (const [error, statusCode, code] of [
|
||||
[new TaskStartNotFoundError(), 404, 'task_not_found'],
|
||||
[new TaskStartFenceRejectedError('definition_changed'), 409, 'task_start_fence_rejected'],
|
||||
[new TaskStartUnavailableError(), 503, 'task_start_unavailable'],
|
||||
]) {
|
||||
const prepared = await pipeline({
|
||||
async startTask() { throw error; },
|
||||
}).prepare(METADATA);
|
||||
const result = await prepared.handle(body());
|
||||
assert.equal(result.statusCode, statusCode);
|
||||
assert.equal(result.body.code, code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerCredentialUnavailableError,
|
||||
} = require('@qinglong/runtime-core/worker-credential');
|
||||
const {
|
||||
workerCredentialSecretDigest,
|
||||
} = require('@qinglong/runtime-core/worker-credential-token');
|
||||
const {
|
||||
createWorkerCredentialAuthenticator,
|
||||
} = require('@qinglong/cluster-control/worker-ingress');
|
||||
|
||||
const NOW = 10_000;
|
||||
const PEPPER = Buffer.alloc(32, 1).toString('base64url');
|
||||
const SECRET = Buffer.alloc(32, 2).toString('base64url');
|
||||
const CREDENTIAL_ID = 'worker_primary';
|
||||
|
||||
function metadata(authorization = `Worker ql3w_${CREDENTIAL_ID}_${SECRET}`) {
|
||||
return {
|
||||
requestId: 'request-1',
|
||||
method: 'POST',
|
||||
path: '/api/v3/worker-ingress/workers/edge-1/sessions/018f5c64-9b9d-7f1a-8c2d-1234567890ac/heartbeat',
|
||||
query: Object.freeze({}),
|
||||
headers: Object.freeze({ authorization }),
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function credential(overrides = {}) {
|
||||
return {
|
||||
credentialId: CREDENTIAL_ID,
|
||||
version: 2,
|
||||
state: 'active',
|
||||
workerId: 'edge-1',
|
||||
secretDigest: workerCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
||||
createdAtMs: 1,
|
||||
notBeforeAtMs: 1,
|
||||
expiresAtMs: 100_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authenticator(value = credential(), overrides = {}) {
|
||||
return createWorkerCredentialAuthenticator(
|
||||
{ async resolve(id) { assert.equal(id, CREDENTIAL_ID); return value; } },
|
||||
PEPPER,
|
||||
{ now: () => NOW, ...overrides },
|
||||
);
|
||||
}
|
||||
|
||||
test('authenticates a ql3w credential as its bound short-lived Worker', async () => {
|
||||
assert.deepEqual(await authenticator().authenticate(metadata()), {
|
||||
workerId: 'edge-1',
|
||||
credentialId: CREDENTIAL_ID,
|
||||
credentialVersion: 2,
|
||||
authenticationId: `worker_credential:${CREDENTIAL_ID}:2`,
|
||||
authenticatedAtMs: NOW,
|
||||
expiresAtMs: 70_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malformed, mismatched, inactive and expired Worker credentials', async () => {
|
||||
let calls = 0;
|
||||
const strict = createWorkerCredentialAuthenticator(
|
||||
{ async resolve() { calls += 1; return credential(); } },
|
||||
PEPPER,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
for (const authorization of [
|
||||
undefined,
|
||||
'Bearer ql3w_worker_primary_invalid',
|
||||
`Worker ql3w_${CREDENTIAL_ID}_${Buffer.alloc(32, 3).toString('base64url')}`,
|
||||
]) {
|
||||
const request = metadata();
|
||||
request.headers = Object.freeze(authorization ? { authorization } : {});
|
||||
assert.equal(await strict.authenticate(request), null);
|
||||
}
|
||||
assert.equal(calls, 1);
|
||||
for (const value of [
|
||||
null,
|
||||
credential({ state: 'revoked' }),
|
||||
credential({ notBeforeAtMs: NOW + 1 }),
|
||||
credential({ expiresAtMs: NOW }),
|
||||
]) assert.equal(await authenticator(value).authenticate(metadata()), null);
|
||||
});
|
||||
|
||||
test('maps storage, corrupt records and cancellation to unavailable', async () => {
|
||||
const unavailable = createWorkerCredentialAuthenticator(
|
||||
{ async resolve() { throw new Error('database unavailable'); } },
|
||||
PEPPER,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(unavailable.authenticate(metadata()), WorkerCredentialUnavailableError);
|
||||
await assert.rejects(
|
||||
authenticator(credential({ secretDigest: 'corrupt' })).authenticate(metadata()),
|
||||
WorkerCredentialUnavailableError,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
authenticator().authenticate({ ...metadata(), signal: controller.signal }),
|
||||
WorkerCredentialUnavailableError,
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user