mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# `@qinglong/worker-runtime`
|
||||
|
||||
This private workspace package incubates the QingLong 3.0 headless `worker`
|
||||
Profile without importing the legacy root application or cluster-control
|
||||
packages.
|
||||
|
||||
Its first responsibility is Worker transport identity:
|
||||
|
||||
- generate a local P-256 private key and PKCS#10 CSR without contacting a CA;
|
||||
- validate an externally issued TLS client certificate against the pending key,
|
||||
expected trust anchors, validity window and client-auth EKU;
|
||||
- atomically install bounded `0600` private-key and certificate files;
|
||||
- coordinate explicit renewal attempts with persisted, bounded exponential
|
||||
backoff and a fail-closed expiry state;
|
||||
- install no watcher, signal handler, timer, database connection or network
|
||||
client by itself.
|
||||
|
||||
External CA adapters and the final `ql-worker` composition root own transport,
|
||||
credential enrollment and scheduling. Certificate CN/SAN remains descriptive
|
||||
metadata and never replaces the independent `ql3w` Worker credential, Session
|
||||
generation or Run Lease fence.
|
||||
|
||||
The explicit `./remote-offer-delivery` subpath now owns the default-off Remote
|
||||
Worker delivery boundary:
|
||||
|
||||
- a versioned, capability-free offer response validator;
|
||||
- one durable pending claim with bounded full-jitter retry across restarts;
|
||||
- a private single-owner atomic file inbox that commits before claim cleanup;
|
||||
- a bounded TLS 1.3 mTLS HTTPS client carrying the independent `ql3w`
|
||||
credential;
|
||||
- one revision-fenced execution inbox authority and an injected-port Processor
|
||||
that persists starting/spawn/started/running barriers without a second
|
||||
journal.
|
||||
|
||||
The Processor can call an explicitly injected Executor and activation client,
|
||||
but this package does not construct either capability, create a polling timer,
|
||||
or import cluster-control/PostgreSQL authority. Ambiguous spawn outcomes enter
|
||||
recovery and are never reported as a definite start failure. The package root
|
||||
intentionally does not export this subpath so certificate-only steady state
|
||||
remains light.
|
||||
|
||||
The `./lease-control` and `./execution-control` subpaths extend the same
|
||||
default-off boundary without adding a package or background timer:
|
||||
|
||||
- one exact, path-bound lease-control exchange over the shared mTLS Agent;
|
||||
- a stable credential fingerprint pool key, so erased request-local TLS
|
||||
buffers cannot strand a later request in the Agent queue;
|
||||
- receipt-first renewal and cancellation/timeout projection using the full
|
||||
Session/Run/Attempt/Offer/Lease fence;
|
||||
- inbox CAS of the next Lease version before exact durable-handle stop;
|
||||
- distinct durable evidence for conclusive and unverified local lease loss;
|
||||
- caller-driven bounded supervision before each headless Pull;
|
||||
- POSIX timeout launch only after starting ACK returns a database-owned durable
|
||||
deadline.
|
||||
|
||||
The explicit `./production` subpath now supplies the concrete execution-plane
|
||||
composition while remaining default-off:
|
||||
|
||||
- one journal owner, one shared mTLS client/Agent and the reviewed
|
||||
Offer/Activation/Secret/Artifact/Completion/Lease adapters;
|
||||
- one Secret-before-Artifact materializer, file-log allocator, reviewed POSIX
|
||||
Executor, receipt store and durable process controller;
|
||||
- bounded startup reconciliation before the first Pull and one Profile-owned,
|
||||
non-overlapping `unref` cadence;
|
||||
- two-stage shutdown that aborts Pull but retains the journal owner until the
|
||||
outer Session is durably draining and all local records are settled.
|
||||
|
||||
The exact `./session-transport` and timer-free `./session-lifecycle` subpaths
|
||||
provide path-bound register/heartbeat/transition v1 contracts over that same
|
||||
client. The default-off `./product` composition root now joins them to the
|
||||
execution plane:
|
||||
|
||||
- startup owns and scans the journal before registering a Session;
|
||||
- advertised slots come only from the same journal, durable pending Pull claim
|
||||
and bounded concurrency budget;
|
||||
- one coalesced Profile cadence drives heartbeat and execution supervision;
|
||||
- shutdown proves execution drain, durable zero-capacity Session state,
|
||||
settled records and offline transition before releasing owner and Agent.
|
||||
|
||||
The outer deployment still owns certificate and `ql3w` credential
|
||||
enrollment/recovery, config, retention and process shutdown policy. Disabled
|
||||
mode reads none of those authorities and creates no timer, socket, process or
|
||||
database connection. Edge defaults keep pages, logs and the single cadence
|
||||
narrow; Node defaults raise bounded capacity, and larger nodes scale by Worker
|
||||
instance rather than by per-Run timers.
|
||||
|
||||
Deployments that already possess an atomically published `ql3w` token can use
|
||||
the explicit `./production-credentials` subpath. It reloads the certificate
|
||||
store active generation, trust anchors and one private token file for every
|
||||
request, revalidates their authority, and lets the shared client erase returned
|
||||
PEM buffers after copying. This supports certificate/token rotation without a
|
||||
watcher, cache, second Agent or timer. It is intentionally not an issuer or
|
||||
credential-recovery client; remote issue/rotate/revoke and secret-delivery
|
||||
acknowledgement remain deployment gates.
|
||||
|
||||
The shared transport exposes only a non-success HTTP status class. Session
|
||||
401/403 responses suspend Pull immediately, 409 fences the Session, and
|
||||
transient failures preserve the last observed lease. A rotated token may
|
||||
recover heartbeat on that same Session; the runtime never creates a replacement
|
||||
Session automatically.
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"name": "@qinglong/worker-runtime",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 headless Worker profile runtime",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./certificate-enrollment": {
|
||||
"types": "./dist/credential/workerCertificateEnrollment.d.ts",
|
||||
"require": "./dist/credential/workerCertificateEnrollment.js",
|
||||
"default": "./dist/credential/workerCertificateEnrollment.js"
|
||||
},
|
||||
"./certificate-store": {
|
||||
"types": "./dist/credential/workerCertificateStore.d.ts",
|
||||
"require": "./dist/credential/workerCertificateStore.js",
|
||||
"default": "./dist/credential/workerCertificateStore.js"
|
||||
},
|
||||
"./renewal": {
|
||||
"types": "./dist/credential/workerCertificateRenewal.d.ts",
|
||||
"require": "./dist/credential/workerCertificateRenewal.js",
|
||||
"default": "./dist/credential/workerCertificateRenewal.js"
|
||||
},
|
||||
"./remote-offer-delivery": {
|
||||
"types": "./dist/remote-execution/remoteOfferDeliveryEntrypoint.d.ts",
|
||||
"require": "./dist/remote-execution/remoteOfferDeliveryEntrypoint.js",
|
||||
"default": "./dist/remote-execution/remoteOfferDeliveryEntrypoint.js"
|
||||
},
|
||||
"./remote-log-artifact": {
|
||||
"types": "./dist/execution/workerFileLogArtifactAllocator.d.ts",
|
||||
"require": "./dist/execution/workerFileLogArtifactAllocator.js",
|
||||
"default": "./dist/execution/workerFileLogArtifactAllocator.js"
|
||||
},
|
||||
"./posix-executor": {
|
||||
"types": "./dist/execution/workerPosixExecutionExecutor.d.ts",
|
||||
"require": "./dist/execution/workerPosixExecutionExecutor.js",
|
||||
"default": "./dist/execution/workerPosixExecutionExecutor.js"
|
||||
},
|
||||
"./completion-coordinator": {
|
||||
"types": "./dist/execution/workerCompletionCoordinator.d.ts",
|
||||
"require": "./dist/execution/workerCompletionCoordinator.js",
|
||||
"default": "./dist/execution/workerCompletionCoordinator.js"
|
||||
},
|
||||
"./completion-transport": {
|
||||
"types": "./dist/remote-execution/transport/remoteWorkerCompletionHttpsClient.d.ts",
|
||||
"require": "./dist/remote-execution/transport/remoteWorkerCompletionHttpsClient.js",
|
||||
"default": "./dist/remote-execution/transport/remoteWorkerCompletionHttpsClient.js"
|
||||
},
|
||||
"./lease-control": {
|
||||
"types": "./dist/remote-execution/transport/remoteWorkerLeaseControlHttpsClient.d.ts",
|
||||
"require": "./dist/remote-execution/transport/remoteWorkerLeaseControlHttpsClient.js",
|
||||
"default": "./dist/remote-execution/transport/remoteWorkerLeaseControlHttpsClient.js"
|
||||
},
|
||||
"./execution-control": {
|
||||
"types": "./dist/execution/workerExecutionControlCoordinator.d.ts",
|
||||
"require": "./dist/execution/workerExecutionControlCoordinator.js",
|
||||
"default": "./dist/execution/workerExecutionControlCoordinator.js"
|
||||
},
|
||||
"./session-transport": {
|
||||
"types": "./dist/session/workerSessionHttpsClient.d.ts",
|
||||
"require": "./dist/session/workerSessionHttpsClient.js",
|
||||
"default": "./dist/session/workerSessionHttpsClient.js"
|
||||
},
|
||||
"./session-lifecycle": {
|
||||
"types": "./dist/session/workerSessionCoordinator.d.ts",
|
||||
"require": "./dist/session/workerSessionCoordinator.js",
|
||||
"default": "./dist/session/workerSessionCoordinator.js"
|
||||
},
|
||||
"./production": {
|
||||
"types": "./dist/application-runtime/productionHeadlessApplication.d.ts",
|
||||
"require": "./dist/application-runtime/productionHeadlessApplication.js",
|
||||
"default": "./dist/application-runtime/productionHeadlessApplication.js"
|
||||
},
|
||||
"./product": {
|
||||
"types": "./dist/application-runtime/productionWorkerApplication.d.ts",
|
||||
"require": "./dist/application-runtime/productionWorkerApplication.js",
|
||||
"default": "./dist/application-runtime/productionWorkerApplication.js"
|
||||
},
|
||||
"./production-credentials": {
|
||||
"types": "./dist/credential/workerProductionCredentialProvider.d.ts",
|
||||
"require": "./dist/credential/workerProductionCredentialProvider.js",
|
||||
"default": "./dist/credential/workerProductionCredentialProvider.js"
|
||||
},
|
||||
"./process-config": {
|
||||
"types": "./dist/process/workerProcessConfig.d.ts",
|
||||
"require": "./dist/process/workerProcessConfig.js",
|
||||
"default": "./dist/process/workerProcessConfig.js"
|
||||
},
|
||||
"./process-identity": {
|
||||
"types": "./dist/process/workerProcessIdentity.d.ts",
|
||||
"require": "./dist/process/workerProcessIdentity.js",
|
||||
"default": "./dist/process/workerProcessIdentity.js"
|
||||
},
|
||||
"./process": {
|
||||
"types": "./dist/process/workerProcessApplication.d.ts",
|
||||
"require": "./dist/process/workerProcessApplication.js",
|
||||
"default": "./dist/process/workerProcessApplication.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"bin": {
|
||||
"ql3-worker": "dist/process/workerProcessCli.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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/local-process": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*",
|
||||
"@peculiar/x509": "2.0.0",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"reflect-metadata": "0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
CompletionReceiptFileStore,
|
||||
LocalProcessController,
|
||||
type LocalProcessControllerOptions,
|
||||
} from '@qinglong/local-process';
|
||||
import { BoundedWorkerRemoteExecutionContextMaterializer } from '../remote-execution/executionContextMaterializer';
|
||||
import {
|
||||
WorkerRemoteExecutionInboxProcessor,
|
||||
type WorkerRemoteExecutionSession,
|
||||
} from '../remote-execution/executionInboxProcessor';
|
||||
import {
|
||||
WorkerRemoteExecutionHeadlessLifecycle,
|
||||
type WorkerRemoteExecutionLifecycleTickResult,
|
||||
} from '../remote-execution/headlessExecutionLifecycle';
|
||||
import { WorkerRemoteExecutionHttpsActivationClient } from '../remote-execution/transport/remoteActivationHttpsClient';
|
||||
import {
|
||||
DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
WorkerRemoteOfferPullCoordinator,
|
||||
} from '../remote-execution/remoteOfferDelivery';
|
||||
import { WorkerRemoteOfferFileJournal } from '../remote-execution/remoteOfferFileJournal';
|
||||
import { WorkerRemoteOfferHttpsTransport } from '../remote-execution/transport/remoteOfferHttpsTransport';
|
||||
import { WorkerRemoteSecretHttpsProvider } from '../remote-execution/transport/remoteSecretHttpsProvider';
|
||||
import {
|
||||
WorkerFileLogArtifactAllocator,
|
||||
workerRemoteLogArtifactPolicy,
|
||||
type WorkerRemoteLogArtifactProfile,
|
||||
} from '../execution/workerFileLogArtifactAllocator';
|
||||
import { WorkerIngressHttpsClient } from '../remote-execution/transport/workerIngressHttpsClient';
|
||||
import {
|
||||
WorkerRemoteArtifactHttpsUploader,
|
||||
WorkerRemoteExecutionHttpsCompletionClient,
|
||||
} from '../remote-execution/transport/remoteWorkerCompletionHttpsClient';
|
||||
import { WorkerRemoteLeaseControlHttpsClient } from '../remote-execution/transport/remoteWorkerLeaseControlHttpsClient';
|
||||
import { WorkerRemoteCompletionCoordinator } from '../execution/workerCompletionCoordinator';
|
||||
import { WorkerRemoteExecutionControlCoordinator } from '../execution/workerExecutionControlCoordinator';
|
||||
import {
|
||||
WorkerInboxExecutionSpawnBarrier,
|
||||
WorkerPosixExecutionExecutor,
|
||||
type WorkerPosixExecutionExecutorOptions,
|
||||
} from '../execution/workerPosixExecutionExecutor';
|
||||
import type { WorkerIngressHttpsCredentialProvider } from '../remote-execution/transport/workerIngressHttpsClient';
|
||||
import type { WorkerCertificateRenewalResult } from '../credential/workerCertificateRenewal';
|
||||
|
||||
const MIN_CADENCE_MS = 100;
|
||||
const MAX_CADENCE_MS = 60_000;
|
||||
const MIN_DRAIN_TIMEOUT_MS = 1_000;
|
||||
const MAX_DRAIN_TIMEOUT_MS = 10 * 60_000;
|
||||
const MIN_DRAIN_POLL_MS = 25;
|
||||
const MAX_DRAIN_POLL_MS = 5_000;
|
||||
const SETTLED_STATES = new Set([
|
||||
'start_failure_acknowledged',
|
||||
'completion_acknowledged',
|
||||
]);
|
||||
|
||||
export interface ProductionWorkerSessionLifecycle {
|
||||
current(): WorkerRemoteExecutionSession | undefined;
|
||||
/** Optional product hook. Called only after bounded startup reconciliation. */
|
||||
register?(): Promise<unknown>;
|
||||
/** Optional caller-driven heartbeat step. Must not own a timer. */
|
||||
tick?(): Promise<unknown>;
|
||||
/** Immediately prevents new work when transport identity is unavailable. */
|
||||
failClosed?(): void;
|
||||
/** Resolves only after new work is durably disabled for the current Session. */
|
||||
beginDrain(): Promise<void>;
|
||||
/** Optional product hook. Called only after all execution records settle. */
|
||||
disconnect?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProductionWorkerCertificateRenewalLifecycle {
|
||||
/** One bounded, caller-driven certificate maintenance step. */
|
||||
run(): Promise<WorkerCertificateRenewalResult>;
|
||||
}
|
||||
|
||||
export interface ProductionWorkerStorageOptions {
|
||||
readonly journalRoot: string;
|
||||
readonly logRoot: string;
|
||||
readonly receiptRoot: string;
|
||||
}
|
||||
|
||||
export interface ProductionWorkerHeadlessApplicationDisabledOptions {
|
||||
readonly enabled?: false;
|
||||
}
|
||||
|
||||
export interface ProductionWorkerHeadlessApplicationEnabledOptions {
|
||||
readonly enabled: true;
|
||||
readonly profile: string;
|
||||
readonly capacityProfile: WorkerRemoteLogArtifactProfile;
|
||||
readonly origin: string | URL;
|
||||
readonly credentials: WorkerIngressHttpsCredentialProvider;
|
||||
/** Shared product client. When supplied, this execution stack never closes it. */
|
||||
readonly client?: WorkerIngressHttpsClient;
|
||||
readonly session: ProductionWorkerSessionLifecycle;
|
||||
/** Reuses this application's cadence; it must not own a timer or watcher. */
|
||||
readonly certificateRenewal?: ProductionWorkerCertificateRenewalLifecycle;
|
||||
readonly storage: ProductionWorkerStorageOptions;
|
||||
readonly cadenceMs?: number;
|
||||
readonly drainTimeoutMs?: number;
|
||||
readonly drainPollMs?: number;
|
||||
readonly maximumJournalEntries?: number;
|
||||
readonly maximumRecordsPerTick?: number;
|
||||
readonly maximumSupervisionRecordsPerTick?: number;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly offerBackoffBaseMs?: number;
|
||||
readonly ownershipStaleMs?: number;
|
||||
readonly processController?: LocalProcessControllerOptions;
|
||||
readonly launcherPath?: string;
|
||||
readonly expectedLauncherSha256?: string;
|
||||
readonly now?: () => number;
|
||||
readonly diagnostic?: (
|
||||
event: Readonly<{
|
||||
code:
|
||||
| 'tick_failed'
|
||||
| 'session_tick_failed'
|
||||
| 'certificate_renewal_failed'
|
||||
| 'certificate_unavailable'
|
||||
| 'recovery_required'
|
||||
| 'drain_failed'
|
||||
| 'disconnect_failed';
|
||||
error?: unknown;
|
||||
offerId?: string;
|
||||
}>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type ProductionWorkerHeadlessApplicationOptions =
|
||||
| ProductionWorkerHeadlessApplicationDisabledOptions
|
||||
| ProductionWorkerHeadlessApplicationEnabledOptions;
|
||||
|
||||
export type ProductionWorkerHeadlessStopResult =
|
||||
| 'stopped'
|
||||
| 'drain_timed_out'
|
||||
| 'recovery_required';
|
||||
|
||||
export type ProductionWorkerHeadlessApplicationResult =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
stop(): Promise<'stopped'>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
tick(): Promise<WorkerRemoteExecutionLifecycleTickResult>;
|
||||
stop(): Promise<ProductionWorkerHeadlessStopResult>;
|
||||
}>;
|
||||
|
||||
export interface ProductionWorkerHeadlessExecutionStack {
|
||||
readonly journal: WorkerRemoteOfferFileJournal;
|
||||
readonly lifecycle: WorkerRemoteExecutionHeadlessLifecycle;
|
||||
readonly client: WorkerIngressHttpsClient;
|
||||
readonly offerTransport: WorkerRemoteOfferHttpsTransport;
|
||||
readonly ownsClient: boolean;
|
||||
}
|
||||
|
||||
export class ProductionWorkerHeadlessApplicationError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'startup_recovery_required'
|
||||
| 'startup_not_converged'
|
||||
| 'certificate_unavailable'
|
||||
| 'session_drain_unproven',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Production Worker headless application failed: ${reason}`, options);
|
||||
this.name = 'ProductionWorkerHeadlessApplicationError';
|
||||
}
|
||||
}
|
||||
|
||||
interface NormalizedProductionWorkerOptions {
|
||||
readonly cadenceMs: number;
|
||||
readonly drainTimeoutMs: number;
|
||||
readonly drainPollMs: number;
|
||||
readonly maximumJournalEntries: number;
|
||||
readonly maximumRecordsPerTick: number;
|
||||
readonly maximumSupervisionRecordsPerTick: number;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new ProductionWorkerHeadlessApplicationError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storagePath(value: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
throw new ProductionWorkerHeadlessApplicationError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function pathsOverlap(left: string, right: string): boolean {
|
||||
const relative = path.relative(left, right);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== '..' &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
options: ProductionWorkerHeadlessApplicationEnabledOptions,
|
||||
): NormalizedProductionWorkerOptions {
|
||||
if (
|
||||
!options ||
|
||||
options.enabled !== true ||
|
||||
options.profile !== 'worker' ||
|
||||
(options.capacityProfile !== 'edge' &&
|
||||
options.capacityProfile !== 'node') ||
|
||||
typeof options.credentials?.load !== 'function' ||
|
||||
(options.client !== undefined &&
|
||||
!(options.client instanceof WorkerIngressHttpsClient)) ||
|
||||
typeof options.session?.current !== 'function' ||
|
||||
typeof options.session?.beginDrain !== 'function' ||
|
||||
(options.certificateRenewal !== undefined &&
|
||||
(typeof options.certificateRenewal.run !== 'function' ||
|
||||
typeof options.session.failClosed !== 'function')) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.diagnostic !== undefined &&
|
||||
typeof options.diagnostic !== 'function')
|
||||
) {
|
||||
throw new ProductionWorkerHeadlessApplicationError('invalid_configuration');
|
||||
}
|
||||
const roots = [
|
||||
storagePath(options.storage?.journalRoot),
|
||||
storagePath(options.storage?.logRoot),
|
||||
storagePath(options.storage?.receiptRoot),
|
||||
];
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
if (
|
||||
pathsOverlap(roots[left]!, roots[right]!) ||
|
||||
pathsOverlap(roots[right]!, roots[left]!)
|
||||
) {
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const edge = options.capacityProfile === 'edge';
|
||||
return Object.freeze({
|
||||
cadenceMs: boundedInteger(
|
||||
options.cadenceMs ?? (edge ? 2_000 : 500),
|
||||
MIN_CADENCE_MS,
|
||||
MAX_CADENCE_MS,
|
||||
),
|
||||
drainTimeoutMs: boundedInteger(
|
||||
options.drainTimeoutMs ?? (edge ? 60_000 : 5 * 60_000),
|
||||
MIN_DRAIN_TIMEOUT_MS,
|
||||
MAX_DRAIN_TIMEOUT_MS,
|
||||
),
|
||||
drainPollMs: boundedInteger(
|
||||
options.drainPollMs ?? (edge ? 500 : 100),
|
||||
MIN_DRAIN_POLL_MS,
|
||||
MAX_DRAIN_POLL_MS,
|
||||
),
|
||||
maximumJournalEntries: boundedInteger(
|
||||
options.maximumJournalEntries ??
|
||||
(edge ? DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES : 256),
|
||||
1,
|
||||
MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
),
|
||||
maximumRecordsPerTick: boundedInteger(
|
||||
options.maximumRecordsPerTick ?? (edge ? 4 : 16),
|
||||
1,
|
||||
64,
|
||||
),
|
||||
maximumSupervisionRecordsPerTick: boundedInteger(
|
||||
options.maximumSupervisionRecordsPerTick ?? (edge ? 4 : 32),
|
||||
1,
|
||||
64,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function createProductionWorkerHeadlessExecutionStack(
|
||||
options: ProductionWorkerHeadlessApplicationEnabledOptions,
|
||||
): ProductionWorkerHeadlessExecutionStack {
|
||||
const normalized = normalizeOptions(options);
|
||||
const currentSession = () => options.session.current();
|
||||
const journal = new WorkerRemoteOfferFileJournal({
|
||||
rootDirectory: options.storage.journalRoot,
|
||||
maximumEntries: normalized.maximumJournalEntries,
|
||||
...(options.ownershipStaleMs === undefined
|
||||
? {}
|
||||
: { ownershipStaleMs: options.ownershipStaleMs }),
|
||||
});
|
||||
const ownsClient = options.client === undefined;
|
||||
const client =
|
||||
options.client ??
|
||||
new WorkerIngressHttpsClient({
|
||||
origin: options.origin,
|
||||
credentials: options.credentials,
|
||||
...(options.requestTimeoutMs === undefined
|
||||
? {}
|
||||
: { requestTimeoutMs: options.requestTimeoutMs }),
|
||||
});
|
||||
try {
|
||||
const offerTransport = new WorkerRemoteOfferHttpsTransport({ client });
|
||||
const offers = new WorkerRemoteOfferPullCoordinator({
|
||||
journal,
|
||||
transport: offerTransport,
|
||||
currentSession,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.offerBackoffBaseMs === undefined
|
||||
? {}
|
||||
: { backoffBaseMs: options.offerBackoffBaseMs }),
|
||||
});
|
||||
const activation = new WorkerRemoteExecutionHttpsActivationClient({
|
||||
client,
|
||||
});
|
||||
const secretProvider = new WorkerRemoteSecretHttpsProvider({
|
||||
client,
|
||||
inbox: journal,
|
||||
});
|
||||
const artifacts = new WorkerFileLogArtifactAllocator({
|
||||
root: options.storage.logRoot,
|
||||
policy: workerRemoteLogArtifactPolicy(options.capacityProfile),
|
||||
});
|
||||
const materializer = new BoundedWorkerRemoteExecutionContextMaterializer({
|
||||
artifacts,
|
||||
secrets: secretProvider,
|
||||
});
|
||||
const barrier = new WorkerInboxExecutionSpawnBarrier(journal);
|
||||
const executorOptions: WorkerPosixExecutionExecutorOptions = {
|
||||
barrier,
|
||||
receiptRoot: options.storage.receiptRoot,
|
||||
...(options.launcherPath === undefined
|
||||
? {}
|
||||
: { launcherPath: options.launcherPath }),
|
||||
...(options.expectedLauncherSha256 === undefined
|
||||
? {}
|
||||
: { expectedLauncherSha256: options.expectedLauncherSha256 }),
|
||||
};
|
||||
const executor = new WorkerPosixExecutionExecutor(executorOptions);
|
||||
const processor = new WorkerRemoteExecutionInboxProcessor({
|
||||
inbox: journal,
|
||||
activation,
|
||||
materializer,
|
||||
executor,
|
||||
currentSession,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
const receipts = new CompletionReceiptFileStore(
|
||||
options.storage.receiptRoot,
|
||||
);
|
||||
const uploader = new WorkerRemoteArtifactHttpsUploader({ client });
|
||||
const completionClient = new WorkerRemoteExecutionHttpsCompletionClient({
|
||||
client,
|
||||
});
|
||||
const completion = new WorkerRemoteCompletionCoordinator(
|
||||
journal,
|
||||
receipts,
|
||||
artifacts,
|
||||
uploader,
|
||||
completionClient,
|
||||
{
|
||||
currentSession,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
},
|
||||
);
|
||||
const leaseControl = new WorkerRemoteLeaseControlHttpsClient({ client });
|
||||
const processes = new LocalProcessController(
|
||||
options.processController ?? {},
|
||||
);
|
||||
const control = new WorkerRemoteExecutionControlCoordinator(
|
||||
journal,
|
||||
completion,
|
||||
leaseControl,
|
||||
processes,
|
||||
{
|
||||
currentSession,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
},
|
||||
);
|
||||
const lifecycle = new WorkerRemoteExecutionHeadlessLifecycle({
|
||||
journal,
|
||||
offers,
|
||||
processor,
|
||||
control,
|
||||
currentSession,
|
||||
maximumRecordsPerTick: normalized.maximumRecordsPerTick,
|
||||
maximumSupervisionRecordsPerTick:
|
||||
normalized.maximumSupervisionRecordsPerTick,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
return Object.freeze({
|
||||
journal,
|
||||
lifecycle,
|
||||
client,
|
||||
offerTransport,
|
||||
ownsClient,
|
||||
});
|
||||
} catch (error) {
|
||||
if (ownsClient) client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectUnsettled(
|
||||
journal: WorkerRemoteOfferFileJournal,
|
||||
maximumEntries: number,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
unsettled: number;
|
||||
recoveryOfferId?: string;
|
||||
}>
|
||||
> {
|
||||
let afterOfferId: string | undefined;
|
||||
let observed = 0;
|
||||
let unsettled = 0;
|
||||
do {
|
||||
const page = await journal.listOffers({
|
||||
...(afterOfferId === undefined ? {} : { afterOfferId }),
|
||||
limit: 64,
|
||||
});
|
||||
observed += page.records.length;
|
||||
if (observed > maximumEntries) {
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
for (const record of page.records) {
|
||||
if (record.state === 'recovery_required') {
|
||||
return Object.freeze({
|
||||
unsettled,
|
||||
recoveryOfferId: record.offer.offerId,
|
||||
});
|
||||
}
|
||||
if (!SETTLED_STATES.has(record.state)) unsettled += 1;
|
||||
}
|
||||
afterOfferId = page.nextAfterOfferId;
|
||||
} while (afterOfferId !== undefined);
|
||||
return Object.freeze({ unsettled });
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
export async function startProductionWorkerHeadlessApplication(
|
||||
options: ProductionWorkerHeadlessApplicationOptions,
|
||||
): Promise<ProductionWorkerHeadlessApplicationResult> {
|
||||
if (!options || options.enabled !== true) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
async stop() {
|
||||
return 'stopped' as const;
|
||||
},
|
||||
});
|
||||
}
|
||||
const stack = createProductionWorkerHeadlessExecutionStack(options);
|
||||
return startProductionWorkerHeadlessApplicationWithStack(options, stack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one already-composed execution stack. Product composition uses this
|
||||
* seam to share the exact HTTPS client with its timer-free Session lifecycle.
|
||||
*/
|
||||
export async function startProductionWorkerHeadlessApplicationWithStack(
|
||||
options: ProductionWorkerHeadlessApplicationEnabledOptions,
|
||||
stack: ProductionWorkerHeadlessExecutionStack,
|
||||
): Promise<
|
||||
Extract<ProductionWorkerHeadlessApplicationResult, { status: 'active' }>
|
||||
> {
|
||||
const normalized = normalizeOptions(options);
|
||||
let lifecycleStarted = false;
|
||||
let certificateUnavailable = false;
|
||||
const emit = (
|
||||
event: Readonly<{
|
||||
code:
|
||||
| 'tick_failed'
|
||||
| 'session_tick_failed'
|
||||
| 'certificate_renewal_failed'
|
||||
| 'certificate_unavailable'
|
||||
| 'recovery_required'
|
||||
| 'drain_failed'
|
||||
| 'disconnect_failed';
|
||||
error?: unknown;
|
||||
offerId?: string;
|
||||
}>,
|
||||
) => {
|
||||
void Promise.resolve()
|
||||
.then(() => options.diagnostic?.(event))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
const maintainCertificate = async (): Promise<boolean> => {
|
||||
if (!options.certificateRenewal) return true;
|
||||
let result: WorkerCertificateRenewalResult;
|
||||
try {
|
||||
result = await options.certificateRenewal.run();
|
||||
} catch (error) {
|
||||
if (!certificateUnavailable) {
|
||||
certificateUnavailable = true;
|
||||
options.session.failClosed!();
|
||||
emit({ code: 'certificate_unavailable', error });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (result.status === 'retry_scheduled') {
|
||||
emit({ code: 'certificate_renewal_failed' });
|
||||
}
|
||||
if (result.status === 'unavailable') {
|
||||
if (!certificateUnavailable) {
|
||||
certificateUnavailable = true;
|
||||
options.session.failClosed!();
|
||||
emit({ code: 'certificate_unavailable' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
certificateUnavailable = false;
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await stack.lifecycle.start();
|
||||
lifecycleStarted = true;
|
||||
if (options.session.register !== undefined) {
|
||||
const startupState = await inspectUnsettled(
|
||||
stack.journal,
|
||||
normalized.maximumJournalEntries,
|
||||
);
|
||||
if (
|
||||
startupState.recoveryOfferId !== undefined ||
|
||||
startupState.unsettled !== 0
|
||||
) {
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'startup_recovery_required',
|
||||
);
|
||||
}
|
||||
}
|
||||
const maximumStartupTicks =
|
||||
Math.ceil(
|
||||
normalized.maximumJournalEntries / normalized.maximumRecordsPerTick,
|
||||
) + 1;
|
||||
let converged = false;
|
||||
for (let tick = 0; tick < maximumStartupTicks; tick += 1) {
|
||||
const result = await stack.lifecycle.tick();
|
||||
if (result.status === 'recovery_required') {
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'startup_recovery_required',
|
||||
);
|
||||
}
|
||||
if (result.status === 'reconciled') {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
if (result.status !== 'reconciling') {
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'startup_not_converged',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!converged) {
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'startup_not_converged',
|
||||
);
|
||||
}
|
||||
if (!(await maintainCertificate())) {
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'certificate_unavailable',
|
||||
);
|
||||
}
|
||||
await options.session.register?.();
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let closed = false;
|
||||
let tickOperation:
|
||||
| Promise<WorkerRemoteExecutionLifecycleTickResult>
|
||||
| undefined;
|
||||
let stopOperation: Promise<ProductionWorkerHeadlessStopResult> | undefined;
|
||||
|
||||
const tickOnce =
|
||||
async (): Promise<WorkerRemoteExecutionLifecycleTickResult> => {
|
||||
if (await maintainCertificate()) {
|
||||
await options.session.tick?.().catch((error) => {
|
||||
emit({ code: 'session_tick_failed', error });
|
||||
});
|
||||
}
|
||||
const result = await stack.lifecycle.tick();
|
||||
if (result.status === 'recovery_required') {
|
||||
emit({ code: 'recovery_required', offerId: result.offerId });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const tick = (): Promise<WorkerRemoteExecutionLifecycleTickResult> => {
|
||||
if (tickOperation) return tickOperation;
|
||||
const operation = tickOnce().finally(() => {
|
||||
if (tickOperation === operation) tickOperation = undefined;
|
||||
});
|
||||
tickOperation = operation;
|
||||
return operation;
|
||||
};
|
||||
const schedule = () => {
|
||||
if (timer || closed) return;
|
||||
timer = setInterval(() => {
|
||||
void tick().catch((error) => emit({ code: 'tick_failed', error }));
|
||||
}, normalized.cadenceMs);
|
||||
timer.unref();
|
||||
};
|
||||
const unschedule = () => {
|
||||
if (!timer) return;
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
const finish = async (): Promise<'stopped'> => {
|
||||
unschedule();
|
||||
await stack.lifecycle.stop();
|
||||
stack.offerTransport.close();
|
||||
if (stack.ownsClient) stack.client.close();
|
||||
closed = true;
|
||||
return 'stopped';
|
||||
};
|
||||
const drainAndStop =
|
||||
async (): Promise<ProductionWorkerHeadlessStopResult> => {
|
||||
unschedule();
|
||||
await stack.lifecycle.beginDrain();
|
||||
try {
|
||||
await options.session.beginDrain();
|
||||
} catch (error) {
|
||||
emit({ code: 'drain_failed', error });
|
||||
schedule();
|
||||
throw error;
|
||||
}
|
||||
const session = options.session.current();
|
||||
if (session?.status === 'available') {
|
||||
schedule();
|
||||
throw new ProductionWorkerHeadlessApplicationError(
|
||||
'session_drain_unproven',
|
||||
);
|
||||
}
|
||||
const deadline = performance.now() + normalized.drainTimeoutMs;
|
||||
while (true) {
|
||||
const tickResult = await tick().catch((error) => {
|
||||
emit({ code: 'drain_failed', error });
|
||||
return undefined;
|
||||
});
|
||||
if (tickResult?.status === 'recovery_required') {
|
||||
schedule();
|
||||
return 'recovery_required';
|
||||
}
|
||||
const state = await inspectUnsettled(
|
||||
stack.journal,
|
||||
normalized.maximumJournalEntries,
|
||||
);
|
||||
if (state.recoveryOfferId !== undefined) {
|
||||
emit({
|
||||
code: 'recovery_required',
|
||||
offerId: state.recoveryOfferId,
|
||||
});
|
||||
schedule();
|
||||
return 'recovery_required';
|
||||
}
|
||||
if (state.unsettled === 0) {
|
||||
try {
|
||||
await options.session.disconnect?.();
|
||||
} catch (error) {
|
||||
emit({ code: 'disconnect_failed', error });
|
||||
schedule();
|
||||
throw error;
|
||||
}
|
||||
return finish();
|
||||
}
|
||||
if (performance.now() >= deadline) {
|
||||
schedule();
|
||||
return 'drain_timed_out';
|
||||
}
|
||||
await wait(normalized.drainPollMs);
|
||||
}
|
||||
};
|
||||
schedule();
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
tick,
|
||||
stop() {
|
||||
if (closed) return Promise.resolve('stopped' as const);
|
||||
if (stopOperation) return stopOperation;
|
||||
const operation = drainAndStop().finally(() => {
|
||||
if (stopOperation === operation && !closed) {
|
||||
stopOperation = undefined;
|
||||
}
|
||||
});
|
||||
stopOperation = operation;
|
||||
return operation;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (lifecycleStarted) {
|
||||
await stack.lifecycle.stop().catch(() => undefined);
|
||||
}
|
||||
stack.offerTransport.close();
|
||||
if (stack.ownsClient) stack.client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { RemoteWorkerCapabilities } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
createProductionWorkerHeadlessExecutionStack,
|
||||
startProductionWorkerHeadlessApplicationWithStack,
|
||||
type ProductionWorkerHeadlessApplicationDisabledOptions,
|
||||
type ProductionWorkerHeadlessApplicationEnabledOptions,
|
||||
type ProductionWorkerHeadlessApplicationResult,
|
||||
type ProductionWorkerSessionLifecycle,
|
||||
} from './productionHeadlessApplication';
|
||||
import { WorkerExecutionCapacityOracle } from '../session/workerExecutionCapacityOracle';
|
||||
import { WorkerIngressHttpsClient } from '../remote-execution/transport/workerIngressHttpsClient';
|
||||
import {
|
||||
WorkerSessionCoordinator,
|
||||
type WorkerSessionCoordinatorTickResult,
|
||||
} from '../session/workerSessionCoordinator';
|
||||
import { WorkerSessionHttpsClient } from '../session/workerSessionHttpsClient';
|
||||
|
||||
export type ProductionWorkerApplicationEnabledOptions = Omit<
|
||||
ProductionWorkerHeadlessApplicationEnabledOptions,
|
||||
'client' | 'session'
|
||||
> &
|
||||
Readonly<{
|
||||
workerId: string;
|
||||
capabilities: RemoteWorkerCapabilities;
|
||||
maxConcurrentRuns: number;
|
||||
leaseDurationMs?: number;
|
||||
heartbeatIntervalMs?: number;
|
||||
}>;
|
||||
|
||||
export type ProductionWorkerApplicationOptions =
|
||||
| ProductionWorkerHeadlessApplicationDisabledOptions
|
||||
| ProductionWorkerApplicationEnabledOptions;
|
||||
|
||||
class ManagedWorkerSessionLifecycle
|
||||
implements ProductionWorkerSessionLifecycle
|
||||
{
|
||||
private oracle?: WorkerExecutionCapacityOracle;
|
||||
|
||||
constructor(private readonly coordinator: WorkerSessionCoordinator) {}
|
||||
|
||||
bind(oracle: WorkerExecutionCapacityOracle): void {
|
||||
if (this.oracle !== undefined)
|
||||
throw new TypeError('capacity already bound');
|
||||
this.oracle = oracle;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.coordinator.current();
|
||||
}
|
||||
|
||||
async register() {
|
||||
const oracle = this.requiredOracle();
|
||||
oracle.prepareRegistration();
|
||||
try {
|
||||
const registered = await this.coordinator.register();
|
||||
oracle.activate();
|
||||
return registered;
|
||||
} catch (error) {
|
||||
oracle.failClosed();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async tick(): Promise<WorkerSessionCoordinatorTickResult> {
|
||||
const result = await this.coordinator.tick();
|
||||
if (result.status === 'heartbeat') this.requiredOracle().activate();
|
||||
if (result.status === 'lease_expired') this.requiredOracle().failClosed();
|
||||
return result;
|
||||
}
|
||||
|
||||
failClosed(): void {
|
||||
this.coordinator.failClosed();
|
||||
this.requiredOracle().failClosed();
|
||||
}
|
||||
|
||||
async beginDrain(): Promise<void> {
|
||||
this.requiredOracle().beginDrain();
|
||||
await this.coordinator.beginDrain();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.coordinator.disconnect();
|
||||
this.requiredOracle().offline();
|
||||
}
|
||||
|
||||
private requiredOracle(): WorkerExecutionCapacityOracle {
|
||||
if (!this.oracle) throw new TypeError('capacity is not bound');
|
||||
return this.oracle;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production composition root for one Remote Worker process. It owns exactly
|
||||
* one HTTPS client/Agent and delegates all periodic work to the headless
|
||||
* application's single cadence.
|
||||
*/
|
||||
export async function startProductionWorkerApplication(
|
||||
options: ProductionWorkerApplicationOptions,
|
||||
): Promise<ProductionWorkerHeadlessApplicationResult> {
|
||||
if (!options || options.enabled !== true) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
async stop() {
|
||||
return 'stopped' as const;
|
||||
},
|
||||
});
|
||||
}
|
||||
const client = new WorkerIngressHttpsClient({
|
||||
origin: options.origin,
|
||||
credentials: options.credentials,
|
||||
...(options.requestTimeoutMs === undefined
|
||||
? {}
|
||||
: { requestTimeoutMs: options.requestTimeoutMs }),
|
||||
});
|
||||
let oracle: WorkerExecutionCapacityOracle | undefined;
|
||||
try {
|
||||
const coordinator = new WorkerSessionCoordinator({
|
||||
client: new WorkerSessionHttpsClient({ client }),
|
||||
workerId: options.workerId,
|
||||
capabilities: options.capabilities,
|
||||
maxConcurrentRuns: options.maxConcurrentRuns,
|
||||
availableSlots: () => oracle?.availableSlots() ?? 0,
|
||||
...(options.leaseDurationMs === undefined
|
||||
? {}
|
||||
: { leaseDurationMs: options.leaseDurationMs }),
|
||||
...(options.heartbeatIntervalMs === undefined
|
||||
? {}
|
||||
: { heartbeatIntervalMs: options.heartbeatIntervalMs }),
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
const session = new ManagedWorkerSessionLifecycle(coordinator);
|
||||
const executionOptions: ProductionWorkerHeadlessApplicationEnabledOptions =
|
||||
{
|
||||
...options,
|
||||
client,
|
||||
session,
|
||||
};
|
||||
const stack =
|
||||
createProductionWorkerHeadlessExecutionStack(executionOptions);
|
||||
oracle = new WorkerExecutionCapacityOracle({
|
||||
journal: stack.journal,
|
||||
maxConcurrentRuns: options.maxConcurrentRuns,
|
||||
});
|
||||
session.bind(oracle);
|
||||
const application = await startProductionWorkerHeadlessApplicationWithStack(
|
||||
executionOptions,
|
||||
stack,
|
||||
);
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
tick: application.tick,
|
||||
async stop() {
|
||||
const result = await application.stop();
|
||||
if (result === 'stopped' && !closed) {
|
||||
closed = true;
|
||||
client.close();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Credential ownership: generate bounded Worker certificate enrollment material.
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { createHash, webcrypto } from 'node:crypto';
|
||||
import {
|
||||
BasicConstraintsExtension,
|
||||
ExtendedKeyUsage,
|
||||
ExtendedKeyUsageExtension,
|
||||
KeyUsageFlags,
|
||||
KeyUsagesExtension,
|
||||
PemConverter,
|
||||
Pkcs10CertificateRequestGenerator,
|
||||
} from '@peculiar/x509';
|
||||
|
||||
const WORKER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const KEY_ALGORITHM = Object.freeze({
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
hash: 'SHA-256',
|
||||
});
|
||||
const MAX_PRIVATE_KEY_BYTES = 16 * 1024;
|
||||
const MAX_CSR_BYTES = 16 * 1024;
|
||||
|
||||
export interface GenerateWorkerCertificateEnrollmentOptions {
|
||||
readonly workerId: string;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateEnrollmentMaterial {
|
||||
readonly algorithm: 'ECDSA_P256_SHA256';
|
||||
readonly workerId: string;
|
||||
readonly privateKeyPem: Buffer;
|
||||
readonly certificateSigningRequestPem: string;
|
||||
readonly publicKeySpkiSha256: string;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class WorkerCertificateEnrollmentError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Worker certificate enrollment is invalid: ${message}`);
|
||||
this.name = 'WorkerCertificateEnrollmentError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertWorkerId(workerId: string): void {
|
||||
if (typeof workerId !== 'string' || !WORKER_ID_PATTERN.test(workerId)) {
|
||||
throw new WorkerCertificateEnrollmentError('workerId is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Worker-local P-256 key and a PKCS#10 request. This function does
|
||||
* not contact a CA, persist the key or grant any Worker authority.
|
||||
*/
|
||||
export async function generateWorkerCertificateEnrollment(
|
||||
options: GenerateWorkerCertificateEnrollmentOptions,
|
||||
): Promise<WorkerCertificateEnrollmentMaterial> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new WorkerCertificateEnrollmentError('options must be an object');
|
||||
}
|
||||
assertWorkerId(options.workerId);
|
||||
|
||||
let privateKeyPem: Buffer | undefined;
|
||||
try {
|
||||
const keys = await webcrypto.subtle.generateKey(KEY_ALGORITHM, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
const request = await Pkcs10CertificateRequestGenerator.create(
|
||||
{
|
||||
name: `CN=${options.workerId}`,
|
||||
keys: keys as unknown as CryptoKeyPair,
|
||||
signingAlgorithm: KEY_ALGORITHM,
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(false, undefined, true),
|
||||
new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], true),
|
||||
new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true),
|
||||
],
|
||||
},
|
||||
webcrypto as unknown as Crypto,
|
||||
);
|
||||
if (!(await request.verify(webcrypto as unknown as Crypto))) {
|
||||
throw new WorkerCertificateEnrollmentError(
|
||||
'generated CSR signature is invalid',
|
||||
);
|
||||
}
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
webcrypto.subtle.exportKey('pkcs8', keys.privateKey),
|
||||
webcrypto.subtle.exportKey('spki', keys.publicKey),
|
||||
]);
|
||||
privateKeyPem = Buffer.from(
|
||||
PemConverter.encode(privateKey, 'PRIVATE KEY'),
|
||||
'ascii',
|
||||
);
|
||||
const certificateSigningRequestPem = request.toString('pem');
|
||||
if (
|
||||
privateKeyPem.byteLength < 1 ||
|
||||
privateKeyPem.byteLength > MAX_PRIVATE_KEY_BYTES ||
|
||||
Buffer.byteLength(certificateSigningRequestPem) < 1 ||
|
||||
Buffer.byteLength(certificateSigningRequestPem) > MAX_CSR_BYTES
|
||||
) {
|
||||
throw new WorkerCertificateEnrollmentError(
|
||||
'generated material exceeds its hard limit',
|
||||
);
|
||||
}
|
||||
const publicKeySpkiSha256 = createHash('sha256')
|
||||
.update(Buffer.from(publicKey))
|
||||
.digest('hex');
|
||||
let disposed = false;
|
||||
const material: WorkerCertificateEnrollmentMaterial = {
|
||||
algorithm: 'ECDSA_P256_SHA256',
|
||||
workerId: options.workerId,
|
||||
privateKeyPem,
|
||||
certificateSigningRequestPem,
|
||||
publicKeySpkiSha256,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
privateKeyPem?.fill(0);
|
||||
},
|
||||
};
|
||||
return Object.freeze(material);
|
||||
} catch (error) {
|
||||
privateKeyPem?.fill(0);
|
||||
if (error instanceof WorkerCertificateEnrollmentError) throw error;
|
||||
throw new WorkerCertificateEnrollmentError('key or CSR generation failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Credential ownership: validate Worker certificate identity and trust semantics.
|
||||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
timingSafeEqual,
|
||||
X509Certificate,
|
||||
} from 'node:crypto';
|
||||
|
||||
const CERTIFICATE_PATTERN =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
const CLIENT_AUTH_OID = '1.3.6.1.5.5.7.3.2';
|
||||
const MAX_CERTIFICATE_MATERIAL_BYTES = 1024 * 1024;
|
||||
const MAX_CERTIFICATES = 16;
|
||||
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type WorkerCertificateIdentityFailureReason =
|
||||
| 'invalid_material'
|
||||
| 'key_mismatch'
|
||||
| 'not_yet_valid'
|
||||
| 'expired'
|
||||
| 'insufficient_validity'
|
||||
| 'not_client_auth'
|
||||
| 'untrusted';
|
||||
|
||||
export class WorkerCertificateIdentityError extends Error {
|
||||
constructor(readonly reason: WorkerCertificateIdentityFailureReason) {
|
||||
super(`Worker certificate identity is unavailable: ${reason}`);
|
||||
this.name = 'WorkerCertificateIdentityError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ValidateWorkerCertificateIdentityInput {
|
||||
readonly privateKeyPem: string | Buffer;
|
||||
readonly certificateChainPem: string | Buffer;
|
||||
readonly trustAnchors: readonly (string | Buffer)[];
|
||||
readonly now?: number;
|
||||
readonly minimumRemainingValidityMs?: number;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateIdentitySummary {
|
||||
readonly certificateSha256: string;
|
||||
readonly publicKeySpkiSha256: string;
|
||||
readonly serialNumber: string;
|
||||
readonly notBeforeMs: number;
|
||||
readonly notAfterMs: number;
|
||||
}
|
||||
|
||||
interface ParsedCertificate {
|
||||
readonly certificate: X509Certificate;
|
||||
readonly fingerprint: string;
|
||||
}
|
||||
|
||||
function materialBytes(
|
||||
value: string | Buffer,
|
||||
privateMaterial = false,
|
||||
): Buffer {
|
||||
if (typeof value !== 'string' && !Buffer.isBuffer(value)) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
const bytes = Buffer.isBuffer(value)
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value, 'utf8');
|
||||
if (
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > MAX_CERTIFICATE_MATERIAL_BYTES
|
||||
) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
if (privateMaterial && !bytes.includes(Buffer.from('PRIVATE KEY'))) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function splitCertificates(
|
||||
value: string | Buffer,
|
||||
remaining: { count: number },
|
||||
): ParsedCertificate[] {
|
||||
const bytes = materialBytes(value);
|
||||
try {
|
||||
const pem = bytes.toString('utf8');
|
||||
const matches = pem.match(CERTIFICATE_PATTERN);
|
||||
if (!matches || matches.length === 0) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
const remainder = matches.reduce(
|
||||
(candidate, match) => candidate.replace(match, ''),
|
||||
pem,
|
||||
);
|
||||
remaining.count -= matches.length;
|
||||
if (remainder.trim() !== '' || remaining.count < 0) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
return matches.map((match) => {
|
||||
try {
|
||||
const certificate = new X509Certificate(`${match}\n`);
|
||||
return {
|
||||
certificate,
|
||||
fingerprint: createHash('sha256')
|
||||
.update(certificate.raw)
|
||||
.digest('hex'),
|
||||
};
|
||||
} catch {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function certificateTime(
|
||||
certificate: X509Certificate,
|
||||
now: number,
|
||||
): { notBeforeMs: number; notAfterMs: number } {
|
||||
const notBeforeMs = Date.parse(certificate.validFrom);
|
||||
const notAfterMs = Date.parse(certificate.validTo);
|
||||
if (!Number.isFinite(notBeforeMs) || !Number.isFinite(notAfterMs)) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
if (now < notBeforeMs) {
|
||||
throw new WorkerCertificateIdentityError('not_yet_valid');
|
||||
}
|
||||
if (now >= notAfterMs) {
|
||||
throw new WorkerCertificateIdentityError('expired');
|
||||
}
|
||||
return { notBeforeMs, notAfterMs };
|
||||
}
|
||||
|
||||
function signedBy(
|
||||
certificate: X509Certificate,
|
||||
issuer: X509Certificate,
|
||||
): boolean {
|
||||
try {
|
||||
return (
|
||||
certificate.checkIssued(issuer) && certificate.verify(issuer.publicKey)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function reachesTrustAnchor(
|
||||
certificate: ParsedCertificate,
|
||||
intermediates: readonly ParsedCertificate[],
|
||||
anchors: readonly ParsedCertificate[],
|
||||
visited: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (
|
||||
visited.has(certificate.fingerprint) ||
|
||||
visited.size >= MAX_CERTIFICATES
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(certificate.fingerprint);
|
||||
for (const anchor of anchors) {
|
||||
if (signedBy(certificate.certificate, anchor.certificate)) return true;
|
||||
}
|
||||
for (const intermediate of intermediates) {
|
||||
if (
|
||||
!nextVisited.has(intermediate.fingerprint) &&
|
||||
signedBy(certificate.certificate, intermediate.certificate) &&
|
||||
reachesTrustAnchor(intermediate, intermediates, anchors, nextVisited)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchingPublicKey(
|
||||
privateKeyPem: Buffer,
|
||||
certificate: X509Certificate,
|
||||
): string {
|
||||
let privateKey;
|
||||
try {
|
||||
privateKey = createPrivateKey(privateKeyPem);
|
||||
} catch {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
const key = createPublicKey(privateKey).export({
|
||||
type: 'spki',
|
||||
format: 'der',
|
||||
});
|
||||
const certificateKey = certificate.publicKey.export({
|
||||
type: 'spki',
|
||||
format: 'der',
|
||||
});
|
||||
if (
|
||||
key.byteLength !== certificateKey.byteLength ||
|
||||
!timingSafeEqual(key, certificateKey)
|
||||
) {
|
||||
throw new WorkerCertificateIdentityError('key_mismatch');
|
||||
}
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
export function assertWorkerCertificateIdentitySummary(
|
||||
value: WorkerCertificateIdentitySummary,
|
||||
): void {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!SHA256_HEX_PATTERN.test(value.certificateSha256) ||
|
||||
!SHA256_HEX_PATTERN.test(value.publicKeySpkiSha256) ||
|
||||
typeof value.serialNumber !== 'string' ||
|
||||
!/^[A-Fa-f0-9]{1,128}$/.test(value.serialNumber) ||
|
||||
!Number.isSafeInteger(value.notBeforeMs) ||
|
||||
!Number.isSafeInteger(value.notAfterMs) ||
|
||||
value.notBeforeMs < 0 ||
|
||||
value.notAfterMs <= value.notBeforeMs
|
||||
) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates key possession, client-auth intent, validity and a bounded chain. */
|
||||
export function validateWorkerCertificateIdentity(
|
||||
input: ValidateWorkerCertificateIdentityInput,
|
||||
): WorkerCertificateIdentitySummary {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
const now = input.now ?? Date.now();
|
||||
const minimumRemainingValidityMs = input.minimumRemainingValidityMs ?? 0;
|
||||
if (
|
||||
!Number.isSafeInteger(now) ||
|
||||
now < 0 ||
|
||||
!Number.isSafeInteger(minimumRemainingValidityMs) ||
|
||||
minimumRemainingValidityMs < 0 ||
|
||||
minimumRemainingValidityMs > 365 * 24 * 60 * 60_000
|
||||
) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
if (
|
||||
!Array.isArray(input.trustAnchors) ||
|
||||
input.trustAnchors.length < 1 ||
|
||||
input.trustAnchors.length > MAX_CERTIFICATES
|
||||
) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
|
||||
const remaining = { count: MAX_CERTIFICATES };
|
||||
const chain = splitCertificates(input.certificateChainPem, remaining);
|
||||
const anchors = input.trustAnchors.flatMap((anchor) =>
|
||||
splitCertificates(anchor, remaining),
|
||||
);
|
||||
const [leaf, ...intermediates] = chain;
|
||||
if (!leaf || anchors.length === 0 || leaf.certificate.ca) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
const leafTime = certificateTime(leaf.certificate, now);
|
||||
if (leafTime.notAfterMs - now < minimumRemainingValidityMs) {
|
||||
throw new WorkerCertificateIdentityError('insufficient_validity');
|
||||
}
|
||||
if (!leaf.certificate.keyUsage?.includes(CLIENT_AUTH_OID)) {
|
||||
throw new WorkerCertificateIdentityError('not_client_auth');
|
||||
}
|
||||
for (const certificate of [...intermediates, ...anchors]) {
|
||||
if (!certificate.certificate.ca) {
|
||||
throw new WorkerCertificateIdentityError('invalid_material');
|
||||
}
|
||||
certificateTime(certificate.certificate, now);
|
||||
}
|
||||
if (!reachesTrustAnchor(leaf, intermediates, anchors, new Set())) {
|
||||
throw new WorkerCertificateIdentityError('untrusted');
|
||||
}
|
||||
|
||||
const privateKeyPem = materialBytes(input.privateKeyPem, true);
|
||||
try {
|
||||
const summary = Object.freeze({
|
||||
certificateSha256: createHash('sha256')
|
||||
.update(leaf.certificate.raw)
|
||||
.digest('hex'),
|
||||
publicKeySpkiSha256: matchingPublicKey(privateKeyPem, leaf.certificate),
|
||||
serialNumber: leaf.certificate.serialNumber,
|
||||
notBeforeMs: leafTime.notBeforeMs,
|
||||
notAfterMs: leafTime.notAfterMs,
|
||||
});
|
||||
assertWorkerCertificateIdentitySummary(summary);
|
||||
return summary;
|
||||
} finally {
|
||||
privateKeyPem.fill(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Credential ownership: coordinate Worker certificate renewal lifecycle state.
|
||||
import type {
|
||||
GenerateWorkerCertificateEnrollmentOptions,
|
||||
WorkerCertificateEnrollmentMaterial,
|
||||
} from './workerCertificateEnrollment';
|
||||
import {
|
||||
type ActiveWorkerCertificateIdentity,
|
||||
type WorkerCertificateRenewalState,
|
||||
type WorkerCertificateStore,
|
||||
} from './workerCertificateStore';
|
||||
|
||||
const HOUR_MS = 60 * 60_000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const MAX_CERTIFICATE_MATERIAL_BYTES = 1024 * 1024;
|
||||
const MAX_ENROLLMENT_MATERIAL_BYTES = 16 * 1024;
|
||||
const MAX_CONSECUTIVE_FAILURES = 16;
|
||||
|
||||
export interface WorkerCertificateIssuer {
|
||||
issue(input: {
|
||||
readonly workerId: string;
|
||||
readonly certificateSigningRequestPem: string;
|
||||
readonly currentCertificateSha256?: string;
|
||||
readonly signal: AbortSignal;
|
||||
}): Promise<{ readonly certificateChainPem: string | Buffer }>;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateTrustAnchorProvider {
|
||||
load(signal: AbortSignal): Promise<readonly (string | Buffer)[]>;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateRenewalPolicy {
|
||||
readonly renewBeforeMs?: number;
|
||||
readonly minimumIssuedValidityMs?: number;
|
||||
readonly operationTimeoutMs?: number;
|
||||
readonly backoffBaseMs?: number;
|
||||
readonly backoffMaximumMs?: number;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateRenewalCoordinatorOptions {
|
||||
readonly workerId: string;
|
||||
readonly store: WorkerCertificateStore;
|
||||
readonly issuer: WorkerCertificateIssuer;
|
||||
readonly trustAnchors: WorkerCertificateTrustAnchorProvider;
|
||||
readonly policy?: WorkerCertificateRenewalPolicy;
|
||||
readonly now?: () => number;
|
||||
readonly random?: () => number;
|
||||
readonly prepareEnrollment?: (
|
||||
options: GenerateWorkerCertificateEnrollmentOptions,
|
||||
) => Promise<WorkerCertificateEnrollmentMaterial>;
|
||||
}
|
||||
|
||||
export type WorkerCertificateRenewalResult =
|
||||
| {
|
||||
readonly status: 'not_due';
|
||||
readonly identity: ActiveWorkerCertificateIdentity;
|
||||
readonly renewAtMs: number;
|
||||
}
|
||||
| {
|
||||
readonly status: 'renewed';
|
||||
readonly identity: ActiveWorkerCertificateIdentity;
|
||||
readonly cleanupPending: boolean;
|
||||
}
|
||||
| {
|
||||
readonly status: 'backing_off';
|
||||
readonly identity: ActiveWorkerCertificateIdentity;
|
||||
readonly nextAttemptAtMs: number;
|
||||
}
|
||||
| {
|
||||
readonly status: 'retry_scheduled';
|
||||
readonly identity?: ActiveWorkerCertificateIdentity;
|
||||
readonly reason: WorkerCertificateRenewalFailureReason;
|
||||
readonly nextAttemptAtMs: number;
|
||||
}
|
||||
| {
|
||||
readonly status: 'unavailable';
|
||||
readonly nextAttemptAtMs: number;
|
||||
};
|
||||
|
||||
export type WorkerCertificateRenewalFailureReason =
|
||||
| 'trust_unavailable'
|
||||
| 'enrollment_failed'
|
||||
| 'issuance_failed'
|
||||
| 'installation_failed'
|
||||
| 'timed_out';
|
||||
|
||||
export class WorkerCertificateRenewalConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Worker certificate renewal is invalid: ${message}`);
|
||||
this.name = 'WorkerCertificateRenewalConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
interface NormalizedPolicy {
|
||||
readonly renewBeforeMs: number;
|
||||
readonly minimumIssuedValidityMs: number;
|
||||
readonly operationTimeoutMs: number;
|
||||
readonly backoffBaseMs: number;
|
||||
readonly backoffMaximumMs: number;
|
||||
}
|
||||
|
||||
type EnrollmentFactory = NonNullable<
|
||||
WorkerCertificateRenewalCoordinatorOptions['prepareEnrollment']
|
||||
>;
|
||||
|
||||
function boundedInteger(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
name: string,
|
||||
): number {
|
||||
const candidate = value ?? fallback;
|
||||
if (
|
||||
!Number.isSafeInteger(candidate) ||
|
||||
candidate < minimum ||
|
||||
candidate > maximum
|
||||
) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(`${name} is invalid`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizePolicy(
|
||||
policy: WorkerCertificateRenewalPolicy | undefined,
|
||||
): NormalizedPolicy {
|
||||
if (
|
||||
policy !== undefined &&
|
||||
(!policy || typeof policy !== 'object' || Array.isArray(policy))
|
||||
) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'policy must be an object',
|
||||
);
|
||||
}
|
||||
const renewBeforeMs = boundedInteger(
|
||||
policy?.renewBeforeMs,
|
||||
7 * DAY_MS,
|
||||
HOUR_MS,
|
||||
30 * DAY_MS,
|
||||
'renewBeforeMs',
|
||||
);
|
||||
const minimumIssuedValidityMs = boundedInteger(
|
||||
policy?.minimumIssuedValidityMs,
|
||||
8 * DAY_MS,
|
||||
HOUR_MS,
|
||||
365 * DAY_MS,
|
||||
'minimumIssuedValidityMs',
|
||||
);
|
||||
if (minimumIssuedValidityMs <= renewBeforeMs) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'minimumIssuedValidityMs must exceed renewBeforeMs',
|
||||
);
|
||||
}
|
||||
const backoffBaseMs = boundedInteger(
|
||||
policy?.backoffBaseMs,
|
||||
30_000,
|
||||
1_000,
|
||||
HOUR_MS,
|
||||
'backoffBaseMs',
|
||||
);
|
||||
const backoffMaximumMs = boundedInteger(
|
||||
policy?.backoffMaximumMs,
|
||||
6 * HOUR_MS,
|
||||
backoffBaseMs,
|
||||
6 * HOUR_MS,
|
||||
'backoffMaximumMs',
|
||||
);
|
||||
return Object.freeze({
|
||||
renewBeforeMs,
|
||||
minimumIssuedValidityMs,
|
||||
operationTimeoutMs: boundedInteger(
|
||||
policy?.operationTimeoutMs,
|
||||
30_000,
|
||||
1_000,
|
||||
120_000,
|
||||
'operationTimeoutMs',
|
||||
),
|
||||
backoffBaseMs,
|
||||
backoffMaximumMs,
|
||||
});
|
||||
}
|
||||
|
||||
function safeClock(clock: () => number): number {
|
||||
const value = clock();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'clock returned an invalid value',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function certificateMaterial(
|
||||
value: string | Buffer,
|
||||
): string | Buffer | undefined {
|
||||
if (typeof value !== 'string' && !Buffer.isBuffer(value)) return undefined;
|
||||
const size = Buffer.byteLength(value);
|
||||
if (size < 1 || size > MAX_CERTIFICATE_MATERIAL_BYTES) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function enrollmentMaterial(
|
||||
value: WorkerCertificateEnrollmentMaterial,
|
||||
workerId: string,
|
||||
): WorkerCertificateEnrollmentMaterial {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
value.algorithm !== 'ECDSA_P256_SHA256' ||
|
||||
value.workerId !== workerId ||
|
||||
!Buffer.isBuffer(value.privateKeyPem) ||
|
||||
value.privateKeyPem.byteLength < 1 ||
|
||||
value.privateKeyPem.byteLength > MAX_ENROLLMENT_MATERIAL_BYTES ||
|
||||
typeof value.certificateSigningRequestPem !== 'string' ||
|
||||
Buffer.byteLength(value.certificateSigningRequestPem) < 1 ||
|
||||
Buffer.byteLength(value.certificateSigningRequestPem) >
|
||||
MAX_ENROLLMENT_MATERIAL_BYTES ||
|
||||
typeof value.publicKeySpkiSha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(value.publicKeySpkiSha256) ||
|
||||
typeof value.dispose !== 'function'
|
||||
) {
|
||||
if (Buffer.isBuffer(value?.privateKeyPem)) value.privateKeyPem.fill(0);
|
||||
throw new Error('enrollment material is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function defaultEnrollmentFactory(
|
||||
options: GenerateWorkerCertificateEnrollmentOptions,
|
||||
): Promise<WorkerCertificateEnrollmentMaterial> {
|
||||
const enrollment = await import('./workerCertificateEnrollment');
|
||||
return enrollment.generateWorkerCertificateEnrollment(options);
|
||||
}
|
||||
|
||||
function timeoutSignal(
|
||||
external: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): { readonly operation: AbortSignal; readonly timeout: AbortSignal } {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return {
|
||||
timeout,
|
||||
operation: external ? AbortSignal.any([external, timeout]) : timeout,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs one explicitly triggered renewal check. It owns no timer, watcher or
|
||||
* signal handler; edge and cluster profiles decide when to call it.
|
||||
*/
|
||||
export class WorkerCertificateRenewalCoordinator {
|
||||
private readonly workerId: string;
|
||||
private readonly store: WorkerCertificateStore;
|
||||
private readonly issuer: WorkerCertificateIssuer;
|
||||
private readonly trustAnchors: WorkerCertificateTrustAnchorProvider;
|
||||
private readonly policy: NormalizedPolicy;
|
||||
private readonly now: () => number;
|
||||
private readonly random: () => number;
|
||||
private readonly prepareEnrollment: EnrollmentFactory;
|
||||
private inFlight?: Promise<WorkerCertificateRenewalResult>;
|
||||
|
||||
constructor(options: WorkerCertificateRenewalCoordinatorOptions) {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'options must be an object',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof options.workerId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(options.workerId)
|
||||
) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'workerId is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options.store ||
|
||||
typeof options.store.readActive !== 'function' ||
|
||||
typeof options.store.install !== 'function' ||
|
||||
typeof options.store.readRenewalState !== 'function' ||
|
||||
typeof options.store.writeRenewalState !== 'function'
|
||||
) {
|
||||
throw new WorkerCertificateRenewalConfigurationError('store is invalid');
|
||||
}
|
||||
if (!options.issuer || typeof options.issuer.issue !== 'function') {
|
||||
throw new WorkerCertificateRenewalConfigurationError('issuer is invalid');
|
||||
}
|
||||
if (
|
||||
!options.trustAnchors ||
|
||||
typeof options.trustAnchors.load !== 'function'
|
||||
) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'trustAnchors is invalid',
|
||||
);
|
||||
}
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw new WorkerCertificateRenewalConfigurationError('now is invalid');
|
||||
}
|
||||
if (options.random !== undefined && typeof options.random !== 'function') {
|
||||
throw new WorkerCertificateRenewalConfigurationError('random is invalid');
|
||||
}
|
||||
if (
|
||||
options.prepareEnrollment !== undefined &&
|
||||
typeof options.prepareEnrollment !== 'function'
|
||||
) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'prepareEnrollment is invalid',
|
||||
);
|
||||
}
|
||||
this.workerId = options.workerId;
|
||||
this.store = options.store;
|
||||
this.issuer = options.issuer;
|
||||
this.trustAnchors = options.trustAnchors;
|
||||
this.policy = normalizePolicy(options.policy);
|
||||
this.now = options.now ?? Date.now;
|
||||
this.random = options.random ?? Math.random;
|
||||
this.prepareEnrollment =
|
||||
options.prepareEnrollment ?? defaultEnrollmentFactory;
|
||||
}
|
||||
|
||||
run(signal?: AbortSignal): Promise<WorkerCertificateRenewalResult> {
|
||||
if (signal !== undefined && !(signal instanceof AbortSignal)) {
|
||||
return Promise.reject(
|
||||
new WorkerCertificateRenewalConfigurationError('signal is invalid'),
|
||||
);
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const operation = this.performRun(signal).finally(() => {
|
||||
if (this.inFlight === operation) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async performRun(
|
||||
externalSignal: AbortSignal | undefined,
|
||||
): Promise<WorkerCertificateRenewalResult> {
|
||||
externalSignal?.throwIfAborted();
|
||||
const observedAtMs = safeClock(this.now);
|
||||
const renewalState = await this.store.readRenewalState();
|
||||
const signals = timeoutSignal(
|
||||
externalSignal,
|
||||
this.policy.operationTimeoutMs,
|
||||
);
|
||||
let failureReason: WorkerCertificateRenewalFailureReason =
|
||||
'trust_unavailable';
|
||||
let current: ActiveWorkerCertificateIdentity | undefined;
|
||||
let trustAnchors: readonly (string | Buffer)[] | undefined;
|
||||
|
||||
try {
|
||||
trustAnchors = await this.trustAnchors.load(signals.operation);
|
||||
signals.operation.throwIfAborted();
|
||||
current = await this.store.readActive(trustAnchors, observedAtMs);
|
||||
} catch (error) {
|
||||
if (externalSignal?.aborted) throw externalSignal.reason ?? error;
|
||||
if (signals.timeout.aborted) failureReason = 'timed_out';
|
||||
}
|
||||
|
||||
if (
|
||||
current &&
|
||||
current.notAfterMs - observedAtMs > this.policy.renewBeforeMs
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'not_due',
|
||||
identity: current,
|
||||
renewAtMs: current.notAfterMs - this.policy.renewBeforeMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
renewalState.nextAttemptAtMs !== null &&
|
||||
renewalState.nextAttemptAtMs > observedAtMs
|
||||
) {
|
||||
if (current) {
|
||||
return Object.freeze({
|
||||
status: 'backing_off',
|
||||
identity: current,
|
||||
nextAttemptAtMs: renewalState.nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'unavailable',
|
||||
nextAttemptAtMs: renewalState.nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
const attemptedAtMs = safeClock(this.now);
|
||||
let enrollment: WorkerCertificateEnrollmentMaterial | undefined;
|
||||
try {
|
||||
if (!trustAnchors || signals.operation.aborted) {
|
||||
if (signals.timeout.aborted) failureReason = 'timed_out';
|
||||
throw new Error('trust is unavailable');
|
||||
}
|
||||
failureReason = 'enrollment_failed';
|
||||
enrollment = enrollmentMaterial(
|
||||
await this.prepareEnrollment({ workerId: this.workerId }),
|
||||
this.workerId,
|
||||
);
|
||||
signals.operation.throwIfAborted();
|
||||
failureReason = 'issuance_failed';
|
||||
const issued = await this.issuer.issue({
|
||||
workerId: this.workerId,
|
||||
certificateSigningRequestPem: enrollment.certificateSigningRequestPem,
|
||||
...(current
|
||||
? { currentCertificateSha256: current.certificateSha256 }
|
||||
: {}),
|
||||
signal: signals.operation,
|
||||
});
|
||||
signals.operation.throwIfAborted();
|
||||
const issuedMaterial = certificateMaterial(issued?.certificateChainPem);
|
||||
if (!issuedMaterial) throw new Error('issued material is invalid');
|
||||
failureReason = 'installation_failed';
|
||||
const installedAtMs = safeClock(this.now);
|
||||
const installed = await this.store.install({
|
||||
privateKeyPem: enrollment.privateKeyPem,
|
||||
certificateChainPem: issuedMaterial,
|
||||
trustAnchors,
|
||||
now: installedAtMs,
|
||||
minimumRemainingValidityMs: this.policy.minimumIssuedValidityMs,
|
||||
});
|
||||
await this.store.writeRenewalState({
|
||||
consecutiveFailures: 0,
|
||||
nextAttemptAtMs: null,
|
||||
lastAttemptAtMs: attemptedAtMs,
|
||||
lastSuccessAtMs: installedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'renewed',
|
||||
identity: installed,
|
||||
cleanupPending: installed.cleanupPending,
|
||||
});
|
||||
} catch (error) {
|
||||
if (externalSignal?.aborted) throw externalSignal.reason ?? error;
|
||||
if (signals.timeout.aborted) failureReason = 'timed_out';
|
||||
const failedAtMs = safeClock(this.now);
|
||||
const nextState = this.failedState(
|
||||
renewalState,
|
||||
attemptedAtMs,
|
||||
failedAtMs,
|
||||
);
|
||||
await this.store.writeRenewalState(nextState);
|
||||
if (!current || current.notAfterMs <= failedAtMs) {
|
||||
return Object.freeze({
|
||||
status: 'unavailable',
|
||||
nextAttemptAtMs: nextState.nextAttemptAtMs!,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'retry_scheduled',
|
||||
identity: current,
|
||||
reason: failureReason,
|
||||
nextAttemptAtMs: nextState.nextAttemptAtMs!,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
enrollment?.dispose();
|
||||
} finally {
|
||||
enrollment?.privateKeyPem.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failedState(
|
||||
previous: WorkerCertificateRenewalState,
|
||||
attemptedAtMs: number,
|
||||
failedAtMs: number,
|
||||
): WorkerCertificateRenewalState {
|
||||
const random = this.random();
|
||||
if (!Number.isFinite(random) || random < 0 || random >= 1) {
|
||||
throw new WorkerCertificateRenewalConfigurationError(
|
||||
'random returned an invalid value',
|
||||
);
|
||||
}
|
||||
const consecutiveFailures = Math.min(
|
||||
MAX_CONSECUTIVE_FAILURES,
|
||||
previous.consecutiveFailures + 1,
|
||||
);
|
||||
const exponential = Math.min(
|
||||
this.policy.backoffMaximumMs,
|
||||
this.policy.backoffBaseMs * 2 ** (consecutiveFailures - 1),
|
||||
);
|
||||
const delayMs = Math.max(1, Math.floor(exponential * (0.5 + random / 2)));
|
||||
return Object.freeze({
|
||||
consecutiveFailures,
|
||||
nextAttemptAtMs: failedAtMs + delayMs,
|
||||
lastAttemptAtMs: attemptedAtMs,
|
||||
lastSuccessAtMs: previous.lastSuccessAtMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
// Credential ownership: persist active Worker certificate material atomically.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
} from 'node:fs/promises';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import {
|
||||
assertWorkerCertificateIdentitySummary,
|
||||
validateWorkerCertificateIdentity,
|
||||
type WorkerCertificateIdentitySummary,
|
||||
} from './workerCertificateIdentity';
|
||||
|
||||
const MAX_IDENTITY_FILE_BYTES = 1024 * 1024;
|
||||
const MAX_MANIFEST_BYTES = 4096;
|
||||
const GENERATION_PATTERN =
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
||||
const MAX_GENERATIONS = 8;
|
||||
|
||||
export interface WorkerCertificateFileStoreOptions {
|
||||
readonly rootDirectory: string;
|
||||
readonly retainedGenerations?: number;
|
||||
}
|
||||
|
||||
export interface InstallWorkerCertificateIdentityInput {
|
||||
readonly privateKeyPem: string | Buffer;
|
||||
readonly certificateChainPem: string | Buffer;
|
||||
readonly trustAnchors: readonly (string | Buffer)[];
|
||||
readonly now?: number;
|
||||
readonly minimumRemainingValidityMs?: number;
|
||||
}
|
||||
|
||||
export interface ActiveWorkerCertificateIdentity
|
||||
extends WorkerCertificateIdentitySummary {
|
||||
readonly generationId: string;
|
||||
readonly installedAtMs: number;
|
||||
readonly privateKeyFile: string;
|
||||
readonly certificateChainFile: string;
|
||||
}
|
||||
|
||||
export interface InstallWorkerCertificateIdentityResult
|
||||
extends ActiveWorkerCertificateIdentity {
|
||||
readonly cleanupPending: boolean;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateRenewalState {
|
||||
readonly consecutiveFailures: number;
|
||||
readonly nextAttemptAtMs: number | null;
|
||||
readonly lastAttemptAtMs: number | null;
|
||||
readonly lastSuccessAtMs: number | null;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateStore {
|
||||
readActive(
|
||||
trustAnchors: readonly (string | Buffer)[],
|
||||
now?: number,
|
||||
): Promise<ActiveWorkerCertificateIdentity | undefined>;
|
||||
install(
|
||||
input: InstallWorkerCertificateIdentityInput,
|
||||
): Promise<InstallWorkerCertificateIdentityResult>;
|
||||
readRenewalState(): Promise<WorkerCertificateRenewalState>;
|
||||
writeRenewalState(state: WorkerCertificateRenewalState): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerCertificateIdentityManifest
|
||||
extends WorkerCertificateIdentitySummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generationId: string;
|
||||
readonly installedAtMs: number;
|
||||
}
|
||||
|
||||
interface RenewalStateManifest extends WorkerCertificateRenewalState {
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
export class WorkerCertificateStoreError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Worker certificate store is unavailable: ${message}`);
|
||||
this.name = 'WorkerCertificateStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
function safeNow(value: number | undefined): number {
|
||||
const now = value ?? Date.now();
|
||||
if (!Number.isSafeInteger(now) || now < 0) {
|
||||
throw new WorkerCertificateStoreError('observation time is invalid');
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
async function safeDirectory(path: string): Promise<void> {
|
||||
let existed = true;
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
existed = false;
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
const stat = await lstat(path);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('directory metadata is unsafe');
|
||||
}
|
||||
if (!existed) await chmod(path, 0o700);
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const handle = await open(path, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSyncedFile(
|
||||
path: string,
|
||||
bytes: Buffer,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
const flags =
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0);
|
||||
const handle = await open(path, flags, mode);
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.chmod(mode);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedFile(
|
||||
path: string,
|
||||
maximumBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(path, flags);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 1 ||
|
||||
stat.size > maximumBytes ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('file metadata is unsafe');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > maximumBytes) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerCertificateStoreError('file size is unsafe');
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCertificateStoreError) throw error;
|
||||
throw new WorkerCertificateStoreError('file is unavailable');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSafeExistingDirectory(path: string): Promise<void> {
|
||||
let stat;
|
||||
try {
|
||||
stat = await lstat(path);
|
||||
} catch {
|
||||
throw new WorkerCertificateStoreError('directory is unavailable');
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('directory metadata is unsafe');
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value).sort();
|
||||
const sorted = [...expected].sort();
|
||||
return (
|
||||
keys.length === sorted.length &&
|
||||
keys.every((key, index) => key === sorted[index])
|
||||
);
|
||||
}
|
||||
|
||||
function identityManifest(value: unknown): WorkerCertificateIdentityManifest {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, [
|
||||
'certificateSha256',
|
||||
'generationId',
|
||||
'installedAtMs',
|
||||
'notAfterMs',
|
||||
'notBeforeMs',
|
||||
'publicKeySpkiSha256',
|
||||
'schemaVersion',
|
||||
'serialNumber',
|
||||
])
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('identity manifest is invalid');
|
||||
}
|
||||
const candidate = value as Partial<WorkerCertificateIdentityManifest>;
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
typeof candidate.generationId !== 'string' ||
|
||||
!GENERATION_PATTERN.test(candidate.generationId) ||
|
||||
!Number.isSafeInteger(candidate.installedAtMs) ||
|
||||
Number(candidate.installedAtMs) < 0
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('identity manifest is invalid');
|
||||
}
|
||||
assertWorkerCertificateIdentitySummary(
|
||||
candidate as WorkerCertificateIdentitySummary,
|
||||
);
|
||||
return Object.freeze(candidate as WorkerCertificateIdentityManifest);
|
||||
}
|
||||
|
||||
function renewalStateManifest(value: unknown): RenewalStateManifest {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, [
|
||||
'consecutiveFailures',
|
||||
'lastAttemptAtMs',
|
||||
'lastSuccessAtMs',
|
||||
'nextAttemptAtMs',
|
||||
'schemaVersion',
|
||||
])
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('renewal state is invalid');
|
||||
}
|
||||
const candidate = value as Partial<RenewalStateManifest>;
|
||||
const optionalTime = (time: unknown): boolean =>
|
||||
time === null || (Number.isSafeInteger(time) && Number(time) >= 0);
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
!Number.isSafeInteger(candidate.consecutiveFailures) ||
|
||||
Number(candidate.consecutiveFailures) < 0 ||
|
||||
Number(candidate.consecutiveFailures) > 16 ||
|
||||
!optionalTime(candidate.nextAttemptAtMs) ||
|
||||
!optionalTime(candidate.lastAttemptAtMs) ||
|
||||
!optionalTime(candidate.lastSuccessAtMs)
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('renewal state is invalid');
|
||||
}
|
||||
return Object.freeze(candidate as RenewalStateManifest);
|
||||
}
|
||||
|
||||
async function parseJsonFile<T>(
|
||||
path: string,
|
||||
parser: (value: unknown) => T,
|
||||
): Promise<T> {
|
||||
const bytes = await readBoundedFile(path, MAX_MANIFEST_BYTES);
|
||||
try {
|
||||
return parser(JSON.parse(bytes.toString('utf8')) as unknown);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCertificateStoreError) throw error;
|
||||
throw new WorkerCertificateStoreError('manifest JSON is invalid');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCertificateFileStore implements WorkerCertificateStore {
|
||||
private readonly rootDirectory: string;
|
||||
private readonly generationsDirectory: string;
|
||||
private readonly retainedGenerations: number;
|
||||
private installing = false;
|
||||
|
||||
constructor(options: WorkerCertificateFileStoreOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.rootDirectory !== 'string' ||
|
||||
!isAbsolute(options.rootDirectory) ||
|
||||
options.rootDirectory.length > 4096 ||
|
||||
/[\0\r\n]/.test(options.rootDirectory)
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('rootDirectory is invalid');
|
||||
}
|
||||
const retainedGenerations = options.retainedGenerations ?? 2;
|
||||
if (
|
||||
!Number.isSafeInteger(retainedGenerations) ||
|
||||
retainedGenerations < 1 ||
|
||||
retainedGenerations > 4
|
||||
) {
|
||||
throw new WorkerCertificateStoreError(
|
||||
'retainedGenerations must be between 1 and 4',
|
||||
);
|
||||
}
|
||||
this.rootDirectory = options.rootDirectory;
|
||||
this.generationsDirectory = join(options.rootDirectory, 'generations');
|
||||
this.retainedGenerations = retainedGenerations;
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
await safeDirectory(this.rootDirectory);
|
||||
await safeDirectory(this.generationsDirectory);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCertificateStoreError) throw error;
|
||||
throw new WorkerCertificateStoreError('directory is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private async writeAtomicManifest(
|
||||
name: string,
|
||||
value: unknown,
|
||||
onCommitted?: () => void,
|
||||
): Promise<void> {
|
||||
const temporary = join(this.rootDirectory, `.${name}.${randomUUID()}.tmp`);
|
||||
const destination = join(this.rootDirectory, name);
|
||||
const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
|
||||
try {
|
||||
if (bytes.byteLength > MAX_MANIFEST_BYTES) {
|
||||
throw new WorkerCertificateStoreError('manifest exceeds hard limit');
|
||||
}
|
||||
await writeSyncedFile(temporary, bytes, 0o600);
|
||||
await rename(temporary, destination);
|
||||
onCommitted?.();
|
||||
await syncDirectory(this.rootDirectory);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async readActiveSummary(): Promise<
|
||||
WorkerCertificateIdentityManifest | undefined
|
||||
> {
|
||||
await this.initialize();
|
||||
try {
|
||||
return await parseJsonFile(
|
||||
join(this.rootDirectory, 'active.json'),
|
||||
identityManifest,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
||||
if (
|
||||
error instanceof WorkerCertificateStoreError &&
|
||||
error.message.endsWith('file is unavailable')
|
||||
) {
|
||||
const stat = await lstat(join(this.rootDirectory, 'active.json')).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!stat) return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async readActive(
|
||||
trustAnchors: readonly (string | Buffer)[],
|
||||
now: number = Date.now(),
|
||||
): Promise<ActiveWorkerCertificateIdentity | undefined> {
|
||||
const manifest = await this.readActiveSummary();
|
||||
if (!manifest) return undefined;
|
||||
const generationDirectory = join(
|
||||
this.generationsDirectory,
|
||||
manifest.generationId,
|
||||
);
|
||||
const privateKeyFile = join(generationDirectory, 'private-key.pem');
|
||||
const certificateChainFile = join(
|
||||
generationDirectory,
|
||||
'certificate-chain.pem',
|
||||
);
|
||||
await assertSafeExistingDirectory(generationDirectory);
|
||||
const privateKeyPem = await readBoundedFile(
|
||||
privateKeyFile,
|
||||
MAX_IDENTITY_FILE_BYTES,
|
||||
);
|
||||
let certificateChainPem: Buffer | undefined;
|
||||
try {
|
||||
certificateChainPem = await readBoundedFile(
|
||||
certificateChainFile,
|
||||
MAX_IDENTITY_FILE_BYTES,
|
||||
);
|
||||
const summary = validateWorkerCertificateIdentity({
|
||||
privateKeyPem,
|
||||
certificateChainPem,
|
||||
trustAnchors,
|
||||
now: safeNow(now),
|
||||
});
|
||||
if (
|
||||
summary.certificateSha256 !== manifest.certificateSha256 ||
|
||||
summary.publicKeySpkiSha256 !== manifest.publicKeySpkiSha256 ||
|
||||
summary.serialNumber !== manifest.serialNumber ||
|
||||
summary.notBeforeMs !== manifest.notBeforeMs ||
|
||||
summary.notAfterMs !== manifest.notAfterMs
|
||||
) {
|
||||
throw new WorkerCertificateStoreError('active identity was modified');
|
||||
}
|
||||
return Object.freeze({
|
||||
...summary,
|
||||
generationId: manifest.generationId,
|
||||
installedAtMs: manifest.installedAtMs,
|
||||
privateKeyFile,
|
||||
certificateChainFile,
|
||||
});
|
||||
} finally {
|
||||
privateKeyPem.fill(0);
|
||||
certificateChainPem?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async install(
|
||||
input: InstallWorkerCertificateIdentityInput,
|
||||
): Promise<InstallWorkerCertificateIdentityResult> {
|
||||
if (this.installing) {
|
||||
throw new WorkerCertificateStoreError('another install is in progress');
|
||||
}
|
||||
this.installing = true;
|
||||
let stagingDirectory: string | undefined;
|
||||
let generationDirectory: string | undefined;
|
||||
let activated = false;
|
||||
try {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new WorkerCertificateStoreError('install input is invalid');
|
||||
}
|
||||
const now = safeNow(input.now);
|
||||
const summary = validateWorkerCertificateIdentity({ ...input, now });
|
||||
await this.initialize();
|
||||
const entries = await readdir(this.generationsDirectory, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
if (entries.length >= MAX_GENERATIONS) {
|
||||
throw new WorkerCertificateStoreError(
|
||||
'generation capacity requires maintenance',
|
||||
);
|
||||
}
|
||||
const generationId = randomUUID();
|
||||
generationDirectory = join(this.generationsDirectory, generationId);
|
||||
stagingDirectory = join(
|
||||
this.generationsDirectory,
|
||||
`.staging-${generationId}`,
|
||||
);
|
||||
await mkdir(stagingDirectory, { mode: 0o700 });
|
||||
const privateKeyBytes = Buffer.isBuffer(input.privateKeyPem)
|
||||
? Buffer.from(input.privateKeyPem)
|
||||
: Buffer.from(input.privateKeyPem, 'utf8');
|
||||
const certificateBytes = Buffer.isBuffer(input.certificateChainPem)
|
||||
? Buffer.from(input.certificateChainPem)
|
||||
: Buffer.from(input.certificateChainPem, 'utf8');
|
||||
const manifest: WorkerCertificateIdentityManifest = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
generationId,
|
||||
installedAtMs: now,
|
||||
...summary,
|
||||
});
|
||||
const manifestBytes = Buffer.from(`${JSON.stringify(manifest)}\n`);
|
||||
try {
|
||||
await Promise.all([
|
||||
writeSyncedFile(
|
||||
join(stagingDirectory, 'private-key.pem'),
|
||||
privateKeyBytes,
|
||||
0o600,
|
||||
),
|
||||
writeSyncedFile(
|
||||
join(stagingDirectory, 'certificate-chain.pem'),
|
||||
certificateBytes,
|
||||
0o600,
|
||||
),
|
||||
writeSyncedFile(
|
||||
join(stagingDirectory, 'metadata.json'),
|
||||
manifestBytes,
|
||||
0o600,
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
privateKeyBytes.fill(0);
|
||||
certificateBytes.fill(0);
|
||||
manifestBytes.fill(0);
|
||||
}
|
||||
await syncDirectory(stagingDirectory);
|
||||
await rename(stagingDirectory, generationDirectory);
|
||||
stagingDirectory = undefined;
|
||||
await syncDirectory(this.generationsDirectory);
|
||||
await this.writeAtomicManifest('active.json', manifest, () => {
|
||||
activated = true;
|
||||
});
|
||||
const cleanupPending = !(await this.pruneRetired(manifest.generationId));
|
||||
return Object.freeze({
|
||||
...summary,
|
||||
generationId,
|
||||
installedAtMs: now,
|
||||
privateKeyFile: join(generationDirectory, 'private-key.pem'),
|
||||
certificateChainFile: join(
|
||||
generationDirectory,
|
||||
'certificate-chain.pem',
|
||||
),
|
||||
cleanupPending,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCertificateStoreError) throw error;
|
||||
throw new WorkerCertificateStoreError('install failed');
|
||||
} finally {
|
||||
if (stagingDirectory) {
|
||||
await rm(stagingDirectory, { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
if (generationDirectory && !activated) {
|
||||
await rm(generationDirectory, { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
this.installing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pruneRetired(activeGenerationId: string): Promise<boolean> {
|
||||
try {
|
||||
const entries = await readdir(this.generationsDirectory, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const generations: Array<{
|
||||
generationId: string;
|
||||
installedAtMs: number;
|
||||
}> = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !GENERATION_PATTERN.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const metadata = await parseJsonFile(
|
||||
join(this.generationsDirectory, entry.name, 'metadata.json'),
|
||||
identityManifest,
|
||||
);
|
||||
if (metadata.generationId !== entry.name) return false;
|
||||
generations.push({
|
||||
generationId: entry.name,
|
||||
installedAtMs: metadata.installedAtMs,
|
||||
});
|
||||
}
|
||||
generations.sort(
|
||||
(left, right) =>
|
||||
right.installedAtMs - left.installedAtMs ||
|
||||
right.generationId.localeCompare(left.generationId),
|
||||
);
|
||||
const keep = new Set(
|
||||
generations
|
||||
.filter((item) => item.generationId !== activeGenerationId)
|
||||
.slice(0, Math.max(0, this.retainedGenerations - 1))
|
||||
.map((item) => item.generationId),
|
||||
);
|
||||
keep.add(activeGenerationId);
|
||||
for (const generation of generations) {
|
||||
if (!keep.has(generation.generationId)) {
|
||||
await rm(join(this.generationsDirectory, generation.generationId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await syncDirectory(this.generationsDirectory);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async readRenewalState(): Promise<WorkerCertificateRenewalState> {
|
||||
await this.initialize();
|
||||
try {
|
||||
const manifest = await parseJsonFile(
|
||||
join(this.rootDirectory, 'renewal.json'),
|
||||
renewalStateManifest,
|
||||
);
|
||||
const { schemaVersion: _schemaVersion, ...state } = manifest;
|
||||
return Object.freeze(state);
|
||||
} catch (error) {
|
||||
const stat = await lstat(join(this.rootDirectory, 'renewal.json')).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!stat) {
|
||||
return Object.freeze({
|
||||
consecutiveFailures: 0,
|
||||
nextAttemptAtMs: null,
|
||||
lastAttemptAtMs: null,
|
||||
lastSuccessAtMs: null,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async writeRenewalState(state: WorkerCertificateRenewalState): Promise<void> {
|
||||
await this.initialize();
|
||||
const manifest = renewalStateManifest({ schemaVersion: 1, ...state });
|
||||
await this.writeAtomicManifest('renewal.json', manifest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Credential ownership: load production mTLS identity and bounded Worker token.
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, normalize, parse } from 'node:path';
|
||||
import { normalizeWorkerCredentialId } from '@qinglong/runtime-core/worker-credential';
|
||||
import {
|
||||
validateWorkerCertificateIdentity,
|
||||
type WorkerCertificateIdentitySummary,
|
||||
} from './workerCertificateIdentity';
|
||||
import type {
|
||||
WorkerCertificateStore,
|
||||
} from './workerCertificateStore';
|
||||
import type {
|
||||
WorkerCertificateTrustAnchorProvider,
|
||||
} from './workerCertificateRenewal';
|
||||
import type {
|
||||
WorkerIngressHttpsCredentialProvider,
|
||||
WorkerIngressHttpsCredentials,
|
||||
} from '../remote-execution/transport/workerIngressHttpsClient';
|
||||
|
||||
const MAX_CREDENTIAL_TOKEN_BYTES = 256;
|
||||
const MAX_TLS_MATERIAL_BYTES = 1024 * 1024;
|
||||
const CREDENTIAL_TOKEN =
|
||||
/^ql3w_([A-Za-z0-9][A-Za-z0-9._:-]{0,63})_([A-Za-z0-9_-]{43})$/;
|
||||
|
||||
export interface WorkerProductionCredentialProviderOptions {
|
||||
readonly certificateStore: Pick<WorkerCertificateStore, 'readActive'>;
|
||||
readonly trustAnchors: WorkerCertificateTrustAnchorProvider;
|
||||
/** Private, atomically replaceable file containing one ql3w token. */
|
||||
readonly credentialTokenFile: string;
|
||||
readonly expectedCredentialId?: string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class WorkerProductionCredentialProviderError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'invalid_configuration' | 'credentials_unavailable',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker production credentials failed: ${reason}`, options);
|
||||
this.name = 'WorkerProductionCredentialProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
function privateFilePath(value: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
parse(value).root === value ||
|
||||
normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) throw new WorkerProductionCredentialProviderError('invalid_configuration');
|
||||
return value;
|
||||
}
|
||||
|
||||
function now(provider: () => number): number {
|
||||
const value = provider();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerProductionCredentialProviderError('credentials_unavailable');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readPrivateFile(
|
||||
path: string,
|
||||
maximumBytes: number,
|
||||
): Promise<Buffer> {
|
||||
let handle;
|
||||
try {
|
||||
const parent = await lstat(dirname(path));
|
||||
if (
|
||||
!parent.isDirectory() ||
|
||||
parent.isSymbolicLink() ||
|
||||
(parent.mode & 0o077) !== 0
|
||||
) throw new Error('unsafe parent');
|
||||
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 1 ||
|
||||
stat.size > maximumBytes ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) throw new Error('unsafe file');
|
||||
const bytes = await handle.readFile();
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > maximumBytes) {
|
||||
bytes.fill(0);
|
||||
throw new Error('unsafe bytes');
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
throw new WorkerProductionCredentialProviderError(
|
||||
'credentials_unavailable', { cause: error },
|
||||
);
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function token(bytes: Buffer, expectedCredentialId?: string): string {
|
||||
try {
|
||||
let length = bytes.byteLength;
|
||||
if (bytes[length - 1] === 0x0a) length -= 1;
|
||||
const value = bytes.subarray(0, length).toString('ascii');
|
||||
const match = CREDENTIAL_TOKEN.exec(value);
|
||||
if (
|
||||
!match ||
|
||||
bytes.subarray(0, length).some((byte) => byte > 0x7f) ||
|
||||
bytes.subarray(0, length).includes(0x0a) ||
|
||||
(expectedCredentialId !== undefined &&
|
||||
match[1] !== expectedCredentialId)
|
||||
) throw new Error('token is invalid');
|
||||
return value;
|
||||
} catch (error) {
|
||||
throw new WorkerProductionCredentialProviderError(
|
||||
'credentials_unavailable', { cause: error },
|
||||
);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
expected: WorkerCertificateIdentitySummary,
|
||||
actual: WorkerCertificateIdentitySummary,
|
||||
): boolean {
|
||||
return actual.certificateSha256 === expected.certificateSha256 &&
|
||||
actual.publicKeySpkiSha256 === expected.publicKeySpkiSha256 &&
|
||||
actual.serialNumber === expected.serialNumber &&
|
||||
actual.notBeforeMs === expected.notBeforeMs &&
|
||||
actual.notAfterMs === expected.notAfterMs;
|
||||
}
|
||||
|
||||
function copyMaterial(value: string | Buffer): Buffer {
|
||||
const bytes = Buffer.isBuffer(value)
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value, 'utf8');
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > MAX_TLS_MATERIAL_BYTES) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerProductionCredentialProviderError(
|
||||
'credentials_unavailable',
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the current certificate generation and ql3w token for every request.
|
||||
* Atomic file replacement therefore rotates credentials without a watcher or
|
||||
* a second Agent. Returned Buffer material is disposable by the HTTPS client.
|
||||
*/
|
||||
export class WorkerProductionCredentialProvider
|
||||
implements WorkerIngressHttpsCredentialProvider {
|
||||
private readonly certificateStore: Pick<WorkerCertificateStore, 'readActive'>;
|
||||
private readonly trustAnchors: WorkerCertificateTrustAnchorProvider;
|
||||
private readonly credentialTokenFile: string;
|
||||
private readonly expectedCredentialId?: string;
|
||||
private readonly nowProvider: () => number;
|
||||
|
||||
constructor(options: WorkerProductionCredentialProviderOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.certificateStore?.readActive !== 'function' ||
|
||||
typeof options.trustAnchors?.load !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) throw new WorkerProductionCredentialProviderError('invalid_configuration');
|
||||
let expectedCredentialId: string | undefined;
|
||||
try {
|
||||
expectedCredentialId = options.expectedCredentialId === undefined
|
||||
? undefined
|
||||
: normalizeWorkerCredentialId(options.expectedCredentialId);
|
||||
} catch (error) {
|
||||
throw new WorkerProductionCredentialProviderError(
|
||||
'invalid_configuration', { cause: error },
|
||||
);
|
||||
}
|
||||
this.certificateStore = options.certificateStore;
|
||||
this.trustAnchors = options.trustAnchors;
|
||||
this.credentialTokenFile = privateFilePath(options.credentialTokenFile);
|
||||
this.expectedCredentialId = expectedCredentialId;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async load(signal?: AbortSignal): Promise<WorkerIngressHttpsCredentials> {
|
||||
const operationSignal = signal ?? new AbortController().signal;
|
||||
operationSignal.throwIfAborted();
|
||||
let certificate: Buffer | undefined;
|
||||
let privateKey: Buffer | undefined;
|
||||
const anchors: Buffer[] = [];
|
||||
try {
|
||||
const observedAtMs = now(this.nowProvider);
|
||||
const trustAnchors = await this.trustAnchors.load(operationSignal);
|
||||
operationSignal.throwIfAborted();
|
||||
const active = await this.certificateStore.readActive(
|
||||
trustAnchors,
|
||||
observedAtMs,
|
||||
);
|
||||
if (!active) {
|
||||
throw new WorkerProductionCredentialProviderError(
|
||||
'credentials_unavailable',
|
||||
);
|
||||
}
|
||||
certificate = await readPrivateFile(
|
||||
active.certificateChainFile,
|
||||
MAX_TLS_MATERIAL_BYTES,
|
||||
);
|
||||
privateKey = await readPrivateFile(
|
||||
active.privateKeyFile,
|
||||
MAX_TLS_MATERIAL_BYTES,
|
||||
);
|
||||
operationSignal.throwIfAborted();
|
||||
const summary = validateWorkerCertificateIdentity({
|
||||
certificateChainPem: certificate,
|
||||
privateKeyPem: privateKey,
|
||||
trustAnchors,
|
||||
now: observedAtMs,
|
||||
});
|
||||
if (!sameIdentity(active, summary)) {
|
||||
throw new WorkerProductionCredentialProviderError(
|
||||
'credentials_unavailable',
|
||||
);
|
||||
}
|
||||
const credentialToken = token(
|
||||
await readPrivateFile(
|
||||
this.credentialTokenFile,
|
||||
MAX_CREDENTIAL_TOKEN_BYTES,
|
||||
),
|
||||
this.expectedCredentialId,
|
||||
);
|
||||
for (const anchor of trustAnchors) anchors.push(copyMaterial(anchor));
|
||||
const disposableCertificate = certificate;
|
||||
const disposablePrivateKey = privateKey;
|
||||
certificate = undefined;
|
||||
privateKey = undefined;
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
authorization: `Worker ${credentialToken}`,
|
||||
certificateChainPem: disposableCertificate,
|
||||
privateKeyPem: disposablePrivateKey,
|
||||
trustAnchors: Object.freeze(anchors),
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
disposableCertificate.fill(0);
|
||||
disposablePrivateKey.fill(0);
|
||||
anchors.forEach((anchor) => anchor.fill(0));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
certificate?.fill(0);
|
||||
privateKey?.fill(0);
|
||||
anchors.forEach((anchor) => anchor.fill(0));
|
||||
if (operationSignal.aborted) throw operationSignal.reason ?? error;
|
||||
if (error instanceof WorkerProductionCredentialProviderError) throw error;
|
||||
throw new WorkerProductionCredentialProviderError(
|
||||
'credentials_unavailable', { cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Worker Execution owns crash-replayable Artifact upload and completion convergence.
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type {
|
||||
CompletionReceipt,
|
||||
CompletionReceiptStore,
|
||||
} from '@qinglong/local-process';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from '../remote-execution/executionInbox';
|
||||
import type { WorkerRemoteExecutionSession } from '../remote-execution/executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteLogArtifactReadLease,
|
||||
WorkerRemoteLogArtifactSource,
|
||||
} from './workerFileLogArtifactAllocator';
|
||||
import type { RemoteWorkerExecutionFence } from '@qinglong/runtime-core/remote-worker-completion';
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const COMPLETION_EVIDENCE_STATES = new Set([
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'recovery_required',
|
||||
]);
|
||||
|
||||
export interface WorkerRemoteLogArtifactUploadCommand {
|
||||
readonly workerId: RemoteWorkerExecutionFence['workerId'];
|
||||
readonly workerSessionId: RemoteWorkerExecutionFence['workerSessionId'];
|
||||
readonly workerGeneration: RemoteWorkerExecutionFence['workerGeneration'];
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly offerId: RemoteWorkerExecutionFence['offerId'];
|
||||
readonly leaseGeneration: RemoteWorkerExecutionFence['leaseGeneration'];
|
||||
readonly leaseToken: RemoteWorkerExecutionFence['leaseToken'];
|
||||
readonly expectedLeaseVersion: RemoteWorkerExecutionFence['expectedLeaseVersion'];
|
||||
readonly logArtifactId: string;
|
||||
readonly byteLength: number;
|
||||
readonly truncated: boolean | undefined;
|
||||
readonly content: AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactUploadResult {
|
||||
readonly status: 'stored' | 'already_stored';
|
||||
readonly logArtifactId: string;
|
||||
readonly byteLength: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactUploader {
|
||||
upload(
|
||||
command: WorkerRemoteLogArtifactUploadCommand,
|
||||
): Promise<Readonly<WorkerRemoteLogArtifactUploadResult>>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionCommand {
|
||||
readonly offerId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly callbackSequence: number;
|
||||
readonly callbackTokenDigest: string;
|
||||
readonly result: Readonly<{
|
||||
outcome: 'succeeded' | 'failed';
|
||||
startedAtMs: number;
|
||||
finishedAtMs: number;
|
||||
exitCode: number;
|
||||
}>;
|
||||
readonly artifact: Readonly<{
|
||||
logArtifactId: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
truncated: boolean | undefined;
|
||||
}>;
|
||||
readonly executorType: string;
|
||||
readonly workerId: string;
|
||||
readonly workerSessionId: string;
|
||||
readonly workerGeneration: number;
|
||||
readonly leaseGeneration: number;
|
||||
readonly leaseToken: string;
|
||||
readonly expectedLeaseVersion: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionResult {
|
||||
readonly status: 'applied' | 'already_completed' | 'already_terminal';
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly callbackSequence: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionClient {
|
||||
complete(
|
||||
command: WorkerRemoteExecutionCompletionCommand,
|
||||
): Promise<Readonly<WorkerRemoteExecutionCompletionResult>>;
|
||||
}
|
||||
|
||||
export type WorkerRemoteCompletionStatus =
|
||||
| 'not_found'
|
||||
| 'deferred'
|
||||
| 'receipt_missing'
|
||||
| 'receipt_unavailable'
|
||||
| 'receipt_invalid'
|
||||
| 'artifact_missing'
|
||||
| 'completion_acknowledged'
|
||||
| 'already_completed'
|
||||
| 'control_plane_terminal';
|
||||
|
||||
export interface WorkerRemoteCompletionResult {
|
||||
readonly offerId: string;
|
||||
readonly status: WorkerRemoteCompletionStatus;
|
||||
readonly receiptCleanup?: 'removed' | 'already_absent' | 'pending';
|
||||
}
|
||||
|
||||
export interface WorkerRemoteCompletionCoordinatorOptions {
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class WorkerRemoteCompletionCoordinatorError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'artifact_response_invalid'
|
||||
| 'completion_response_invalid',
|
||||
) {
|
||||
super(`Worker remote completion coordination failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteCompletionCoordinatorError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-replayable Worker completion pipeline. The local receipt is an
|
||||
* authenticated capability, the log is uploaded before terminal mutation,
|
||||
* and receipt deletion happens only after the inbox completion barrier.
|
||||
*/
|
||||
export class WorkerRemoteCompletionCoordinator {
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Promise<WorkerRemoteCompletionResult>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly inbox: Pick<
|
||||
WorkerRemoteExecutionInbox,
|
||||
'readOffer' | 'replaceOffer'
|
||||
>,
|
||||
private readonly receipts: Pick<CompletionReceiptStore, 'read' | 'remove'>,
|
||||
private readonly artifacts: WorkerRemoteLogArtifactSource,
|
||||
private readonly uploader: WorkerRemoteLogArtifactUploader,
|
||||
private readonly completion: WorkerRemoteExecutionCompletionClient,
|
||||
options: WorkerRemoteCompletionCoordinatorOptions,
|
||||
) {
|
||||
if (
|
||||
typeof inbox?.readOffer !== 'function' ||
|
||||
typeof inbox?.replaceOffer !== 'function' ||
|
||||
typeof receipts?.read !== 'function' ||
|
||||
typeof receipts?.remove !== 'function' ||
|
||||
typeof artifacts?.open !== 'function' ||
|
||||
typeof uploader?.upload !== 'function' ||
|
||||
typeof completion?.complete !== 'function' ||
|
||||
typeof options?.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError('invalid_configuration');
|
||||
}
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
recover(offerId: string): Promise<WorkerRemoteCompletionResult> {
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.process(offerId).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) this.inFlight.delete(offerId);
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async process(offerId: string): Promise<WorkerRemoteCompletionResult> {
|
||||
const value = await this.inbox.readOffer(offerId);
|
||||
if (!value) return Object.freeze({ offerId, status: 'not_found' });
|
||||
let record = normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'already_completed',
|
||||
receiptCleanup: await this.cleanup(record.offer.candidate.attemptId),
|
||||
});
|
||||
}
|
||||
if (!this.canSubmit(record)) {
|
||||
return Object.freeze({ offerId, status: 'deferred' });
|
||||
}
|
||||
|
||||
let receipt: CompletionReceipt | undefined;
|
||||
try {
|
||||
receipt = await this.receipts.read(record.offer.candidate.attemptId);
|
||||
} catch {
|
||||
return Object.freeze({ offerId, status: 'receipt_unavailable' });
|
||||
}
|
||||
if (!receipt) return Object.freeze({ offerId, status: 'receipt_missing' });
|
||||
try {
|
||||
this.authenticate(record, receipt);
|
||||
} catch {
|
||||
return Object.freeze({ offerId, status: 'receipt_invalid' });
|
||||
}
|
||||
|
||||
const artifact = await this.artifacts.open({
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
logArtifactId: record.logArtifactId!,
|
||||
});
|
||||
if (!artifact) return Object.freeze({ offerId, status: 'artifact_missing' });
|
||||
const uploaded = await this.upload(record, artifact);
|
||||
const completed = await this.completion.complete(Object.freeze({
|
||||
offerId: record.offer.offerId,
|
||||
projectId: record.offer.candidate.projectId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
callbackSequence: receipt.callbackSequence,
|
||||
callbackTokenDigest: record.completionReceiptTokenDigest!,
|
||||
result: Object.freeze({
|
||||
outcome: receipt.exitCode === 0 ? 'succeeded' as const : 'failed' as const,
|
||||
startedAtMs: receipt.startedAtMs,
|
||||
finishedAtMs: receipt.finishedAtMs,
|
||||
exitCode: receipt.exitCode,
|
||||
}),
|
||||
artifact: Object.freeze({
|
||||
logArtifactId: uploaded.logArtifactId,
|
||||
byteLength: uploaded.byteLength,
|
||||
sha256: uploaded.sha256,
|
||||
truncated: artifact.truncated,
|
||||
}),
|
||||
executorType: record.offer.candidate.executorType,
|
||||
workerId: record.offer.lease.workerId,
|
||||
workerSessionId: record.offer.lease.workerSessionId,
|
||||
workerGeneration: record.offer.lease.workerGeneration,
|
||||
leaseGeneration: record.offer.lease.leaseGeneration,
|
||||
leaseToken: record.offer.leaseToken,
|
||||
expectedLeaseVersion: record.offer.lease.version,
|
||||
}));
|
||||
this.assertCompletionResponse(record, receipt, completed);
|
||||
if (completed.status === 'already_terminal') {
|
||||
return Object.freeze({ offerId, status: 'control_plane_terminal' });
|
||||
}
|
||||
record = await this.markAcknowledged(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_acknowledged',
|
||||
receiptCleanup: await this.cleanup(record.offer.candidate.attemptId),
|
||||
});
|
||||
}
|
||||
|
||||
private canSubmit(record: WorkerRemoteExecutionInboxRecord): boolean {
|
||||
if (!COMPLETION_EVIDENCE_STATES.has(record.state)) return false;
|
||||
if (
|
||||
record.state === 'recovery_required' &&
|
||||
record.recoveryReason !== 'launch_outcome_unknown'
|
||||
) return false;
|
||||
const current = this.currentSessionProvider();
|
||||
const now = this.now();
|
||||
return Boolean(
|
||||
current &&
|
||||
current.workerId === record.offer.worker.workerId &&
|
||||
current.sessionId === record.offer.worker.sessionId &&
|
||||
current.generation === record.offer.worker.generation &&
|
||||
current.status !== 'offline' &&
|
||||
current.leaseExpiresAtMs > now &&
|
||||
record.offer.lease.expiresAtMs > now &&
|
||||
record.executorStartedAtMs !== undefined &&
|
||||
record.logArtifactId !== undefined &&
|
||||
record.completionReceiptCallbackSequence !== undefined &&
|
||||
record.completionReceiptTokenDigest !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
private authenticate(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
receipt: CompletionReceipt,
|
||||
): void {
|
||||
if (
|
||||
receipt.runId !== record.offer.candidate.runId ||
|
||||
receipt.attemptId !== record.offer.candidate.attemptId ||
|
||||
receipt.callbackSequence !== record.completionReceiptCallbackSequence ||
|
||||
receipt.startedAtMs !== record.executorStartedAtMs ||
|
||||
receipt.finishedAtMs > this.now() ||
|
||||
!record.completionReceiptTokenDigest ||
|
||||
!SHA256.test(record.completionReceiptTokenDigest)
|
||||
) throw new Error('Completion receipt authority does not match');
|
||||
const token = Buffer.from(receipt.token, 'base64url');
|
||||
try {
|
||||
if (token.byteLength !== 32 || token.toString('base64url') !== receipt.token) {
|
||||
throw new Error('Completion receipt capability is not canonical');
|
||||
}
|
||||
const expected = Buffer.from(record.completionReceiptTokenDigest, 'hex');
|
||||
const actual = createHash('sha256').update(token).digest();
|
||||
if (!timingSafeEqual(expected, actual)) {
|
||||
throw new Error('Completion receipt capability does not match');
|
||||
}
|
||||
} finally {
|
||||
token.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private async upload(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
artifact: WorkerRemoteLogArtifactReadLease,
|
||||
): Promise<WorkerRemoteLogArtifactUploadResult> {
|
||||
const digest = createHash('sha256');
|
||||
let observedBytes = 0;
|
||||
const content = (async function* () {
|
||||
for await (const chunk of artifact.chunks()) {
|
||||
observedBytes += chunk.byteLength;
|
||||
digest.update(chunk);
|
||||
yield chunk;
|
||||
}
|
||||
})();
|
||||
let result: Readonly<WorkerRemoteLogArtifactUploadResult>;
|
||||
try {
|
||||
result = await this.uploader.upload(Object.freeze({
|
||||
workerId: record.offer.lease.workerId,
|
||||
workerSessionId: record.offer.lease.workerSessionId,
|
||||
workerGeneration: record.offer.lease.workerGeneration,
|
||||
projectId: record.offer.candidate.projectId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
offerId: record.offer.offerId,
|
||||
leaseGeneration: record.offer.lease.leaseGeneration,
|
||||
leaseToken: record.offer.leaseToken,
|
||||
expectedLeaseVersion: record.offer.lease.version,
|
||||
logArtifactId: artifact.logArtifactId,
|
||||
byteLength: artifact.byteLength,
|
||||
truncated: artifact.truncated,
|
||||
content,
|
||||
}));
|
||||
} finally {
|
||||
await artifact.close();
|
||||
}
|
||||
const actualDigest = digest.digest('hex');
|
||||
if (
|
||||
observedBytes !== artifact.byteLength ||
|
||||
(result?.status !== 'stored' && result?.status !== 'already_stored') ||
|
||||
result.logArtifactId !== artifact.logArtifactId ||
|
||||
result.byteLength !== artifact.byteLength ||
|
||||
!SHA256.test(result.sha256) ||
|
||||
result.sha256 !== actualDigest
|
||||
) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...result });
|
||||
}
|
||||
|
||||
private assertCompletionResponse(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
receipt: CompletionReceipt,
|
||||
result: Readonly<WorkerRemoteExecutionCompletionResult>,
|
||||
): void {
|
||||
if (
|
||||
!['applied', 'already_completed', 'already_terminal'].includes(result?.status) ||
|
||||
result.runId !== record.offer.candidate.runId ||
|
||||
result.attemptId !== record.offer.candidate.attemptId ||
|
||||
result.callbackSequence !== receipt.callbackSequence
|
||||
) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError(
|
||||
'completion_response_invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async markAcknowledged(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const updatedAtMs = Math.max(this.now(), record.updatedAtMs);
|
||||
const { recoveryReason: _recoveryReason, ...authority } = record;
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...authority,
|
||||
revision: record.revision + 1,
|
||||
state: 'completion_acknowledged',
|
||||
updatedAtMs,
|
||||
completionAcknowledgedAtMs: updatedAtMs,
|
||||
});
|
||||
try {
|
||||
await this.inbox.replaceOffer(next, record.revision);
|
||||
return next;
|
||||
} catch (error) {
|
||||
const current = await this.inbox.readOffer(record.offer.offerId);
|
||||
if (current) {
|
||||
const normalized = normalizeWorkerRemoteExecutionInboxRecord(current);
|
||||
if (normalized.state === 'completion_acknowledged') return normalized;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(
|
||||
attemptId: string,
|
||||
): Promise<'removed' | 'already_absent' | 'pending'> {
|
||||
try {
|
||||
return (await this.receipts.remove(attemptId))
|
||||
? 'removed'
|
||||
: 'already_absent';
|
||||
} catch {
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const value = this.nowProvider();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// Worker Execution owns lease renewal, stop fencing, and completion convergence.
|
||||
import type {
|
||||
LocalProcessController,
|
||||
LocalProcessStopResult,
|
||||
} from '@qinglong/local-process';
|
||||
import type { RemoteWorkerLeaseControlResult } from '@qinglong/runtime-core/remote-worker-lease-control';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from '../remote-execution/executionInbox';
|
||||
import type { WorkerRemoteExecutionSession } from '../remote-execution/executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteCompletionResult,
|
||||
WorkerRemoteCompletionStatus,
|
||||
} from './workerCompletionCoordinator';
|
||||
import type { WorkerRemoteLeaseControlClient } from '../remote-execution/transport/remoteWorkerLeaseControlHttpsClient';
|
||||
|
||||
export interface WorkerRemoteExecutionControlCoordinatorOptions {
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionControlResult = Readonly<{
|
||||
readonly offerId: string;
|
||||
readonly completionStatus?: WorkerRemoteCompletionStatus;
|
||||
} & (
|
||||
| { readonly status: 'not_found' }
|
||||
| { readonly status: 'completion_acknowledged' }
|
||||
| {
|
||||
readonly status: 'renewed';
|
||||
readonly leaseVersion: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
| {
|
||||
readonly status: 'stop_requested' | 'stop_unverified';
|
||||
readonly reason: NonNullable<RemoteWorkerLeaseControlResult['stop']>['reason'];
|
||||
readonly leaseVersion: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly stop: LocalProcessStopResult;
|
||||
}
|
||||
| {
|
||||
readonly status: 'terminal';
|
||||
readonly terminalStatus: NonNullable<
|
||||
RemoteWorkerLeaseControlResult['terminalStatus']
|
||||
>;
|
||||
readonly stop: LocalProcessStopResult;
|
||||
}
|
||||
| {
|
||||
readonly status: 'completion_terminal';
|
||||
readonly stop: LocalProcessStopResult;
|
||||
}
|
||||
| {
|
||||
readonly status: 'lease_expired';
|
||||
readonly stop: LocalProcessStopResult;
|
||||
readonly recoveryReason:
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified';
|
||||
}
|
||||
| { readonly status: 'session_unavailable' }
|
||||
)>;
|
||||
|
||||
export class WorkerRemoteExecutionControlCoordinatorError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'invalid_clock'
|
||||
| 'lease_response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote execution control failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteExecutionControlCoordinatorError';
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_TERMINAL_TRANSITIONS = new Set([
|
||||
'accepted',
|
||||
'starting_acknowledged',
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'start_failed',
|
||||
'recovery_required',
|
||||
]);
|
||||
|
||||
/**
|
||||
* One bounded, timer-free supervision step for an accepted remote execution.
|
||||
* Completion evidence is replayed first; live authority is then renewed or an
|
||||
* exact durable process identity is stopped before recovery evidence is stored.
|
||||
*/
|
||||
export class WorkerRemoteExecutionControlCoordinator {
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Promise<WorkerRemoteExecutionControlResult>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly inbox: Pick<
|
||||
WorkerRemoteExecutionInbox,
|
||||
'readOffer' | 'replaceOffer'
|
||||
>,
|
||||
private readonly completion: Pick<
|
||||
{ recover(offerId: string): Promise<WorkerRemoteCompletionResult> },
|
||||
'recover'
|
||||
>,
|
||||
private readonly leaseControl: WorkerRemoteLeaseControlClient,
|
||||
private readonly processes: Pick<LocalProcessController, 'stop'>,
|
||||
options: WorkerRemoteExecutionControlCoordinatorOptions,
|
||||
) {
|
||||
if (
|
||||
typeof inbox?.readOffer !== 'function' ||
|
||||
typeof inbox?.replaceOffer !== 'function' ||
|
||||
typeof completion?.recover !== 'function' ||
|
||||
typeof leaseControl?.control !== 'function' ||
|
||||
typeof processes?.stop !== 'function' ||
|
||||
typeof options?.currentSession !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
reconcile(offerId: string): Promise<WorkerRemoteExecutionControlResult> {
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.reconcileOnce(offerId).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) this.inFlight.delete(offerId);
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async reconcileOnce(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionControlResult> {
|
||||
const value = await this.inbox.readOffer(offerId);
|
||||
if (!value) return Object.freeze({ offerId, status: 'not_found' as const });
|
||||
let record = normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_acknowledged' as const,
|
||||
});
|
||||
}
|
||||
|
||||
let replay: WorkerRemoteCompletionResult | undefined;
|
||||
try {
|
||||
replay = await this.completion.recover(offerId);
|
||||
} catch {
|
||||
// Completion and lease transports are intentionally isolated: a failed
|
||||
// Artifact upload must not prevent the Worker from retaining authority.
|
||||
}
|
||||
if (
|
||||
replay?.status === 'completion_acknowledged' ||
|
||||
replay?.status === 'already_completed'
|
||||
) {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_acknowledged' as const,
|
||||
completionStatus: replay.status,
|
||||
});
|
||||
}
|
||||
record = await this.readRequired(offerId);
|
||||
const completionStatus = replay?.status;
|
||||
if (replay?.status === 'control_plane_terminal') {
|
||||
const stop = await this.stop(record);
|
||||
await this.markControlPlaneTerminal(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_terminal' as const,
|
||||
stop,
|
||||
completionStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
if (record.offer.lease.expiresAtMs <= now) {
|
||||
const stop = await this.stop(record);
|
||||
const recoveryReason = this.stopWasConclusive(stop)
|
||||
? 'lease_lost_local_execution_stopped' as const
|
||||
: 'lease_lost_local_execution_unverified' as const;
|
||||
await this.markLeaseLost(record, recoveryReason);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'lease_expired' as const,
|
||||
stop,
|
||||
recoveryReason,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.sessionMatches(record, now)) {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'session_unavailable' as const,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
const control = await this.leaseControl.control(Object.freeze({
|
||||
workerId: record.offer.lease.workerId,
|
||||
workerSessionId: record.offer.lease.workerSessionId,
|
||||
workerGeneration: record.offer.lease.workerGeneration,
|
||||
projectId: record.offer.candidate.projectId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
offerId: record.offer.offerId,
|
||||
leaseGeneration: record.offer.lease.leaseGeneration,
|
||||
leaseToken: record.offer.leaseToken,
|
||||
expectedLeaseVersion: record.offer.lease.version,
|
||||
}));
|
||||
this.assertAuthority(record, control);
|
||||
if (control.status === 'terminal') {
|
||||
const stop = await this.stop(record);
|
||||
await this.markControlPlaneTerminal(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'terminal' as const,
|
||||
terminalStatus: control.terminalStatus!,
|
||||
stop,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
record = await this.persistRenewedLease(record, control);
|
||||
if (control.status === 'renewed') {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'renewed' as const,
|
||||
leaseVersion: record.offer.lease.version,
|
||||
expiresAtMs: record.offer.lease.expiresAtMs,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
const stop = await this.stop(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: this.stopWasConclusive(stop)
|
||||
? 'stop_requested' as const
|
||||
: 'stop_unverified' as const,
|
||||
reason: control.stop!.reason,
|
||||
leaseVersion: record.offer.lease.version,
|
||||
expiresAtMs: record.offer.lease.expiresAtMs,
|
||||
stop,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
private assertAuthority(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
result: Readonly<RemoteWorkerLeaseControlResult>,
|
||||
): void {
|
||||
if (
|
||||
result.projectId !== record.offer.candidate.projectId ||
|
||||
result.runId !== record.offer.candidate.runId ||
|
||||
result.attemptId !== record.offer.candidate.attemptId ||
|
||||
result.offerId !== record.offer.offerId ||
|
||||
result.leaseGeneration !== record.offer.lease.leaseGeneration ||
|
||||
(result.status !== 'terminal' &&
|
||||
result.leaseVersion !== record.offer.lease.version + 1)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'lease_response_invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRenewedLease(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
result: Readonly<RemoteWorkerLeaseControlResult>,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
if (
|
||||
result.leaseVersion === undefined ||
|
||||
result.renewedAtMs === undefined ||
|
||||
result.expiresAtMs === undefined ||
|
||||
result.renewedAtMs < record.offer.lease.renewedAtMs ||
|
||||
result.renewedAtMs < record.offer.lease.updatedAtMs ||
|
||||
result.expiresAtMs <= result.renewedAtMs
|
||||
) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'lease_response_invalid',
|
||||
);
|
||||
}
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...record,
|
||||
revision: record.revision + 1,
|
||||
updatedAtMs: Math.max(record.updatedAtMs, result.renewedAtMs),
|
||||
offer: {
|
||||
...record.offer,
|
||||
lease: {
|
||||
...record.offer.lease,
|
||||
version: result.leaseVersion,
|
||||
renewedAtMs: result.renewedAtMs,
|
||||
expiresAtMs: result.expiresAtMs,
|
||||
updatedAtMs: result.renewedAtMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.inbox.replaceOffer(next, record.revision);
|
||||
return next;
|
||||
} catch (error) {
|
||||
const current = await this.inbox.readOffer(record.offer.offerId);
|
||||
if (current) {
|
||||
const normalized = normalizeWorkerRemoteExecutionInboxRecord(current);
|
||||
if (normalized.offer.lease.version >= result.leaseVersion) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async markLeaseLost(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
recoveryReason:
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified',
|
||||
): Promise<void> {
|
||||
if (record.state === 'recovery_required') return;
|
||||
if (record.state === 'start_failure_acknowledged') return;
|
||||
await this.replaceRecovery(record, recoveryReason);
|
||||
}
|
||||
|
||||
private async markControlPlaneTerminal(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<void> {
|
||||
if (
|
||||
record.state === 'recovery_required' ||
|
||||
!CONTROL_PLANE_TERMINAL_TRANSITIONS.has(record.state)
|
||||
) return;
|
||||
await this.replaceRecovery(record, 'control_plane_terminal');
|
||||
}
|
||||
|
||||
private async replaceRecovery(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
recoveryReason: WorkerRemoteExecutionInboxRecord['recoveryReason'],
|
||||
): Promise<void> {
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...record,
|
||||
revision: record.revision + 1,
|
||||
state: 'recovery_required',
|
||||
recoveryReason,
|
||||
updatedAtMs: Math.max(record.updatedAtMs, this.now()),
|
||||
});
|
||||
try {
|
||||
await this.inbox.replaceOffer(next, record.revision);
|
||||
} catch (error) {
|
||||
const current = await this.inbox.readOffer(record.offer.offerId);
|
||||
if (
|
||||
current &&
|
||||
normalizeWorkerRemoteExecutionInboxRecord(current).state ===
|
||||
'recovery_required'
|
||||
) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<LocalProcessStopResult> {
|
||||
if (!record.executorHandle) {
|
||||
return record.state === 'launching'
|
||||
? Object.freeze({ status: 'unknown' as const, reason: 'invalid_handle' as const })
|
||||
: Object.freeze({ status: 'already_exited' as const });
|
||||
}
|
||||
return this.processes.stop(record.executorHandle);
|
||||
}
|
||||
|
||||
private stopWasConclusive(result: LocalProcessStopResult): boolean {
|
||||
return result.status === 'stopped' || result.status === 'already_exited';
|
||||
}
|
||||
|
||||
private sessionMatches(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
now: number,
|
||||
): boolean {
|
||||
const session = this.currentSessionProvider();
|
||||
return Boolean(
|
||||
session &&
|
||||
session.workerId === record.offer.worker.workerId &&
|
||||
session.sessionId === record.offer.worker.sessionId &&
|
||||
session.generation === record.offer.worker.generation &&
|
||||
session.status !== 'offline' &&
|
||||
session.leaseExpiresAtMs > now
|
||||
);
|
||||
}
|
||||
|
||||
private async readRequired(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const value = await this.inbox.readOffer(offerId);
|
||||
if (!value) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
return normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const value = this.nowProvider();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError('invalid_clock');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// Worker Execution owns bounded local log allocation, output, and read leases.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import type {
|
||||
WorkerRemoteExecutionOutputChunk,
|
||||
WorkerRemoteExecutionOutputSink,
|
||||
} from '../remote-execution/executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteLogArtifactAllocator,
|
||||
WorkerRemoteLogArtifactPreparation,
|
||||
} from '../remote-execution/executionContextMaterializer';
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const MAXIMUM_POLICY_BYTES = 1024 * MEBIBYTE;
|
||||
const MAXIMUM_RESERVE_BYTES = 1024 * 1024 * MEBIBYTE;
|
||||
const ARTIFACT_ID_PREFIX = 'wlog-';
|
||||
const ARTIFACT_ID_DIGEST_LENGTH = 30;
|
||||
const ARTIFACT_ID_DOMAIN = 'qinglong/worker-log-artifact@v1';
|
||||
const WORKER_FILE_LOG_OUTPUT_PLAN = Symbol('worker-file-log-output-plan');
|
||||
|
||||
export type WorkerRemoteLogArtifactProfile = 'edge' | 'node';
|
||||
|
||||
export interface WorkerRemoteLogArtifactPolicy {
|
||||
readonly maximumAttemptBytes: number;
|
||||
readonly minimumFreeBytes: number;
|
||||
readonly maximumWriteChunkBytes: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactCapacityProbe {
|
||||
availableBytes(root: string): Promise<bigint>;
|
||||
}
|
||||
|
||||
export interface WorkerFileLogArtifactAllocatorOptions {
|
||||
readonly root: string;
|
||||
readonly policy: WorkerRemoteLogArtifactPolicy;
|
||||
readonly capacity?: WorkerRemoteLogArtifactCapacityProbe;
|
||||
}
|
||||
|
||||
export interface WorkerFileLogOutputPlan {
|
||||
readonly filePath: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly logArtifactId: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactReadRequest {
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly logArtifactId: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactReadLease {
|
||||
readonly logArtifactId: string;
|
||||
readonly byteLength: number;
|
||||
/** Undefined means the launcher's bounded truncation fact was unavailable. */
|
||||
readonly truncated: boolean | undefined;
|
||||
chunks(): AsyncIterable<Uint8Array>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactSource {
|
||||
open(
|
||||
request: WorkerRemoteLogArtifactReadRequest,
|
||||
): Promise<WorkerRemoteLogArtifactReadLease | undefined>;
|
||||
}
|
||||
|
||||
type PlannedWorkerRemoteExecutionOutputSink = WorkerRemoteExecutionOutputSink & {
|
||||
readonly [WORKER_FILE_LOG_OUTPUT_PLAN]?: () => WorkerFileLogOutputPlan;
|
||||
};
|
||||
|
||||
/** Adapter-private path capability; it is non-enumerable on the output sink. */
|
||||
export function workerFileLogOutputPlan(
|
||||
output: WorkerRemoteExecutionOutputSink,
|
||||
): Readonly<WorkerFileLogOutputPlan> | undefined {
|
||||
return (output as PlannedWorkerRemoteExecutionOutputSink)[
|
||||
WORKER_FILE_LOG_OUTPUT_PLAN
|
||||
]?.();
|
||||
}
|
||||
|
||||
export class WorkerRemoteLogArtifactError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'invalid_request'
|
||||
| 'capacity_unavailable'
|
||||
| 'unsafe_path'
|
||||
| 'quota_exceeded'
|
||||
| 'invalid_output'
|
||||
| 'closed',
|
||||
) {
|
||||
super(`Worker remote log Artifact failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteLogArtifactError';
|
||||
}
|
||||
}
|
||||
|
||||
export function workerRemoteLogArtifactPolicy(
|
||||
profile: WorkerRemoteLogArtifactProfile,
|
||||
): Readonly<WorkerRemoteLogArtifactPolicy> {
|
||||
if (profile === 'edge') {
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: 4 * MEBIBYTE,
|
||||
minimumFreeBytes: 32 * MEBIBYTE,
|
||||
maximumWriteChunkBytes: MEBIBYTE,
|
||||
});
|
||||
}
|
||||
if (profile === 'node') {
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: 64 * MEBIBYTE,
|
||||
minimumFreeBytes: 256 * MEBIBYTE,
|
||||
maximumWriteChunkBytes: MEBIBYTE,
|
||||
});
|
||||
}
|
||||
throw new WorkerRemoteLogArtifactError('invalid_configuration');
|
||||
}
|
||||
|
||||
function normalizePolicy(
|
||||
policy: WorkerRemoteLogArtifactPolicy,
|
||||
): Readonly<WorkerRemoteLogArtifactPolicy> {
|
||||
if (
|
||||
!policy ||
|
||||
!Number.isSafeInteger(policy.maximumAttemptBytes) ||
|
||||
policy.maximumAttemptBytes < 1 ||
|
||||
policy.maximumAttemptBytes > MAXIMUM_POLICY_BYTES ||
|
||||
!Number.isSafeInteger(policy.minimumFreeBytes) ||
|
||||
policy.minimumFreeBytes < 0 ||
|
||||
policy.minimumFreeBytes > MAXIMUM_RESERVE_BYTES ||
|
||||
!Number.isSafeInteger(policy.maximumWriteChunkBytes) ||
|
||||
policy.maximumWriteChunkBytes < 1 ||
|
||||
policy.maximumWriteChunkBytes > MEBIBYTE
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_configuration');
|
||||
}
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: policy.maximumAttemptBytes,
|
||||
minimumFreeBytes: policy.minimumFreeBytes,
|
||||
maximumWriteChunkBytes: policy.maximumWriteChunkBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function currentUid(): number | undefined {
|
||||
return typeof process.getuid === 'function' ? process.getuid() : undefined;
|
||||
}
|
||||
|
||||
function assertOwnedOrdinaryFile(stat: Awaited<ReturnType<FileHandle['stat']>>): void {
|
||||
const uid = currentUid();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.nlink !== 1 ||
|
||||
(uid !== undefined && stat.uid !== uid)
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
}
|
||||
|
||||
async function privateDirectory(directory: string): Promise<Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
}>> {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const before = await fs.lstat(directory);
|
||||
const uid = currentUid();
|
||||
if (
|
||||
!before.isDirectory() ||
|
||||
before.isSymbolicLink() ||
|
||||
(uid !== undefined && before.uid !== uid)
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
await fs.chmod(directory, 0o700);
|
||||
const after = await fs.lstat(directory);
|
||||
if (
|
||||
!after.isDirectory() ||
|
||||
after.isSymbolicLink() ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
(after.mode & 0o777) !== 0o700 ||
|
||||
(uid !== undefined && after.uid !== uid)
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
return Object.freeze({ dev: after.dev, ino: after.ino });
|
||||
}
|
||||
|
||||
function requestId(name: string, value: unknown): string {
|
||||
try {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError('invalid ID');
|
||||
}
|
||||
assertRunDispatchId(name, value);
|
||||
return value;
|
||||
} catch {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_request');
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkerRemoteLogArtifactId(request: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
}>): string {
|
||||
if (!request || typeof request !== 'object') {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_request');
|
||||
}
|
||||
const values = [
|
||||
requestId('projectId', request.projectId),
|
||||
requestId('runId', request.runId),
|
||||
requestId('attemptId', request.attemptId),
|
||||
requestId('offerId', request.offerId),
|
||||
];
|
||||
const digest = createHash('sha256');
|
||||
digest.update(ARTIFACT_ID_DOMAIN, 'utf8');
|
||||
for (const value of values) {
|
||||
digest.update('\0', 'utf8');
|
||||
digest.update(value, 'utf8');
|
||||
}
|
||||
const artifactId = `${ARTIFACT_ID_PREFIX}${digest.digest('hex').slice(
|
||||
0,
|
||||
ARTIFACT_ID_DIGEST_LENGTH,
|
||||
)}`;
|
||||
assertRunDispatchId('logArtifactId', artifactId);
|
||||
return artifactId;
|
||||
}
|
||||
|
||||
class FileSystemCapacityProbe implements WorkerRemoteLogArtifactCapacityProbe {
|
||||
async availableBytes(root: string): Promise<bigint> {
|
||||
const stat = await fs.statfs(root, { bigint: true });
|
||||
return stat.bavail * stat.bsize;
|
||||
}
|
||||
}
|
||||
|
||||
class WorkerFileLogOutput implements WorkerRemoteExecutionOutputSink {
|
||||
private pending: Promise<unknown> = Promise.resolve();
|
||||
private closeOperation: Promise<void> | undefined;
|
||||
private state: 'open' | 'closing' | 'closed' = 'open';
|
||||
private remainingBytes: number;
|
||||
|
||||
constructor(
|
||||
readonly logArtifactId: string,
|
||||
private readonly file: FileHandle,
|
||||
maximumBytes: number,
|
||||
existingBytes: number,
|
||||
private readonly maximumWriteChunkBytes: number,
|
||||
outputFilePath: string,
|
||||
) {
|
||||
this.remainingBytes = maximumBytes - existingBytes;
|
||||
Object.defineProperty(this, WORKER_FILE_LOG_OUTPUT_PLAN, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: () => Object.freeze({
|
||||
filePath: outputFilePath,
|
||||
maximumBytes,
|
||||
logArtifactId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
write(output: WorkerRemoteExecutionOutputChunk): Promise<void> {
|
||||
if (this.state !== 'open') {
|
||||
return Promise.reject(new WorkerRemoteLogArtifactError('closed'));
|
||||
}
|
||||
if (
|
||||
!output ||
|
||||
typeof output !== 'object' ||
|
||||
(output.stream !== 'stdout' && output.stream !== 'stderr') ||
|
||||
!(output.chunk instanceof Uint8Array) ||
|
||||
output.chunk.byteLength > this.maximumWriteChunkBytes ||
|
||||
!Number.isSafeInteger(output.observedAtMs) ||
|
||||
output.observedAtMs < 0
|
||||
) {
|
||||
return Promise.reject(new WorkerRemoteLogArtifactError('invalid_output'));
|
||||
}
|
||||
const chunk = Buffer.from(output.chunk);
|
||||
const operation = this.pending.then(async () => {
|
||||
if (chunk.byteLength === 0) return;
|
||||
if (this.remainingBytes <= 0) {
|
||||
throw new WorkerRemoteLogArtifactError('quota_exceeded');
|
||||
}
|
||||
const accepted = chunk.subarray(
|
||||
0,
|
||||
Math.min(chunk.byteLength, this.remainingBytes),
|
||||
);
|
||||
let offset = 0;
|
||||
while (offset < accepted.byteLength) {
|
||||
const result = await this.file.write(accepted.subarray(offset));
|
||||
if (result.bytesWritten < 1) {
|
||||
throw new Error('Worker remote log Artifact write made no progress');
|
||||
}
|
||||
offset += result.bytesWritten;
|
||||
this.remainingBytes -= result.bytesWritten;
|
||||
}
|
||||
if (accepted.byteLength !== chunk.byteLength) {
|
||||
throw new WorkerRemoteLogArtifactError('quota_exceeded');
|
||||
}
|
||||
});
|
||||
this.pending = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
if (this.closeOperation) return this.closeOperation;
|
||||
this.state = 'closing';
|
||||
this.closeOperation = (async () => {
|
||||
await this.pending.catch(() => undefined);
|
||||
let syncError: unknown;
|
||||
try {
|
||||
await this.file.datasync();
|
||||
} catch (error) {
|
||||
syncError = error;
|
||||
}
|
||||
try {
|
||||
await this.file.close();
|
||||
} finally {
|
||||
this.state = 'closed';
|
||||
}
|
||||
if (syncError !== undefined) throw syncError;
|
||||
})();
|
||||
return this.closeOperation;
|
||||
}
|
||||
}
|
||||
|
||||
class WorkerFileLogReadLease implements WorkerRemoteLogArtifactReadLease {
|
||||
private consumed = false;
|
||||
private closeOperation: Promise<void> | undefined;
|
||||
|
||||
constructor(
|
||||
readonly logArtifactId: string,
|
||||
readonly byteLength: number,
|
||||
readonly truncated: boolean | undefined,
|
||||
private readonly file: FileHandle,
|
||||
private readonly chunkBytes: number,
|
||||
) {}
|
||||
|
||||
chunks(): AsyncIterable<Uint8Array> {
|
||||
if (this.consumed) {
|
||||
throw new WorkerRemoteLogArtifactError('closed');
|
||||
}
|
||||
this.consumed = true;
|
||||
const file = this.file;
|
||||
const byteLength = this.byteLength;
|
||||
const chunkBytes = this.chunkBytes;
|
||||
const close = () => this.close();
|
||||
return (async function* () {
|
||||
let offset = 0;
|
||||
try {
|
||||
while (offset < byteLength) {
|
||||
const buffer = Buffer.allocUnsafe(
|
||||
Math.min(chunkBytes, byteLength - offset),
|
||||
);
|
||||
const result = await file.read(buffer, 0, buffer.length, offset);
|
||||
if (result.bytesRead < 1) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
offset += result.bytesRead;
|
||||
yield Buffer.from(buffer.subarray(0, result.bytesRead));
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.closeOperation ??= this.file.close();
|
||||
return this.closeOperation;
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function readTruncationFact(
|
||||
directory: string,
|
||||
request: WorkerRemoteLogArtifactReadRequest,
|
||||
maximumBytes: number,
|
||||
): Promise<boolean | undefined> {
|
||||
const target = path.join(
|
||||
directory,
|
||||
`.${request.logArtifactId}.log.truncated.json`,
|
||||
);
|
||||
let file: FileHandle;
|
||||
try {
|
||||
file = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (isCode(error, 'ELOOP')) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
assertOwnedOrdinaryFile(stat);
|
||||
if (stat.size < 1 || stat.size > 4096 || (stat.mode & 0o777) !== 0o600) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(stat.size + 1);
|
||||
const result = await file.read(bytes, 0, bytes.length, 0);
|
||||
if (result.bytesRead !== stat.size) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(bytes.subarray(0, result.bytesRead).toString('utf8'));
|
||||
} catch {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
if (
|
||||
JSON.stringify(keys) !== JSON.stringify([
|
||||
'attemptId', 'logArtifactId', 'maximumBytes', 'observedAtMs',
|
||||
'quotaReached', 'runId', 'schemaVersion',
|
||||
]) ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.runId !== request.runId ||
|
||||
record.attemptId !== request.attemptId ||
|
||||
record.logArtifactId !== request.logArtifactId ||
|
||||
record.maximumBytes !== maximumBytes ||
|
||||
typeof record.quotaReached !== 'boolean' ||
|
||||
!Number.isSafeInteger(record.observedAtMs) ||
|
||||
(record.observedAtMs as number) < 0
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
return record.quotaReached;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerFileLogArtifactAllocator
|
||||
implements WorkerRemoteLogArtifactAllocator, WorkerRemoteLogArtifactSource {
|
||||
private readonly root: string;
|
||||
private readonly policy: Readonly<WorkerRemoteLogArtifactPolicy>;
|
||||
private readonly capacity: WorkerRemoteLogArtifactCapacityProbe;
|
||||
|
||||
constructor(options: WorkerFileLogArtifactAllocatorOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.root !== 'string' ||
|
||||
!path.isAbsolute(options.root) ||
|
||||
options.root.includes('\0') ||
|
||||
(options.capacity !== undefined &&
|
||||
typeof options.capacity.availableBytes !== 'function')
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_configuration');
|
||||
}
|
||||
this.root = path.resolve(options.root);
|
||||
this.policy = normalizePolicy(options.policy);
|
||||
this.capacity = options.capacity ?? new FileSystemCapacityProbe();
|
||||
}
|
||||
|
||||
async prepare(request: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
}>): Promise<WorkerRemoteLogArtifactPreparation> {
|
||||
const logArtifactId = createWorkerRemoteLogArtifactId(request);
|
||||
const rootIdentity = await privateDirectory(this.root);
|
||||
let availableBytes: bigint;
|
||||
try {
|
||||
availableBytes = await this.capacity.availableBytes(this.root);
|
||||
} catch {
|
||||
throw new WorkerRemoteLogArtifactError('capacity_unavailable');
|
||||
}
|
||||
if (typeof availableBytes !== 'bigint' || availableBytes < 0n) {
|
||||
throw new WorkerRemoteLogArtifactError('capacity_unavailable');
|
||||
}
|
||||
const requiredBytes = BigInt(this.policy.minimumFreeBytes) +
|
||||
BigInt(this.policy.maximumAttemptBytes);
|
||||
if (availableBytes < requiredBytes) {
|
||||
throw new WorkerRemoteLogArtifactError('capacity_unavailable');
|
||||
}
|
||||
const rootAfterCapacity = await fs.lstat(this.root);
|
||||
if (
|
||||
rootAfterCapacity.dev !== rootIdentity.dev ||
|
||||
rootAfterCapacity.ino !== rootIdentity.ino
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
const shard = logArtifactId.slice(
|
||||
ARTIFACT_ID_PREFIX.length,
|
||||
ARTIFACT_ID_PREFIX.length + 2,
|
||||
);
|
||||
const directory = path.join(this.root, shard);
|
||||
const directoryIdentity = await privateDirectory(directory);
|
||||
const outputFilePath = path.join(directory, `${logArtifactId}.log`);
|
||||
let file: FileHandle;
|
||||
try {
|
||||
file = await fs.open(
|
||||
outputFilePath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_APPEND |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ELOOP') {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
assertOwnedOrdinaryFile(stat);
|
||||
if (
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > this.policy.maximumAttemptBytes
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('quota_exceeded');
|
||||
}
|
||||
await file.chmod(0o600);
|
||||
const pathStat = await fs.lstat(outputFilePath);
|
||||
const directoryAfterOpen = await fs.lstat(directory);
|
||||
if (
|
||||
pathStat.isSymbolicLink() ||
|
||||
pathStat.dev !== stat.dev ||
|
||||
pathStat.ino !== stat.ino ||
|
||||
(pathStat.mode & 0o777) !== 0o600 ||
|
||||
directoryAfterOpen.dev !== directoryIdentity.dev ||
|
||||
directoryAfterOpen.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
const output = new WorkerFileLogOutput(
|
||||
logArtifactId,
|
||||
file,
|
||||
this.policy.maximumAttemptBytes,
|
||||
stat.size,
|
||||
this.policy.maximumWriteChunkBytes,
|
||||
outputFilePath,
|
||||
);
|
||||
let ownership: 'prepared' | 'handed_off' | 'released' = 'prepared';
|
||||
return Object.freeze({
|
||||
logArtifactId,
|
||||
takeOutput() {
|
||||
if (ownership !== 'prepared') {
|
||||
throw new WorkerRemoteLogArtifactError('closed');
|
||||
}
|
||||
ownership = 'handed_off';
|
||||
return output;
|
||||
},
|
||||
async release() {
|
||||
if (ownership === 'released' || ownership === 'handed_off') return;
|
||||
ownership = 'released';
|
||||
await output.close();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await file.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async open(
|
||||
request: WorkerRemoteLogArtifactReadRequest,
|
||||
): Promise<WorkerRemoteLogArtifactReadLease | undefined> {
|
||||
const runId = requestId('runId', request?.runId);
|
||||
const attemptId = requestId('attemptId', request?.attemptId);
|
||||
const logArtifactId = requestId('logArtifactId', request?.logArtifactId);
|
||||
if (!/^wlog-[0-9a-f]{30}$/.test(logArtifactId)) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_request');
|
||||
}
|
||||
const rootIdentity = await privateDirectory(this.root);
|
||||
const directory = path.join(this.root, logArtifactId.slice(5, 7));
|
||||
const directoryIdentity = await privateDirectory(directory);
|
||||
const target = path.join(directory, `${logArtifactId}.log`);
|
||||
let file: FileHandle;
|
||||
try {
|
||||
file = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (isCode(error, 'ELOOP')) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
assertOwnedOrdinaryFile(stat);
|
||||
if (
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > this.policy.maximumAttemptBytes ||
|
||||
(stat.mode & 0o777) !== 0o600
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
const pathStat = await fs.lstat(target);
|
||||
const rootAfterOpen = await fs.lstat(this.root);
|
||||
const directoryAfterOpen = await fs.lstat(directory);
|
||||
if (
|
||||
pathStat.isSymbolicLink() ||
|
||||
pathStat.dev !== stat.dev ||
|
||||
pathStat.ino !== stat.ino ||
|
||||
rootAfterOpen.dev !== rootIdentity.dev ||
|
||||
rootAfterOpen.ino !== rootIdentity.ino ||
|
||||
directoryAfterOpen.dev !== directoryIdentity.dev ||
|
||||
directoryAfterOpen.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
const truncated = await readTruncationFact(
|
||||
directory,
|
||||
{ runId, attemptId, logArtifactId },
|
||||
this.policy.maximumAttemptBytes,
|
||||
);
|
||||
return new WorkerFileLogReadLease(
|
||||
logArtifactId,
|
||||
stat.size,
|
||||
truncated,
|
||||
file,
|
||||
Math.min(this.policy.maximumWriteChunkBytes, 64 * 1024),
|
||||
);
|
||||
} catch (error) {
|
||||
await file.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Worker Execution owns the fenced POSIX launch adapter and spawn barrier.
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
LocalProcessLaunchError,
|
||||
LocalProcessLauncher,
|
||||
type LocalProcessIdentityProvider,
|
||||
} from '@qinglong/local-process';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
} from '../remote-execution/executionInbox';
|
||||
import type {
|
||||
WorkerRemoteExecutionExecutor,
|
||||
WorkerRemoteExecutionLaunch,
|
||||
} from '../remote-execution/executionInboxProcessor';
|
||||
import { workerFileLogOutputPlan } from './workerFileLogArtifactAllocator';
|
||||
|
||||
export interface WorkerRemoteExecutionSpawnBarrier {
|
||||
verify(input: Readonly<{
|
||||
offerId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
callbackSequence: number;
|
||||
callbackTokenDigest: string;
|
||||
logArtifactId: string;
|
||||
executorStartedAtMs: number;
|
||||
}>): Promise<void>;
|
||||
}
|
||||
|
||||
export class WorkerInboxExecutionSpawnBarrier
|
||||
implements WorkerRemoteExecutionSpawnBarrier {
|
||||
constructor(private readonly inbox: Pick<WorkerRemoteExecutionInbox, 'readOffer'>) {
|
||||
if (!inbox || typeof inbox.readOffer !== 'function') {
|
||||
throw new TypeError('Worker execution spawn barrier inbox is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async verify(input: Readonly<{
|
||||
offerId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
callbackSequence: number;
|
||||
callbackTokenDigest: string;
|
||||
logArtifactId: string;
|
||||
executorStartedAtMs: number;
|
||||
}>): Promise<void> {
|
||||
const record = await this.inbox.readOffer(input.offerId);
|
||||
if (!record) throw new Error('Worker execution spawn barrier is missing');
|
||||
const current = normalizeWorkerRemoteExecutionInboxRecord(record);
|
||||
if (
|
||||
current.state !== 'launching' ||
|
||||
current.offer.offerId !== input.offerId ||
|
||||
current.offer.candidate.runId !== input.runId ||
|
||||
current.offer.candidate.attemptId !== input.attemptId ||
|
||||
current.completionReceiptCallbackSequence !== input.callbackSequence ||
|
||||
current.completionReceiptTokenDigest !== input.callbackTokenDigest ||
|
||||
current.logArtifactId !== input.logArtifactId ||
|
||||
current.executorStartedAtMs !== input.executorStartedAtMs
|
||||
) {
|
||||
throw new Error('Worker execution spawn barrier authority drifted');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkerPosixExecutionExecutorOptions {
|
||||
readonly barrier: WorkerRemoteExecutionSpawnBarrier;
|
||||
readonly receiptRoot: string;
|
||||
readonly launcherPath?: string;
|
||||
readonly expectedLauncherSha256?: string;
|
||||
readonly identityProvider?: LocalProcessIdentityProvider;
|
||||
readonly clock?: { now(): number };
|
||||
readonly createHandleId?: () => string;
|
||||
}
|
||||
|
||||
export class WorkerPosixExecutionExecutor
|
||||
implements WorkerRemoteExecutionExecutor {
|
||||
private readonly options: WorkerPosixExecutionExecutorOptions;
|
||||
|
||||
constructor(options: WorkerPosixExecutionExecutorOptions) {
|
||||
if (!options || typeof options.barrier?.verify !== 'function') {
|
||||
throw new TypeError('Worker POSIX Executor barrier is invalid');
|
||||
}
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async start(launch: WorkerRemoteExecutionLaunch): Promise<
|
||||
| Readonly<{
|
||||
status: 'started';
|
||||
executorHandle: string;
|
||||
executorStartedAtMs: number;
|
||||
}>
|
||||
| Readonly<{ status: 'rejected' }>
|
||||
> {
|
||||
let callbackToken: Buffer | undefined;
|
||||
try {
|
||||
assertRunDispatchId('offerId', launch.offerId);
|
||||
assertRunDispatchId('runId', launch.runId);
|
||||
assertRunDispatchId('attemptId', launch.attemptId);
|
||||
assertRunDispatchId('logArtifactId', launch.logArtifactId);
|
||||
if (
|
||||
(launch.timeoutMs === undefined) !==
|
||||
(launch.executionDeadlineAtMs === undefined) ||
|
||||
(launch.executionDeadlineAtMs !== undefined &&
|
||||
(!Number.isSafeInteger(launch.executionDeadlineAtMs) ||
|
||||
launch.executionDeadlineAtMs < 0))
|
||||
) return this.reject(launch);
|
||||
const outputPlan = workerFileLogOutputPlan(launch.output);
|
||||
if (
|
||||
!outputPlan ||
|
||||
outputPlan.logArtifactId !== launch.logArtifactId
|
||||
) return this.reject(launch);
|
||||
callbackToken = Buffer.from(launch.completionCallback.token);
|
||||
if (callbackToken.byteLength !== 32) return this.reject(launch);
|
||||
const callbackTokenDigest = createHash('sha256')
|
||||
.update(callbackToken)
|
||||
.digest('hex');
|
||||
const environment: Record<string, string> = {};
|
||||
for (const entry of launch.environment) {
|
||||
if (Object.hasOwn(environment, entry.name)) return this.reject(launch);
|
||||
environment[entry.name] = entry.value;
|
||||
}
|
||||
await launch.output.close();
|
||||
const launcher = new LocalProcessLauncher(
|
||||
{
|
||||
register: async (registered: Readonly<{
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
registeredAtMs: number;
|
||||
}>) => {
|
||||
if (
|
||||
registered.runId !== launch.runId ||
|
||||
registered.attemptId !== launch.attemptId
|
||||
) throw new Error('Worker POSIX Executor journal authority drifted');
|
||||
await this.options.barrier.verify(Object.freeze({
|
||||
offerId: launch.offerId,
|
||||
runId: launch.runId,
|
||||
attemptId: launch.attemptId,
|
||||
callbackSequence: launch.completionCallback.sequence,
|
||||
callbackTokenDigest,
|
||||
logArtifactId: launch.logArtifactId,
|
||||
executorStartedAtMs: launch.executorStartedAtMs,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
receiptRoot: this.options.receiptRoot,
|
||||
...(this.options.launcherPath === undefined
|
||||
? {}
|
||||
: { launcherPath: this.options.launcherPath }),
|
||||
...(this.options.expectedLauncherSha256 === undefined
|
||||
? {}
|
||||
: { expectedLauncherSha256: this.options.expectedLauncherSha256 }),
|
||||
...(this.options.identityProvider === undefined
|
||||
? {}
|
||||
: { identityProvider: this.options.identityProvider }),
|
||||
...(this.options.clock === undefined
|
||||
? {}
|
||||
: { clock: this.options.clock }),
|
||||
...(this.options.createHandleId === undefined
|
||||
? {}
|
||||
: { createHandleId: this.options.createHandleId }),
|
||||
},
|
||||
);
|
||||
const command = launch.command.kind === 'argv'
|
||||
? Object.freeze({
|
||||
kind: 'argv' as const,
|
||||
file: launch.command.file,
|
||||
args: launch.command.args,
|
||||
})
|
||||
: Object.freeze({
|
||||
kind: 'shell' as const,
|
||||
command: launch.command.command,
|
||||
...(launch.command.shell === undefined
|
||||
? {}
|
||||
: { shell: launch.command.shell as '/bin/sh' | '/bin/bash' }),
|
||||
});
|
||||
const handle = await launcher.start({
|
||||
runId: launch.runId,
|
||||
attemptId: launch.attemptId,
|
||||
startedAtMs: launch.executorStartedAtMs,
|
||||
callbackSequence: launch.completionCallback.sequence,
|
||||
callbackToken: callbackToken.toString('base64url'),
|
||||
command,
|
||||
environment,
|
||||
...(launch.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: launch.workingDirectory }),
|
||||
output: outputPlan,
|
||||
});
|
||||
void handle.completion;
|
||||
return Object.freeze({
|
||||
status: 'started' as const,
|
||||
executorHandle: handle.durableHandle,
|
||||
executorStartedAtMs: handle.startedAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
await launch?.output?.close?.().catch(() => undefined);
|
||||
if (
|
||||
error instanceof LocalProcessLaunchError &&
|
||||
error.spawnOutcome === 'unknown'
|
||||
) throw error;
|
||||
return Object.freeze({ status: 'rejected' as const });
|
||||
} finally {
|
||||
callbackToken?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private async reject(
|
||||
launch: WorkerRemoteExecutionLaunch,
|
||||
): Promise<Readonly<{ status: 'rejected' }>> {
|
||||
await launch.output.close().catch(() => undefined);
|
||||
return Object.freeze({ status: 'rejected' as const });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
GenerateWorkerCertificateEnrollmentOptions,
|
||||
WorkerCertificateEnrollmentMaterial,
|
||||
} from './credential/workerCertificateEnrollment';
|
||||
export * from './credential/workerCertificateIdentity';
|
||||
export * from './credential/workerCertificateStore';
|
||||
export * from './credential/workerCertificateRenewal';
|
||||
@@ -0,0 +1,304 @@
|
||||
// Worker Process owns configuration-to-runtime activation, signals, and shutdown.
|
||||
import type { WorkerIngressHttpsCredentialProvider } from '../remote-execution/transport/workerIngressHttpsClient';
|
||||
import {
|
||||
startProductionWorkerApplication,
|
||||
type ProductionWorkerApplicationEnabledOptions,
|
||||
type ProductionWorkerApplicationOptions,
|
||||
} from '../application-runtime/productionWorkerApplication';
|
||||
import {
|
||||
loadWorkerProcessConfig,
|
||||
type EnabledWorkerProcessConfig,
|
||||
type WorkerProcessEnvironment,
|
||||
} from './workerProcessConfig';
|
||||
import { createWorkerProcessCredentialProvider } from './workerProcessIdentity';
|
||||
import type {
|
||||
ProductionWorkerCertificateRenewalLifecycle,
|
||||
ProductionWorkerHeadlessApplicationResult,
|
||||
ProductionWorkerHeadlessStopResult,
|
||||
} from '../application-runtime/productionHeadlessApplication';
|
||||
|
||||
export type WorkerProcessSignal = 'SIGINT' | 'SIGTERM';
|
||||
|
||||
export interface WorkerProcessSignalSource {
|
||||
subscribe(listener: (signal: WorkerProcessSignal) => void): () => void;
|
||||
}
|
||||
|
||||
export interface WorkerProcessEvent {
|
||||
readonly schemaVersion: 1;
|
||||
readonly component: 'qinglong3-worker';
|
||||
readonly level: 'info' | 'error';
|
||||
readonly event:
|
||||
| 'starting'
|
||||
| 'active'
|
||||
| 'shutdown_requested'
|
||||
| 'shutdown_deferred'
|
||||
| 'stopped'
|
||||
| 'runtime_diagnostic';
|
||||
readonly workerId?: string;
|
||||
readonly capacityProfile?: 'edge' | 'node';
|
||||
readonly signal?: WorkerProcessSignal;
|
||||
readonly stopResult?: ProductionWorkerHeadlessStopResult;
|
||||
readonly diagnostic?: Readonly<{
|
||||
readonly code:
|
||||
| 'tick_failed'
|
||||
| 'session_tick_failed'
|
||||
| 'certificate_renewal_failed'
|
||||
| 'certificate_unavailable'
|
||||
| 'recovery_required'
|
||||
| 'drain_failed'
|
||||
| 'disconnect_failed';
|
||||
}>;
|
||||
}
|
||||
|
||||
export type WorkerProcessStarter = (
|
||||
options: ProductionWorkerApplicationOptions,
|
||||
) => Promise<ProductionWorkerHeadlessApplicationResult>;
|
||||
|
||||
export type WorkerProcessCredentialFactory = (
|
||||
config: EnabledWorkerProcessConfig['identity'],
|
||||
) => Promise<Readonly<WorkerIngressHttpsCredentialProvider>>;
|
||||
|
||||
export type WorkerProcessCertificateRenewalFactory = (
|
||||
config: EnabledWorkerProcessConfig,
|
||||
credentials: Readonly<WorkerIngressHttpsCredentialProvider>,
|
||||
) => Promise<ProductionWorkerCertificateRenewalLifecycle | undefined>;
|
||||
|
||||
export interface ProductionWorkerProcessOptions {
|
||||
readonly environment: WorkerProcessEnvironment;
|
||||
readonly signals: WorkerProcessSignalSource;
|
||||
readonly emit: (event: WorkerProcessEvent) => void | Promise<void>;
|
||||
readonly start?: WorkerProcessStarter;
|
||||
readonly createCredentials?: WorkerProcessCredentialFactory;
|
||||
/** Deployment-owned CA adapter; omitted profiles keep zero renewal cost. */
|
||||
readonly createCertificateRenewal?: WorkerProcessCertificateRenewalFactory;
|
||||
readonly waitBeforeStopRetry?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class WorkerProcessError extends Error {
|
||||
readonly code:
|
||||
| 'QL3_WORKER_PROCESS_DISABLED'
|
||||
| 'QL3_WORKER_PROCESS_ACTIVATION_FAILED';
|
||||
|
||||
constructor(
|
||||
code: WorkerProcessError['code'],
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'WorkerProcessError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
|
||||
function productionOptions(
|
||||
config: EnabledWorkerProcessConfig,
|
||||
credentials: Readonly<WorkerIngressHttpsCredentialProvider>,
|
||||
certificateRenewal: ProductionWorkerCertificateRenewalLifecycle | undefined,
|
||||
diagnostic: NonNullable<
|
||||
ProductionWorkerApplicationEnabledOptions['diagnostic']
|
||||
>,
|
||||
): ProductionWorkerApplicationEnabledOptions {
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: config.capacityProfile,
|
||||
origin: config.origin,
|
||||
credentials,
|
||||
...(certificateRenewal === undefined ? {} : { certificateRenewal }),
|
||||
workerId: config.workerId,
|
||||
capabilities: config.capabilities,
|
||||
maxConcurrentRuns: config.maxConcurrentRuns,
|
||||
storage: config.storage,
|
||||
cadenceMs: config.lifecycle.cadenceMs,
|
||||
leaseDurationMs: config.lifecycle.leaseDurationMs,
|
||||
heartbeatIntervalMs: config.lifecycle.heartbeatIntervalMs,
|
||||
drainTimeoutMs: config.lifecycle.drainTimeoutMs,
|
||||
drainPollMs: config.lifecycle.drainPollMs,
|
||||
requestTimeoutMs: config.lifecycle.requestTimeoutMs,
|
||||
maximumJournalEntries: config.lifecycle.maximumJournalEntries,
|
||||
maximumRecordsPerTick: config.lifecycle.maximumRecordsPerTick,
|
||||
maximumSupervisionRecordsPerTick:
|
||||
config.lifecycle.maximumSupervisionRecordsPerTick,
|
||||
...(config.executor.launcherPath === undefined
|
||||
? {}
|
||||
: {
|
||||
launcherPath: config.executor.launcherPath,
|
||||
expectedLauncherSha256: config.executor.expectedLauncherSha256!,
|
||||
}),
|
||||
diagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one production Worker process. Incomplete drain never releases the
|
||||
* application or returns success; it keeps the existing owner/Agent alive and
|
||||
* retries the same proof-bearing stop operation with a ref'ed shutdown wait.
|
||||
*/
|
||||
export async function runProductionWorkerProcess(
|
||||
options: ProductionWorkerProcessOptions,
|
||||
): Promise<'stopped'> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
typeof options.emit !== 'function' ||
|
||||
typeof options.signals?.subscribe !== 'function' ||
|
||||
(options.start !== undefined && typeof options.start !== 'function') ||
|
||||
(options.createCredentials !== undefined &&
|
||||
typeof options.createCredentials !== 'function') ||
|
||||
(options.createCertificateRenewal !== undefined &&
|
||||
typeof options.createCertificateRenewal !== 'function') ||
|
||||
(options.waitBeforeStopRetry !== undefined &&
|
||||
typeof options.waitBeforeStopRetry !== 'function')
|
||||
) {
|
||||
throw new TypeError('Worker process options are invalid');
|
||||
}
|
||||
|
||||
let resolveSignal: ((signal: WorkerProcessSignal) => void) | undefined;
|
||||
const requestedSignal = new Promise<WorkerProcessSignal>((resolve) => {
|
||||
resolveSignal = resolve;
|
||||
});
|
||||
let acceptedSignal = false;
|
||||
const unsubscribe = options.signals.subscribe((signal) => {
|
||||
if (acceptedSignal) return;
|
||||
acceptedSignal = true;
|
||||
resolveSignal?.(signal);
|
||||
});
|
||||
// A pending Promise does not keep Node's event loop alive. The Worker owns
|
||||
// the process until an OS shutdown signal arrives, so retain one inexpensive
|
||||
// referenced handle and release it with the rest of the signal authority.
|
||||
const lifecycleReference = setInterval(() => undefined, 2_147_483_647);
|
||||
const emit = (event: WorkerProcessEvent): void => {
|
||||
void Promise.resolve(options.emit(event)).catch(() => undefined);
|
||||
};
|
||||
|
||||
try {
|
||||
const config = await loadWorkerProcessConfig(options.environment);
|
||||
if (!config.enabled) {
|
||||
throw new WorkerProcessError(
|
||||
'QL3_WORKER_PROCESS_DISABLED',
|
||||
'The Worker process requires an enabled worker profile',
|
||||
);
|
||||
}
|
||||
emit(
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker',
|
||||
level: 'info',
|
||||
event: 'starting',
|
||||
workerId: config.workerId,
|
||||
capacityProfile: config.capacityProfile,
|
||||
}),
|
||||
);
|
||||
const credentials = await (
|
||||
options.createCredentials ?? createWorkerProcessCredentialProvider
|
||||
)(config.identity);
|
||||
if (!credentials || typeof credentials.load !== 'function') {
|
||||
throw new WorkerProcessError(
|
||||
'QL3_WORKER_PROCESS_ACTIVATION_FAILED',
|
||||
'Worker credential provider is invalid',
|
||||
);
|
||||
}
|
||||
const certificateRenewal = await options.createCertificateRenewal?.(
|
||||
config,
|
||||
credentials,
|
||||
);
|
||||
if (
|
||||
certificateRenewal !== undefined &&
|
||||
typeof certificateRenewal.run !== 'function'
|
||||
) {
|
||||
throw new WorkerProcessError(
|
||||
'QL3_WORKER_PROCESS_ACTIVATION_FAILED',
|
||||
'Worker certificate renewal lifecycle is invalid',
|
||||
);
|
||||
}
|
||||
const application = await (
|
||||
options.start ?? startProductionWorkerApplication
|
||||
)(
|
||||
productionOptions(config, credentials, certificateRenewal, (fact) => {
|
||||
emit(
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker',
|
||||
level: 'error',
|
||||
event: 'runtime_diagnostic',
|
||||
workerId: config.workerId,
|
||||
capacityProfile: config.capacityProfile,
|
||||
diagnostic: Object.freeze({ code: fact.code }),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (application.status !== 'active') {
|
||||
throw new WorkerProcessError(
|
||||
'QL3_WORKER_PROCESS_ACTIVATION_FAILED',
|
||||
'Worker application did not activate',
|
||||
);
|
||||
}
|
||||
emit(
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker',
|
||||
level: 'info',
|
||||
event: 'active',
|
||||
workerId: config.workerId,
|
||||
capacityProfile: config.capacityProfile,
|
||||
}),
|
||||
);
|
||||
|
||||
const signal = await requestedSignal;
|
||||
emit(
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker',
|
||||
level: 'info',
|
||||
event: 'shutdown_requested',
|
||||
workerId: config.workerId,
|
||||
capacityProfile: config.capacityProfile,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
const waitBeforeRetry = options.waitBeforeStopRetry ?? delay;
|
||||
while (true) {
|
||||
let result: ProductionWorkerHeadlessStopResult;
|
||||
try {
|
||||
result = await application.stop();
|
||||
} catch {
|
||||
result = 'recovery_required';
|
||||
}
|
||||
if (result === 'stopped') {
|
||||
emit(
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker',
|
||||
level: 'info',
|
||||
event: 'stopped',
|
||||
workerId: config.workerId,
|
||||
capacityProfile: config.capacityProfile,
|
||||
stopResult: result,
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
emit(
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker',
|
||||
level: 'error',
|
||||
event: 'shutdown_deferred',
|
||||
workerId: config.workerId,
|
||||
capacityProfile: config.capacityProfile,
|
||||
stopResult: result,
|
||||
}),
|
||||
);
|
||||
await waitBeforeRetry();
|
||||
}
|
||||
} finally {
|
||||
clearInterval(lifecycleReference);
|
||||
unsubscribe();
|
||||
resolveSignal = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Worker Process owns the OS signal and diagnostic stream CLI adapter.
|
||||
import {
|
||||
runProductionWorkerProcess,
|
||||
type WorkerProcessSignal,
|
||||
type WorkerProcessSignalSource,
|
||||
} from './workerProcessApplication';
|
||||
|
||||
function signalSource(): WorkerProcessSignalSource {
|
||||
return Object.freeze({
|
||||
subscribe(listener: (signal: WorkerProcessSignal) => void): () => void {
|
||||
const onInterrupt = () => listener('SIGINT');
|
||||
const onTerminate = () => listener('SIGTERM');
|
||||
process.once('SIGINT', onInterrupt);
|
||||
process.once('SIGTERM', onTerminate);
|
||||
return () => {
|
||||
process.off('SIGINT', onInterrupt);
|
||||
process.off('SIGTERM', onTerminate);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runProductionWorkerProcess({
|
||||
environment: process.env,
|
||||
signals: signalSource(),
|
||||
emit(event) {
|
||||
const output = `${JSON.stringify(event)}\n`;
|
||||
if (event.level === 'error') process.stderr.write(output);
|
||||
else process.stdout.write(output);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string'
|
||||
? candidate.name.slice(0, 128)
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string'
|
||||
? { code: candidate.code.slice(0, 128) }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
// Worker Process owns bounded environment-to-runtime configuration mapping.
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import {
|
||||
isAbsolute,
|
||||
normalize,
|
||||
parse,
|
||||
} from 'node:path';
|
||||
import {
|
||||
canonicalRemoteWorkerCapabilities,
|
||||
type RemoteWorkerCapabilities,
|
||||
} from '@qinglong/runtime-core/remote-dispatch';
|
||||
|
||||
const MAX_CAPABILITIES_FILE_BYTES = 20 * 1024;
|
||||
const WORKER_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CREDENTIAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
|
||||
export type WorkerProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export interface DisabledWorkerProcessConfig {
|
||||
readonly enabled: false;
|
||||
readonly profile: string;
|
||||
}
|
||||
|
||||
export interface EnabledWorkerProcessConfig {
|
||||
readonly enabled: true;
|
||||
readonly profile: 'worker';
|
||||
readonly capacityProfile: 'edge' | 'node';
|
||||
readonly workerId: string;
|
||||
readonly origin: string;
|
||||
readonly capabilities: RemoteWorkerCapabilities;
|
||||
readonly maxConcurrentRuns: number;
|
||||
readonly storage: Readonly<{
|
||||
readonly journalRoot: string;
|
||||
readonly logRoot: string;
|
||||
readonly receiptRoot: string;
|
||||
}>;
|
||||
readonly identity: Readonly<{
|
||||
readonly certificateStoreRoot: string;
|
||||
readonly trustAnchorFile: string;
|
||||
readonly credentialTokenFile: string;
|
||||
readonly expectedCredentialId?: string;
|
||||
readonly bootstrap?: Readonly<{
|
||||
readonly privateKeyFile: string;
|
||||
readonly certificateChainFile: string;
|
||||
}>;
|
||||
}>;
|
||||
readonly lifecycle: Readonly<{
|
||||
readonly cadenceMs: number;
|
||||
readonly leaseDurationMs: number;
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly drainTimeoutMs: number;
|
||||
readonly drainPollMs: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly maximumJournalEntries: number;
|
||||
readonly maximumRecordsPerTick: number;
|
||||
readonly maximumSupervisionRecordsPerTick: number;
|
||||
}>;
|
||||
readonly executor: Readonly<{
|
||||
readonly launcherPath?: string;
|
||||
readonly expectedLauncherSha256?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type WorkerProcessConfig =
|
||||
| DisabledWorkerProcessConfig
|
||||
| EnabledWorkerProcessConfig;
|
||||
|
||||
export class WorkerProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_WORKER_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Worker process configuration is invalid: ${message}`, options);
|
||||
this.name = 'WorkerProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: WorkerProcessEnvironment,
|
||||
name: string,
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return fallback;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw new WorkerProcessConfigError(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: WorkerProcessEnvironment,
|
||||
name: string,
|
||||
maximumBytes: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') {
|
||||
if (required) throw new WorkerProcessConfigError(`${name} is required`);
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes ||
|
||||
/[\0\r\n]/.test(value)
|
||||
) {
|
||||
throw new WorkerProcessConfigError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: WorkerProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return fallback;
|
||||
if (!/^(0|[1-9]\d*)$/.test(value)) {
|
||||
throw new WorkerProcessConfigError(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new WorkerProcessConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function absolutePath(
|
||||
environment: WorkerProcessEnvironment,
|
||||
name: string,
|
||||
required = true,
|
||||
): string | undefined {
|
||||
const value = boundedValue(environment, name, 4096, required);
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!isAbsolute(value) ||
|
||||
parse(value).root === value ||
|
||||
normalize(value) !== value
|
||||
) {
|
||||
throw new WorkerProcessConfigError(`${name} must be a normalized absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function origin(environment: WorkerProcessEnvironment): string {
|
||||
const value = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CONTROL_ORIGIN',
|
||||
2048,
|
||||
true,
|
||||
)!;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch (error) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'QL3_WORKER_CONTROL_ORIGIN is invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
parsed.username !== '' ||
|
||||
parsed.password !== '' ||
|
||||
parsed.pathname !== '/' ||
|
||||
parsed.search !== '' ||
|
||||
parsed.hash !== ''
|
||||
) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'QL3_WORKER_CONTROL_ORIGIN must be an HTTPS origin',
|
||||
);
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
async function capabilities(path: string): Promise<RemoteWorkerCapabilities> {
|
||||
let handle;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
handle = await open(
|
||||
path,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_CAPABILITIES_FILE_BYTES ||
|
||||
(stat.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new Error('unsafe capabilities metadata');
|
||||
}
|
||||
bytes = await handle.readFile();
|
||||
if (
|
||||
bytes.byteLength < 2 ||
|
||||
bytes.byteLength > MAX_CAPABILITIES_FILE_BYTES
|
||||
) {
|
||||
throw new Error('unsafe capabilities size');
|
||||
}
|
||||
const parsed = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||
) as unknown;
|
||||
return canonicalRemoteWorkerCapabilities(parsed).capabilities;
|
||||
} catch (error) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'QL3_WORKER_CAPABILITIES_FILE is unavailable or invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadWorkerProcessConfig(
|
||||
environment: WorkerProcessEnvironment,
|
||||
): Promise<WorkerProcessConfig> {
|
||||
if (
|
||||
!environment ||
|
||||
typeof environment !== 'object' ||
|
||||
Array.isArray(environment)
|
||||
) {
|
||||
throw new WorkerProcessConfigError('environment must be an object');
|
||||
}
|
||||
const enabled = booleanValue(
|
||||
environment,
|
||||
'QL3_WORKER_RUNTIME_ENABLED',
|
||||
false,
|
||||
);
|
||||
const profile = environment.QL_DEPLOYMENT_PROFILE ?? 'standalone';
|
||||
if (!enabled) return Object.freeze({ enabled: false, profile });
|
||||
if (profile !== 'worker') {
|
||||
throw new WorkerProcessConfigError(
|
||||
'enabled runtime requires QL_DEPLOYMENT_PROFILE=worker',
|
||||
);
|
||||
}
|
||||
|
||||
const capacityProfile =
|
||||
boundedValue(environment, 'QL3_WORKER_CAPACITY_PROFILE', 8) ?? 'edge';
|
||||
if (capacityProfile !== 'edge' && capacityProfile !== 'node') {
|
||||
throw new WorkerProcessConfigError(
|
||||
'QL3_WORKER_CAPACITY_PROFILE must be edge or node',
|
||||
);
|
||||
}
|
||||
const edge = capacityProfile === 'edge';
|
||||
const workerId = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ID',
|
||||
128,
|
||||
true,
|
||||
)!;
|
||||
if (!WORKER_ID.test(workerId)) {
|
||||
throw new WorkerProcessConfigError('QL3_WORKER_ID is invalid');
|
||||
}
|
||||
const expectedCredentialId = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_EXPECTED_CREDENTIAL_ID',
|
||||
64,
|
||||
);
|
||||
if (
|
||||
expectedCredentialId !== undefined &&
|
||||
!CREDENTIAL_ID.test(expectedCredentialId)
|
||||
) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'QL3_WORKER_EXPECTED_CREDENTIAL_ID is invalid',
|
||||
);
|
||||
}
|
||||
const bootstrapPrivateKeyFile = absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_IDENTITY_BOOTSTRAP_PRIVATE_KEY_FILE',
|
||||
false,
|
||||
);
|
||||
const bootstrapCertificateChainFile = absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_IDENTITY_BOOTSTRAP_CERTIFICATE_FILE',
|
||||
false,
|
||||
);
|
||||
if (
|
||||
(bootstrapPrivateKeyFile === undefined) !==
|
||||
(bootstrapCertificateChainFile === undefined)
|
||||
) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'Worker identity bootstrap key and certificate must be configured together',
|
||||
);
|
||||
}
|
||||
const launcherPath = absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_LAUNCHER_PATH',
|
||||
false,
|
||||
);
|
||||
const expectedLauncherSha256 = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_LAUNCHER_SHA256',
|
||||
64,
|
||||
);
|
||||
if (
|
||||
(launcherPath === undefined) !==
|
||||
(expectedLauncherSha256 === undefined) ||
|
||||
(expectedLauncherSha256 !== undefined &&
|
||||
!SHA256.test(expectedLauncherSha256))
|
||||
) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'Worker launcher path and SHA-256 must be configured together',
|
||||
);
|
||||
}
|
||||
const capabilitiesFile = absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_CAPABILITIES_FILE',
|
||||
)!;
|
||||
const leaseDurationMs = integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_SESSION_LEASE_DURATION_MS',
|
||||
45_000,
|
||||
15_000,
|
||||
10 * 60_000,
|
||||
);
|
||||
const heartbeatIntervalMs = integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_HEARTBEAT_INTERVAL_MS',
|
||||
10_000,
|
||||
5_000,
|
||||
5 * 60_000,
|
||||
);
|
||||
if (heartbeatIntervalMs * 2 > leaseDurationMs) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'Worker heartbeat interval must fit twice inside the Session lease',
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile,
|
||||
workerId,
|
||||
origin: origin(environment),
|
||||
capabilities: await capabilities(capabilitiesFile),
|
||||
maxConcurrentRuns: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_MAX_CONCURRENT_RUNS',
|
||||
edge ? 1 : 8,
|
||||
1,
|
||||
edge ? 4 : 64,
|
||||
),
|
||||
storage: Object.freeze({
|
||||
journalRoot: absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_JOURNAL_ROOT',
|
||||
)!,
|
||||
logRoot: absolutePath(environment, 'QL3_WORKER_LOG_ROOT')!,
|
||||
receiptRoot: absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_RECEIPT_ROOT',
|
||||
)!,
|
||||
}),
|
||||
identity: Object.freeze({
|
||||
certificateStoreRoot: absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_CERTIFICATE_STORE_ROOT',
|
||||
)!,
|
||||
trustAnchorFile: absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_TRUST_ANCHOR_FILE',
|
||||
)!,
|
||||
credentialTokenFile: absolutePath(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_TOKEN_FILE',
|
||||
)!,
|
||||
...(expectedCredentialId === undefined
|
||||
? {}
|
||||
: { expectedCredentialId }),
|
||||
...(bootstrapPrivateKeyFile === undefined
|
||||
? {}
|
||||
: {
|
||||
bootstrap: Object.freeze({
|
||||
privateKeyFile: bootstrapPrivateKeyFile,
|
||||
certificateChainFile: bootstrapCertificateChainFile!,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
lifecycle: Object.freeze({
|
||||
cadenceMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CADENCE_MS',
|
||||
edge ? 2_000 : 500,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
leaseDurationMs,
|
||||
heartbeatIntervalMs,
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_DRAIN_TIMEOUT_MS',
|
||||
edge ? 60_000 : 5 * 60_000,
|
||||
1_000,
|
||||
10 * 60_000,
|
||||
),
|
||||
drainPollMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_DRAIN_POLL_MS',
|
||||
edge ? 500 : 100,
|
||||
25,
|
||||
5_000,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_REQUEST_TIMEOUT_MS',
|
||||
15_000,
|
||||
100,
|
||||
120_000,
|
||||
),
|
||||
maximumJournalEntries: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_MAXIMUM_JOURNAL_ENTRIES',
|
||||
edge ? 64 : 256,
|
||||
1,
|
||||
1024,
|
||||
),
|
||||
maximumRecordsPerTick: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_MAXIMUM_RECORDS_PER_TICK',
|
||||
edge ? 4 : 16,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
maximumSupervisionRecordsPerTick: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_MAXIMUM_SUPERVISION_RECORDS_PER_TICK',
|
||||
edge ? 4 : 32,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
}),
|
||||
executor: Object.freeze({
|
||||
...(launcherPath === undefined
|
||||
? {}
|
||||
: {
|
||||
launcherPath,
|
||||
expectedLauncherSha256: expectedLauncherSha256!,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Worker Process owns private bootstrap material and active credential composition.
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import type {
|
||||
WorkerCertificateTrustAnchorProvider,
|
||||
} from '../credential/workerCertificateRenewal';
|
||||
import { WorkerCertificateFileStore } from '../credential/workerCertificateStore';
|
||||
import { validateWorkerCertificateIdentity } from '../credential/workerCertificateIdentity';
|
||||
import { WorkerProductionCredentialProvider } from '../credential/workerProductionCredentialProvider';
|
||||
import type { EnabledWorkerProcessConfig } from './workerProcessConfig';
|
||||
|
||||
const MAX_TLS_MATERIAL_BYTES = 1024 * 1024;
|
||||
|
||||
export class WorkerProcessIdentityError extends Error {
|
||||
readonly code = 'QL3_WORKER_PROCESS_IDENTITY_UNAVAILABLE';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Worker process identity is unavailable: ${message}`, options);
|
||||
this.name = 'WorkerProcessIdentityError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readMaterial(
|
||||
path: string,
|
||||
privateMaterial: boolean,
|
||||
): Promise<Buffer> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(
|
||||
path,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_TLS_MATERIAL_BYTES ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(privateMaterial && (stat.mode & 0o077) !== 0)
|
||||
) {
|
||||
throw new Error('unsafe identity material metadata');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > MAX_TLS_MATERIAL_BYTES
|
||||
) {
|
||||
bytes.fill(0);
|
||||
throw new Error('unsafe identity material size');
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
throw new WorkerProcessIdentityError('material read failed', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerTrustAnchorFileProvider
|
||||
implements WorkerCertificateTrustAnchorProvider
|
||||
{
|
||||
constructor(private readonly path: string) {
|
||||
if (typeof path !== 'string' || path.length < 1) {
|
||||
throw new WorkerProcessIdentityError('trust anchor path is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async load(signal: AbortSignal): Promise<readonly Buffer[]> {
|
||||
if (!(signal instanceof AbortSignal)) {
|
||||
throw new WorkerProcessIdentityError('trust signal is invalid');
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const bytes = await readMaterial(this.path, false);
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
return Object.freeze([bytes]);
|
||||
} catch (error) {
|
||||
bytes.fill(0);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies or bootstraps one durable Worker certificate store, then returns
|
||||
* the per-request credential provider. Bootstrap material is optional and is
|
||||
* only installed when its validated leaf differs from the active generation.
|
||||
*/
|
||||
export async function createWorkerProcessCredentialProvider(
|
||||
config: EnabledWorkerProcessConfig['identity'],
|
||||
): Promise<Readonly<WorkerProductionCredentialProvider>> {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new WorkerProcessIdentityError('configuration is invalid');
|
||||
}
|
||||
const store = new WorkerCertificateFileStore({
|
||||
rootDirectory: config.certificateStoreRoot,
|
||||
retainedGenerations: 2,
|
||||
});
|
||||
const trustAnchors = new WorkerTrustAnchorFileProvider(
|
||||
config.trustAnchorFile,
|
||||
);
|
||||
const signal = new AbortController().signal;
|
||||
let anchors: readonly Buffer[] | undefined;
|
||||
let privateKey: Buffer | undefined;
|
||||
let certificate: Buffer | undefined;
|
||||
try {
|
||||
anchors = await trustAnchors.load(signal);
|
||||
const active = await store.readActive(anchors);
|
||||
if (config.bootstrap !== undefined) {
|
||||
[privateKey, certificate] = await Promise.all([
|
||||
readMaterial(config.bootstrap.privateKeyFile, true),
|
||||
readMaterial(config.bootstrap.certificateChainFile, false),
|
||||
]);
|
||||
const bootstrap = validateWorkerCertificateIdentity({
|
||||
privateKeyPem: privateKey,
|
||||
certificateChainPem: certificate,
|
||||
trustAnchors: anchors,
|
||||
});
|
||||
if (active?.certificateSha256 !== bootstrap.certificateSha256) {
|
||||
await store.install({
|
||||
privateKeyPem: privateKey,
|
||||
certificateChainPem: certificate,
|
||||
trustAnchors: anchors,
|
||||
});
|
||||
}
|
||||
} else if (!active) {
|
||||
throw new WorkerProcessIdentityError(
|
||||
'no active identity or bootstrap material',
|
||||
);
|
||||
}
|
||||
return new WorkerProductionCredentialProvider({
|
||||
certificateStore: store,
|
||||
trustAnchors,
|
||||
credentialTokenFile: config.credentialTokenFile,
|
||||
...(config.expectedCredentialId === undefined
|
||||
? {}
|
||||
: { expectedCredentialId: config.expectedCredentialId }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerProcessIdentityError) throw error;
|
||||
throw new WorkerProcessIdentityError('activation failed', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
privateKey?.fill(0);
|
||||
certificate?.fill(0);
|
||||
anchors?.forEach((anchor) => anchor.fill(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Remote Execution owns bounded Secret and Artifact context materialization.
|
||||
import {
|
||||
MAX_LOCAL_DISPATCH_ENVIRONMENT_BYTES,
|
||||
MAX_LOCAL_DISPATCH_SECRET_REFS,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import type {
|
||||
MaterializedWorkerRemoteExecutionContext,
|
||||
WorkerRemoteExecutionContextMaterializer,
|
||||
WorkerRemoteExecutionOutputSink,
|
||||
} from './executionInboxProcessor';
|
||||
|
||||
export interface WorkerRemoteSecretResolution {
|
||||
readonly values: readonly Readonly<{
|
||||
secretRef: string;
|
||||
value: string;
|
||||
}>[];
|
||||
readonly dispose?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteSecretEnvironmentProvider {
|
||||
resolve(request: Readonly<{
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
taskRevision: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
executionDigest: string;
|
||||
secretRefs: readonly string[];
|
||||
}>): Promise<WorkerRemoteSecretResolution | undefined>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactPreparation {
|
||||
readonly logArtifactId: string;
|
||||
/** Transfers the prepared writer once; release must not close it afterwards. */
|
||||
readonly takeOutput: () => WorkerRemoteExecutionOutputSink;
|
||||
/** Releases only preparation resources; it must not delete a handed-off log. */
|
||||
readonly release: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactAllocator {
|
||||
prepare(request: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
}>): Promise<WorkerRemoteLogArtifactPreparation | undefined>;
|
||||
}
|
||||
|
||||
export interface BoundedWorkerRemoteExecutionContextMaterializerOptions {
|
||||
readonly artifacts: WorkerRemoteLogArtifactAllocator;
|
||||
readonly secrets?: WorkerRemoteSecretEnvironmentProvider;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionMaterializationError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'secret_unavailable'
|
||||
| 'secret_response_invalid'
|
||||
| 'environment_budget_exceeded'
|
||||
| 'artifact_unavailable'
|
||||
| 'artifact_response_invalid',
|
||||
) {
|
||||
super(`Worker remote execution materialization failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionMaterializationError';
|
||||
}
|
||||
}
|
||||
|
||||
function environmentValue(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 16 * 1024
|
||||
) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function disposeQuietly(
|
||||
operation: (() => Promise<void> | void) | undefined,
|
||||
): Promise<void> {
|
||||
await Promise.resolve().then(() => operation?.()).catch(() => undefined);
|
||||
}
|
||||
|
||||
export class BoundedWorkerRemoteExecutionContextMaterializer
|
||||
implements WorkerRemoteExecutionContextMaterializer {
|
||||
private readonly artifacts: WorkerRemoteLogArtifactAllocator;
|
||||
private readonly secrets?: WorkerRemoteSecretEnvironmentProvider;
|
||||
|
||||
constructor(options: BoundedWorkerRemoteExecutionContextMaterializerOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.artifacts?.prepare !== 'function' ||
|
||||
(options.secrets !== undefined &&
|
||||
typeof options.secrets.resolve !== 'function')
|
||||
) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
this.artifacts = options.artifacts;
|
||||
this.secrets = options.secrets;
|
||||
}
|
||||
|
||||
async prepare(input: Readonly<{
|
||||
offer: ClusterRemoteExecutionOffer;
|
||||
}>): Promise<MaterializedWorkerRemoteExecutionContext> {
|
||||
let offer: ClusterRemoteExecutionOffer;
|
||||
try {
|
||||
offer = createClusterRemoteExecutionOffer(input?.offer);
|
||||
} catch {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
const bindings = offer.executionRevision.environment;
|
||||
const secretRefs = Object.freeze([
|
||||
...new Set(bindings.flatMap((binding) =>
|
||||
binding.kind === 'secret' ? [binding.secretRef] : [])),
|
||||
]);
|
||||
if (secretRefs.length > MAX_LOCAL_DISPATCH_SECRET_REFS) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'environment_budget_exceeded',
|
||||
);
|
||||
}
|
||||
let secretResolution: WorkerRemoteSecretResolution | undefined;
|
||||
const secretByRef = new Map<string, string>();
|
||||
if (secretRefs.length > 0) {
|
||||
if (!this.secrets) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_unavailable',
|
||||
);
|
||||
}
|
||||
try {
|
||||
secretResolution = await this.secrets.resolve(Object.freeze({
|
||||
projectId: offer.candidate.projectId,
|
||||
taskId: offer.candidate.taskId,
|
||||
taskRevision: offer.candidate.taskRevision,
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
offerId: offer.offerId,
|
||||
executionDigest: offer.executionDigest,
|
||||
secretRefs,
|
||||
}));
|
||||
} catch {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_unavailable',
|
||||
);
|
||||
}
|
||||
if (!secretResolution) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_unavailable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(secretResolution).some((key) =>
|
||||
key !== 'values' && key !== 'dispose') ||
|
||||
!Array.isArray(secretResolution.values) ||
|
||||
secretResolution.values.length !== secretRefs.length ||
|
||||
(secretResolution.dispose !== undefined &&
|
||||
typeof secretResolution.dispose !== 'function')
|
||||
) {
|
||||
await disposeQuietly(secretResolution.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
for (const entry of secretResolution.values) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry !== 'object' ||
|
||||
Object.keys(entry).length !== 2 ||
|
||||
!Object.hasOwn(entry, 'secretRef') ||
|
||||
!Object.hasOwn(entry, 'value') ||
|
||||
typeof entry.secretRef !== 'string' ||
|
||||
!secretRefs.includes(entry.secretRef) ||
|
||||
secretByRef.has(entry.secretRef)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
secretByRef.set(entry.secretRef, environmentValue(entry.value));
|
||||
}
|
||||
} catch (error) {
|
||||
await disposeQuietly(secretResolution.dispose);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let environmentBytes = 0;
|
||||
let environment: MaterializedWorkerRemoteExecutionContext['environment'];
|
||||
try {
|
||||
environment = Object.freeze(bindings.map((binding) => {
|
||||
const value = binding.kind === 'public'
|
||||
? binding.value
|
||||
: secretByRef.get(binding.secretRef);
|
||||
if (value === undefined) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
environmentBytes += Buffer.byteLength(binding.name, 'utf8') +
|
||||
Buffer.byteLength(value, 'utf8');
|
||||
if (environmentBytes > MAX_LOCAL_DISPATCH_ENVIRONMENT_BYTES) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'environment_budget_exceeded',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ name: binding.name, value });
|
||||
}));
|
||||
} catch (error) {
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw error;
|
||||
}
|
||||
let artifact: WorkerRemoteLogArtifactPreparation | undefined;
|
||||
try {
|
||||
artifact = await this.artifacts.prepare(Object.freeze({
|
||||
projectId: offer.candidate.projectId,
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
offerId: offer.offerId,
|
||||
}));
|
||||
} catch {
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_unavailable',
|
||||
);
|
||||
}
|
||||
if (!artifact) {
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_unavailable',
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (
|
||||
Object.keys(artifact).length !== 3 ||
|
||||
!Object.hasOwn(artifact, 'logArtifactId') ||
|
||||
!Object.hasOwn(artifact, 'takeOutput') ||
|
||||
!Object.hasOwn(artifact, 'release')
|
||||
) {
|
||||
throw new Error('invalid artifact preparation');
|
||||
}
|
||||
assertRunDispatchId('logArtifactId', artifact.logArtifactId);
|
||||
if (
|
||||
artifact.logArtifactId.length > 36 ||
|
||||
typeof artifact.takeOutput !== 'function' ||
|
||||
typeof artifact.release !== 'function'
|
||||
) {
|
||||
throw new Error('invalid artifact preparation');
|
||||
}
|
||||
} catch {
|
||||
await disposeQuietly(artifact.release);
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
let disposed = false;
|
||||
let outputTaken = false;
|
||||
return Object.freeze({
|
||||
environment,
|
||||
logArtifactId: artifact.logArtifactId,
|
||||
takeOutput() {
|
||||
if (disposed || outputTaken) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
const output = artifact!.takeOutput();
|
||||
if (
|
||||
!output ||
|
||||
typeof output !== 'object' ||
|
||||
output.logArtifactId !== artifact!.logArtifactId ||
|
||||
typeof output.write !== 'function' ||
|
||||
typeof output.close !== 'function'
|
||||
) {
|
||||
void Promise.resolve(output?.close?.()).catch(() => undefined);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
outputTaken = true;
|
||||
return output;
|
||||
},
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
await Promise.all([
|
||||
disposeQuietly(artifact!.release),
|
||||
disposeQuietly(secretResolution?.dispose),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Remote Execution owns the durable offer inbox authority and transition contract.
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
|
||||
export const WORKER_REMOTE_EXECUTION_INBOX_STATES = [
|
||||
'accepted',
|
||||
'starting_acknowledged',
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'start_failed',
|
||||
'start_failure_acknowledged',
|
||||
'completion_acknowledged',
|
||||
'recovery_required',
|
||||
] as const;
|
||||
|
||||
export type WorkerRemoteExecutionInboxState =
|
||||
(typeof WORKER_REMOTE_EXECUTION_INBOX_STATES)[number];
|
||||
|
||||
export type WorkerRemoteExecutionRecoveryReason =
|
||||
| 'launch_outcome_unknown'
|
||||
| 'control_plane_already_running'
|
||||
| 'control_plane_terminal'
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified';
|
||||
|
||||
export interface WorkerRemoteExecutionInboxRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly revision: number;
|
||||
readonly state: WorkerRemoteExecutionInboxState;
|
||||
readonly offer: ClusterRemoteExecutionOffer;
|
||||
readonly acceptedAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
readonly executorHandle?: string;
|
||||
readonly executorStartedAtMs?: number;
|
||||
readonly logArtifactId?: string;
|
||||
readonly completionReceiptCallbackSequence?: number;
|
||||
readonly completionReceiptTokenDigest?: string;
|
||||
readonly completionAcknowledgedAtMs?: number;
|
||||
readonly recoveryReason?: WorkerRemoteExecutionRecoveryReason;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionInboxPage {
|
||||
readonly records: readonly WorkerRemoteExecutionInboxRecord[];
|
||||
readonly nextAfterOfferId?: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionInbox {
|
||||
readOffer(offerId: string): Promise<WorkerRemoteExecutionInboxRecord | undefined>;
|
||||
replaceOffer(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<void>;
|
||||
listOffers(options?: Readonly<{
|
||||
afterOfferId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<WorkerRemoteExecutionInboxPage>;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionInboxError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_record'
|
||||
| 'authority_conflict'
|
||||
| 'revision_conflict'
|
||||
| 'invalid_transition',
|
||||
) {
|
||||
super(`Worker remote execution inbox failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionInboxError';
|
||||
}
|
||||
}
|
||||
|
||||
const STATES = new Set<string>(WORKER_REMOTE_EXECUTION_INBOX_STATES);
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const RECOVERY_REASONS = new Set<string>([
|
||||
'launch_outcome_unknown',
|
||||
'control_plane_already_running',
|
||||
'control_plane_terminal',
|
||||
'lease_lost_local_execution_stopped',
|
||||
'lease_lost_local_execution_unverified',
|
||||
]);
|
||||
const OPTIONAL_FIELDS = [
|
||||
'executorHandle',
|
||||
'executorStartedAtMs',
|
||||
'logArtifactId',
|
||||
'completionReceiptCallbackSequence',
|
||||
'completionReceiptTokenDigest',
|
||||
'completionAcknowledgedAtMs',
|
||||
'recoveryReason',
|
||||
] as const;
|
||||
const BASE_FIELDS = [
|
||||
'schemaVersion',
|
||||
'revision',
|
||||
'state',
|
||||
'offer',
|
||||
'acceptedAtMs',
|
||||
'updatedAtMs',
|
||||
] as const;
|
||||
|
||||
function invalid(): never {
|
||||
throw new WorkerRemoteExecutionInboxError('invalid_record');
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) invalid();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > maximum ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameOfferAuthority(
|
||||
left: ClusterRemoteExecutionOffer,
|
||||
right: ClusterRemoteExecutionOffer,
|
||||
): boolean {
|
||||
const first = createClusterRemoteExecutionOffer(left);
|
||||
const second = createClusterRemoteExecutionOffer(right);
|
||||
return (
|
||||
first.offerId === second.offerId &&
|
||||
first.executionDigest === second.executionDigest &&
|
||||
first.deliveryKind === second.deliveryKind &&
|
||||
JSON.stringify(first.candidate) === JSON.stringify(second.candidate) &&
|
||||
JSON.stringify(first.worker) === JSON.stringify(second.worker) &&
|
||||
first.lease.runId === second.lease.runId &&
|
||||
first.lease.attemptId === second.lease.attemptId &&
|
||||
first.lease.workerId === second.lease.workerId &&
|
||||
first.lease.workerSessionId === second.lease.workerSessionId &&
|
||||
first.lease.workerGeneration === second.lease.workerGeneration &&
|
||||
first.lease.leaseGeneration === second.lease.leaseGeneration &&
|
||||
first.lease.leaseTokenDigest === second.lease.leaseTokenDigest &&
|
||||
first.leaseToken === second.leaseToken &&
|
||||
JSON.stringify(first.executionRevision) ===
|
||||
JSON.stringify(second.executionRevision)
|
||||
);
|
||||
}
|
||||
|
||||
function exactOptional<T extends keyof WorkerRemoteExecutionInboxRecord>(
|
||||
value: WorkerRemoteExecutionInboxRecord,
|
||||
key: T,
|
||||
): WorkerRemoteExecutionInboxRecord[T] | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(value, key) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
export function normalizeWorkerRemoteExecutionInboxRecord(
|
||||
value: WorkerRemoteExecutionInboxRecord,
|
||||
): WorkerRemoteExecutionInboxRecord {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const allowed = new Set<string>([...BASE_FIELDS, ...OPTIONAL_FIELDS]);
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
BASE_FIELDS.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key)) ||
|
||||
!STATES.has(value.state)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const revision = safeInteger(value.revision);
|
||||
const acceptedAtMs = safeInteger(value.acceptedAtMs);
|
||||
const updatedAtMs = safeInteger(value.updatedAtMs);
|
||||
if (updatedAtMs < acceptedAtMs) invalid();
|
||||
const offer = createClusterRemoteExecutionOffer(value.offer);
|
||||
|
||||
const executorHandle = exactOptional(value, 'executorHandle');
|
||||
const executorStartedAtMs = exactOptional(value, 'executorStartedAtMs');
|
||||
const logArtifactId = exactOptional(value, 'logArtifactId');
|
||||
const callbackSequence = exactOptional(
|
||||
value,
|
||||
'completionReceiptCallbackSequence',
|
||||
);
|
||||
const tokenDigest = exactOptional(value, 'completionReceiptTokenDigest');
|
||||
const completionAcknowledgedAtMs = exactOptional(
|
||||
value,
|
||||
'completionAcknowledgedAtMs',
|
||||
);
|
||||
const recoveryReason = exactOptional(value, 'recoveryReason');
|
||||
|
||||
if (executorHandle !== undefined && executorStartedAtMs === undefined) invalid();
|
||||
if (executorHandle !== undefined) boundedText(executorHandle, 512);
|
||||
if (executorStartedAtMs !== undefined) safeInteger(executorStartedAtMs);
|
||||
if (logArtifactId !== undefined) boundedText(logArtifactId, 36);
|
||||
if ((callbackSequence === undefined) !== (tokenDigest === undefined)) invalid();
|
||||
if (
|
||||
callbackSequence !== undefined &&
|
||||
(safeInteger(callbackSequence) < 1 || callbackSequence > 2_147_483_647)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (tokenDigest !== undefined && !SHA256.test(tokenDigest)) invalid();
|
||||
if (completionAcknowledgedAtMs !== undefined) {
|
||||
safeInteger(completionAcknowledgedAtMs);
|
||||
}
|
||||
if (
|
||||
recoveryReason !== undefined &&
|
||||
!RECOVERY_REASONS.has(recoveryReason)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
|
||||
const hasExecutor = executorHandle !== undefined;
|
||||
const hasExecutorStartedAt = executorStartedAtMs !== undefined;
|
||||
const hasLogArtifact = logArtifactId !== undefined;
|
||||
const hasReceiptAuthentication = callbackSequence !== undefined;
|
||||
const executorRequired = [
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
].includes(value.state);
|
||||
const executorStartedAtRequired = [
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'completion_acknowledged',
|
||||
].includes(value.state);
|
||||
const executorStartedAtOptional = [
|
||||
'start_failed',
|
||||
'start_failure_acknowledged',
|
||||
'recovery_required',
|
||||
].includes(value.state);
|
||||
const receiptAuthenticationRequired = [
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'completion_acknowledged',
|
||||
].includes(value.state);
|
||||
const receiptAuthenticationOptional = [
|
||||
'start_failed',
|
||||
'start_failure_acknowledged',
|
||||
'recovery_required',
|
||||
].includes(value.state);
|
||||
const logArtifactRequired = [
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'completion_acknowledged',
|
||||
].includes(value.state);
|
||||
if (
|
||||
(!['completion_acknowledged', 'recovery_required'].includes(value.state) &&
|
||||
executorRequired !== hasExecutor) ||
|
||||
(!executorStartedAtOptional &&
|
||||
executorStartedAtRequired !== hasExecutorStartedAt) ||
|
||||
(hasExecutor &&
|
||||
!['started', 'running_acknowledged', 'completion_acknowledged', 'recovery_required']
|
||||
.includes(value.state)) ||
|
||||
(hasExecutorStartedAt &&
|
||||
!['launching', 'started', 'running_acknowledged', 'start_failed',
|
||||
'start_failure_acknowledged', 'completion_acknowledged',
|
||||
'recovery_required'].includes(value.state)) ||
|
||||
(!receiptAuthenticationOptional &&
|
||||
receiptAuthenticationRequired !== hasReceiptAuthentication) ||
|
||||
(hasReceiptAuthentication &&
|
||||
!['launching', 'started', 'running_acknowledged', 'start_failed',
|
||||
'start_failure_acknowledged', 'completion_acknowledged',
|
||||
'recovery_required'].includes(value.state)) ||
|
||||
(logArtifactRequired && !hasLogArtifact) ||
|
||||
(hasLogArtifact && ['accepted', 'starting_acknowledged'].includes(value.state))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (
|
||||
completionAcknowledgedAtMs !== undefined !==
|
||||
(value.state === 'completion_acknowledged') ||
|
||||
recoveryReason !== undefined !== (value.state === 'recovery_required')
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
revision,
|
||||
state: value.state,
|
||||
offer,
|
||||
acceptedAtMs,
|
||||
updatedAtMs,
|
||||
...(executorHandle === undefined ? {} : { executorHandle }),
|
||||
...(executorStartedAtMs === undefined ? {} : { executorStartedAtMs }),
|
||||
...(logArtifactId === undefined ? {} : { logArtifactId }),
|
||||
...(callbackSequence === undefined
|
||||
? {}
|
||||
: { completionReceiptCallbackSequence: callbackSequence }),
|
||||
...(tokenDigest === undefined
|
||||
? {}
|
||||
: { completionReceiptTokenDigest: tokenDigest }),
|
||||
...(completionAcknowledgedAtMs === undefined
|
||||
? {}
|
||||
: { completionAcknowledgedAtMs }),
|
||||
...(recoveryReason === undefined ? {} : { recoveryReason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerRemoteExecutionInboxRecord(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
acceptedAtMs: number,
|
||||
): WorkerRemoteExecutionInboxRecord {
|
||||
return normalizeWorkerRemoteExecutionInboxRecord({
|
||||
schemaVersion: 1,
|
||||
revision: 0,
|
||||
state: 'accepted',
|
||||
offer,
|
||||
acceptedAtMs,
|
||||
updatedAtMs: acceptedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function transitions(
|
||||
...states: WorkerRemoteExecutionInboxState[]
|
||||
): ReadonlySet<WorkerRemoteExecutionInboxState> {
|
||||
return new Set(states);
|
||||
}
|
||||
|
||||
const TRANSITIONS: Readonly<Record<
|
||||
WorkerRemoteExecutionInboxState,
|
||||
ReadonlySet<WorkerRemoteExecutionInboxState>
|
||||
>> = Object.freeze({
|
||||
accepted: transitions('accepted', 'starting_acknowledged', 'recovery_required'),
|
||||
starting_acknowledged: transitions(
|
||||
'starting_acknowledged', 'launching', 'start_failed', 'recovery_required',
|
||||
),
|
||||
launching: transitions(
|
||||
'launching', 'started', 'start_failed', 'completion_acknowledged',
|
||||
'recovery_required',
|
||||
),
|
||||
started: transitions(
|
||||
'started', 'running_acknowledged', 'completion_acknowledged',
|
||||
'recovery_required',
|
||||
),
|
||||
running_acknowledged: transitions(
|
||||
'running_acknowledged', 'completion_acknowledged', 'recovery_required',
|
||||
),
|
||||
start_failed: transitions(
|
||||
'start_failed', 'start_failure_acknowledged', 'recovery_required',
|
||||
),
|
||||
start_failure_acknowledged: transitions('start_failure_acknowledged'),
|
||||
completion_acknowledged: transitions('completion_acknowledged'),
|
||||
recovery_required: transitions('recovery_required', 'completion_acknowledged'),
|
||||
});
|
||||
|
||||
export function assertWorkerRemoteExecutionInboxTransition(
|
||||
previousValue: WorkerRemoteExecutionInboxRecord,
|
||||
nextValue: WorkerRemoteExecutionInboxRecord,
|
||||
): void {
|
||||
const previous = normalizeWorkerRemoteExecutionInboxRecord(previousValue);
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord(nextValue);
|
||||
if (!sameOfferAuthority(previous.offer, next.offer)) {
|
||||
throw new WorkerRemoteExecutionInboxError('authority_conflict');
|
||||
}
|
||||
if (
|
||||
next.revision !== previous.revision + 1 ||
|
||||
next.acceptedAtMs !== previous.acceptedAtMs ||
|
||||
next.updatedAtMs < previous.updatedAtMs ||
|
||||
next.offer.lease.version < previous.offer.lease.version ||
|
||||
(next.offer.lease.version === previous.offer.lease.version &&
|
||||
JSON.stringify(next.offer.lease) !== JSON.stringify(previous.offer.lease))
|
||||
) {
|
||||
throw new WorkerRemoteExecutionInboxError('revision_conflict');
|
||||
}
|
||||
if (!TRANSITIONS[previous.state].has(next.state)) {
|
||||
throw new WorkerRemoteExecutionInboxError('invalid_transition');
|
||||
}
|
||||
for (const key of OPTIONAL_FIELDS) {
|
||||
const before = exactOptional(previous, key);
|
||||
const after = exactOptional(next, key);
|
||||
if (
|
||||
key === 'recoveryReason' &&
|
||||
next.state === 'completion_acknowledged'
|
||||
) continue;
|
||||
if (before !== undefined && before !== after) {
|
||||
throw new WorkerRemoteExecutionInboxError('invalid_transition');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
// Remote Execution owns offer activation, launch barriers, and recovery processing.
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
AcknowledgeRemoteRunRunningCommand,
|
||||
AcknowledgeRemoteRunStartingCommand,
|
||||
FailRemoteRunStartCommand,
|
||||
RemoteRunActivationResult,
|
||||
} from '@qinglong/runtime-core/remote-activation';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionRecoveryReason,
|
||||
} from './executionInbox';
|
||||
|
||||
export interface WorkerRemoteExecutionSession {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
readonly status: 'available' | 'draining' | 'offline';
|
||||
readonly leaseExpiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionActivationClient {
|
||||
acknowledgeStarting(
|
||||
command: AcknowledgeRemoteRunStartingCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>>;
|
||||
acknowledgeRunning(
|
||||
command: AcknowledgeRemoteRunRunningCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>>;
|
||||
failStart(
|
||||
command: FailRemoteRunStartCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionCallback {
|
||||
readonly sequence: number;
|
||||
/** Ephemeral capability. Implementations must not persist or log it. */
|
||||
readonly token: Uint8Array;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionOutputStream = 'stdout' | 'stderr';
|
||||
|
||||
export interface WorkerRemoteExecutionOutputChunk {
|
||||
readonly stream: WorkerRemoteExecutionOutputStream;
|
||||
readonly chunk: Uint8Array;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionOutputSink {
|
||||
readonly logArtifactId: string;
|
||||
write(output: WorkerRemoteExecutionOutputChunk): Promise<void>;
|
||||
/** Flushes accepted bytes and releases the writer. Must be idempotent. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MaterializedWorkerRemoteExecutionContext {
|
||||
readonly environment: readonly Readonly<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>[];
|
||||
readonly logArtifactId: string;
|
||||
/** Transfers the prepared writer exactly once after the durable spawn barrier. */
|
||||
readonly takeOutput: () => WorkerRemoteExecutionOutputSink;
|
||||
readonly dispose?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionContextMaterializer {
|
||||
prepare(input: Readonly<{
|
||||
offer: ClusterRemoteExecutionOffer;
|
||||
completionCallback: WorkerRemoteExecutionCompletionCallback;
|
||||
}>): Promise<MaterializedWorkerRemoteExecutionContext>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionLaunch {
|
||||
readonly offerId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
/** Durable pre-spawn timestamp from the launching inbox barrier. */
|
||||
readonly executorStartedAtMs: number;
|
||||
readonly command: ClusterRemoteExecutionOffer['executionRevision']['command'];
|
||||
readonly environment: MaterializedWorkerRemoteExecutionContext['environment'];
|
||||
readonly workingDirectory?: string;
|
||||
readonly timeoutMs?: number;
|
||||
/** Durable database-clock timeout authority returned by starting ACK. */
|
||||
readonly executionDeadlineAtMs?: number;
|
||||
readonly logArtifactId: string;
|
||||
/**
|
||||
* Ownership transfers to the Executor when start() is called. The Executor
|
||||
* must close it on every known terminal path, including explicit rejection.
|
||||
*/
|
||||
readonly output: WorkerRemoteExecutionOutputSink;
|
||||
readonly completionCallback: WorkerRemoteExecutionCompletionCallback;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionExecutor {
|
||||
/** A rejected result proves no execution started; a thrown error is unknown. */
|
||||
start(launch: WorkerRemoteExecutionLaunch): Promise<
|
||||
| Readonly<{
|
||||
status: 'started';
|
||||
executorHandle: string;
|
||||
executorStartedAtMs: number;
|
||||
}>
|
||||
| Readonly<{ status: 'rejected' }>
|
||||
>;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionProcessResult = Readonly<{
|
||||
status:
|
||||
| 'running'
|
||||
| 'already_running'
|
||||
| 'start_failed'
|
||||
| 'already_failed'
|
||||
| 'already_completed'
|
||||
| 'recovery_required';
|
||||
offerId: string;
|
||||
executorHandle?: string;
|
||||
recoveryReason?: WorkerRemoteExecutionRecoveryReason;
|
||||
}>;
|
||||
|
||||
export interface WorkerRemoteExecutionInboxProcessorOptions {
|
||||
readonly inbox: WorkerRemoteExecutionInbox;
|
||||
readonly activation: WorkerRemoteExecutionActivationClient;
|
||||
readonly materializer: WorkerRemoteExecutionContextMaterializer;
|
||||
readonly executor: WorkerRemoteExecutionExecutor;
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly now?: () => number;
|
||||
readonly randomCapability?: () => Uint8Array;
|
||||
readonly eventId?: () => string;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionProcessorError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'offer_missing'
|
||||
| 'target_fenced'
|
||||
| 'offer_expired'
|
||||
| 'activation_response_invalid'
|
||||
| 'materialized_context_invalid',
|
||||
) {
|
||||
super(`Worker remote execution processor failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionProcessorError';
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function safeTime(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximum ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateEnvironment(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
context: MaterializedWorkerRemoteExecutionContext,
|
||||
): MaterializedWorkerRemoteExecutionContext['environment'] {
|
||||
if (!context || typeof context !== 'object' || !Array.isArray(context.environment)) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const expected = offer.executionRevision.environment;
|
||||
if (context.environment.length !== expected.length) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const values = new Map<string, string>();
|
||||
for (const entry of context.environment) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const name = boundedText(entry.name, 255);
|
||||
if (
|
||||
name.includes('=') ||
|
||||
typeof entry.value !== 'string' ||
|
||||
entry.value.includes('\0') ||
|
||||
values.has(name)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
values.set(name, entry.value);
|
||||
}
|
||||
const normalized = expected.map((binding) => {
|
||||
const value = values.get(binding.name);
|
||||
if (
|
||||
value === undefined ||
|
||||
(binding.kind === 'public' && value !== binding.value)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
return Object.freeze({ name: binding.name, value });
|
||||
});
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
async function validateOutput(
|
||||
context: MaterializedWorkerRemoteExecutionContext,
|
||||
logArtifactId: string,
|
||||
): Promise<WorkerRemoteExecutionOutputSink> {
|
||||
if (typeof context.takeOutput !== 'function') {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const output = context.takeOutput();
|
||||
if (
|
||||
!output ||
|
||||
typeof output !== 'object' ||
|
||||
output.logArtifactId !== logArtifactId ||
|
||||
typeof output.write !== 'function' ||
|
||||
typeof output.close !== 'function'
|
||||
) {
|
||||
if (
|
||||
output &&
|
||||
typeof output === 'object' &&
|
||||
typeof (output as Partial<WorkerRemoteExecutionOutputSink>).close ===
|
||||
'function'
|
||||
) {
|
||||
await Promise.resolve().then(() =>
|
||||
(output as WorkerRemoteExecutionOutputSink).close()
|
||||
).catch(() => undefined);
|
||||
}
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionInboxProcessor {
|
||||
private readonly inbox: WorkerRemoteExecutionInbox;
|
||||
private readonly activation: WorkerRemoteExecutionActivationClient;
|
||||
private readonly materializer: WorkerRemoteExecutionContextMaterializer;
|
||||
private readonly executor: WorkerRemoteExecutionExecutor;
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly randomCapabilityProvider: () => Uint8Array;
|
||||
private readonly eventIdProvider: () => string;
|
||||
private readonly inFlight = new Map<string, Promise<WorkerRemoteExecutionProcessResult>>();
|
||||
|
||||
constructor(options: WorkerRemoteExecutionInboxProcessorOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.inbox?.readOffer !== 'function' ||
|
||||
typeof options.inbox?.replaceOffer !== 'function' ||
|
||||
typeof options.activation?.acknowledgeStarting !== 'function' ||
|
||||
typeof options.activation?.acknowledgeRunning !== 'function' ||
|
||||
typeof options.activation?.failStart !== 'function' ||
|
||||
typeof options.materializer?.prepare !== 'function' ||
|
||||
typeof options.executor?.start !== 'function' ||
|
||||
typeof options.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
this.inbox = options.inbox;
|
||||
this.activation = options.activation;
|
||||
this.materializer = options.materializer;
|
||||
this.executor = options.executor;
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
this.randomCapabilityProvider = options.randomCapability ??
|
||||
(() => randomBytes(32));
|
||||
this.eventIdProvider = options.eventId ?? randomUUID;
|
||||
}
|
||||
|
||||
process(offerId: string): Promise<WorkerRemoteExecutionProcessResult> {
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.processOnce(offerId).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) this.inFlight.delete(offerId);
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async processOnce(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionProcessResult> {
|
||||
let record = await this.inbox.readOffer(offerId);
|
||||
if (!record) {
|
||||
throw new WorkerRemoteExecutionProcessorError('offer_missing');
|
||||
}
|
||||
record = normalizeWorkerRemoteExecutionInboxRecord(record);
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return Object.freeze({ status: 'already_completed', offerId });
|
||||
}
|
||||
if (record.state === 'running_acknowledged') {
|
||||
return Object.freeze({
|
||||
status: 'already_running',
|
||||
offerId,
|
||||
executorHandle: record.executorHandle,
|
||||
});
|
||||
}
|
||||
if (record.state === 'start_failure_acknowledged') {
|
||||
return Object.freeze({ status: 'already_failed', offerId });
|
||||
}
|
||||
if (record.state === 'recovery_required') return this.recoveryResult(record);
|
||||
this.assertCurrentTarget(record.offer);
|
||||
|
||||
if (record.state === 'launching') {
|
||||
record = await this.recover(record, 'launch_outcome_unknown');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
if (record.state === 'accepted') {
|
||||
const starting = await this.activation.acknowledgeStarting({
|
||||
...this.fence(record.offer),
|
||||
eventId: this.eventId(),
|
||||
});
|
||||
this.assertActivation(record.offer, starting);
|
||||
if (starting.status === 'already_running') {
|
||||
record = await this.recover(record, 'control_plane_already_running');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
if (starting.status === 'already_terminal') {
|
||||
record = await this.recover(record, 'control_plane_terminal');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
record = await this.replace(record, { state: 'starting_acknowledged' });
|
||||
}
|
||||
if (record.state === 'start_failed') {
|
||||
return this.reportStartFailure(record);
|
||||
}
|
||||
if (record.state === 'starting_acknowledged') {
|
||||
record = await this.launch(record);
|
||||
if (record.state === 'start_failed') return this.reportStartFailure(record);
|
||||
if (record.state === 'recovery_required') return this.recoveryResult(record);
|
||||
}
|
||||
if (record.state !== 'started') {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
const running = await this.activation.acknowledgeRunning({
|
||||
...this.fence(record.offer),
|
||||
attemptEventId: this.eventId(),
|
||||
runEventId: this.eventId(),
|
||||
executorHandle: record.executorHandle!,
|
||||
...(record.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: record.logArtifactId }),
|
||||
callbackSequence: record.completionReceiptCallbackSequence!,
|
||||
callbackTokenDigest: record.completionReceiptTokenDigest!,
|
||||
});
|
||||
this.assertActivation(record.offer, running);
|
||||
if (running.status === 'already_terminal') {
|
||||
record = await this.recover(record, 'control_plane_terminal');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
if (
|
||||
running.status !== 'applied' &&
|
||||
running.status !== 'already_running'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
record = await this.replace(record, { state: 'running_acknowledged' });
|
||||
return Object.freeze({
|
||||
status: running.status === 'already_running' ? 'already_running' : 'running',
|
||||
offerId,
|
||||
executorHandle: record.executorHandle,
|
||||
});
|
||||
}
|
||||
|
||||
private async launch(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const callback = await this.nextCallbackAuthority(record.offer);
|
||||
const callbackSequence = callback.sequence;
|
||||
const token = Buffer.from(this.randomCapabilityProvider());
|
||||
if (token.byteLength !== 32) {
|
||||
token.fill(0);
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
const tokenDigest = createHash('sha256').update(token).digest('hex');
|
||||
let context: MaterializedWorkerRemoteExecutionContext | undefined;
|
||||
try {
|
||||
try {
|
||||
context = await this.materializer.prepare({
|
||||
offer: createClusterRemoteExecutionOffer(record.offer),
|
||||
completionCallback: Object.freeze({
|
||||
sequence: callbackSequence,
|
||||
token,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
let environment: MaterializedWorkerRemoteExecutionContext['environment'];
|
||||
let logArtifactId: string;
|
||||
try {
|
||||
environment = validateEnvironment(record.offer, context);
|
||||
logArtifactId = boundedText(context.logArtifactId, 36);
|
||||
if (typeof context.takeOutput !== 'function') {
|
||||
throw new WorkerRemoteExecutionProcessorError(
|
||||
'materialized_context_invalid',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
record = await this.replace(record, {
|
||||
state: 'launching',
|
||||
executorStartedAtMs: this.now(),
|
||||
logArtifactId,
|
||||
completionReceiptCallbackSequence: callbackSequence,
|
||||
completionReceiptTokenDigest: tokenDigest,
|
||||
});
|
||||
let output: WorkerRemoteExecutionOutputSink;
|
||||
try {
|
||||
output = await validateOutput(context, logArtifactId);
|
||||
} catch {
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
let outcome: Awaited<ReturnType<WorkerRemoteExecutionExecutor['start']>>;
|
||||
try {
|
||||
outcome = await this.executor.start(Object.freeze({
|
||||
offerId: record.offer.offerId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
executorStartedAtMs: record.executorStartedAtMs!,
|
||||
command: record.offer.executionRevision.command,
|
||||
environment,
|
||||
...(record.offer.executionRevision.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: record.offer.executionRevision.workingDirectory }),
|
||||
...(record.offer.executionRevision.timeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
timeoutMs: record.offer.executionRevision.timeoutMs,
|
||||
executionDeadlineAtMs: callback.deadlineAtMs,
|
||||
}),
|
||||
logArtifactId,
|
||||
output,
|
||||
completionCallback: Object.freeze({
|
||||
sequence: callbackSequence,
|
||||
token,
|
||||
}),
|
||||
}));
|
||||
} catch {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
if (outcome?.status === 'rejected') {
|
||||
await output.close().catch(() => undefined);
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
if (outcome?.status !== 'started') {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
let executorHandle: string;
|
||||
let executorStartedAtMs: number;
|
||||
try {
|
||||
executorHandle = boundedText(outcome.executorHandle, 512);
|
||||
executorStartedAtMs = outcome.executorStartedAtMs;
|
||||
if (
|
||||
!Number.isSafeInteger(executorStartedAtMs) ||
|
||||
executorStartedAtMs < 0 ||
|
||||
executorStartedAtMs > this.now() ||
|
||||
executorStartedAtMs !== record.executorStartedAtMs
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError(
|
||||
'materialized_context_invalid',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
try {
|
||||
return await this.replace(record, {
|
||||
state: 'started',
|
||||
executorHandle,
|
||||
logArtifactId,
|
||||
});
|
||||
} catch {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
} finally {
|
||||
token.fill(0);
|
||||
await context?.dispose?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async nextCallbackAuthority(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
): Promise<Readonly<{ sequence: number; deadlineAtMs?: number }>> {
|
||||
const replay = await this.activation.acknowledgeStarting({
|
||||
...this.fence(offer),
|
||||
eventId: this.eventId(),
|
||||
});
|
||||
this.assertActivation(offer, replay);
|
||||
if (
|
||||
replay.status !== 'already_starting' &&
|
||||
replay.status !== 'applied'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
const sequence = replay.snapshot.callbackSequence + 1;
|
||||
if (!Number.isSafeInteger(sequence) || sequence < 1 || sequence > 2_147_483_647) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
sequence,
|
||||
...(replay.snapshot.deadlineAtMs === undefined
|
||||
? {}
|
||||
: { deadlineAtMs: replay.snapshot.deadlineAtMs }),
|
||||
});
|
||||
}
|
||||
|
||||
private async reportStartFailure(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<WorkerRemoteExecutionProcessResult> {
|
||||
const result = await this.activation.failStart({
|
||||
...this.fence(record.offer),
|
||||
attemptEventId: this.eventId(),
|
||||
runEventId: this.eventId(),
|
||||
});
|
||||
this.assertActivation(record.offer, result, true);
|
||||
if (result.status === 'already_running') {
|
||||
const recovery = await this.recover(
|
||||
record,
|
||||
'control_plane_already_running',
|
||||
);
|
||||
return this.recoveryResult(recovery);
|
||||
}
|
||||
if (result.status !== 'applied' && result.status !== 'already_terminal') {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
await this.replace(record, { state: 'start_failure_acknowledged' });
|
||||
return Object.freeze({ status: 'start_failed', offerId: record.offer.offerId });
|
||||
}
|
||||
|
||||
private assertCurrentTarget(offer: ClusterRemoteExecutionOffer): void {
|
||||
const current = this.currentSessionProvider();
|
||||
const now = this.now();
|
||||
if (
|
||||
!current ||
|
||||
current.workerId !== offer.worker.workerId ||
|
||||
current.sessionId !== offer.worker.sessionId ||
|
||||
current.generation !== offer.worker.generation ||
|
||||
current.status === 'offline' ||
|
||||
(current.status === 'draining' && offer.deliveryKind === 'new_claim') ||
|
||||
current.leaseExpiresAtMs <= now
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('target_fenced');
|
||||
}
|
||||
if (offer.lease.expiresAtMs <= now) {
|
||||
throw new WorkerRemoteExecutionProcessorError('offer_expired');
|
||||
}
|
||||
}
|
||||
|
||||
private assertActivation(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
result: Readonly<RemoteRunActivationResult>,
|
||||
allowCompletedLease = false,
|
||||
): void {
|
||||
const snapshot = result?.snapshot;
|
||||
if (
|
||||
!['applied', 'already_starting', 'already_running', 'already_terminal']
|
||||
.includes(result?.status) ||
|
||||
!snapshot ||
|
||||
snapshot.runId !== offer.candidate.runId ||
|
||||
snapshot.attemptId !== offer.candidate.attemptId ||
|
||||
snapshot.leaseGeneration !== offer.lease.leaseGeneration ||
|
||||
!Number.isSafeInteger(snapshot.leaseVersion) ||
|
||||
snapshot.leaseVersion < offer.lease.version ||
|
||||
snapshot.leaseVersion > offer.lease.version + (allowCompletedLease ? 1 : 0) ||
|
||||
!Number.isSafeInteger(snapshot.callbackSequence) ||
|
||||
snapshot.callbackSequence < 0 ||
|
||||
snapshot.callbackSequence > 2_147_483_647
|
||||
|| (offer.executionRevision.timeoutMs === undefined) !==
|
||||
(snapshot.deadlineAtMs === undefined)
|
||||
|| (snapshot.deadlineAtMs !== undefined &&
|
||||
(!Number.isSafeInteger(snapshot.deadlineAtMs) ||
|
||||
snapshot.deadlineAtMs < 0))
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private fence(offer: ClusterRemoteExecutionOffer) {
|
||||
return Object.freeze({
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
workerId: offer.worker.workerId,
|
||||
workerSessionId: offer.worker.sessionId,
|
||||
workerGeneration: offer.worker.generation,
|
||||
offerId: offer.offerId,
|
||||
leaseGeneration: offer.lease.leaseGeneration,
|
||||
leaseToken: offer.leaseToken,
|
||||
expectedLeaseVersion: offer.lease.version,
|
||||
});
|
||||
}
|
||||
|
||||
private async replace(
|
||||
previous: WorkerRemoteExecutionInboxRecord,
|
||||
patch: Partial<WorkerRemoteExecutionInboxRecord>,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...previous,
|
||||
...patch,
|
||||
schemaVersion: 1,
|
||||
revision: previous.revision + 1,
|
||||
updatedAtMs: Math.max(this.now(), previous.updatedAtMs),
|
||||
});
|
||||
await this.inbox.replaceOffer(next, previous.revision);
|
||||
return next;
|
||||
}
|
||||
|
||||
private recover(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
recoveryReason: WorkerRemoteExecutionRecoveryReason,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
return this.replace(record, { state: 'recovery_required', recoveryReason });
|
||||
}
|
||||
|
||||
private recoveryResult(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): WorkerRemoteExecutionProcessResult {
|
||||
return Object.freeze({
|
||||
status: 'recovery_required',
|
||||
offerId: record.offer.offerId,
|
||||
recoveryReason: record.recoveryReason,
|
||||
});
|
||||
}
|
||||
|
||||
private eventId(): string {
|
||||
const value = this.eventIdProvider();
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 36 ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return safeTime(this.nowProvider());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Remote Execution owns the caller-driven offer supervision and drain lifecycle.
|
||||
import type { WorkerRemoteExecutionInbox } from './executionInbox';
|
||||
import type {
|
||||
WorkerRemoteExecutionInboxProcessor,
|
||||
WorkerRemoteExecutionProcessResult,
|
||||
WorkerRemoteExecutionSession,
|
||||
} from './executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteOfferPullCoordinator,
|
||||
WorkerRemoteOfferPullResult,
|
||||
} from './remoteOfferDelivery';
|
||||
import type {
|
||||
WorkerRemoteExecutionControlCoordinator,
|
||||
WorkerRemoteExecutionControlResult,
|
||||
} from '../execution/workerExecutionControlCoordinator';
|
||||
|
||||
export interface WorkerRemoteExecutionLifecycleJournal
|
||||
extends WorkerRemoteExecutionInbox {
|
||||
acquireOwnership(): Promise<void>;
|
||||
releaseOwnership(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionHeadlessLifecycleOptions {
|
||||
readonly journal: WorkerRemoteExecutionLifecycleJournal;
|
||||
readonly offers: Pick<WorkerRemoteOfferPullCoordinator, 'pull'>;
|
||||
readonly processor: Pick<WorkerRemoteExecutionInboxProcessor, 'process'>;
|
||||
readonly control: Pick<WorkerRemoteExecutionControlCoordinator, 'reconcile'>;
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly maximumRecordsPerTick?: number;
|
||||
readonly maximumSupervisionRecordsPerTick?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionLifecycleTickResult =
|
||||
| Readonly<{
|
||||
status: 'reconciling';
|
||||
processed: number;
|
||||
nextAfterOfferId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'reconciled';
|
||||
processed: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'recovery_required';
|
||||
offerId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'session_unavailable';
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'draining';
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'processed';
|
||||
offerId: string;
|
||||
execution: WorkerRemoteExecutionProcessResult;
|
||||
pull?: WorkerRemoteOfferPullResult;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'pull_result';
|
||||
pull: WorkerRemoteOfferPullResult;
|
||||
}>;
|
||||
|
||||
export class WorkerRemoteExecutionLifecycleError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'inactive'
|
||||
| 'stopping'
|
||||
| 'draining',
|
||||
) {
|
||||
super(`Worker remote execution lifecycle failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionLifecycleError';
|
||||
}
|
||||
}
|
||||
|
||||
type Mode = 'inactive' | 'running' | 'stopping';
|
||||
|
||||
export class WorkerRemoteExecutionHeadlessLifecycle {
|
||||
private readonly journal: WorkerRemoteExecutionLifecycleJournal;
|
||||
private readonly offers: Pick<WorkerRemoteOfferPullCoordinator, 'pull'>;
|
||||
private readonly processor: Pick<WorkerRemoteExecutionInboxProcessor, 'process'>;
|
||||
private readonly control: Pick<
|
||||
WorkerRemoteExecutionControlCoordinator,
|
||||
'reconcile'
|
||||
>;
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly maximumRecordsPerTick: number;
|
||||
private readonly maximumSupervisionRecordsPerTick: number;
|
||||
private readonly nowProvider: () => number;
|
||||
private mode: Mode = 'inactive';
|
||||
private draining = false;
|
||||
private startupAfterOfferId?: string;
|
||||
private startupComplete = false;
|
||||
private supervisionAfterOfferId?: string;
|
||||
private retryOfferId?: string;
|
||||
private recoveryOfferId?: string;
|
||||
private inFlight?: Promise<WorkerRemoteExecutionLifecycleTickResult>;
|
||||
private stopController?: AbortController;
|
||||
private drainOperation?: Promise<void>;
|
||||
private stopOperation?: Promise<void>;
|
||||
|
||||
constructor(options: WorkerRemoteExecutionHeadlessLifecycleOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.journal?.acquireOwnership !== 'function' ||
|
||||
typeof options.journal?.releaseOwnership !== 'function' ||
|
||||
typeof options.journal?.listOffers !== 'function' ||
|
||||
typeof options.offers?.pull !== 'function' ||
|
||||
typeof options.processor?.process !== 'function' ||
|
||||
typeof options.control?.reconcile !== 'function' ||
|
||||
typeof options.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionLifecycleError('invalid_configuration');
|
||||
}
|
||||
const maximumRecordsPerTick = options.maximumRecordsPerTick ?? 16;
|
||||
const maximumSupervisionRecordsPerTick =
|
||||
options.maximumSupervisionRecordsPerTick ?? maximumRecordsPerTick;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumRecordsPerTick) ||
|
||||
maximumRecordsPerTick < 1 ||
|
||||
maximumRecordsPerTick > 64
|
||||
|| !Number.isSafeInteger(maximumSupervisionRecordsPerTick)
|
||||
|| maximumSupervisionRecordsPerTick < 1
|
||||
|| maximumSupervisionRecordsPerTick > 64
|
||||
) {
|
||||
throw new WorkerRemoteExecutionLifecycleError('invalid_configuration');
|
||||
}
|
||||
this.journal = options.journal;
|
||||
this.offers = options.offers;
|
||||
this.processor = options.processor;
|
||||
this.control = options.control;
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.maximumRecordsPerTick = maximumRecordsPerTick;
|
||||
this.maximumSupervisionRecordsPerTick = maximumSupervisionRecordsPerTick;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async start(): Promise<'started' | 'already_started'> {
|
||||
if (this.mode === 'running') return 'already_started';
|
||||
if (this.mode === 'stopping') {
|
||||
throw new WorkerRemoteExecutionLifecycleError('stopping');
|
||||
}
|
||||
await this.journal.acquireOwnership();
|
||||
this.mode = 'running';
|
||||
this.startupAfterOfferId = undefined;
|
||||
this.startupComplete = false;
|
||||
this.supervisionAfterOfferId = undefined;
|
||||
this.retryOfferId = undefined;
|
||||
this.recoveryOfferId = undefined;
|
||||
this.draining = false;
|
||||
this.drainOperation = undefined;
|
||||
this.stopController = new AbortController();
|
||||
return 'started';
|
||||
}
|
||||
|
||||
beginDrain(): Promise<void> {
|
||||
if (this.mode === 'stopping') {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('stopping'),
|
||||
);
|
||||
}
|
||||
if (this.mode !== 'running' || !this.stopController) {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('inactive'),
|
||||
);
|
||||
}
|
||||
if (this.drainOperation) return this.drainOperation;
|
||||
if (this.draining) return Promise.resolve();
|
||||
this.draining = true;
|
||||
this.stopController.abort(
|
||||
new WorkerRemoteExecutionLifecycleError('draining'),
|
||||
);
|
||||
const operation = (async () => {
|
||||
await this.inFlight?.catch(() => undefined);
|
||||
if (this.mode === 'running') this.stopController = new AbortController();
|
||||
})().finally(() => {
|
||||
if (this.drainOperation === operation) this.drainOperation = undefined;
|
||||
});
|
||||
this.drainOperation = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
tick(signal?: AbortSignal): Promise<WorkerRemoteExecutionLifecycleTickResult> {
|
||||
if (this.mode === 'stopping') {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('stopping'),
|
||||
);
|
||||
}
|
||||
if (this.mode !== 'running' || !this.stopController) {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('inactive'),
|
||||
);
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const combinedSignal = signal === undefined
|
||||
? this.stopController.signal
|
||||
: AbortSignal.any([signal, this.stopController.signal]);
|
||||
const operation = this.tickOnce(combinedSignal).finally(() => {
|
||||
if (this.inFlight === operation) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
if (this.stopOperation) return this.stopOperation;
|
||||
if (this.mode === 'inactive') return Promise.resolve();
|
||||
this.mode = 'stopping';
|
||||
this.stopController?.abort(
|
||||
new WorkerRemoteExecutionLifecycleError('stopping'),
|
||||
);
|
||||
const operation = (async () => {
|
||||
try {
|
||||
await this.drainOperation?.catch(() => undefined);
|
||||
await this.inFlight?.catch(() => undefined);
|
||||
await this.journal.releaseOwnership();
|
||||
} finally {
|
||||
this.mode = 'inactive';
|
||||
this.draining = false;
|
||||
this.stopController = undefined;
|
||||
this.drainOperation = undefined;
|
||||
this.stopOperation = undefined;
|
||||
}
|
||||
})();
|
||||
this.stopOperation = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async tickOnce(
|
||||
signal: AbortSignal,
|
||||
): Promise<WorkerRemoteExecutionLifecycleTickResult> {
|
||||
if (this.recoveryOfferId) {
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: this.recoveryOfferId,
|
||||
});
|
||||
}
|
||||
if (!this.startupComplete) {
|
||||
return this.reconcileStartupPage();
|
||||
}
|
||||
if (this.retryOfferId) {
|
||||
const offerId = this.retryOfferId;
|
||||
const execution = await this.processor.process(offerId);
|
||||
return this.observeExecution(offerId, execution);
|
||||
}
|
||||
const supervision = await this.supervisePage();
|
||||
if (supervision) return supervision;
|
||||
if (this.draining) {
|
||||
return Object.freeze({ status: 'draining' as const });
|
||||
}
|
||||
const session = this.currentSessionProvider();
|
||||
const now = this.nowProvider();
|
||||
if (
|
||||
!session ||
|
||||
session.status !== 'available' ||
|
||||
!Number.isSafeInteger(now) ||
|
||||
now < 0 ||
|
||||
session.leaseExpiresAtMs <= now
|
||||
) {
|
||||
return Object.freeze({ status: 'session_unavailable' as const });
|
||||
}
|
||||
if (signal.aborted) throw signal.reason;
|
||||
const pull = await this.offers.pull(session, signal);
|
||||
if (pull.status !== 'accepted' && pull.status !== 'replayed') {
|
||||
return Object.freeze({ status: 'pull_result' as const, pull });
|
||||
}
|
||||
this.retryOfferId = pull.offerId;
|
||||
const execution = await this.processor.process(pull.offerId);
|
||||
return this.observeExecution(pull.offerId, execution, pull);
|
||||
}
|
||||
|
||||
private async supervisePage(): Promise<
|
||||
WorkerRemoteExecutionLifecycleTickResult | undefined
|
||||
> {
|
||||
const page = await this.journal.listOffers({
|
||||
...(this.supervisionAfterOfferId === undefined
|
||||
? {}
|
||||
: { afterOfferId: this.supervisionAfterOfferId }),
|
||||
limit: this.maximumSupervisionRecordsPerTick,
|
||||
});
|
||||
for (const record of page.records) {
|
||||
if (record.state === 'recovery_required') {
|
||||
this.recoveryOfferId = record.offer.offerId;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: record.offer.offerId,
|
||||
});
|
||||
}
|
||||
if (
|
||||
record.state !== 'launching' &&
|
||||
record.state !== 'started' &&
|
||||
record.state !== 'running_acknowledged'
|
||||
) continue;
|
||||
const control = await this.control.reconcile(record.offer.offerId);
|
||||
if (this.controlRequiresRecovery(control)) {
|
||||
this.recoveryOfferId = record.offer.offerId;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: record.offer.offerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.supervisionAfterOfferId = page.nextAfterOfferId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private controlRequiresRecovery(
|
||||
result: WorkerRemoteExecutionControlResult,
|
||||
): boolean {
|
||||
return result.status === 'lease_expired' ||
|
||||
result.status === 'terminal' ||
|
||||
result.status === 'completion_terminal';
|
||||
}
|
||||
|
||||
private async reconcileStartupPage(): Promise<
|
||||
WorkerRemoteExecutionLifecycleTickResult
|
||||
> {
|
||||
const page = await this.journal.listOffers({
|
||||
...(this.startupAfterOfferId === undefined
|
||||
? {}
|
||||
: { afterOfferId: this.startupAfterOfferId }),
|
||||
limit: this.maximumRecordsPerTick,
|
||||
});
|
||||
let processed = 0;
|
||||
for (const record of page.records) {
|
||||
if (record.state === 'recovery_required') {
|
||||
this.recoveryOfferId = record.offer.offerId;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: record.offer.offerId,
|
||||
});
|
||||
}
|
||||
if (
|
||||
record.state === 'accepted' ||
|
||||
record.state === 'starting_acknowledged' ||
|
||||
record.state === 'launching' ||
|
||||
record.state === 'started' ||
|
||||
record.state === 'start_failed'
|
||||
) {
|
||||
const execution = await this.processor.process(record.offer.offerId);
|
||||
processed += 1;
|
||||
const observed = this.observeExecution(record.offer.offerId, execution);
|
||||
if (observed.status === 'recovery_required') return observed;
|
||||
}
|
||||
}
|
||||
if (page.nextAfterOfferId !== undefined) {
|
||||
this.startupAfterOfferId = page.nextAfterOfferId;
|
||||
return Object.freeze({
|
||||
status: 'reconciling' as const,
|
||||
processed,
|
||||
nextAfterOfferId: page.nextAfterOfferId,
|
||||
});
|
||||
}
|
||||
this.startupAfterOfferId = undefined;
|
||||
this.startupComplete = true;
|
||||
return Object.freeze({
|
||||
status: 'reconciled' as const,
|
||||
processed,
|
||||
});
|
||||
}
|
||||
|
||||
private observeExecution(
|
||||
offerId: string,
|
||||
execution: WorkerRemoteExecutionProcessResult,
|
||||
pull?: WorkerRemoteOfferPullResult,
|
||||
): WorkerRemoteExecutionLifecycleTickResult {
|
||||
if (execution.status === 'recovery_required') {
|
||||
this.recoveryOfferId = offerId;
|
||||
this.retryOfferId = undefined;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId,
|
||||
});
|
||||
}
|
||||
this.retryOfferId = undefined;
|
||||
return Object.freeze({
|
||||
status: 'processed' as const,
|
||||
offerId,
|
||||
execution,
|
||||
...(pull === undefined ? {} : { pull }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// Remote Execution owns stable offer claiming, delivery, and durable admission.
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
assertWorkerId,
|
||||
assertWorkerSessionId,
|
||||
} from '@qinglong/runtime-core/worker-session';
|
||||
import {
|
||||
InvalidRemoteExecutionOfferDeliveryError,
|
||||
MAX_REMOTE_EXECUTION_OFFER_RESPONSE_BYTES,
|
||||
normalizeRemoteExecutionOfferClaimAuthority,
|
||||
parseRemoteExecutionOfferPullResponse,
|
||||
type RemoteExecutionOfferClaimAuthority,
|
||||
type RemoteExecutionOfferDeliveryStats,
|
||||
type RemoteExecutionOfferIdleReason,
|
||||
} from '@qinglong/runtime-core/remote-offer-delivery';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from './executionInbox';
|
||||
|
||||
export const MAX_WORKER_REMOTE_OFFER_ATTEMPTS = 16;
|
||||
export const MAX_WORKER_REMOTE_OFFER_BACKOFF_MS = 60_000;
|
||||
export const MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES = 1024;
|
||||
export const DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES = 64;
|
||||
export const MAX_WORKER_REMOTE_OFFER_RECORD_BYTES = 160 * 1024;
|
||||
|
||||
export interface WorkerRemoteOfferClaimRecord
|
||||
extends RemoteExecutionOfferClaimAuthority {
|
||||
readonly schemaVersion: 1;
|
||||
readonly revision: number;
|
||||
readonly attemptCount: number;
|
||||
readonly createdAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
readonly lastAttemptAtMs: number | null;
|
||||
readonly nextAttemptAtMs: number;
|
||||
}
|
||||
|
||||
/** @deprecated Use WorkerRemoteExecutionInboxRecord. */
|
||||
export type WorkerRemoteOfferInboxRecord = WorkerRemoteExecutionInboxRecord;
|
||||
|
||||
export type WorkerRemoteOfferInboxAcceptResult = Readonly<{
|
||||
status: 'accepted' | 'replayed';
|
||||
record: WorkerRemoteOfferInboxRecord;
|
||||
}>;
|
||||
|
||||
export interface WorkerRemoteOfferDeliveryJournal {
|
||||
readPendingClaim(): Promise<WorkerRemoteOfferClaimRecord | undefined>;
|
||||
createPendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
): Promise<WorkerRemoteOfferClaimRecord>;
|
||||
replacePendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<WorkerRemoteOfferClaimRecord>;
|
||||
clearPendingClaim(offerId: string, expectedRevision: number): Promise<void>;
|
||||
acceptOffer(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
acceptedAtMs: number,
|
||||
): Promise<WorkerRemoteOfferInboxAcceptResult>;
|
||||
readOffer(offerId: string): Promise<WorkerRemoteOfferInboxRecord | undefined>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteOfferTransport {
|
||||
exchange(request: Readonly<{
|
||||
path: string;
|
||||
body: Readonly<{
|
||||
workerGeneration: number;
|
||||
offerId: string;
|
||||
leaseToken: string;
|
||||
}>;
|
||||
maximumResponseBytes: number;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<Uint8Array | string>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteOfferSession {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteOfferPullCoordinatorOptions {
|
||||
readonly journal: WorkerRemoteOfferDeliveryJournal;
|
||||
readonly transport: WorkerRemoteOfferTransport;
|
||||
readonly currentSession: () => WorkerRemoteOfferSession | undefined;
|
||||
readonly now?: () => number;
|
||||
readonly random?: () => number;
|
||||
readonly backoffBaseMs?: number;
|
||||
}
|
||||
|
||||
export type WorkerRemoteOfferPullResult =
|
||||
| Readonly<{
|
||||
status: 'accepted' | 'replayed';
|
||||
offerId: string;
|
||||
stats: RemoteExecutionOfferDeliveryStats;
|
||||
truncated: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'idle';
|
||||
reason: RemoteExecutionOfferIdleReason;
|
||||
stats: RemoteExecutionOfferDeliveryStats;
|
||||
truncated: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'backoff' | 'unavailable' | 'invalid_response';
|
||||
offerId: string;
|
||||
nextAttemptAtMs: number;
|
||||
}>;
|
||||
|
||||
export class WorkerRemoteOfferDeliveryError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'claim_conflict'
|
||||
| 'claim_revision_conflict'
|
||||
| 'offer_conflict'
|
||||
| 'attempt_budget_exhausted',
|
||||
) {
|
||||
super(`Worker remote offer delivery failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteOfferDeliveryError';
|
||||
}
|
||||
}
|
||||
|
||||
function safeTime(value: number, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeRevision(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_revision_conflict');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeWorkerRemoteOfferClaimRecord(
|
||||
value: WorkerRemoteOfferClaimRecord,
|
||||
): WorkerRemoteOfferClaimRecord {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
const expected = [
|
||||
'schemaVersion', 'revision', 'workerId', 'workerSessionId',
|
||||
'workerGeneration', 'offerId', 'leaseToken', 'attemptCount',
|
||||
'createdAtMs', 'updatedAtMs', 'lastAttemptAtMs', 'nextAttemptAtMs',
|
||||
].sort();
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index]) ||
|
||||
value.schemaVersion !== 1
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
const authority = normalizeRemoteExecutionOfferClaimAuthority({
|
||||
workerId: value.workerId,
|
||||
workerSessionId: value.workerSessionId,
|
||||
workerGeneration: value.workerGeneration,
|
||||
offerId: value.offerId,
|
||||
leaseToken: value.leaseToken,
|
||||
});
|
||||
safeRevision(value.revision);
|
||||
if (
|
||||
!Number.isSafeInteger(value.attemptCount) ||
|
||||
value.attemptCount < 0 ||
|
||||
value.attemptCount > MAX_WORKER_REMOTE_OFFER_ATTEMPTS
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
const createdAtMs = safeTime(value.createdAtMs, 'createdAtMs');
|
||||
const updatedAtMs = safeTime(value.updatedAtMs, 'updatedAtMs');
|
||||
const nextAttemptAtMs = safeTime(value.nextAttemptAtMs, 'nextAttemptAtMs');
|
||||
if (
|
||||
updatedAtMs < createdAtMs ||
|
||||
nextAttemptAtMs < createdAtMs ||
|
||||
(value.lastAttemptAtMs !== null &&
|
||||
(!Number.isSafeInteger(value.lastAttemptAtMs) ||
|
||||
value.lastAttemptAtMs < createdAtMs ||
|
||||
value.lastAttemptAtMs > updatedAtMs))
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
revision: value.revision,
|
||||
...authority,
|
||||
attemptCount: value.attemptCount,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
lastAttemptAtMs: value.lastAttemptAtMs,
|
||||
nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerRemoteOfferClaimRecord(
|
||||
authority: RemoteExecutionOfferClaimAuthority,
|
||||
createdAtMs: number,
|
||||
): WorkerRemoteOfferClaimRecord {
|
||||
const normalized = normalizeRemoteExecutionOfferClaimAuthority(authority);
|
||||
const now = safeTime(createdAtMs, 'createdAtMs');
|
||||
return normalizeWorkerRemoteOfferClaimRecord({
|
||||
schemaVersion: 1,
|
||||
revision: 0,
|
||||
...normalized,
|
||||
attemptCount: 0,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
lastAttemptAtMs: null,
|
||||
nextAttemptAtMs: now,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeWorkerRemoteOfferInboxRecord(
|
||||
value: WorkerRemoteOfferInboxRecord,
|
||||
): WorkerRemoteOfferInboxRecord {
|
||||
try {
|
||||
return normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferDeliveryError('offer_conflict');
|
||||
}
|
||||
}
|
||||
|
||||
export function sameWorkerRemoteOfferAuthority(
|
||||
left: ClusterRemoteExecutionOffer,
|
||||
right: ClusterRemoteExecutionOffer,
|
||||
): boolean {
|
||||
const leftOffer = createClusterRemoteExecutionOffer(left);
|
||||
const rightOffer = createClusterRemoteExecutionOffer(right);
|
||||
return (
|
||||
leftOffer.offerId === rightOffer.offerId &&
|
||||
leftOffer.executionDigest === rightOffer.executionDigest &&
|
||||
JSON.stringify(leftOffer.candidate) === JSON.stringify(rightOffer.candidate) &&
|
||||
JSON.stringify(leftOffer.worker) === JSON.stringify(rightOffer.worker) &&
|
||||
leftOffer.lease.runId === rightOffer.lease.runId &&
|
||||
leftOffer.lease.attemptId === rightOffer.lease.attemptId &&
|
||||
leftOffer.lease.leaseGeneration === rightOffer.lease.leaseGeneration &&
|
||||
leftOffer.lease.leaseTokenDigest === rightOffer.lease.leaseTokenDigest &&
|
||||
leftOffer.leaseToken === rightOffer.leaseToken &&
|
||||
JSON.stringify(leftOffer.executionRevision) ===
|
||||
JSON.stringify(rightOffer.executionRevision)
|
||||
);
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferPullCoordinator {
|
||||
private readonly journal: WorkerRemoteOfferDeliveryJournal;
|
||||
private readonly transport: WorkerRemoteOfferTransport;
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteOfferSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly randomProvider: () => number;
|
||||
private readonly backoffBaseMs: number;
|
||||
private inFlight?: Readonly<{
|
||||
session: WorkerRemoteOfferSession;
|
||||
operation: Promise<WorkerRemoteOfferPullResult>;
|
||||
}>;
|
||||
|
||||
constructor(options: WorkerRemoteOfferPullCoordinatorOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.journal?.readPendingClaim !== 'function' ||
|
||||
typeof options.transport?.exchange !== 'function' ||
|
||||
typeof options.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
const backoffBaseMs = options.backoffBaseMs ?? 1_000;
|
||||
if (
|
||||
!Number.isSafeInteger(backoffBaseMs) ||
|
||||
backoffBaseMs < 100 ||
|
||||
backoffBaseMs > MAX_WORKER_REMOTE_OFFER_BACKOFF_MS
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
this.journal = options.journal;
|
||||
this.transport = options.transport;
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
this.randomProvider = options.random ?? Math.random;
|
||||
this.backoffBaseMs = backoffBaseMs;
|
||||
}
|
||||
|
||||
pull(
|
||||
session: WorkerRemoteOfferSession,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerRemoteOfferPullResult> {
|
||||
assertWorkerId(session.workerId);
|
||||
assertWorkerSessionId(session.sessionId);
|
||||
if (!Number.isSafeInteger(session.generation) || session.generation < 1) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
const normalizedSession = Object.freeze({
|
||||
workerId: session.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
});
|
||||
this.assertCurrentSession(normalizedSession);
|
||||
if (this.inFlight) {
|
||||
if (
|
||||
this.inFlight.session.workerId !== normalizedSession.workerId ||
|
||||
this.inFlight.session.sessionId !== normalizedSession.sessionId ||
|
||||
this.inFlight.session.generation !== normalizedSession.generation
|
||||
) {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteOfferDeliveryError('claim_conflict'),
|
||||
);
|
||||
}
|
||||
return this.inFlight.operation;
|
||||
}
|
||||
const operation = this.pullOnce(normalizedSession, signal)
|
||||
.finally(() => {
|
||||
if (this.inFlight?.operation === operation) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = Object.freeze({
|
||||
session: normalizedSession,
|
||||
operation,
|
||||
});
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async pullOnce(
|
||||
session: WorkerRemoteOfferSession,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerRemoteOfferPullResult> {
|
||||
const now = this.now();
|
||||
let claim = await this.journal.readPendingClaim();
|
||||
if (claim) {
|
||||
if (
|
||||
claim.workerId !== session.workerId ||
|
||||
claim.workerSessionId !== session.sessionId ||
|
||||
claim.workerGeneration !== session.generation
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
} else {
|
||||
const generated = normalizeRemoteExecutionOfferClaimAuthority({
|
||||
workerId: session.workerId,
|
||||
workerSessionId: session.sessionId,
|
||||
workerGeneration: session.generation,
|
||||
offerId: randomUUID(),
|
||||
leaseToken: randomBytes(32).toString('base64url'),
|
||||
});
|
||||
claim = await this.journal.createPendingClaim(
|
||||
createWorkerRemoteOfferClaimRecord(generated, now),
|
||||
);
|
||||
}
|
||||
if (claim.nextAttemptAtMs > now) {
|
||||
return Object.freeze({
|
||||
status: 'backoff' as const,
|
||||
offerId: claim.offerId,
|
||||
nextAttemptAtMs: claim.nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
if (claim.attemptCount >= MAX_WORKER_REMOTE_OFFER_ATTEMPTS) {
|
||||
throw new WorkerRemoteOfferDeliveryError('attempt_budget_exhausted');
|
||||
}
|
||||
claim = await this.journal.replacePendingClaim(
|
||||
normalizeWorkerRemoteOfferClaimRecord({
|
||||
...claim,
|
||||
revision: claim.revision + 1,
|
||||
attemptCount: claim.attemptCount + 1,
|
||||
lastAttemptAtMs: now,
|
||||
updatedAtMs: now,
|
||||
nextAttemptAtMs: now,
|
||||
}),
|
||||
claim.revision,
|
||||
);
|
||||
try {
|
||||
const serialized = await this.transport.exchange({
|
||||
path: `/api/v3/worker-ingress/workers/${claim.workerId}/sessions/${claim.workerSessionId}/offers`,
|
||||
body: Object.freeze({
|
||||
workerGeneration: claim.workerGeneration,
|
||||
offerId: claim.offerId,
|
||||
leaseToken: claim.leaseToken,
|
||||
}),
|
||||
maximumResponseBytes: MAX_REMOTE_EXECUTION_OFFER_RESPONSE_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const result = parseRemoteExecutionOfferPullResponse(serialized, {
|
||||
workerId: claim.workerId,
|
||||
workerSessionId: claim.workerSessionId,
|
||||
workerGeneration: claim.workerGeneration,
|
||||
offerId: claim.offerId,
|
||||
leaseToken: claim.leaseToken,
|
||||
});
|
||||
if (result.status === 'idle') {
|
||||
this.assertCurrentSession(session);
|
||||
await this.journal.clearPendingClaim(claim.offerId, claim.revision);
|
||||
return Object.freeze({
|
||||
status: 'idle' as const,
|
||||
reason: result.reason,
|
||||
stats: result.stats,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
}
|
||||
this.assertCurrentSession(session);
|
||||
const accepted = await this.journal.acceptOffer(result.offer, this.now());
|
||||
await this.journal.clearPendingClaim(claim.offerId, claim.revision);
|
||||
return Object.freeze({
|
||||
status: accepted.status,
|
||||
offerId: accepted.record.offer.offerId,
|
||||
stats: result.stats,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
const nextAttemptAtMs = this.nextAttemptAt(claim.attemptCount, this.now());
|
||||
await this.journal.replacePendingClaim(
|
||||
normalizeWorkerRemoteOfferClaimRecord({
|
||||
...claim,
|
||||
revision: claim.revision + 1,
|
||||
updatedAtMs: this.now(),
|
||||
nextAttemptAtMs,
|
||||
}),
|
||||
claim.revision,
|
||||
);
|
||||
return Object.freeze({
|
||||
status:
|
||||
error instanceof InvalidRemoteExecutionOfferDeliveryError
|
||||
? 'invalid_response' as const
|
||||
: 'unavailable' as const,
|
||||
offerId: claim.offerId,
|
||||
nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private nextAttemptAt(attemptCount: number, now: number): number {
|
||||
const random = this.randomProvider();
|
||||
if (!Number.isFinite(random) || random < 0 || random >= 1) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
const ceiling = Math.min(
|
||||
MAX_WORKER_REMOTE_OFFER_BACKOFF_MS,
|
||||
this.backoffBaseMs * 2 ** Math.max(0, attemptCount - 1),
|
||||
);
|
||||
return safeTime(now + Math.floor(random * ceiling), 'nextAttemptAtMs');
|
||||
}
|
||||
|
||||
private assertCurrentSession(expected: WorkerRemoteOfferSession): void {
|
||||
const current = this.currentSessionProvider();
|
||||
if (
|
||||
!current ||
|
||||
current.workerId !== expected.workerId ||
|
||||
current.sessionId !== expected.sessionId ||
|
||||
current.generation !== expected.generation
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return safeTime(this.nowProvider(), 'now');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Remote Execution owns its public delivery composition and transport exports.
|
||||
export * from './executionInbox';
|
||||
export * from './executionInboxProcessor';
|
||||
export * from './executionContextMaterializer';
|
||||
export * from './headlessExecutionLifecycle';
|
||||
export * from './remoteOfferDelivery';
|
||||
export * from './remoteOfferFileJournal';
|
||||
export * from './transport/remoteActivationHttpsClient';
|
||||
export * from './transport/remoteOfferHttpsTransport';
|
||||
export * from './transport/workerIngressHttpsClient';
|
||||
export * from './transport/remoteSecretHttpsProvider';
|
||||
export * from '../execution/workerFileLogArtifactAllocator';
|
||||
export * from './transport/remoteWorkerCompletionHttpsClient';
|
||||
export * from './transport/remoteWorkerLeaseControlHttpsClient';
|
||||
export * from '../execution/workerExecutionControlCoordinator';
|
||||
@@ -0,0 +1,543 @@
|
||||
// Remote Execution owns the private atomic offer journal and its single-owner fence.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
chmod,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
} from 'node:fs/promises';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
MAX_WORKER_REMOTE_OFFER_RECORD_BYTES,
|
||||
normalizeWorkerRemoteOfferClaimRecord,
|
||||
sameWorkerRemoteOfferAuthority,
|
||||
type WorkerRemoteOfferClaimRecord,
|
||||
type WorkerRemoteOfferDeliveryJournal,
|
||||
type WorkerRemoteOfferInboxAcceptResult,
|
||||
} from './remoteOfferDelivery';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
assertWorkerRemoteExecutionInboxTransition,
|
||||
createWorkerRemoteExecutionInboxRecord,
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
WorkerRemoteExecutionInboxError,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxPage,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from './executionInbox';
|
||||
|
||||
const OFFER_FILE = /^([A-Za-z0-9._:-]{1,128})\.json$/;
|
||||
const MIN_OWNERSHIP_STALE_MS = 5_000;
|
||||
const MAX_OWNERSHIP_STALE_MS = 5 * 60_000;
|
||||
|
||||
export interface WorkerRemoteOfferFileJournalOptions {
|
||||
readonly rootDirectory: string;
|
||||
readonly maximumEntries?: number;
|
||||
readonly ownershipStaleMs?: number;
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferFileJournalError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'not_owned'
|
||||
| 'already_owned'
|
||||
| 'ownership_compromised'
|
||||
| 'unsafe_storage'
|
||||
| 'capacity_exhausted'
|
||||
| 'claim_revision_conflict'
|
||||
| 'offer_revision_conflict'
|
||||
| 'invalid_transition'
|
||||
| 'offer_conflict',
|
||||
) {
|
||||
super(`Worker remote offer file journal failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteOfferFileJournalError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as NodeJS.ErrnoException).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function safeDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
let created = false;
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
created = true;
|
||||
}
|
||||
const stat = await lstat(path);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
if (created) await chmod(path, 0o700);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const handle = await open(path, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function serialize(value: unknown): Buffer {
|
||||
const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
|
||||
if (bytes.byteLength < 2 || bytes.byteLength > MAX_WORKER_REMOTE_OFFER_RECORD_BYTES) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function readJson(path: string): Promise<unknown> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_WORKER_REMOTE_OFFER_RECORD_BYTES ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8')) as unknown;
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferFileJournal
|
||||
implements WorkerRemoteOfferDeliveryJournal, WorkerRemoteExecutionInbox {
|
||||
private readonly rootDirectory: string;
|
||||
private readonly offersDirectory: string;
|
||||
private readonly maximumEntries: number;
|
||||
private readonly ownershipStaleMs: number;
|
||||
private releaseOwnershipLock?: () => Promise<void>;
|
||||
private compromised = false;
|
||||
private mutationTail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: WorkerRemoteOfferFileJournalOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.rootDirectory !== 'string' ||
|
||||
!isAbsolute(options.rootDirectory) ||
|
||||
options.rootDirectory.length > 4096 ||
|
||||
/[\0\r\n]/.test(options.rootDirectory)
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
const maximumEntries =
|
||||
options.maximumEntries ?? DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumEntries) ||
|
||||
maximumEntries < 1 ||
|
||||
maximumEntries > MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
const ownershipStaleMs = options.ownershipStaleMs ?? 30_000;
|
||||
if (
|
||||
!Number.isSafeInteger(ownershipStaleMs) ||
|
||||
ownershipStaleMs < MIN_OWNERSHIP_STALE_MS ||
|
||||
ownershipStaleMs > MAX_OWNERSHIP_STALE_MS
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
this.rootDirectory = options.rootDirectory;
|
||||
this.offersDirectory = join(options.rootDirectory, 'offers');
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.ownershipStaleMs = ownershipStaleMs;
|
||||
}
|
||||
|
||||
async acquireOwnership(): Promise<void> {
|
||||
if (this.releaseOwnershipLock) {
|
||||
throw new WorkerRemoteOfferFileJournalError('already_owned');
|
||||
}
|
||||
await safeDirectory(this.rootDirectory);
|
||||
await safeDirectory(this.offersDirectory);
|
||||
try {
|
||||
this.compromised = false;
|
||||
this.releaseOwnershipLock = await lock(this.rootDirectory, {
|
||||
stale: this.ownershipStaleMs,
|
||||
update: Math.floor(this.ownershipStaleMs / 2),
|
||||
retries: 0,
|
||||
realpath: true,
|
||||
lockfilePath: join(this.rootDirectory, '.owner.lock'),
|
||||
onCompromised: () => {
|
||||
this.compromised = true;
|
||||
this.releaseOwnershipLock = undefined;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('already_owned');
|
||||
}
|
||||
}
|
||||
|
||||
async releaseOwnership(): Promise<void> {
|
||||
this.assertOwned();
|
||||
const release = this.releaseOwnershipLock!;
|
||||
this.releaseOwnershipLock = undefined;
|
||||
await this.mutationTail.catch(() => undefined);
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('ownership_compromised');
|
||||
}
|
||||
}
|
||||
|
||||
async readPendingClaim(): Promise<WorkerRemoteOfferClaimRecord | undefined> {
|
||||
this.assertOwned();
|
||||
const value = await readJson(join(this.rootDirectory, 'pending-claim.json'));
|
||||
if (value === undefined) return undefined;
|
||||
return normalizeWorkerRemoteOfferClaimRecord(
|
||||
value as WorkerRemoteOfferClaimRecord,
|
||||
);
|
||||
}
|
||||
|
||||
createPendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
): Promise<WorkerRemoteOfferClaimRecord> {
|
||||
return this.mutate(async () => {
|
||||
const candidate = normalizeWorkerRemoteOfferClaimRecord(record);
|
||||
const existing = await this.readPendingClaim();
|
||||
if (existing) {
|
||||
if (!this.sameClaim(existing, candidate)) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
await this.writeFirst(join(this.rootDirectory, 'pending-claim.json'), candidate);
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
replacePendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<WorkerRemoteOfferClaimRecord> {
|
||||
return this.mutate(async () => {
|
||||
const candidate = normalizeWorkerRemoteOfferClaimRecord(record);
|
||||
const existing = await this.readPendingClaim();
|
||||
if (
|
||||
!existing ||
|
||||
existing.revision !== expectedRevision ||
|
||||
candidate.revision !== expectedRevision + 1 ||
|
||||
!this.sameClaim(existing, candidate)
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('claim_revision_conflict');
|
||||
}
|
||||
await this.writeReplacement(
|
||||
join(this.rootDirectory, 'pending-claim.json'),
|
||||
candidate,
|
||||
);
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
clearPendingClaim(offerId: string, expectedRevision: number): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
assertRunDispatchId('offerId', offerId);
|
||||
const existing = await this.readPendingClaim();
|
||||
if (
|
||||
!existing ||
|
||||
existing.offerId !== offerId ||
|
||||
existing.revision !== expectedRevision
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('claim_revision_conflict');
|
||||
}
|
||||
try {
|
||||
await unlink(join(this.rootDirectory, 'pending-claim.json'));
|
||||
await syncDirectory(this.rootDirectory);
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
acceptOffer(
|
||||
delivered: ClusterRemoteExecutionOffer,
|
||||
acceptedAtMs: number,
|
||||
): Promise<WorkerRemoteOfferInboxAcceptResult> {
|
||||
return this.mutate(async () => {
|
||||
const offer = createClusterRemoteExecutionOffer(delivered);
|
||||
const existing = await this.readOffer(offer.offerId);
|
||||
if (existing) {
|
||||
if (!sameWorkerRemoteOfferAuthority(existing.offer, offer)) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
if (offer.lease.version > existing.offer.lease.version) {
|
||||
const updated = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...existing,
|
||||
revision: existing.revision + 1,
|
||||
offer,
|
||||
updatedAtMs: acceptedAtMs,
|
||||
});
|
||||
this.assertOfferTransition(existing, updated);
|
||||
await this.writeReplacement(this.offerPath(offer.offerId), updated);
|
||||
return Object.freeze({ status: 'replayed' as const, record: updated });
|
||||
}
|
||||
return Object.freeze({ status: 'replayed' as const, record: existing });
|
||||
}
|
||||
const names = await this.offerNames();
|
||||
if (names.length >= this.maximumEntries) {
|
||||
throw new WorkerRemoteOfferFileJournalError('capacity_exhausted');
|
||||
}
|
||||
const record = createWorkerRemoteExecutionInboxRecord(offer, acceptedAtMs);
|
||||
await this.writeFirst(this.offerPath(offer.offerId), record);
|
||||
return Object.freeze({ status: 'accepted' as const, record });
|
||||
});
|
||||
}
|
||||
|
||||
async readOffer(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord | undefined> {
|
||||
this.assertOwned();
|
||||
const value = await readJson(this.offerPath(offerId));
|
||||
if (value === undefined) return undefined;
|
||||
return normalizeWorkerRemoteExecutionInboxRecord(
|
||||
value as WorkerRemoteExecutionInboxRecord,
|
||||
);
|
||||
}
|
||||
|
||||
replaceOffer(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
const candidate = normalizeWorkerRemoteExecutionInboxRecord(record);
|
||||
const existing = await this.readOffer(candidate.offer.offerId);
|
||||
if (
|
||||
!existing ||
|
||||
existing.revision !== expectedRevision ||
|
||||
candidate.revision !== expectedRevision + 1
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_revision_conflict');
|
||||
}
|
||||
this.assertOfferTransition(existing, candidate);
|
||||
await this.writeReplacement(this.offerPath(candidate.offer.offerId), candidate);
|
||||
});
|
||||
}
|
||||
|
||||
async listOffers(options: Readonly<{
|
||||
afterOfferId?: string;
|
||||
limit?: number;
|
||||
}> = {}): Promise<WorkerRemoteExecutionInboxPage> {
|
||||
this.assertOwned();
|
||||
const limit = options.limit ?? 16;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
if (options.afterOfferId !== undefined) {
|
||||
this.offerPath(options.afterOfferId);
|
||||
}
|
||||
const names = await this.offerNames();
|
||||
const selected = names
|
||||
.filter((offerId) =>
|
||||
options.afterOfferId === undefined || offerId > options.afterOfferId)
|
||||
.slice(0, limit);
|
||||
const records: WorkerRemoteExecutionInboxRecord[] = [];
|
||||
for (const offerId of selected) {
|
||||
const record = await this.readOffer(offerId);
|
||||
if (!record) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
const hasMore = selected.length > 0 &&
|
||||
names.some((offerId) => offerId > selected[selected.length - 1]!);
|
||||
return Object.freeze({
|
||||
records: Object.freeze(records),
|
||||
...(hasMore
|
||||
? { nextAfterOfferId: selected[selected.length - 1]! }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
private assertOfferTransition(
|
||||
previous: WorkerRemoteExecutionInboxRecord,
|
||||
next: WorkerRemoteExecutionInboxRecord,
|
||||
): void {
|
||||
try {
|
||||
assertWorkerRemoteExecutionInboxTransition(previous, next);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof WorkerRemoteExecutionInboxError &&
|
||||
error.reason === 'revision_conflict'
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_revision_conflict');
|
||||
}
|
||||
if (
|
||||
error instanceof WorkerRemoteExecutionInboxError &&
|
||||
error.reason === 'invalid_transition'
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_transition');
|
||||
}
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
}
|
||||
|
||||
private mutate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
this.assertOwned();
|
||||
const result = this.mutationTail.then(operation, operation);
|
||||
this.mutationTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertOwned(): void {
|
||||
if (this.compromised) {
|
||||
throw new WorkerRemoteOfferFileJournalError('ownership_compromised');
|
||||
}
|
||||
if (!this.releaseOwnershipLock) {
|
||||
throw new WorkerRemoteOfferFileJournalError('not_owned');
|
||||
}
|
||||
}
|
||||
|
||||
private sameClaim(
|
||||
left: WorkerRemoteOfferClaimRecord,
|
||||
right: WorkerRemoteOfferClaimRecord,
|
||||
): boolean {
|
||||
return (
|
||||
left.workerId === right.workerId &&
|
||||
left.workerSessionId === right.workerSessionId &&
|
||||
left.workerGeneration === right.workerGeneration &&
|
||||
left.offerId === right.offerId &&
|
||||
left.leaseToken === right.leaseToken
|
||||
);
|
||||
}
|
||||
|
||||
private offerPath(offerId: string): string {
|
||||
assertRunDispatchId('offerId', offerId);
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(offerId)) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
return join(this.offersDirectory, `${offerId}.json`);
|
||||
}
|
||||
|
||||
private async offerNames(): Promise<string[]> {
|
||||
this.assertOwned();
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(this.offersDirectory, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const match = OFFER_FILE.exec(entry.name);
|
||||
if (!entry.isFile() || !match) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
names.push(match[1]!);
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
private temporary(target: string): string {
|
||||
return join(
|
||||
this.rootDirectory,
|
||||
`.${target.split('/').at(-1)}.${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
}
|
||||
|
||||
private async writeFirst(target: string, value: unknown): Promise<void> {
|
||||
const temporary = this.temporary(target);
|
||||
const bytes = serialize(value);
|
||||
try {
|
||||
const handle = await open(
|
||||
temporary,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.chmod(0o600);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await link(temporary, target);
|
||||
await syncDirectory(target.startsWith(this.offersDirectory)
|
||||
? this.offersDirectory
|
||||
: this.rootDirectory);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeReplacement(target: string, value: unknown): Promise<void> {
|
||||
const temporary = this.temporary(target);
|
||||
const bytes = serialize(value);
|
||||
try {
|
||||
const handle = await open(
|
||||
temporary,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.chmod(0o600);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, target);
|
||||
await syncDirectory(target.startsWith(this.offersDirectory)
|
||||
? this.offersDirectory
|
||||
: this.rootDirectory);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
// Remote Execution transport owns starting, running, and start-failure acknowledgements.
|
||||
import {
|
||||
assertAcknowledgeRemoteRunRunningCommand,
|
||||
assertAcknowledgeRemoteRunStartingCommand,
|
||||
assertFailRemoteRunStartCommand,
|
||||
type AcknowledgeRemoteRunRunningCommand,
|
||||
type AcknowledgeRemoteRunStartingCommand,
|
||||
type FailRemoteRunStartCommand,
|
||||
type RemoteRunActivationResult,
|
||||
} from '@qinglong/runtime-core/remote-activation';
|
||||
import {
|
||||
MAX_REMOTE_RUN_ACTIVATION_RESPONSE_BYTES,
|
||||
parseRemoteRunActivationResponse,
|
||||
} from '@qinglong/runtime-core/remote-activation-delivery';
|
||||
import type { WorkerRemoteExecutionActivationClient } from '../executionInboxProcessor';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
type ActivationCommand =
|
||||
| AcknowledgeRemoteRunStartingCommand
|
||||
| AcknowledgeRemoteRunRunningCommand
|
||||
| FailRemoteRunStartCommand;
|
||||
|
||||
export interface WorkerRemoteExecutionHttpsActivationClientOptions {
|
||||
readonly client: WorkerIngressHttpsClient;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionHttpsActivationError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'request_invalid'
|
||||
| 'transport_unavailable'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote execution activation failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteExecutionHttpsActivationError';
|
||||
}
|
||||
}
|
||||
|
||||
function path(command: ActivationCommand, operation: string): string {
|
||||
return '/api/v3/worker-ingress/workers/' + command.workerId +
|
||||
'/sessions/' + command.workerSessionId + '/' + operation;
|
||||
}
|
||||
|
||||
function fenceBody(command: ActivationCommand): Readonly<{
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
workerGeneration: number;
|
||||
offerId: string;
|
||||
leaseGeneration: number;
|
||||
leaseToken: string;
|
||||
expectedLeaseVersion: number;
|
||||
}> {
|
||||
return Object.freeze({
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedLeaseVersion: command.expectedLeaseVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionHttpsActivationClient
|
||||
implements WorkerRemoteExecutionActivationClient {
|
||||
private readonly client: WorkerIngressHttpsClient;
|
||||
|
||||
constructor(options: WorkerRemoteExecutionHttpsActivationClientOptions) {
|
||||
if (!options || !(options.client instanceof WorkerIngressHttpsClient)) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async acknowledgeStarting(
|
||||
command: AcknowledgeRemoteRunStartingCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
try {
|
||||
assertAcknowledgeRemoteRunStartingCommand(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'request_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return this.exchange('starting', command, fenceBody(command));
|
||||
}
|
||||
|
||||
async acknowledgeRunning(
|
||||
command: AcknowledgeRemoteRunRunningCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
try {
|
||||
assertAcknowledgeRemoteRunRunningCommand(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'request_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return this.exchange('running', command, Object.freeze({
|
||||
...fenceBody(command),
|
||||
executorHandle: command.executorHandle,
|
||||
logArtifactId: command.logArtifactId ?? null,
|
||||
callbackSequence: command.callbackSequence,
|
||||
callbackTokenDigest: command.callbackTokenDigest,
|
||||
}));
|
||||
}
|
||||
|
||||
async failStart(
|
||||
command: FailRemoteRunStartCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
try {
|
||||
assertFailRemoteRunStartCommand(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'request_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return this.exchange('start-failure', command, fenceBody(command));
|
||||
}
|
||||
|
||||
private async exchange(
|
||||
operation: 'starting' | 'running' | 'start-failure',
|
||||
command: ActivationCommand,
|
||||
body: unknown,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
let serialized: Uint8Array;
|
||||
try {
|
||||
serialized = await this.client.postJson({
|
||||
path: path(command, operation),
|
||||
body,
|
||||
maximumResponseBytes: MAX_REMOTE_RUN_ACTIVATION_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'transport_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = parseRemoteRunActivationResponse(serialized);
|
||||
if (
|
||||
result.snapshot.runId !== command.runId ||
|
||||
result.snapshot.attemptId !== command.attemptId ||
|
||||
result.snapshot.leaseGeneration !== command.leaseGeneration
|
||||
) {
|
||||
throw new TypeError('activation response authority mismatch');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'response_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
Buffer.from(serialized.buffer, serialized.byteOffset, serialized.byteLength)
|
||||
.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
// Remote Execution transport owns bounded offer exchange over the shared mTLS client.
|
||||
import type { Agent } from 'node:https';
|
||||
import type { WorkerRemoteOfferTransport } from '../remoteOfferDelivery';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
type WorkerIngressHttpsCredentialProvider,
|
||||
type WorkerIngressHttpsCredentials,
|
||||
type WorkerIngressHttpsRequestFactory,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
const OFFER_PATH =
|
||||
/^\/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}\/offers$/;
|
||||
|
||||
export type WorkerRemoteOfferHttpsCredentials = WorkerIngressHttpsCredentials;
|
||||
export type WorkerRemoteOfferHttpsCredentialProvider =
|
||||
WorkerIngressHttpsCredentialProvider;
|
||||
export type WorkerRemoteOfferHttpsRequestFactory =
|
||||
WorkerIngressHttpsRequestFactory;
|
||||
|
||||
export interface WorkerRemoteOfferHttpsTransportOptions {
|
||||
readonly client?: WorkerIngressHttpsClient;
|
||||
readonly origin?: string | URL;
|
||||
readonly credentials?: WorkerRemoteOfferHttpsCredentialProvider;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly agent?: Agent;
|
||||
/** Injectable only for deterministic transport contract tests. */
|
||||
readonly requestFactory?: WorkerRemoteOfferHttpsRequestFactory;
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferHttpsTransportError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'credentials_unavailable'
|
||||
| 'request_rejected'
|
||||
| 'response_rejected'
|
||||
| 'response_too_large'
|
||||
| 'closed',
|
||||
) {
|
||||
super(`Worker remote offer HTTPS transport failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteOfferHttpsTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferHttpsTransport
|
||||
implements WorkerRemoteOfferTransport {
|
||||
private readonly client: WorkerIngressHttpsClient;
|
||||
private readonly ownsClient: boolean;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: WorkerRemoteOfferHttpsTransportOptions) {
|
||||
if (!options) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('invalid_configuration');
|
||||
}
|
||||
if (options.client) {
|
||||
if (
|
||||
options.origin !== undefined ||
|
||||
options.credentials !== undefined ||
|
||||
options.requestTimeoutMs !== undefined ||
|
||||
options.agent !== undefined ||
|
||||
options.requestFactory !== undefined
|
||||
) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
this.ownsClient = false;
|
||||
return;
|
||||
}
|
||||
if (options.origin === undefined || options.credentials === undefined) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('invalid_configuration');
|
||||
}
|
||||
try {
|
||||
this.client = new WorkerIngressHttpsClient({
|
||||
origin: options.origin,
|
||||
credentials: options.credentials,
|
||||
...(options.requestTimeoutMs === undefined
|
||||
? {}
|
||||
: { requestTimeoutMs: options.requestTimeoutMs }),
|
||||
...(options.agent === undefined ? {} : { agent: options.agent }),
|
||||
...(options.requestFactory === undefined
|
||||
? {}
|
||||
: { requestFactory: options.requestFactory }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError(error.reason);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.ownsClient = true;
|
||||
}
|
||||
|
||||
async exchange(request: Readonly<{
|
||||
path: string;
|
||||
body: Readonly<{
|
||||
workerGeneration: number;
|
||||
offerId: string;
|
||||
leaseToken: string;
|
||||
}>;
|
||||
maximumResponseBytes: number;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<Uint8Array> {
|
||||
if (this.closed) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('closed');
|
||||
}
|
||||
if (!request || typeof request.path !== 'string' || !OFFER_PATH.test(request.path)) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('request_rejected');
|
||||
}
|
||||
try {
|
||||
return await this.client.postJson(request);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError(error.reason);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.ownsClient) this.client.close();
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// Remote Execution transport owns capability-bound Secret delivery.
|
||||
import {
|
||||
MAX_REMOTE_SECRET_DELIVERY_REQUEST_BYTES,
|
||||
MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES,
|
||||
createRemoteWorkerSecretDeliveryRequestBody,
|
||||
parseRemoteWorkerSecretDeliveryResponse,
|
||||
} from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import type { WorkerRemoteExecutionInbox } from '../executionInbox';
|
||||
import type {
|
||||
WorkerRemoteSecretEnvironmentProvider,
|
||||
WorkerRemoteSecretResolution,
|
||||
} from '../executionContextMaterializer';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
type WorkerIngressHttpsPostRequest,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
export class WorkerRemoteSecretHttpsProviderError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'offer_unavailable'
|
||||
| 'authority_mismatch'
|
||||
| 'delivery_unavailable'
|
||||
| 'response_invalid',
|
||||
) {
|
||||
super(`Worker remote Secret HTTPS provider failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteSecretHttpsProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkerRemoteSecretHttpsProviderOptions {
|
||||
readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
readonly inbox: Pick<WorkerRemoteExecutionInbox, 'readOffer'>;
|
||||
}
|
||||
|
||||
export class WorkerRemoteSecretHttpsProvider
|
||||
implements WorkerRemoteSecretEnvironmentProvider {
|
||||
private readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
private readonly inbox: Pick<WorkerRemoteExecutionInbox, 'readOffer'>;
|
||||
|
||||
constructor(options: WorkerRemoteSecretHttpsProviderOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.client?.postJson !== 'function' ||
|
||||
typeof options.inbox?.readOffer !== 'function'
|
||||
) throw new WorkerRemoteSecretHttpsProviderError('invalid_configuration');
|
||||
this.client = options.client;
|
||||
this.inbox = options.inbox;
|
||||
}
|
||||
|
||||
async resolve(request: Parameters<WorkerRemoteSecretEnvironmentProvider['resolve']>[0])
|
||||
: Promise<WorkerRemoteSecretResolution | undefined> {
|
||||
let record;
|
||||
try {
|
||||
record = await this.inbox.readOffer(request.offerId);
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('offer_unavailable');
|
||||
}
|
||||
if (!record || record.state !== 'starting_acknowledged') {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('offer_unavailable');
|
||||
}
|
||||
let offer;
|
||||
try {
|
||||
offer = createClusterRemoteExecutionOffer(record.offer);
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('authority_mismatch');
|
||||
}
|
||||
const expectedRefs = Object.freeze([
|
||||
...new Set(offer.executionRevision.environment.flatMap((binding) =>
|
||||
binding.kind === 'secret' ? [binding.secretRef] : [])),
|
||||
]);
|
||||
if (
|
||||
offer.offerId !== request.offerId ||
|
||||
offer.executionDigest !== request.executionDigest ||
|
||||
offer.candidate.projectId !== request.projectId ||
|
||||
offer.candidate.taskId !== request.taskId ||
|
||||
offer.candidate.taskRevision !== request.taskRevision ||
|
||||
offer.candidate.runId !== request.runId ||
|
||||
offer.candidate.attemptId !== request.attemptId ||
|
||||
JSON.stringify(expectedRefs) !== JSON.stringify(request.secretRefs)
|
||||
) throw new WorkerRemoteSecretHttpsProviderError('authority_mismatch');
|
||||
|
||||
const path = `/api/v3/worker-ingress/workers/${offer.worker.workerId}` +
|
||||
`/sessions/${offer.worker.sessionId}/secrets`;
|
||||
const body = createRemoteWorkerSecretDeliveryRequestBody({
|
||||
workerId: offer.worker.workerId,
|
||||
workerSessionId: offer.worker.sessionId,
|
||||
workerGeneration: offer.worker.generation,
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
projectId: offer.candidate.projectId,
|
||||
taskId: offer.candidate.taskId,
|
||||
taskRevision: offer.candidate.taskRevision,
|
||||
executionDigest: offer.executionDigest,
|
||||
offerId: offer.offerId,
|
||||
leaseGeneration: offer.lease.leaseGeneration,
|
||||
leaseToken: offer.leaseToken,
|
||||
expectedLeaseVersion: offer.lease.version,
|
||||
secretRefs: expectedRefs,
|
||||
});
|
||||
let serialized: Uint8Array;
|
||||
try {
|
||||
const transportRequest: WorkerIngressHttpsPostRequest = {
|
||||
path,
|
||||
body,
|
||||
maximumRequestBytes: MAX_REMOTE_SECRET_DELIVERY_REQUEST_BYTES,
|
||||
maximumResponseBytes: MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES,
|
||||
};
|
||||
serialized = await this.client.postJson(transportRequest);
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('delivery_unavailable');
|
||||
}
|
||||
try {
|
||||
const delivered = parseRemoteWorkerSecretDeliveryResponse(serialized, {
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
offerId: offer.offerId,
|
||||
executionDigest: offer.executionDigest,
|
||||
secretRefs: expectedRefs,
|
||||
});
|
||||
const values = Object.freeze(delivered.values.map((entry) =>
|
||||
Object.freeze({ secretRef: entry.secretRef, value: entry.value })));
|
||||
return Object.freeze({
|
||||
values,
|
||||
dispose() {
|
||||
// JavaScript strings cannot be zeroized. Drop all retained references;
|
||||
// the transport bytes were already scrubbed by the parser.
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('response_invalid');
|
||||
} finally {
|
||||
if (Buffer.isBuffer(serialized)) serialized.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
// Remote Execution transport owns Artifact upload and completion acknowledgement.
|
||||
import {
|
||||
MAX_REMOTE_WORKER_ARTIFACT_RESPONSE_BYTES,
|
||||
MAX_REMOTE_WORKER_COMPLETION_REQUEST_BYTES,
|
||||
MAX_REMOTE_WORKER_COMPLETION_RESPONSE_BYTES,
|
||||
createRemoteWorkerArtifactUploadPreamble,
|
||||
createRemoteWorkerCompletionRequestBody,
|
||||
parseRemoteWorkerArtifactUploadResponse,
|
||||
parseRemoteWorkerCompletionResponse,
|
||||
type RemoteWorkerCompletionCommand,
|
||||
} from '@qinglong/runtime-core/remote-worker-completion';
|
||||
import type {
|
||||
WorkerRemoteExecutionCompletionClient,
|
||||
WorkerRemoteExecutionCompletionCommand,
|
||||
WorkerRemoteExecutionCompletionResult,
|
||||
WorkerRemoteLogArtifactUploadCommand,
|
||||
WorkerRemoteLogArtifactUploadResult,
|
||||
WorkerRemoteLogArtifactUploader,
|
||||
} from '../../execution/workerCompletionCoordinator';
|
||||
import type { WorkerIngressHttpsClient } from './workerIngressHttpsClient';
|
||||
|
||||
export class WorkerRemoteCompletionHttpsError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'request_invalid'
|
||||
| 'transport_unavailable'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote completion HTTPS failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteCompletionHttpsError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkerRemoteArtifactHttpsUploaderOptions {
|
||||
readonly client: Pick<WorkerIngressHttpsClient, 'postStream'>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionHttpsCompletionClientOptions {
|
||||
readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
}
|
||||
|
||||
function path(
|
||||
command: Readonly<{ workerId: string; workerSessionId: string }>,
|
||||
operation: 'artifacts' | 'completion',
|
||||
): string {
|
||||
return `/api/v3/worker-ingress/workers/${command.workerId}` +
|
||||
`/sessions/${command.workerSessionId}/${operation}`;
|
||||
}
|
||||
|
||||
function erase(value: Uint8Array | undefined): void {
|
||||
if (!value) return;
|
||||
Buffer.from(value.buffer, value.byteOffset, value.byteLength).fill(0);
|
||||
}
|
||||
|
||||
export class WorkerRemoteArtifactHttpsUploader
|
||||
implements WorkerRemoteLogArtifactUploader {
|
||||
private readonly client: Pick<WorkerIngressHttpsClient, 'postStream'>;
|
||||
|
||||
constructor(options: WorkerRemoteArtifactHttpsUploaderOptions) {
|
||||
if (!options || typeof options.client?.postStream !== 'function') {
|
||||
throw new WorkerRemoteCompletionHttpsError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async upload(
|
||||
command: WorkerRemoteLogArtifactUploadCommand,
|
||||
): Promise<Readonly<WorkerRemoteLogArtifactUploadResult>> {
|
||||
let preamble: Buffer;
|
||||
try {
|
||||
preamble = createRemoteWorkerArtifactUploadPreamble({
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedLeaseVersion: command.expectedLeaseVersion,
|
||||
logArtifactId: command.logArtifactId,
|
||||
byteLength: command.byteLength,
|
||||
...(command.truncated === undefined
|
||||
? {}
|
||||
: { truncated: command.truncated }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('request_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
let serialized: Uint8Array | undefined;
|
||||
try {
|
||||
const content = command.content;
|
||||
serialized = await this.client.postStream({
|
||||
path: path(command, 'artifacts'),
|
||||
body: (async function* () {
|
||||
yield preamble;
|
||||
for await (const chunk of content) yield chunk;
|
||||
})(),
|
||||
byteLength: preamble.byteLength + command.byteLength,
|
||||
maximumResponseBytes: MAX_REMOTE_WORKER_ARTIFACT_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('transport_unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
preamble.fill(0);
|
||||
}
|
||||
try {
|
||||
const receipt = parseRemoteWorkerArtifactUploadResponse(serialized);
|
||||
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 TypeError('Artifact response authority does not match');
|
||||
}
|
||||
return Object.freeze({
|
||||
status: receipt.status,
|
||||
logArtifactId: receipt.logArtifactId,
|
||||
byteLength: receipt.byteLength,
|
||||
sha256: receipt.sha256,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('response_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
erase(serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionHttpsCompletionClient
|
||||
implements WorkerRemoteExecutionCompletionClient {
|
||||
private readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
|
||||
constructor(options: WorkerRemoteExecutionHttpsCompletionClientOptions) {
|
||||
if (!options || typeof options.client?.postJson !== 'function') {
|
||||
throw new WorkerRemoteCompletionHttpsError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async complete(
|
||||
command: WorkerRemoteExecutionCompletionCommand,
|
||||
): Promise<Readonly<WorkerRemoteExecutionCompletionResult>> {
|
||||
let body;
|
||||
try {
|
||||
if (command.executorType !== 'remote_worker') {
|
||||
throw new TypeError('completion executor type is invalid');
|
||||
}
|
||||
const wire: RemoteWorkerCompletionCommand = {
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedLeaseVersion: command.expectedLeaseVersion,
|
||||
callbackSequence: command.callbackSequence,
|
||||
callbackTokenDigest: command.callbackTokenDigest,
|
||||
result: command.result,
|
||||
artifact: command.artifact,
|
||||
};
|
||||
body = createRemoteWorkerCompletionRequestBody(wire);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('request_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
let serialized: Uint8Array | undefined;
|
||||
try {
|
||||
serialized = await this.client.postJson({
|
||||
path: path(command, 'completion'),
|
||||
body,
|
||||
maximumRequestBytes: MAX_REMOTE_WORKER_COMPLETION_REQUEST_BYTES,
|
||||
maximumResponseBytes: MAX_REMOTE_WORKER_COMPLETION_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('transport_unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const completed = parseRemoteWorkerCompletionResponse(serialized);
|
||||
if (
|
||||
completed.runId !== command.runId ||
|
||||
completed.attemptId !== command.attemptId ||
|
||||
completed.callbackSequence !== command.callbackSequence
|
||||
) {
|
||||
throw new TypeError('completion response authority does not match');
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('response_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
erase(serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// Remote Execution transport owns fenced lease-control exchange.
|
||||
import {
|
||||
MAX_REMOTE_WORKER_LEASE_CONTROL_REQUEST_BYTES,
|
||||
MAX_REMOTE_WORKER_LEASE_CONTROL_RESPONSE_BYTES,
|
||||
createRemoteWorkerLeaseControlRequestBody,
|
||||
parseRemoteWorkerLeaseControlResponse,
|
||||
type RemoteWorkerLeaseControlCommand,
|
||||
type RemoteWorkerLeaseControlResult,
|
||||
} from '@qinglong/runtime-core/remote-worker-lease-control';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
export interface WorkerRemoteLeaseControlClient {
|
||||
control(
|
||||
command: RemoteWorkerLeaseControlCommand,
|
||||
): Promise<Readonly<RemoteWorkerLeaseControlResult>>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLeaseControlHttpsClientOptions {
|
||||
readonly client: WorkerIngressHttpsClient;
|
||||
}
|
||||
|
||||
export class WorkerRemoteLeaseControlHttpsError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'request_invalid'
|
||||
| 'transport_unavailable'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote lease control failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteLeaseControlHttpsError';
|
||||
}
|
||||
}
|
||||
|
||||
function path(command: RemoteWorkerLeaseControlCommand): string {
|
||||
return '/api/v3/worker-ingress/workers/' + command.workerId +
|
||||
'/sessions/' + command.workerSessionId + '/lease-control';
|
||||
}
|
||||
|
||||
export class WorkerRemoteLeaseControlHttpsClient
|
||||
implements WorkerRemoteLeaseControlClient {
|
||||
private readonly client: WorkerIngressHttpsClient;
|
||||
|
||||
constructor(options: WorkerRemoteLeaseControlHttpsClientOptions) {
|
||||
if (!options || !(options.client instanceof WorkerIngressHttpsClient)) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async control(
|
||||
command: RemoteWorkerLeaseControlCommand,
|
||||
): Promise<Readonly<RemoteWorkerLeaseControlResult>> {
|
||||
let body;
|
||||
try {
|
||||
body = createRemoteWorkerLeaseControlRequestBody(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError(
|
||||
'request_invalid', { cause: error },
|
||||
);
|
||||
}
|
||||
let serialized: Uint8Array;
|
||||
try {
|
||||
serialized = await this.client.postJson({
|
||||
path: path(command),
|
||||
body,
|
||||
maximumRequestBytes: MAX_REMOTE_WORKER_LEASE_CONTROL_REQUEST_BYTES,
|
||||
maximumResponseBytes: MAX_REMOTE_WORKER_LEASE_CONTROL_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError(
|
||||
'transport_unavailable', { cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = parseRemoteWorkerLeaseControlResponse(serialized);
|
||||
if (
|
||||
result.projectId !== command.projectId ||
|
||||
result.runId !== command.runId ||
|
||||
result.attemptId !== command.attemptId ||
|
||||
result.offerId !== command.offerId ||
|
||||
result.leaseGeneration !== command.leaseGeneration ||
|
||||
(result.status !== 'terminal' &&
|
||||
result.leaseVersion !== command.expectedLeaseVersion + 1)
|
||||
) throw new TypeError('lease control response authority mismatch');
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError(
|
||||
'response_invalid', { cause: error },
|
||||
);
|
||||
} finally {
|
||||
Buffer.from(serialized.buffer, serialized.byteOffset, serialized.byteLength)
|
||||
.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+641
@@ -0,0 +1,641 @@
|
||||
// Remote Execution transport owns the shared bounded TLS 1.3 Worker Ingress client.
|
||||
import { Agent, request as nodeHttpsRequest } from 'node:https';
|
||||
import type { RequestOptions } from 'node:https';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { isIP } from 'node:net';
|
||||
import { REMOTE_WORKER_ARTIFACT_CONTENT_TYPE } from '@qinglong/runtime-core/remote-worker-completion';
|
||||
|
||||
const AUTHORIZATION =
|
||||
/^Worker ql3w_([A-Za-z0-9][A-Za-z0-9._:-]{0,63})_([A-Za-z0-9_-]{43})$/;
|
||||
const WORKER_INGRESS_JSON_PATH =
|
||||
/^\/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|offers|starting|running|start-failure|secrets|completion|lease-control)$/;
|
||||
const WORKER_INGRESS_ARTIFACT_PATH =
|
||||
/^\/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}\/artifacts$/;
|
||||
const MAX_TLS_MATERIAL_BYTES = 1024 * 1024;
|
||||
const MAX_REQUEST_BYTES = 4096;
|
||||
const HARD_MAX_REQUEST_BYTES = 64 * 1024;
|
||||
const HARD_MAX_STREAM_REQUEST_BYTES = 64 * 1024 * 1024 + 4 * 1024 + 4;
|
||||
const MAX_RESPONSE_BYTES = 128 * 1024;
|
||||
const CREDENTIAL_POOL_KEY = Symbol('qinglong.worker-ingress-credential-pool-key');
|
||||
|
||||
export const WORKER_INGRESS_ARTIFACT_CONTENT_TYPE =
|
||||
REMOTE_WORKER_ARTIFACT_CONTENT_TYPE;
|
||||
|
||||
export interface WorkerIngressHttpsCredentials {
|
||||
readonly authorization: string;
|
||||
readonly certificateChainPem: string | Buffer;
|
||||
readonly privateKeyPem: string | Buffer;
|
||||
readonly trustAnchors: readonly (string | Buffer)[];
|
||||
/** Erases provider-owned transient material after the client copies it. */
|
||||
readonly dispose?: () => void;
|
||||
}
|
||||
|
||||
export interface WorkerIngressHttpsCredentialProvider {
|
||||
load(signal?: AbortSignal): Promise<WorkerIngressHttpsCredentials>;
|
||||
}
|
||||
|
||||
export type WorkerIngressHttpsRequestFactory = (
|
||||
options: RequestOptions,
|
||||
callback: (response: IncomingMessage) => void,
|
||||
) => ClientRequest;
|
||||
|
||||
export interface WorkerIngressHttpsClientOptions {
|
||||
readonly origin: string | URL;
|
||||
readonly credentials: WorkerIngressHttpsCredentialProvider;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly agent?: Agent;
|
||||
/** Injectable only for deterministic transport contract tests. */
|
||||
readonly requestFactory?: WorkerIngressHttpsRequestFactory;
|
||||
}
|
||||
|
||||
export interface WorkerIngressHttpsPostRequest {
|
||||
readonly path: string;
|
||||
readonly body: unknown;
|
||||
readonly maximumResponseBytes: number;
|
||||
/** Defaults to 4 KiB. Larger budgets are opt-in and capped at 64 KiB. */
|
||||
readonly maximumRequestBytes?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WorkerIngressHttpsStreamRequest {
|
||||
readonly path: string;
|
||||
readonly body: AsyncIterable<Uint8Array>;
|
||||
readonly byteLength: number;
|
||||
readonly maximumResponseBytes: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class WorkerIngressHttpsClientError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'credentials_unavailable'
|
||||
| 'request_rejected'
|
||||
| 'response_rejected'
|
||||
| 'response_too_large'
|
||||
| 'closed',
|
||||
readonly httpStatus?: number,
|
||||
) {
|
||||
super(`Worker ingress HTTPS client failed: ${reason}`);
|
||||
this.name = 'WorkerIngressHttpsClientError';
|
||||
}
|
||||
}
|
||||
|
||||
function boundedMaterial(value: string | Buffer): Buffer {
|
||||
const bytes = Buffer.isBuffer(value)
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value, 'utf8');
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > MAX_TLS_MATERIAL_BYTES) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string | URL): URL {
|
||||
let origin: URL;
|
||||
try {
|
||||
origin = new URL(value);
|
||||
} catch {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
if (
|
||||
origin.protocol !== 'https:' ||
|
||||
origin.username !== '' ||
|
||||
origin.password !== '' ||
|
||||
origin.pathname !== '/' ||
|
||||
origin.search !== '' ||
|
||||
origin.hash !== ''
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
interface WorkerIngressHttpsCredentialMaterial {
|
||||
readonly authorization: string;
|
||||
readonly certificate: Buffer;
|
||||
readonly privateKey: Buffer;
|
||||
readonly trustAnchors: readonly Buffer[];
|
||||
readonly poolKey: string;
|
||||
}
|
||||
|
||||
interface WorkerIngressHttpsAgentRequestOptions extends RequestOptions {
|
||||
readonly [CREDENTIAL_POOL_KEY]?: string;
|
||||
}
|
||||
|
||||
class WorkerIngressHttpsAgent extends Agent {
|
||||
override getName(options: RequestOptions = {}): string {
|
||||
const poolKey = (options as WorkerIngressHttpsAgentRequestOptions)[
|
||||
CREDENTIAL_POOL_KEY
|
||||
];
|
||||
if (poolKey === undefined) return super.getName(options);
|
||||
// Node recomputes the HTTPS pool name when a socket becomes free. The
|
||||
// request-local TLS Buffers have already been erased by then, so their
|
||||
// mutable contents cannot safely participate in that name.
|
||||
return `${super.getName({
|
||||
...options,
|
||||
ca: undefined,
|
||||
cert: undefined,
|
||||
key: undefined,
|
||||
})}:qinglong:${poolKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
function credentialPoolKey(
|
||||
certificate: Buffer,
|
||||
privateKey: Buffer,
|
||||
trustAnchors: readonly Buffer[],
|
||||
): string {
|
||||
const hash = createHash('sha256');
|
||||
for (const value of [certificate, privateKey, ...trustAnchors]) {
|
||||
const length = Buffer.allocUnsafe(4);
|
||||
length.writeUInt32BE(value.byteLength);
|
||||
hash.update(length);
|
||||
hash.update(value);
|
||||
length.fill(0);
|
||||
}
|
||||
return hash.digest('base64url');
|
||||
}
|
||||
|
||||
async function loadCredentialMaterial(
|
||||
provider: WorkerIngressHttpsCredentialProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerIngressHttpsCredentialMaterial> {
|
||||
let loaded: WorkerIngressHttpsCredentials;
|
||||
try {
|
||||
loaded = await provider.load(signal);
|
||||
} catch {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ??
|
||||
new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
let certificate: Buffer | undefined;
|
||||
let privateKey: Buffer | undefined;
|
||||
const trustAnchors: Buffer[] = [];
|
||||
let failure: unknown;
|
||||
try {
|
||||
if (
|
||||
!loaded ||
|
||||
!AUTHORIZATION.test(loaded.authorization) ||
|
||||
!Array.isArray(loaded.trustAnchors) ||
|
||||
loaded.trustAnchors.length < 1 ||
|
||||
loaded.trustAnchors.length > 8 ||
|
||||
(loaded.dispose !== undefined && typeof loaded.dispose !== 'function')
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
certificate = boundedMaterial(loaded.certificateChainPem);
|
||||
privateKey = boundedMaterial(loaded.privateKeyPem);
|
||||
for (const anchor of loaded.trustAnchors) {
|
||||
trustAnchors.push(boundedMaterial(anchor));
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
loaded?.dispose?.();
|
||||
} catch {
|
||||
failure ??= new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
if (failure !== undefined || !certificate || !privateKey) {
|
||||
certificate?.fill(0);
|
||||
privateKey?.fill(0);
|
||||
trustAnchors.forEach((value) => value.fill(0));
|
||||
if (failure instanceof WorkerIngressHttpsClientError) throw failure;
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
return {
|
||||
authorization: loaded.authorization,
|
||||
certificate,
|
||||
privateKey,
|
||||
trustAnchors: Object.freeze(trustAnchors),
|
||||
poolKey: credentialPoolKey(certificate, privateKey, trustAnchors),
|
||||
};
|
||||
}
|
||||
|
||||
function eraseCredentialMaterial(
|
||||
material: WorkerIngressHttpsCredentialMaterial,
|
||||
): void {
|
||||
material.certificate.fill(0);
|
||||
material.privateKey.fill(0);
|
||||
material.trustAnchors.forEach((value) => value.fill(0));
|
||||
}
|
||||
|
||||
export class WorkerIngressHttpsClient {
|
||||
private readonly origin: URL;
|
||||
private readonly credentials: WorkerIngressHttpsCredentialProvider;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly requestFactory: WorkerIngressHttpsRequestFactory;
|
||||
private readonly agent: Agent;
|
||||
private readonly ownsAgent: boolean;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: WorkerIngressHttpsClientOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.credentials?.load !== 'function' ||
|
||||
(options.requestFactory !== undefined &&
|
||||
typeof options.requestFactory !== 'function')
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
||||
if (
|
||||
!Number.isSafeInteger(requestTimeoutMs) ||
|
||||
requestTimeoutMs < 1_000 ||
|
||||
requestTimeoutMs > 60_000
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
this.origin = normalizeOrigin(options.origin);
|
||||
this.credentials = options.credentials;
|
||||
this.requestTimeoutMs = requestTimeoutMs;
|
||||
this.requestFactory = options.requestFactory ?? nodeHttpsRequest;
|
||||
this.ownsAgent = options.agent === undefined;
|
||||
this.agent = options.agent ?? new WorkerIngressHttpsAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: 1,
|
||||
maxFreeSockets: 1,
|
||||
timeout: requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async postJson(request: WorkerIngressHttpsPostRequest): Promise<Uint8Array> {
|
||||
if (this.closed) throw new WorkerIngressHttpsClientError('closed');
|
||||
if (
|
||||
!request ||
|
||||
typeof request.path !== 'string' ||
|
||||
!WORKER_INGRESS_JSON_PATH.test(request.path) ||
|
||||
!Number.isSafeInteger(request.maximumResponseBytes) ||
|
||||
request.maximumResponseBytes < 2 ||
|
||||
request.maximumResponseBytes > MAX_RESPONSE_BYTES ||
|
||||
(request.maximumRequestBytes !== undefined &&
|
||||
(!Number.isSafeInteger(request.maximumRequestBytes) ||
|
||||
request.maximumRequestBytes < 2 ||
|
||||
request.maximumRequestBytes > HARD_MAX_REQUEST_BYTES))
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
if (request.signal?.aborted) {
|
||||
throw request.signal.reason ??
|
||||
new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
const material = await loadCredentialMaterial(
|
||||
this.credentials,
|
||||
request.signal,
|
||||
);
|
||||
let body: Buffer;
|
||||
try {
|
||||
body = Buffer.from(JSON.stringify(request.body), 'utf8');
|
||||
} catch {
|
||||
eraseCredentialMaterial(material);
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
const maximumRequestBytes = request.maximumRequestBytes ?? MAX_REQUEST_BYTES;
|
||||
if (body.byteLength < 2 || body.byteLength > maximumRequestBytes) {
|
||||
eraseCredentialMaterial(material);
|
||||
body.fill(0);
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
try {
|
||||
return await this.perform(
|
||||
request.path,
|
||||
body,
|
||||
request.maximumResponseBytes,
|
||||
material.authorization,
|
||||
material.certificate,
|
||||
material.privateKey,
|
||||
material.trustAnchors,
|
||||
material.poolKey,
|
||||
request.signal,
|
||||
);
|
||||
} finally {
|
||||
eraseCredentialMaterial(material);
|
||||
body.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async postStream(
|
||||
request: WorkerIngressHttpsStreamRequest,
|
||||
): Promise<Uint8Array> {
|
||||
if (this.closed) throw new WorkerIngressHttpsClientError('closed');
|
||||
if (
|
||||
!request ||
|
||||
typeof request.path !== 'string' ||
|
||||
!WORKER_INGRESS_ARTIFACT_PATH.test(request.path) ||
|
||||
!request.body ||
|
||||
typeof request.body[Symbol.asyncIterator] !== 'function' ||
|
||||
!Number.isSafeInteger(request.byteLength) ||
|
||||
request.byteLength < 1 ||
|
||||
request.byteLength > HARD_MAX_STREAM_REQUEST_BYTES ||
|
||||
!Number.isSafeInteger(request.maximumResponseBytes) ||
|
||||
request.maximumResponseBytes < 2 ||
|
||||
request.maximumResponseBytes > MAX_RESPONSE_BYTES
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
if (request.signal?.aborted) {
|
||||
throw request.signal.reason ??
|
||||
new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
const material = await loadCredentialMaterial(
|
||||
this.credentials,
|
||||
request.signal,
|
||||
);
|
||||
try {
|
||||
return await this.performStream(
|
||||
request.path,
|
||||
request.body,
|
||||
request.byteLength,
|
||||
request.maximumResponseBytes,
|
||||
material.authorization,
|
||||
material.certificate,
|
||||
material.privateKey,
|
||||
material.trustAnchors,
|
||||
material.poolKey,
|
||||
request.signal,
|
||||
);
|
||||
} finally {
|
||||
eraseCredentialMaterial(material);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.ownsAgent) this.agent.destroy();
|
||||
}
|
||||
|
||||
private perform(
|
||||
path: string,
|
||||
body: Buffer,
|
||||
maximumResponseBytes: number,
|
||||
authorization: string,
|
||||
certificate: Buffer,
|
||||
privateKey: Buffer,
|
||||
trustAnchors: readonly Buffer[],
|
||||
poolKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (error?: unknown, bytes?: Buffer): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (error) reject(error);
|
||||
else resolve(bytes!);
|
||||
};
|
||||
let clientRequest: ClientRequest;
|
||||
const abort = (): void => {
|
||||
clientRequest.destroy(
|
||||
signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
};
|
||||
try {
|
||||
clientRequest = this.requestFactory({
|
||||
protocol: 'https:',
|
||||
hostname: this.origin.hostname,
|
||||
port: this.origin.port || 443,
|
||||
...(isIP(this.origin.hostname) === 0
|
||||
? { servername: this.origin.hostname }
|
||||
: {}),
|
||||
method: 'POST',
|
||||
path,
|
||||
agent: this.agent,
|
||||
minVersion: 'TLSv1.3',
|
||||
rejectUnauthorized: true,
|
||||
cert: certificate,
|
||||
key: privateKey,
|
||||
ca: [...trustAnchors],
|
||||
[CREDENTIAL_POOL_KEY]: poolKey,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization,
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(body.byteLength),
|
||||
},
|
||||
} as WorkerIngressHttpsAgentRequestOptions, (response) => {
|
||||
const contentType = response.headers['content-type'];
|
||||
const contentEncoding = response.headers['content-encoding'];
|
||||
const contentLength = response.headers['content-length'];
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
typeof contentType !== 'string' ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(contentType) ||
|
||||
(contentEncoding !== undefined && contentEncoding !== 'identity') ||
|
||||
(contentLength !== undefined &&
|
||||
(!/^\d+$/.test(contentLength) ||
|
||||
Number(contentLength) > maximumResponseBytes))
|
||||
) {
|
||||
response.resume();
|
||||
settle(new WorkerIngressHttpsClientError(
|
||||
'response_rejected', response.statusCode,
|
||||
));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
response.on('data', (chunk: Buffer | string) => {
|
||||
if (settled) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += bytes.byteLength;
|
||||
if (total > maximumResponseBytes) {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
response.destroy();
|
||||
settle(new WorkerIngressHttpsClientError('response_too_large'));
|
||||
return;
|
||||
}
|
||||
chunks.push(Buffer.from(bytes));
|
||||
});
|
||||
response.once('end', () => {
|
||||
if (settled) return;
|
||||
const result = Buffer.concat(chunks, total);
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
if (result.byteLength < 2) {
|
||||
result.fill(0);
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
return;
|
||||
}
|
||||
settle(undefined, result);
|
||||
});
|
||||
response.once('error', () => {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
settle(new WorkerIngressHttpsClientError(
|
||||
'response_rejected', response.statusCode,
|
||||
));
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
return;
|
||||
}
|
||||
clientRequest.once('error', (error) => {
|
||||
if (signal?.aborted) settle(signal.reason ?? error);
|
||||
else settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
});
|
||||
clientRequest.setTimeout(this.requestTimeoutMs, () => {
|
||||
clientRequest.destroy(
|
||||
new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
clientRequest.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
private performStream(
|
||||
path: string,
|
||||
body: AsyncIterable<Uint8Array>,
|
||||
byteLength: number,
|
||||
maximumResponseBytes: number,
|
||||
authorization: string,
|
||||
certificate: Buffer,
|
||||
privateKey: Buffer,
|
||||
trustAnchors: readonly Buffer[],
|
||||
poolKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (error?: unknown, bytes?: Buffer): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (error) reject(error);
|
||||
else resolve(bytes!);
|
||||
};
|
||||
let clientRequest: ClientRequest;
|
||||
const abort = (): void => {
|
||||
clientRequest.destroy(
|
||||
signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
};
|
||||
try {
|
||||
clientRequest = this.requestFactory({
|
||||
protocol: 'https:',
|
||||
hostname: this.origin.hostname,
|
||||
port: this.origin.port || 443,
|
||||
...(isIP(this.origin.hostname) === 0
|
||||
? { servername: this.origin.hostname }
|
||||
: {}),
|
||||
method: 'POST',
|
||||
path,
|
||||
agent: this.agent,
|
||||
minVersion: 'TLSv1.3',
|
||||
rejectUnauthorized: true,
|
||||
cert: certificate,
|
||||
key: privateKey,
|
||||
ca: [...trustAnchors],
|
||||
[CREDENTIAL_POOL_KEY]: poolKey,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization,
|
||||
'content-type': WORKER_INGRESS_ARTIFACT_CONTENT_TYPE,
|
||||
'content-length': String(byteLength),
|
||||
},
|
||||
} as WorkerIngressHttpsAgentRequestOptions, (response) => {
|
||||
const contentType = response.headers['content-type'];
|
||||
const contentEncoding = response.headers['content-encoding'];
|
||||
const contentLength = response.headers['content-length'];
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
typeof contentType !== 'string' ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(contentType) ||
|
||||
(contentEncoding !== undefined && contentEncoding !== 'identity') ||
|
||||
(contentLength !== undefined &&
|
||||
(!/^\d+$/.test(contentLength) ||
|
||||
Number(contentLength) > maximumResponseBytes))
|
||||
) {
|
||||
response.resume();
|
||||
clientRequest.destroy();
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
response.on('data', (chunk: Buffer | string) => {
|
||||
if (settled) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += bytes.byteLength;
|
||||
if (total > maximumResponseBytes) {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
response.destroy();
|
||||
settle(new WorkerIngressHttpsClientError('response_too_large'));
|
||||
return;
|
||||
}
|
||||
chunks.push(Buffer.from(bytes));
|
||||
});
|
||||
response.once('end', () => {
|
||||
if (settled) return;
|
||||
const result = Buffer.concat(chunks, total);
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
if (result.byteLength < 2) {
|
||||
result.fill(0);
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
return;
|
||||
}
|
||||
settle(undefined, result);
|
||||
});
|
||||
response.once('error', () => {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
return;
|
||||
}
|
||||
clientRequest.once('error', (error) => {
|
||||
if (signal?.aborted) settle(signal.reason ?? error);
|
||||
else settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
});
|
||||
clientRequest.setTimeout(this.requestTimeoutMs, () => {
|
||||
clientRequest.destroy(
|
||||
new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
void (async () => {
|
||||
let total = 0;
|
||||
for await (const chunk of body) {
|
||||
if (settled) return;
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (!(chunk instanceof Uint8Array)) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
total += chunk.byteLength;
|
||||
if (total > byteLength) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
if (!clientRequest.write(chunk)) {
|
||||
await once(
|
||||
clientRequest,
|
||||
'drain',
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (total !== byteLength) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
clientRequest.end();
|
||||
})().catch((error: unknown) => {
|
||||
clientRequest.destroy(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Session ownership: derive advertised capacity from the durable execution journal.
|
||||
import { assertWorkerConcurrency } from '@qinglong/runtime-core/worker-session';
|
||||
import type {
|
||||
WorkerRemoteExecutionInboxPage,
|
||||
WorkerRemoteExecutionInboxRecord,
|
||||
} from '../remote-execution/executionInbox';
|
||||
import {
|
||||
MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
type WorkerRemoteOfferClaimRecord,
|
||||
} from '../remote-execution/remoteOfferDelivery';
|
||||
|
||||
const SETTLED_STATES = new Set<WorkerRemoteExecutionInboxRecord['state']>([
|
||||
'start_failure_acknowledged',
|
||||
'completion_acknowledged',
|
||||
]);
|
||||
|
||||
export interface WorkerExecutionCapacityJournal {
|
||||
listOffers(options: Readonly<{
|
||||
afterOfferId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<WorkerRemoteExecutionInboxPage>;
|
||||
readPendingClaim(): Promise<WorkerRemoteOfferClaimRecord | undefined>;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionCapacityOracleOptions {
|
||||
readonly journal: WorkerExecutionCapacityJournal;
|
||||
readonly maxConcurrentRuns: number;
|
||||
}
|
||||
|
||||
export type WorkerExecutionCapacityMode =
|
||||
| 'reconciling'
|
||||
| 'registering'
|
||||
| 'active'
|
||||
| 'draining'
|
||||
| 'recovery_required'
|
||||
| 'offline';
|
||||
|
||||
export class WorkerExecutionCapacityOracleError extends Error {
|
||||
constructor(readonly reason: 'invalid_configuration' | 'invalid_transition') {
|
||||
super(`Worker execution capacity oracle failed: ${reason}`);
|
||||
this.name = 'WorkerExecutionCapacityOracleError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives advertised capacity exclusively from the owned execution journal.
|
||||
* It has no timer and never treats a deployment-supplied slot count as truth.
|
||||
*/
|
||||
export class WorkerExecutionCapacityOracle {
|
||||
private readonly journal: WorkerExecutionCapacityJournal;
|
||||
private readonly maxConcurrentRuns: number;
|
||||
private modeValue: WorkerExecutionCapacityMode = 'reconciling';
|
||||
private operation?: Promise<number>;
|
||||
|
||||
constructor(options: WorkerExecutionCapacityOracleOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.journal?.listOffers !== 'function' ||
|
||||
typeof options.journal?.readPendingClaim !== 'function'
|
||||
) throw new WorkerExecutionCapacityOracleError('invalid_configuration');
|
||||
try {
|
||||
assertWorkerConcurrency(options.maxConcurrentRuns, 0);
|
||||
} catch {
|
||||
throw new WorkerExecutionCapacityOracleError('invalid_configuration');
|
||||
}
|
||||
this.journal = options.journal;
|
||||
this.maxConcurrentRuns = options.maxConcurrentRuns;
|
||||
}
|
||||
|
||||
mode(): WorkerExecutionCapacityMode {
|
||||
return this.modeValue;
|
||||
}
|
||||
|
||||
prepareRegistration(): void {
|
||||
this.transition('reconciling', 'registering');
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
this.transition('registering', 'active');
|
||||
}
|
||||
|
||||
beginDrain(): void {
|
||||
if (
|
||||
this.modeValue === 'draining' ||
|
||||
this.modeValue === 'offline'
|
||||
) return;
|
||||
this.transition('active', 'draining');
|
||||
}
|
||||
|
||||
failClosed(): void {
|
||||
this.modeValue = 'recovery_required';
|
||||
}
|
||||
|
||||
offline(): void {
|
||||
if (this.modeValue === 'offline') return;
|
||||
this.transition('draining', 'offline');
|
||||
}
|
||||
|
||||
availableSlots(): Promise<number> {
|
||||
if (
|
||||
this.modeValue !== 'registering' &&
|
||||
this.modeValue !== 'active'
|
||||
) return Promise.resolve(0);
|
||||
if (this.operation) return this.operation;
|
||||
const operation = this.readAvailableSlots().finally(() => {
|
||||
if (this.operation === operation) this.operation = undefined;
|
||||
});
|
||||
this.operation = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async readAvailableSlots(): Promise<number> {
|
||||
let afterOfferId: string | undefined;
|
||||
let observed = 0;
|
||||
const active = new Set<string>();
|
||||
do {
|
||||
const page = await this.journal.listOffers({
|
||||
...(afterOfferId === undefined ? {} : { afterOfferId }),
|
||||
limit: 64,
|
||||
});
|
||||
observed += page.records.length;
|
||||
if (observed > MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES) {
|
||||
this.failClosed();
|
||||
return 0;
|
||||
}
|
||||
for (const record of page.records) {
|
||||
if (record.state === 'recovery_required') {
|
||||
this.failClosed();
|
||||
return 0;
|
||||
}
|
||||
if (!SETTLED_STATES.has(record.state)) {
|
||||
active.add(record.offer.offerId);
|
||||
}
|
||||
}
|
||||
afterOfferId = page.nextAfterOfferId;
|
||||
} while (afterOfferId !== undefined);
|
||||
const pending = await this.journal.readPendingClaim();
|
||||
if (pending !== undefined) active.add(pending.offerId);
|
||||
return Math.max(0, this.maxConcurrentRuns - active.size);
|
||||
}
|
||||
|
||||
private transition(
|
||||
expected: WorkerExecutionCapacityMode,
|
||||
next: WorkerExecutionCapacityMode,
|
||||
): void {
|
||||
if (this.modeValue !== expected) {
|
||||
throw new WorkerExecutionCapacityOracleError('invalid_transition');
|
||||
}
|
||||
this.modeValue = next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// Session ownership: coordinate registration, heartbeat, drain, and lease fencing.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
assertWorkerConcurrency,
|
||||
assertWorkerId,
|
||||
assertWorkerSessionLeaseDuration,
|
||||
type WorkerSessionStatus,
|
||||
} from '@qinglong/runtime-core/worker-session';
|
||||
import {
|
||||
canonicalRemoteWorkerCapabilities,
|
||||
type RemoteWorkerCapabilities,
|
||||
} from '@qinglong/runtime-core/remote-dispatch';
|
||||
import type { WorkerRemoteExecutionSession } from '../remote-execution/executionInboxProcessor';
|
||||
import {
|
||||
WorkerSessionHttpsClient,
|
||||
WorkerSessionHttpsClientError,
|
||||
} from './workerSessionHttpsClient';
|
||||
|
||||
export const MIN_WORKER_PRODUCT_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
export const MAX_WORKER_PRODUCT_HEARTBEAT_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export interface WorkerSessionCoordinatorOptions {
|
||||
readonly client: WorkerSessionHttpsClient;
|
||||
readonly workerId: string;
|
||||
readonly capabilities: RemoteWorkerCapabilities;
|
||||
readonly maxConcurrentRuns: number;
|
||||
readonly availableSlots: () => number | Promise<number>;
|
||||
readonly leaseDurationMs?: number;
|
||||
readonly heartbeatIntervalMs?: number;
|
||||
readonly now?: () => number;
|
||||
readonly createSessionId?: (nowMs: number) => string;
|
||||
}
|
||||
|
||||
export interface WorkerSessionCoordinatorRecord
|
||||
extends WorkerRemoteExecutionSession {
|
||||
readonly version: number;
|
||||
readonly nextHeartbeatAtMs: number;
|
||||
}
|
||||
|
||||
export type WorkerSessionCoordinatorTickResult = Readonly<
|
||||
| { status: 'inactive' | 'not_due' | 'lease_expired' }
|
||||
| { status: 'heartbeat'; session: WorkerSessionCoordinatorRecord }
|
||||
>;
|
||||
|
||||
export class WorkerSessionCoordinatorError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'already_registered'
|
||||
| 'inactive'
|
||||
| 'lease_expired'
|
||||
| 'invalid_capacity'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker Session coordinator failed: ${reason}`, options);
|
||||
this.name = 'WorkerSessionCoordinatorError';
|
||||
}
|
||||
}
|
||||
|
||||
function safeNow(provider: () => number): number {
|
||||
const value = provider();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerSessionCoordinatorError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uuidV7(nowMs: number): string {
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0 || nowMs > 0xffffffffffff) {
|
||||
throw new WorkerSessionCoordinatorError('invalid_configuration');
|
||||
}
|
||||
const bytes = randomBytes(16);
|
||||
let timestamp = nowMs;
|
||||
for (let index = 5; index >= 0; index -= 1) {
|
||||
bytes[index] = timestamp % 256;
|
||||
timestamp = Math.floor(timestamp / 256);
|
||||
}
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x70;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = bytes.toString('hex');
|
||||
bytes.fill(0);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-` +
|
||||
`${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
function executionStatus(
|
||||
status: WorkerSessionStatus,
|
||||
): WorkerRemoteExecutionSession['status'] {
|
||||
if (status === 'online') return 'available';
|
||||
return status;
|
||||
}
|
||||
|
||||
export class WorkerSessionCoordinator {
|
||||
private readonly client: WorkerSessionHttpsClient;
|
||||
private readonly workerId: string;
|
||||
private readonly capabilitiesJson: string;
|
||||
private readonly capabilitiesHash: string;
|
||||
private readonly maxConcurrentRuns: number;
|
||||
private readonly availableSlotsProvider: () => number | Promise<number>;
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly heartbeatIntervalMs: number;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly createSessionId: (nowMs: number) => string;
|
||||
private session?: WorkerSessionCoordinatorRecord;
|
||||
private blocked = false;
|
||||
private operation?: Promise<unknown>;
|
||||
|
||||
constructor(options: WorkerSessionCoordinatorOptions) {
|
||||
if (
|
||||
!options ||
|
||||
!(options.client instanceof WorkerSessionHttpsClient) ||
|
||||
typeof options.availableSlots !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.createSessionId !== undefined &&
|
||||
typeof options.createSessionId !== 'function')
|
||||
) throw new WorkerSessionCoordinatorError('invalid_configuration');
|
||||
try {
|
||||
assertWorkerId(options.workerId);
|
||||
assertWorkerConcurrency(options.maxConcurrentRuns, 0);
|
||||
} catch (error) {
|
||||
throw new WorkerSessionCoordinatorError(
|
||||
'invalid_configuration', { cause: error },
|
||||
);
|
||||
}
|
||||
const leaseDurationMs = options.leaseDurationMs ?? 45_000;
|
||||
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? 10_000;
|
||||
try {
|
||||
assertWorkerSessionLeaseDuration(leaseDurationMs);
|
||||
} catch (error) {
|
||||
throw new WorkerSessionCoordinatorError(
|
||||
'invalid_configuration', { cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(heartbeatIntervalMs) ||
|
||||
heartbeatIntervalMs < MIN_WORKER_PRODUCT_HEARTBEAT_INTERVAL_MS ||
|
||||
heartbeatIntervalMs > MAX_WORKER_PRODUCT_HEARTBEAT_INTERVAL_MS ||
|
||||
heartbeatIntervalMs * 2 > leaseDurationMs
|
||||
) throw new WorkerSessionCoordinatorError('invalid_configuration');
|
||||
let canonical;
|
||||
try {
|
||||
canonical = canonicalRemoteWorkerCapabilities(options.capabilities);
|
||||
} catch (error) {
|
||||
throw new WorkerSessionCoordinatorError(
|
||||
'invalid_configuration', { cause: error },
|
||||
);
|
||||
}
|
||||
this.client = options.client;
|
||||
this.workerId = options.workerId;
|
||||
this.capabilitiesJson = canonical.json;
|
||||
this.capabilitiesHash = canonical.hash;
|
||||
this.maxConcurrentRuns = options.maxConcurrentRuns;
|
||||
this.availableSlotsProvider = options.availableSlots;
|
||||
this.leaseDurationMs = leaseDurationMs;
|
||||
this.heartbeatIntervalMs = heartbeatIntervalMs;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
this.createSessionId = options.createSessionId ?? uuidV7;
|
||||
}
|
||||
|
||||
current(): WorkerRemoteExecutionSession | undefined {
|
||||
const session = this.session;
|
||||
if (
|
||||
!session ||
|
||||
this.blocked ||
|
||||
session.leaseExpiresAtMs <= safeNow(this.nowProvider)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
workerId: session.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
status: session.status,
|
||||
leaseExpiresAtMs: session.leaseExpiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
currentRecord(): WorkerSessionCoordinatorRecord | undefined {
|
||||
return this.session ? Object.freeze({ ...this.session }) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the Session from execution admission without destroying its
|
||||
* durable identity. A later authenticated exchange is the only operation
|
||||
* that can clear this transport fence.
|
||||
*/
|
||||
failClosed(): void {
|
||||
this.blocked = true;
|
||||
}
|
||||
|
||||
register(): Promise<WorkerSessionCoordinatorRecord> {
|
||||
return this.serial(async () => {
|
||||
if (this.session) {
|
||||
throw new WorkerSessionCoordinatorError('already_registered');
|
||||
}
|
||||
const now = safeNow(this.nowProvider);
|
||||
const sessionId = this.createSessionId(now);
|
||||
const availableSlots = await this.readAvailableSlots();
|
||||
const response = await this.exchange(() => this.client.register({
|
||||
workerId: this.workerId,
|
||||
sessionId,
|
||||
capabilitiesJson: this.capabilitiesJson,
|
||||
capabilitiesHash: this.capabilitiesHash,
|
||||
maxConcurrentRuns: this.maxConcurrentRuns,
|
||||
availableSlots,
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
}));
|
||||
if (response.leaseExpiresAtMs <= now) {
|
||||
throw new WorkerSessionCoordinatorError('response_invalid');
|
||||
}
|
||||
return this.accept(response, now);
|
||||
});
|
||||
}
|
||||
|
||||
tick(): Promise<WorkerSessionCoordinatorTickResult> {
|
||||
return this.serial(async () => {
|
||||
const session = this.session;
|
||||
if (!session) return Object.freeze({ status: 'inactive' as const });
|
||||
const now = safeNow(this.nowProvider);
|
||||
if (session.leaseExpiresAtMs <= now) {
|
||||
return Object.freeze({ status: 'lease_expired' as const });
|
||||
}
|
||||
if (now < session.nextHeartbeatAtMs) {
|
||||
return Object.freeze({ status: 'not_due' as const });
|
||||
}
|
||||
const availableSlots = session.status === 'available'
|
||||
? await this.readAvailableSlots()
|
||||
: 0;
|
||||
const response = await this.exchange(() => this.client.heartbeat({
|
||||
workerId: session.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
expectedVersion: session.version,
|
||||
availableSlots,
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
}));
|
||||
return Object.freeze({
|
||||
status: 'heartbeat' as const,
|
||||
session: this.accept(response, now),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beginDrain(): Promise<void> {
|
||||
return this.serial(async () => {
|
||||
const session = this.requireLiveSession();
|
||||
if (session.status === 'draining') return;
|
||||
if (session.status === 'offline') return;
|
||||
const now = safeNow(this.nowProvider);
|
||||
const response = await this.exchange(() => this.client.transition({
|
||||
workerId: session.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
expectedVersion: session.version,
|
||||
status: 'draining',
|
||||
}));
|
||||
this.accept(response, now);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): Promise<void> {
|
||||
return this.serial(async () => {
|
||||
const session = this.requireLiveSession();
|
||||
if (session.status === 'offline') return;
|
||||
if (session.status !== 'draining') {
|
||||
throw new WorkerSessionCoordinatorError('inactive');
|
||||
}
|
||||
const now = safeNow(this.nowProvider);
|
||||
const response = await this.exchange(() => this.client.transition({
|
||||
workerId: session.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
expectedVersion: session.version,
|
||||
status: 'offline',
|
||||
}));
|
||||
this.accept(response, now);
|
||||
});
|
||||
}
|
||||
|
||||
private accept(
|
||||
response: Readonly<{
|
||||
workerId: string;
|
||||
sessionId: string;
|
||||
generation: number;
|
||||
version: number;
|
||||
status: WorkerSessionStatus;
|
||||
leaseExpiresAtMs: number;
|
||||
}>,
|
||||
now: number,
|
||||
): WorkerSessionCoordinatorRecord {
|
||||
const previous = this.session;
|
||||
if (
|
||||
response.workerId !== this.workerId ||
|
||||
(previous !== undefined &&
|
||||
(response.sessionId !== previous.sessionId ||
|
||||
response.generation !== previous.generation ||
|
||||
response.version !== previous.version + 1)) ||
|
||||
response.leaseExpiresAtMs < now
|
||||
) throw new WorkerSessionCoordinatorError('response_invalid');
|
||||
const record = Object.freeze({
|
||||
workerId: response.workerId,
|
||||
sessionId: response.sessionId,
|
||||
generation: response.generation,
|
||||
version: response.version,
|
||||
status: executionStatus(response.status),
|
||||
leaseExpiresAtMs: response.leaseExpiresAtMs,
|
||||
nextHeartbeatAtMs: now + this.heartbeatIntervalMs,
|
||||
});
|
||||
this.session = record;
|
||||
return record;
|
||||
}
|
||||
|
||||
private requireLiveSession(): WorkerSessionCoordinatorRecord {
|
||||
const session = this.session;
|
||||
if (!session) throw new WorkerSessionCoordinatorError('inactive');
|
||||
if (session.leaseExpiresAtMs <= safeNow(this.nowProvider)) {
|
||||
throw new WorkerSessionCoordinatorError('lease_expired');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private async readAvailableSlots(): Promise<number> {
|
||||
const availableSlots = await this.availableSlotsProvider();
|
||||
try {
|
||||
assertWorkerConcurrency(this.maxConcurrentRuns, availableSlots);
|
||||
} catch (error) {
|
||||
throw new WorkerSessionCoordinatorError(
|
||||
'invalid_capacity', { cause: error },
|
||||
);
|
||||
}
|
||||
return availableSlots;
|
||||
}
|
||||
|
||||
private async exchange<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
const result = await operation();
|
||||
this.blocked = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof WorkerSessionHttpsClientError &&
|
||||
(error.reason === 'credential_rejected' ||
|
||||
error.reason === 'session_fenced')
|
||||
) this.blocked = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private serial<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const current = (this.operation ?? Promise.resolve())
|
||||
.catch(() => undefined)
|
||||
.then(operation);
|
||||
this.operation = current;
|
||||
return current.finally(() => {
|
||||
if (this.operation === current) this.operation = undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Session ownership: exchange bounded register, heartbeat, and transition messages.
|
||||
import {
|
||||
MAX_WORKER_SESSION_REGISTER_REQUEST_BYTES,
|
||||
MAX_WORKER_SESSION_REQUEST_BYTES,
|
||||
MAX_WORKER_SESSION_RESPONSE_BYTES,
|
||||
createWorkerSessionHeartbeatRequestBody,
|
||||
createWorkerSessionRegisterRequestBody,
|
||||
createWorkerSessionTransitionRequestBody,
|
||||
parseWorkerSessionHeartbeatResponseBody,
|
||||
parseWorkerSessionRegisterResponseBody,
|
||||
parseWorkerSessionTransitionResponseBody,
|
||||
type WorkerSessionHeartbeatResponseBody,
|
||||
type WorkerSessionRegisterResponseBody,
|
||||
type WorkerSessionTransitionResponseBody,
|
||||
} from '@qinglong/runtime-core/worker-session-transport';
|
||||
import type {
|
||||
HeartbeatWorkerSessionCommand,
|
||||
RegisterWorkerSessionCommand,
|
||||
TransitionWorkerSessionCommand,
|
||||
} from '@qinglong/runtime-core/worker-session';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
} from '../remote-execution/transport/workerIngressHttpsClient';
|
||||
|
||||
export interface WorkerSessionHttpsClientOptions {
|
||||
readonly client: WorkerIngressHttpsClient;
|
||||
}
|
||||
|
||||
export class WorkerSessionHttpsClientError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'request_invalid'
|
||||
| 'credential_rejected'
|
||||
| 'session_fenced'
|
||||
| 'transport_unavailable'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker Session HTTPS client failed: ${reason}`, options);
|
||||
this.name = 'WorkerSessionHttpsClientError';
|
||||
}
|
||||
}
|
||||
|
||||
function path(
|
||||
command: Readonly<{ workerId: string; sessionId: string }>,
|
||||
operation: 'register' | 'heartbeat' | 'transition',
|
||||
): string {
|
||||
return '/api/v3/worker-ingress/workers/' + command.workerId +
|
||||
'/sessions/' + command.sessionId + '/' + operation;
|
||||
}
|
||||
|
||||
function parseJson(bytes: Uint8Array): unknown {
|
||||
return JSON.parse(
|
||||
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8'),
|
||||
) as unknown;
|
||||
}
|
||||
|
||||
export class WorkerSessionHttpsClient {
|
||||
private readonly client: WorkerIngressHttpsClient;
|
||||
|
||||
constructor(options: WorkerSessionHttpsClientOptions) {
|
||||
if (!options || !(options.client instanceof WorkerIngressHttpsClient)) {
|
||||
throw new WorkerSessionHttpsClientError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async register(
|
||||
command: RegisterWorkerSessionCommand,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerSessionRegisterResponseBody> {
|
||||
let body;
|
||||
try {
|
||||
body = createWorkerSessionRegisterRequestBody(command);
|
||||
} catch (error) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'request_invalid', { cause: error },
|
||||
);
|
||||
}
|
||||
const response = await this.exchange(
|
||||
path(command, 'register'),
|
||||
body,
|
||||
MAX_WORKER_SESSION_REGISTER_REQUEST_BYTES,
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
const result = parseWorkerSessionRegisterResponseBody(parseJson(response));
|
||||
if (
|
||||
result.workerId !== command.workerId ||
|
||||
result.sessionId !== command.sessionId ||
|
||||
result.status !== 'online'
|
||||
) throw new TypeError('register response authority mismatch');
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'response_invalid', { cause: error },
|
||||
);
|
||||
} finally {
|
||||
Buffer.from(response.buffer, response.byteOffset, response.byteLength)
|
||||
.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async heartbeat(
|
||||
command: HeartbeatWorkerSessionCommand,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerSessionHeartbeatResponseBody> {
|
||||
let body;
|
||||
try {
|
||||
body = createWorkerSessionHeartbeatRequestBody(command);
|
||||
} catch (error) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'request_invalid', { cause: error },
|
||||
);
|
||||
}
|
||||
const response = await this.exchange(
|
||||
path(command, 'heartbeat'),
|
||||
body,
|
||||
MAX_WORKER_SESSION_REQUEST_BYTES,
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
const result = parseWorkerSessionHeartbeatResponseBody(parseJson(response));
|
||||
if (
|
||||
result.workerId !== command.workerId ||
|
||||
result.sessionId !== command.sessionId ||
|
||||
result.generation !== command.generation ||
|
||||
result.version !== command.expectedVersion + 1 ||
|
||||
result.status === 'offline'
|
||||
) throw new TypeError('heartbeat response authority mismatch');
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'response_invalid', { cause: error },
|
||||
);
|
||||
} finally {
|
||||
Buffer.from(response.buffer, response.byteOffset, response.byteLength)
|
||||
.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async transition(
|
||||
command: TransitionWorkerSessionCommand,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerSessionTransitionResponseBody> {
|
||||
let body;
|
||||
try {
|
||||
body = createWorkerSessionTransitionRequestBody(command);
|
||||
} catch (error) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'request_invalid', { cause: error },
|
||||
);
|
||||
}
|
||||
const response = await this.exchange(
|
||||
path(command, 'transition'),
|
||||
body,
|
||||
MAX_WORKER_SESSION_REQUEST_BYTES,
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
const result = parseWorkerSessionTransitionResponseBody(parseJson(response));
|
||||
if (
|
||||
result.workerId !== command.workerId ||
|
||||
result.sessionId !== command.sessionId ||
|
||||
result.generation !== command.generation ||
|
||||
result.version !== command.expectedVersion + 1 ||
|
||||
result.status !== command.status
|
||||
) throw new TypeError('transition response authority mismatch');
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'response_invalid', { cause: error },
|
||||
);
|
||||
} finally {
|
||||
Buffer.from(response.buffer, response.byteOffset, response.byteLength)
|
||||
.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private async exchange(
|
||||
requestPath: string,
|
||||
body: unknown,
|
||||
maximumRequestBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
return await this.client.postJson({
|
||||
path: requestPath,
|
||||
body,
|
||||
maximumRequestBytes,
|
||||
maximumResponseBytes: MAX_WORKER_SESSION_RESPONSE_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
if (error.httpStatus === 401 || error.httpStatus === 403) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'credential_rejected', { cause: error },
|
||||
);
|
||||
}
|
||||
if (error.httpStatus === 409) {
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'session_fenced', { cause: error },
|
||||
);
|
||||
}
|
||||
throw new WorkerSessionHttpsClientError(
|
||||
'transport_unavailable', { cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, X509Certificate } = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
generateWorkerCertificateEnrollment,
|
||||
} = require('../dist/credential/workerCertificateEnrollment');
|
||||
const {
|
||||
validateWorkerCertificateIdentity,
|
||||
} = require('../dist/credential/workerCertificateIdentity');
|
||||
const {
|
||||
createCertificateAuthority,
|
||||
} = require('./helpers/certificateAuthority.cjs');
|
||||
|
||||
const HOUR_MS = 60 * 60_000;
|
||||
|
||||
async function identityFixture(options = {}) {
|
||||
const now = options.now ?? Date.now();
|
||||
const ca = await createCertificateAuthority({ now });
|
||||
const enrollment = await generateWorkerCertificateEnrollment({
|
||||
workerId: 'worker-identity-01',
|
||||
});
|
||||
const certificateChainPem = await ca.issue(
|
||||
enrollment.certificateSigningRequestPem,
|
||||
options.issue,
|
||||
);
|
||||
return { now, ca, enrollment, certificateChainPem };
|
||||
}
|
||||
|
||||
test('validates the leaf identity independently of PEM chain formatting', async () => {
|
||||
const fixture = await identityFixture();
|
||||
try {
|
||||
const summary = validateWorkerCertificateIdentity({
|
||||
privateKeyPem: fixture.enrollment.privateKeyPem,
|
||||
certificateChainPem: `${fixture.certificateChainPem}\n`,
|
||||
trustAnchors: [fixture.ca.certificatePem],
|
||||
now: fixture.now,
|
||||
minimumRemainingValidityMs: HOUR_MS,
|
||||
});
|
||||
const leaf = new X509Certificate(fixture.certificateChainPem);
|
||||
|
||||
assert.equal(
|
||||
summary.certificateSha256,
|
||||
createHash('sha256').update(leaf.raw).digest('hex'),
|
||||
);
|
||||
assert.equal(
|
||||
summary.publicKeySpkiSha256,
|
||||
fixture.enrollment.publicKeySpkiSha256,
|
||||
);
|
||||
} finally {
|
||||
fixture.enrollment.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for an untrusted issuer', async () => {
|
||||
const fixture = await identityFixture();
|
||||
const otherCa = await createCertificateAuthority({ now: fixture.now });
|
||||
try {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateWorkerCertificateIdentity({
|
||||
privateKeyPem: fixture.enrollment.privateKeyPem,
|
||||
certificateChainPem: fixture.certificateChainPem,
|
||||
trustAnchors: [otherCa.certificatePem],
|
||||
now: fixture.now,
|
||||
}),
|
||||
(error) => error.reason === 'untrusted',
|
||||
);
|
||||
} finally {
|
||||
fixture.enrollment.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects expired, short-lived and non-client-auth leaves', async () => {
|
||||
const now = Date.now();
|
||||
const expired = await identityFixture({
|
||||
now,
|
||||
issue: { notBeforeMs: now - 2 * HOUR_MS, notAfterMs: now - HOUR_MS },
|
||||
});
|
||||
const shortLived = await identityFixture({
|
||||
now,
|
||||
issue: { notAfterMs: now + 2 * HOUR_MS },
|
||||
});
|
||||
const wrongUsage = await identityFixture({
|
||||
now,
|
||||
issue: { clientAuth: false },
|
||||
});
|
||||
try {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateWorkerCertificateIdentity({
|
||||
privateKeyPem: expired.enrollment.privateKeyPem,
|
||||
certificateChainPem: expired.certificateChainPem,
|
||||
trustAnchors: [expired.ca.certificatePem],
|
||||
now,
|
||||
}),
|
||||
(error) => error.reason === 'expired',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateWorkerCertificateIdentity({
|
||||
privateKeyPem: shortLived.enrollment.privateKeyPem,
|
||||
certificateChainPem: shortLived.certificateChainPem,
|
||||
trustAnchors: [shortLived.ca.certificatePem],
|
||||
now,
|
||||
minimumRemainingValidityMs: 3 * HOUR_MS,
|
||||
}),
|
||||
(error) => error.reason === 'insufficient_validity',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateWorkerCertificateIdentity({
|
||||
privateKeyPem: wrongUsage.enrollment.privateKeyPem,
|
||||
certificateChainPem: wrongUsage.certificateChainPem,
|
||||
trustAnchors: [wrongUsage.ca.certificatePem],
|
||||
now,
|
||||
}),
|
||||
(error) => error.reason === 'not_client_auth',
|
||||
);
|
||||
} finally {
|
||||
expired.enrollment.dispose();
|
||||
shortLived.enrollment.dispose();
|
||||
wrongUsage.enrollment.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
access,
|
||||
chmod,
|
||||
lstat,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
rm,
|
||||
} = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
generateWorkerCertificateEnrollment,
|
||||
} = require('../dist/credential/workerCertificateEnrollment');
|
||||
const {
|
||||
WorkerCertificateFileStore,
|
||||
} = require('../dist/credential/workerCertificateStore');
|
||||
const {
|
||||
createCertificateAuthority,
|
||||
} = require('./helpers/certificateAuthority.cjs');
|
||||
|
||||
async function temporaryStore(t, retainedGenerations = 2) {
|
||||
const parent = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-store-'));
|
||||
t.after(() => rm(parent, { recursive: true, force: true }));
|
||||
return new WorkerCertificateFileStore({
|
||||
rootDirectory: path.join(parent, 'identity'),
|
||||
retainedGenerations,
|
||||
});
|
||||
}
|
||||
|
||||
async function issueIdentity(ca, workerId, now) {
|
||||
const enrollment = await generateWorkerCertificateEnrollment({ workerId });
|
||||
try {
|
||||
return {
|
||||
privateKeyPem: Buffer.from(enrollment.privateKeyPem),
|
||||
certificateChainPem: await ca.issue(
|
||||
enrollment.certificateSigningRequestPem,
|
||||
),
|
||||
trustAnchors: [ca.certificatePem],
|
||||
now,
|
||||
};
|
||||
} finally {
|
||||
enrollment.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
test('atomically installs and revalidates a private Worker identity', async (t) => {
|
||||
const now = Date.now();
|
||||
const ca = await createCertificateAuthority({ now });
|
||||
const store = await temporaryStore(t);
|
||||
const input = await issueIdentity(ca, 'worker-01', now);
|
||||
|
||||
try {
|
||||
const installed = await store.install(input);
|
||||
const active = await store.readActive([ca.certificatePem], now);
|
||||
|
||||
assert.equal(active.certificateSha256, installed.certificateSha256);
|
||||
assert.equal(active.publicKeySpkiSha256, installed.publicKeySpkiSha256);
|
||||
assert.equal((await lstat(active.privateKeyFile)).mode & 0o777, 0o600);
|
||||
assert.equal(
|
||||
(await lstat(path.dirname(active.privateKeyFile))).mode & 0o777,
|
||||
0o700,
|
||||
);
|
||||
} finally {
|
||||
input.privateKeyPem.fill(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('retains only the configured number of complete generations', async (t) => {
|
||||
const now = Date.now();
|
||||
const ca = await createCertificateAuthority({ now });
|
||||
const store = await temporaryStore(t, 1);
|
||||
const first = await issueIdentity(ca, 'worker-02', now);
|
||||
const second = await issueIdentity(ca, 'worker-02', now + 1_000);
|
||||
|
||||
try {
|
||||
const firstInstalled = await store.install(first);
|
||||
const secondInstalled = await store.install(second);
|
||||
const generations = await readdir(
|
||||
path.join(path.dirname(secondInstalled.privateKeyFile), '..'),
|
||||
);
|
||||
|
||||
assert.deepEqual(generations, [secondInstalled.generationId]);
|
||||
await assert.rejects(access(path.dirname(firstInstalled.privateKeyFile)));
|
||||
} finally {
|
||||
first.privateKeyPem.fill(0);
|
||||
second.privateKeyPem.fill(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a certificate that does not match its private key', async (t) => {
|
||||
const now = Date.now();
|
||||
const ca = await createCertificateAuthority({ now });
|
||||
const store = await temporaryStore(t);
|
||||
const left = await issueIdentity(ca, 'worker-left', now);
|
||||
const right = await issueIdentity(ca, 'worker-right', now);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
store.install({
|
||||
...left,
|
||||
privateKeyPem: right.privateKeyPem,
|
||||
}),
|
||||
/install failed/,
|
||||
);
|
||||
assert.equal(await store.readActiveSummary(), undefined);
|
||||
} finally {
|
||||
left.privateKeyPem.fill(0);
|
||||
right.privateKeyPem.fill(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects active identity files whose private permissions drift', async (t) => {
|
||||
const now = Date.now();
|
||||
const ca = await createCertificateAuthority({ now });
|
||||
const store = await temporaryStore(t);
|
||||
const input = await issueIdentity(ca, 'worker-permissions', now);
|
||||
|
||||
try {
|
||||
const installed = await store.install(input);
|
||||
await chmod(installed.certificateChainFile, 0o644);
|
||||
await assert.rejects(
|
||||
store.readActive([ca.certificatePem], now),
|
||||
/file metadata is unsafe/,
|
||||
);
|
||||
} finally {
|
||||
input.privateKeyPem.fill(0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createPublicKey } = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
|
||||
require('reflect-metadata');
|
||||
const { Pkcs10CertificateRequest } = require('@peculiar/x509');
|
||||
const {
|
||||
generateWorkerCertificateEnrollment,
|
||||
} = require('../dist/credential/workerCertificateEnrollment');
|
||||
|
||||
test('generates a verifiable P-256 CSR and disposable PKCS#8 key', async () => {
|
||||
const enrollment = await generateWorkerCertificateEnrollment({
|
||||
workerId: 'worker.edge-01',
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(enrollment.algorithm, 'ECDSA_P256_SHA256');
|
||||
assert.equal(enrollment.workerId, 'worker.edge-01');
|
||||
assert.match(
|
||||
enrollment.certificateSigningRequestPem,
|
||||
/BEGIN CERTIFICATE REQUEST/,
|
||||
);
|
||||
assert.equal(enrollment.publicKeySpkiSha256.length, 64);
|
||||
assert.equal(
|
||||
createPublicKey(enrollment.privateKeyPem).asymmetricKeyType,
|
||||
'ec',
|
||||
);
|
||||
|
||||
const request = new Pkcs10CertificateRequest(
|
||||
enrollment.certificateSigningRequestPem,
|
||||
);
|
||||
assert.equal(await request.verify(), true);
|
||||
assert.equal(request.subject, 'CN=worker.edge-01');
|
||||
} finally {
|
||||
enrollment.dispose();
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
enrollment.privateKeyPem.every((byte) => byte === 0),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unbounded or unsafe worker identifiers', async () => {
|
||||
await assert.rejects(
|
||||
generateWorkerCertificateEnrollment({ workerId: '../worker' }),
|
||||
/workerId is invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
generateWorkerCertificateEnrollment({ workerId: 'x'.repeat(129) }),
|
||||
/workerId is invalid/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
test('main entrypoint leaves enrollment PKI out of steady-state memory', () => {
|
||||
const before = new Set(Object.keys(require.cache));
|
||||
const runtime = require('../dist');
|
||||
const loaded = Object.keys(require.cache).filter((file) => !before.has(file));
|
||||
|
||||
assert.equal(typeof runtime.WorkerCertificateFileStore, 'function');
|
||||
assert.equal(typeof runtime.WorkerCertificateRenewalCoordinator, 'function');
|
||||
assert.equal(
|
||||
loaded.some(
|
||||
(file) =>
|
||||
file.includes('/@peculiar/x509/') || file.includes('/@peculiar+x509@'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
loaded.some((file) => file.includes('/ql3-runtime-core/')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('offer delivery subpath avoids runtime root and cluster/database modules', () => {
|
||||
const before = new Set(Object.keys(require.cache));
|
||||
const delivery = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
const loaded = Object.keys(require.cache).filter((file) => !before.has(file));
|
||||
|
||||
assert.equal(typeof delivery.WorkerRemoteOfferPullCoordinator, 'function');
|
||||
assert.equal(typeof delivery.WorkerRemoteOfferHttpsTransport, 'function');
|
||||
assert.equal(typeof delivery.WorkerRemoteSecretHttpsProvider, 'function');
|
||||
assert.equal(
|
||||
loaded.some((file) => /ql3-runtime-core\/dist\/index\.js$/.test(file)),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
loaded.some(
|
||||
(file) =>
|
||||
file.includes('/ql3-cluster-') ||
|
||||
file.includes('/pg/') ||
|
||||
file.includes('/drizzle-orm/') ||
|
||||
file.includes('/croner/') ||
|
||||
file.includes('/semver/'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/cluster-execution-revision');
|
||||
const {
|
||||
createClusterRemoteExecutionOffer,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('@qinglong/runtime-core/run-dispatch-lease');
|
||||
const {
|
||||
createSecretRef,
|
||||
} = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
BoundedWorkerRemoteExecutionContextMaterializer,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
|
||||
function secret(name) {
|
||||
return createSecretRef({ projectId: 'project-1', name });
|
||||
}
|
||||
|
||||
function offer(environment) {
|
||||
const executionRevision = 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: '/bin/true', args: [] },
|
||||
environment,
|
||||
createdAtMs: 1,
|
||||
});
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: 'offer-materializer-1',
|
||||
deliveryKind: 'new_claim',
|
||||
executionDigest: executionRevision.contentDigest,
|
||||
candidate: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
priority: 1,
|
||||
queuedAtMs: 10,
|
||||
attemptCreatedAtMs: 11,
|
||||
attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
},
|
||||
worker: { workerId: 'edge-1', sessionId: SESSION_ID, generation: 2 },
|
||||
lease: {
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'leased',
|
||||
version: 0,
|
||||
leaseGeneration: 1,
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
leaseTokenDigest: digestRunDispatchLeaseToken(LEASE_TOKEN),
|
||||
acquiredAtMs: 20,
|
||||
renewedAtMs: 20,
|
||||
expiresAtMs: 30_020,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
leaseToken: LEASE_TOKEN,
|
||||
executionRevision,
|
||||
placementScore: 0,
|
||||
});
|
||||
}
|
||||
|
||||
test('resolves deduplicated Secrets before allocating one Attempt log', async () => {
|
||||
const secretRef = secret('shared');
|
||||
const acceptedOffer = offer([
|
||||
{ name: 'PUBLIC', kind: 'public', value: 'visible' },
|
||||
{ name: 'SECRET_A', kind: 'secret', secretRef },
|
||||
{ name: 'SECRET_B', kind: 'secret', secretRef },
|
||||
]);
|
||||
const events = [];
|
||||
let secretRequest;
|
||||
let artifactRequest;
|
||||
const output = {
|
||||
logArtifactId: 'remote-log-1',
|
||||
async write() {},
|
||||
async close() {},
|
||||
};
|
||||
const materializer = new BoundedWorkerRemoteExecutionContextMaterializer({
|
||||
secrets: {
|
||||
async resolve(request) {
|
||||
events.push('secrets');
|
||||
secretRequest = request;
|
||||
return {
|
||||
values: [{ secretRef, value: 'resolved-value' }],
|
||||
dispose() { events.push('dispose-secrets'); },
|
||||
};
|
||||
},
|
||||
},
|
||||
artifacts: {
|
||||
async prepare(request) {
|
||||
events.push('artifact');
|
||||
artifactRequest = request;
|
||||
return {
|
||||
logArtifactId: 'remote-log-1',
|
||||
takeOutput() { events.push('take-output'); return output; },
|
||||
release() { events.push('release-artifact'); },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const context = await materializer.prepare({
|
||||
offer: acceptedOffer,
|
||||
completionCallback: { sequence: 1, token: Buffer.alloc(32) },
|
||||
});
|
||||
assert.deepEqual(secretRequest, {
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-materializer-1',
|
||||
executionDigest: acceptedOffer.executionDigest,
|
||||
secretRefs: [secretRef],
|
||||
});
|
||||
assert.deepEqual(artifactRequest, {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-materializer-1',
|
||||
});
|
||||
assert.equal(JSON.stringify([secretRequest, artifactRequest]).includes(LEASE_TOKEN), false);
|
||||
assert.deepEqual(context.environment, [
|
||||
{ name: 'PUBLIC', value: 'visible' },
|
||||
{ name: 'SECRET_A', value: 'resolved-value' },
|
||||
{ name: 'SECRET_B', value: 'resolved-value' },
|
||||
]);
|
||||
assert.equal(context.logArtifactId, 'remote-log-1');
|
||||
assert.deepEqual(events, ['secrets', 'artifact']);
|
||||
assert.equal(context.takeOutput(), output);
|
||||
assert.throws(() => context.takeOutput(), /artifact_response_invalid/);
|
||||
await context.dispose();
|
||||
await context.dispose();
|
||||
assert.deepEqual(events.slice(2).sort(), [
|
||||
'dispose-secrets', 'release-artifact', 'take-output',
|
||||
]);
|
||||
});
|
||||
|
||||
test('fails before Artifact allocation when Secret authority is unavailable', async () => {
|
||||
let artifacts = 0;
|
||||
const materializer = new BoundedWorkerRemoteExecutionContextMaterializer({
|
||||
artifacts: { async prepare() { artifacts += 1; } },
|
||||
});
|
||||
await assert.rejects(
|
||||
materializer.prepare({
|
||||
offer: offer([{ name: 'SECRET', kind: 'secret', secretRef: secret('one') }]),
|
||||
}),
|
||||
/secret_unavailable/,
|
||||
);
|
||||
assert.equal(artifacts, 0);
|
||||
});
|
||||
|
||||
test('disposes malformed Secret and Artifact responses without exposing values', async () => {
|
||||
let disposedSecrets = 0;
|
||||
let releasedArtifact = 0;
|
||||
const secretRef = secret('one');
|
||||
const malformedSecrets = new BoundedWorkerRemoteExecutionContextMaterializer({
|
||||
secrets: {
|
||||
async resolve() {
|
||||
return {
|
||||
values: [
|
||||
{ secretRef, value: 'first' },
|
||||
{ secretRef, value: 'duplicate' },
|
||||
],
|
||||
dispose() { disposedSecrets += 1; },
|
||||
};
|
||||
},
|
||||
},
|
||||
artifacts: { async prepare() { throw new Error('must not allocate'); } },
|
||||
});
|
||||
await assert.rejects(
|
||||
malformedSecrets.prepare({
|
||||
offer: offer([
|
||||
{ name: 'A', kind: 'secret', secretRef },
|
||||
{ name: 'B', kind: 'secret', secretRef: secret('two') },
|
||||
]),
|
||||
}),
|
||||
/secret_response_invalid/,
|
||||
);
|
||||
assert.equal(disposedSecrets, 1);
|
||||
|
||||
const malformedArtifact = new BoundedWorkerRemoteExecutionContextMaterializer({
|
||||
artifacts: {
|
||||
async prepare() {
|
||||
return {
|
||||
logArtifactId: 'x'.repeat(37),
|
||||
release() { releasedArtifact += 1; },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
malformedArtifact.prepare({
|
||||
offer: offer([{ name: 'PUBLIC', kind: 'public', value: 'visible' }]),
|
||||
}),
|
||||
/artifact_response_invalid/,
|
||||
);
|
||||
assert.equal(releasedArtifact, 1);
|
||||
});
|
||||
|
||||
test('enforces the resolved environment byte budget before Artifact allocation', async () => {
|
||||
const bindings = Array.from({ length: 5 }, (_, index) => ({
|
||||
name: `SECRET_${index}`,
|
||||
kind: 'secret',
|
||||
secretRef: secret(`item-${index}`),
|
||||
}));
|
||||
let disposed = 0;
|
||||
let artifacts = 0;
|
||||
const materializer = new BoundedWorkerRemoteExecutionContextMaterializer({
|
||||
secrets: {
|
||||
async resolve(request) {
|
||||
return {
|
||||
values: request.secretRefs.map((secretRef) => ({
|
||||
secretRef,
|
||||
value: 'x'.repeat(16 * 1024),
|
||||
})),
|
||||
dispose() { disposed += 1; },
|
||||
};
|
||||
},
|
||||
},
|
||||
artifacts: { async prepare() { artifacts += 1; } },
|
||||
});
|
||||
await assert.rejects(
|
||||
materializer.prepare({ offer: offer(bindings) }),
|
||||
/environment_budget_exceeded/,
|
||||
);
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(artifacts, 0);
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/cluster-execution-revision');
|
||||
const {
|
||||
createClusterRemoteExecutionOffer,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('@qinglong/runtime-core/run-dispatch-lease');
|
||||
const {
|
||||
createSecretRef,
|
||||
} = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
WorkerRemoteExecutionInboxProcessor,
|
||||
assertWorkerRemoteExecutionInboxTransition,
|
||||
createWorkerRemoteExecutionInboxRecord,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
const {
|
||||
WorkerInboxExecutionSpawnBarrier,
|
||||
} = require('../dist/execution/workerPosixExecutionExecutor');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
|
||||
function offer(timeoutMs) {
|
||||
const executionRevision = 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: '/bin/true', args: [] },
|
||||
environment: [
|
||||
{ name: 'PUBLIC_VALUE', kind: 'public', value: 'visible' },
|
||||
{
|
||||
name: 'SECRET_VALUE',
|
||||
kind: 'secret',
|
||||
secretRef: createSecretRef({ projectId: 'project-1', name: 'item-1' }),
|
||||
},
|
||||
],
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
createdAtMs: 1,
|
||||
});
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: 'offer-processor-1',
|
||||
deliveryKind: 'new_claim',
|
||||
executionDigest: executionRevision.contentDigest,
|
||||
candidate: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
priority: 1,
|
||||
queuedAtMs: 10,
|
||||
attemptCreatedAtMs: 11,
|
||||
attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
},
|
||||
worker: {
|
||||
workerId: 'edge-1',
|
||||
sessionId: SESSION_ID,
|
||||
generation: 2,
|
||||
},
|
||||
lease: {
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'leased',
|
||||
version: 0,
|
||||
leaseGeneration: 1,
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
leaseTokenDigest: digestRunDispatchLeaseToken(LEASE_TOKEN),
|
||||
acquiredAtMs: 20,
|
||||
renewedAtMs: 20,
|
||||
expiresAtMs: 30_020,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
leaseToken: LEASE_TOKEN,
|
||||
executionRevision,
|
||||
placementScore: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function inboxFixture(initial = createWorkerRemoteExecutionInboxRecord(offer(), 100)) {
|
||||
let record = initial;
|
||||
const states = [];
|
||||
return {
|
||||
inbox: {
|
||||
async readOffer(offerId) {
|
||||
return record?.offer.offerId === offerId ? record : undefined;
|
||||
},
|
||||
async replaceOffer(next, expectedRevision) {
|
||||
assert.equal(record.revision, expectedRevision);
|
||||
assertWorkerRemoteExecutionInboxTransition(record, next);
|
||||
record = next;
|
||||
states.push(next.state);
|
||||
},
|
||||
async listOffers() { return { records: record ? [record] : [] }; },
|
||||
},
|
||||
states,
|
||||
record: () => record,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
runStatus: 'dispatching',
|
||||
attemptStatus: 'starting',
|
||||
leaseVersion: 0,
|
||||
leaseGeneration: 1,
|
||||
callbackSequence: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function outputSink(logArtifactId = 'log-1', onClose = () => undefined) {
|
||||
return {
|
||||
logArtifactId,
|
||||
async write() {},
|
||||
async close() { onClose(); },
|
||||
};
|
||||
}
|
||||
|
||||
function options(fixture, overrides = {}) {
|
||||
let event = 0;
|
||||
const activation = overrides.activation ?? {
|
||||
async acknowledgeStarting() {
|
||||
return { status: 'already_starting', snapshot: snapshot() };
|
||||
},
|
||||
async acknowledgeRunning(command) {
|
||||
return {
|
||||
status: 'applied',
|
||||
snapshot: snapshot({
|
||||
runStatus: 'running',
|
||||
attemptStatus: 'running',
|
||||
callbackSequence: command.callbackSequence,
|
||||
executorHandle: command.executorHandle,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async failStart() {
|
||||
return {
|
||||
status: 'applied',
|
||||
snapshot: snapshot({
|
||||
runStatus: 'failed',
|
||||
attemptStatus: 'failed',
|
||||
leaseVersion: 1,
|
||||
callbackSequence: 1,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
inbox: fixture.inbox,
|
||||
activation,
|
||||
currentSession: () => ({
|
||||
workerId: 'edge-1',
|
||||
sessionId: SESSION_ID,
|
||||
generation: 2,
|
||||
status: 'available',
|
||||
leaseExpiresAtMs: 30_000,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
randomCapability: () => Buffer.alloc(32, 7),
|
||||
eventId: () => `event-${++event}`,
|
||||
materializer: overrides.materializer ?? {
|
||||
async prepare() {
|
||||
const output = outputSink();
|
||||
let taken = false;
|
||||
return {
|
||||
environment: [
|
||||
{ name: 'SECRET_VALUE', value: 'resolved-secret' },
|
||||
{ name: 'PUBLIC_VALUE', value: 'visible' },
|
||||
],
|
||||
logArtifactId: 'log-1',
|
||||
takeOutput() {
|
||||
assert.equal(taken, false);
|
||||
taken = true;
|
||||
return output;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
executor: overrides.executor ?? {
|
||||
async start(launch) {
|
||||
return {
|
||||
status: 'started', executorHandle: 'process-1',
|
||||
executorStartedAtMs: launch.executorStartedAtMs,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('persists every ACK and spawn barrier in the one delivery inbox', async () => {
|
||||
const fixture = inboxFixture();
|
||||
let startingCalls = 0;
|
||||
let launchedToken;
|
||||
let runningCommand;
|
||||
const activation = {
|
||||
async acknowledgeStarting() {
|
||||
startingCalls += 1;
|
||||
return {
|
||||
status: startingCalls === 1 ? 'applied' : 'already_starting',
|
||||
snapshot: snapshot(),
|
||||
};
|
||||
},
|
||||
async acknowledgeRunning(command) {
|
||||
runningCommand = command;
|
||||
assert.equal(fixture.record().state, 'started');
|
||||
return {
|
||||
status: 'applied',
|
||||
snapshot: snapshot({
|
||||
runStatus: 'running',
|
||||
attemptStatus: 'running',
|
||||
callbackSequence: command.callbackSequence,
|
||||
executorHandle: command.executorHandle,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async failStart() { throw new Error('must not fail'); },
|
||||
};
|
||||
const processor = new WorkerRemoteExecutionInboxProcessor(options(fixture, {
|
||||
activation,
|
||||
executor: {
|
||||
async start(launch) {
|
||||
assert.equal(fixture.record().state, 'launching');
|
||||
assert.deepEqual(
|
||||
launch.environment.map((entry) => entry.name),
|
||||
['PUBLIC_VALUE', 'SECRET_VALUE'],
|
||||
);
|
||||
assert.equal(launch.logArtifactId, 'log-1');
|
||||
assert.equal(launch.output.logArtifactId, launch.logArtifactId);
|
||||
launchedToken = launch.completionCallback.token;
|
||||
return {
|
||||
status: 'started', executorHandle: 'process-1',
|
||||
executorStartedAtMs: launch.executorStartedAtMs,
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
const result = await processor.process('offer-processor-1');
|
||||
assert.equal(result.status, 'running');
|
||||
assert.deepEqual(fixture.states, [
|
||||
'starting_acknowledged',
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
]);
|
||||
assert.equal(startingCalls, 2);
|
||||
assert.equal(runningCommand.callbackSequence, 1);
|
||||
assert.match(runningCommand.callbackTokenDigest, /^[a-f0-9]{64}$/);
|
||||
assert.ok([...launchedToken].every((value) => value === 0));
|
||||
});
|
||||
|
||||
test('passes timeout to the Executor only with durable starting deadline authority', async () => {
|
||||
const fixture = inboxFixture(
|
||||
createWorkerRemoteExecutionInboxRecord(offer(5_000), 100),
|
||||
);
|
||||
let launch;
|
||||
const base = options(fixture, {
|
||||
activation: {
|
||||
async acknowledgeStarting() {
|
||||
return {
|
||||
status: 'already_starting',
|
||||
snapshot: snapshot({ deadlineAtMs: 6_000 }),
|
||||
};
|
||||
},
|
||||
async acknowledgeRunning(command) {
|
||||
return {
|
||||
status: 'applied',
|
||||
snapshot: snapshot({
|
||||
runStatus: 'running', attemptStatus: 'running',
|
||||
callbackSequence: command.callbackSequence,
|
||||
executorHandle: command.executorHandle,
|
||||
deadlineAtMs: 6_000,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async failStart() { throw new Error('must not fail'); },
|
||||
},
|
||||
executor: {
|
||||
async start(value) {
|
||||
launch = value;
|
||||
return {
|
||||
status: 'started', executorHandle: 'process-1',
|
||||
executorStartedAtMs: value.executorStartedAtMs,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await new WorkerRemoteExecutionInboxProcessor(base)
|
||||
.process('offer-processor-1');
|
||||
assert.equal(result.status, 'running');
|
||||
assert.equal(launch.timeoutMs, 5_000);
|
||||
assert.equal(launch.executionDeadlineAtMs, 6_000);
|
||||
});
|
||||
|
||||
test('fails closed before spawn when timeout revision lacks durable deadline authority', async () => {
|
||||
const fixture = inboxFixture(
|
||||
createWorkerRemoteExecutionInboxRecord(offer(5_000), 100),
|
||||
);
|
||||
let starts = 0;
|
||||
const base = options(fixture, {
|
||||
executor: { async start() { starts += 1; return { status: 'rejected' }; } },
|
||||
});
|
||||
await assert.rejects(
|
||||
new WorkerRemoteExecutionInboxProcessor(base).process('offer-processor-1'),
|
||||
/activation_response_invalid/,
|
||||
);
|
||||
assert.equal(starts, 0);
|
||||
});
|
||||
|
||||
test('treats an ambiguous executor error as recovery, never start failure', async () => {
|
||||
const fixture = inboxFixture();
|
||||
let failCalls = 0;
|
||||
let closes = 0;
|
||||
const base = options(fixture, {
|
||||
materializer: {
|
||||
async prepare() {
|
||||
return {
|
||||
environment: [
|
||||
{ name: 'SECRET_VALUE', value: 'resolved-secret' },
|
||||
{ name: 'PUBLIC_VALUE', value: 'visible' },
|
||||
],
|
||||
logArtifactId: 'log-1',
|
||||
takeOutput: () => outputSink('log-1', () => { closes += 1; }),
|
||||
};
|
||||
},
|
||||
},
|
||||
executor: { async start() { throw new Error('response lost after spawn'); } },
|
||||
});
|
||||
base.activation.failStart = async () => {
|
||||
failCalls += 1;
|
||||
throw new Error('must not be called');
|
||||
};
|
||||
const result = await new WorkerRemoteExecutionInboxProcessor(base)
|
||||
.process('offer-processor-1');
|
||||
assert.equal(result.status, 'recovery_required');
|
||||
assert.equal(result.recoveryReason, 'launch_outcome_unknown');
|
||||
assert.equal(failCalls, 0);
|
||||
assert.equal(closes, 0);
|
||||
assert.equal(fixture.record().state, 'recovery_required');
|
||||
});
|
||||
|
||||
test('reports only an explicit no-spawn rejection as start failure', async () => {
|
||||
const fixture = inboxFixture();
|
||||
let failureCommand;
|
||||
let closes = 0;
|
||||
const base = options(fixture, {
|
||||
materializer: {
|
||||
async prepare() {
|
||||
return {
|
||||
environment: [
|
||||
{ name: 'SECRET_VALUE', value: 'resolved-secret' },
|
||||
{ name: 'PUBLIC_VALUE', value: 'visible' },
|
||||
],
|
||||
logArtifactId: 'log-1',
|
||||
takeOutput: () => outputSink('log-1', () => { closes += 1; }),
|
||||
};
|
||||
},
|
||||
},
|
||||
executor: { async start() { return { status: 'rejected' }; } },
|
||||
});
|
||||
base.activation.failStart = async (command) => {
|
||||
failureCommand = command;
|
||||
return {
|
||||
status: 'applied',
|
||||
snapshot: snapshot({
|
||||
runStatus: 'failed',
|
||||
attemptStatus: 'failed',
|
||||
leaseVersion: 1,
|
||||
callbackSequence: 1,
|
||||
}),
|
||||
};
|
||||
};
|
||||
const result = await new WorkerRemoteExecutionInboxProcessor(base)
|
||||
.process('offer-processor-1');
|
||||
assert.equal(result.status, 'start_failed');
|
||||
assert.equal(failureCommand.offerId, 'offer-processor-1');
|
||||
assert.equal(closes, 1);
|
||||
assert.equal(fixture.record().state, 'start_failure_acknowledged');
|
||||
});
|
||||
|
||||
test('takes output only after the durable launching barrier', async () => {
|
||||
const fixture = inboxFixture();
|
||||
let starts = 0;
|
||||
const base = options(fixture, {
|
||||
materializer: {
|
||||
async prepare() {
|
||||
return {
|
||||
environment: [
|
||||
{ name: 'SECRET_VALUE', value: 'resolved-secret' },
|
||||
{ name: 'PUBLIC_VALUE', value: 'visible' },
|
||||
],
|
||||
logArtifactId: 'log-1',
|
||||
takeOutput() {
|
||||
assert.equal(fixture.record().state, 'launching');
|
||||
return outputSink();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
executor: {
|
||||
async start(launch) {
|
||||
starts += 1;
|
||||
return {
|
||||
status: 'started', executorHandle: 'process-1',
|
||||
executorStartedAtMs: launch.executorStartedAtMs,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await new WorkerRemoteExecutionInboxProcessor(base)
|
||||
.process('offer-processor-1');
|
||||
assert.equal(result.status, 'running');
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
|
||||
test('fails without spawning when the handed-off output identity drifts', async () => {
|
||||
const fixture = inboxFixture();
|
||||
let starts = 0;
|
||||
let closes = 0;
|
||||
const base = options(fixture, {
|
||||
materializer: {
|
||||
async prepare() {
|
||||
return {
|
||||
environment: [
|
||||
{ name: 'SECRET_VALUE', value: 'resolved-secret' },
|
||||
{ name: 'PUBLIC_VALUE', value: 'visible' },
|
||||
],
|
||||
logArtifactId: 'log-1',
|
||||
takeOutput: () => outputSink('different-log', () => { closes += 1; }),
|
||||
};
|
||||
},
|
||||
},
|
||||
executor: {
|
||||
async start() {
|
||||
starts += 1;
|
||||
return {
|
||||
status: 'started', executorHandle: 'process-1', executorStartedAtMs: 900,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await new WorkerRemoteExecutionInboxProcessor(base)
|
||||
.process('offer-processor-1');
|
||||
assert.equal(result.status, 'start_failed');
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(closes, 1);
|
||||
});
|
||||
|
||||
test('never respawns a restart-visible launching record', async () => {
|
||||
const accepted = createWorkerRemoteExecutionInboxRecord(offer(), 100);
|
||||
const starting = {
|
||||
...accepted,
|
||||
revision: 1,
|
||||
state: 'starting_acknowledged',
|
||||
updatedAtMs: 101,
|
||||
};
|
||||
assertWorkerRemoteExecutionInboxTransition(accepted, starting);
|
||||
const launching = {
|
||||
...starting,
|
||||
revision: 2,
|
||||
state: 'launching',
|
||||
updatedAtMs: 102,
|
||||
executorStartedAtMs: 102,
|
||||
logArtifactId: 'log-1',
|
||||
completionReceiptCallbackSequence: 1,
|
||||
completionReceiptTokenDigest: 'b'.repeat(64),
|
||||
};
|
||||
assertWorkerRemoteExecutionInboxTransition(starting, launching);
|
||||
const fixture = inboxFixture(launching);
|
||||
let sideEffects = 0;
|
||||
const base = options(fixture);
|
||||
base.activation.acknowledgeStarting = async () => { sideEffects += 1; };
|
||||
base.executor.start = async () => { sideEffects += 1; };
|
||||
const result = await new WorkerRemoteExecutionInboxProcessor(base)
|
||||
.process('offer-processor-1');
|
||||
assert.equal(result.status, 'recovery_required');
|
||||
assert.equal(sideEffects, 0);
|
||||
assert.equal(fixture.record().state, 'recovery_required');
|
||||
});
|
||||
|
||||
test('revalidates the exact durable log and callback barrier before POSIX spawn', async () => {
|
||||
const accepted = createWorkerRemoteExecutionInboxRecord(offer(), 100);
|
||||
const starting = {
|
||||
...accepted,
|
||||
revision: 1,
|
||||
state: 'starting_acknowledged',
|
||||
updatedAtMs: 101,
|
||||
};
|
||||
const launching = {
|
||||
...starting,
|
||||
revision: 2,
|
||||
state: 'launching',
|
||||
updatedAtMs: 102,
|
||||
executorStartedAtMs: 102,
|
||||
logArtifactId: 'log-1',
|
||||
completionReceiptCallbackSequence: 1,
|
||||
completionReceiptTokenDigest: 'b'.repeat(64),
|
||||
};
|
||||
assertWorkerRemoteExecutionInboxTransition(accepted, starting);
|
||||
assertWorkerRemoteExecutionInboxTransition(starting, launching);
|
||||
const barrier = new WorkerInboxExecutionSpawnBarrier({
|
||||
async readOffer() { return launching; },
|
||||
});
|
||||
const exact = {
|
||||
offerId: 'offer-processor-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: 'b'.repeat(64),
|
||||
logArtifactId: 'log-1',
|
||||
executorStartedAtMs: 102,
|
||||
};
|
||||
await barrier.verify(exact);
|
||||
await assert.rejects(
|
||||
barrier.verify({ ...exact, logArtifactId: 'log-2' }),
|
||||
/authority drifted/,
|
||||
);
|
||||
await assert.rejects(
|
||||
barrier.verify({ ...exact, callbackTokenDigest: 'c'.repeat(64) }),
|
||||
/authority drifted/,
|
||||
);
|
||||
});
|
||||
|
||||
test('reports failure before the spawn barrier when materialized public data drifts', async () => {
|
||||
const fixture = inboxFixture();
|
||||
let starts = 0;
|
||||
const base = options(fixture, {
|
||||
materializer: {
|
||||
async prepare() {
|
||||
return {
|
||||
environment: [
|
||||
{ name: 'PUBLIC_VALUE', value: 'tampered' },
|
||||
{ name: 'SECRET_VALUE', value: 'resolved-secret' },
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
executor: {
|
||||
async start() {
|
||||
starts += 1;
|
||||
return { status: 'started', executorHandle: 'x', executorStartedAtMs: 900 };
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await new WorkerRemoteExecutionInboxProcessor(base)
|
||||
.process('offer-processor-1');
|
||||
assert.equal(result.status, 'start_failed');
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(fixture.record().state, 'start_failure_acknowledged');
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerRemoteExecutionHeadlessLifecycle,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const session = Object.freeze({
|
||||
workerId: 'edge-1',
|
||||
sessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
generation: 2,
|
||||
status: 'available',
|
||||
leaseExpiresAtMs: 20_000,
|
||||
});
|
||||
|
||||
function record(offerId, state) {
|
||||
return { state, offer: { offerId } };
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const calls = [];
|
||||
const journal = overrides.journal ?? {
|
||||
async acquireOwnership() { calls.push('acquire'); },
|
||||
async releaseOwnership() { calls.push('release'); },
|
||||
async listOffers() { calls.push('list'); return { records: [] }; },
|
||||
};
|
||||
const offers = overrides.offers ?? {
|
||||
async pull() {
|
||||
calls.push('pull');
|
||||
return {
|
||||
status: 'idle',
|
||||
reason: 'no_candidates',
|
||||
stats: {
|
||||
pages: 0,
|
||||
candidates: 0,
|
||||
plansUnavailable: 0,
|
||||
placementMismatches: 0,
|
||||
claimAttempts: 0,
|
||||
claimRaces: 0,
|
||||
},
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
const processor = overrides.processor ?? {
|
||||
async process(offerId) {
|
||||
calls.push(`process:${offerId}`);
|
||||
return { status: 'running', offerId, executorHandle: `handle:${offerId}` };
|
||||
},
|
||||
};
|
||||
const control = overrides.control ?? {
|
||||
async reconcile(offerId) {
|
||||
calls.push(`control:${offerId}`);
|
||||
return { status: 'renewed', offerId, leaseVersion: 1, expiresAtMs: 30_000 };
|
||||
},
|
||||
};
|
||||
const lifecycle = new WorkerRemoteExecutionHeadlessLifecycle({
|
||||
journal,
|
||||
offers,
|
||||
processor,
|
||||
control,
|
||||
currentSession: overrides.currentSession ?? (() => session),
|
||||
maximumRecordsPerTick: overrides.maximumRecordsPerTick ?? 2,
|
||||
now: () => 10_000,
|
||||
});
|
||||
return { calls, lifecycle };
|
||||
}
|
||||
|
||||
test('is inert until explicit start and releases the single journal owner', async () => {
|
||||
const { calls, lifecycle } = fixture();
|
||||
assert.deepEqual(calls, []);
|
||||
await assert.rejects(lifecycle.tick(), /inactive/);
|
||||
assert.equal(await lifecycle.start(), 'started');
|
||||
assert.equal(await lifecycle.start(), 'already_started');
|
||||
assert.deepEqual(calls, ['acquire']);
|
||||
assert.deepEqual(await lifecycle.tick(), {
|
||||
status: 'reconciled',
|
||||
processed: 0,
|
||||
});
|
||||
await lifecycle.stop();
|
||||
assert.deepEqual(calls, ['acquire', 'list', 'release']);
|
||||
await assert.rejects(lifecycle.tick(), /inactive/);
|
||||
});
|
||||
|
||||
test('finishes bounded startup reconciliation before pulling and processing', async () => {
|
||||
const pages = [
|
||||
{
|
||||
records: [record('offer-1', 'accepted'), record('offer-2', 'running_acknowledged')],
|
||||
nextAfterOfferId: 'offer-2',
|
||||
},
|
||||
{ records: [record('offer-3', 'completion_acknowledged')] },
|
||||
];
|
||||
const { calls, lifecycle } = fixture({
|
||||
journal: {
|
||||
async acquireOwnership() { calls.push('acquire'); },
|
||||
async releaseOwnership() { calls.push('release'); },
|
||||
async listOffers() { calls.push('list'); return pages.shift() ?? { records: [] }; },
|
||||
},
|
||||
offers: {
|
||||
async pull() {
|
||||
calls.push('pull');
|
||||
return {
|
||||
status: 'accepted',
|
||||
offerId: 'offer-new',
|
||||
stats: {
|
||||
pages: 1,
|
||||
candidates: 1,
|
||||
plansUnavailable: 0,
|
||||
placementMismatches: 0,
|
||||
claimAttempts: 1,
|
||||
claimRaces: 0,
|
||||
},
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await lifecycle.start();
|
||||
assert.deepEqual(await lifecycle.tick(), {
|
||||
status: 'reconciling',
|
||||
processed: 1,
|
||||
nextAfterOfferId: 'offer-2',
|
||||
});
|
||||
assert.equal(calls.includes('pull'), false);
|
||||
assert.deepEqual(await lifecycle.tick(), {
|
||||
status: 'reconciled',
|
||||
processed: 0,
|
||||
});
|
||||
const pulled = await lifecycle.tick();
|
||||
assert.equal(pulled.status, 'processed');
|
||||
assert.equal(pulled.offerId, 'offer-new');
|
||||
assert.deepEqual(calls.filter((call) => call.startsWith('process:')), [
|
||||
'process:offer-1',
|
||||
'process:offer-new',
|
||||
]);
|
||||
await lifecycle.stop();
|
||||
});
|
||||
|
||||
test('supervises a bounded active page before pulling new work', async () => {
|
||||
let lists = 0;
|
||||
const { calls, lifecycle } = fixture({
|
||||
journal: {
|
||||
async acquireOwnership() { calls.push('acquire'); },
|
||||
async releaseOwnership() { calls.push('release'); },
|
||||
async listOffers() {
|
||||
lists += 1;
|
||||
if (lists === 1) return { records: [] };
|
||||
return {
|
||||
records: [
|
||||
record('offer-running', 'running_acknowledged'),
|
||||
record('offer-complete', 'completion_acknowledged'),
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await lifecycle.start();
|
||||
await lifecycle.tick();
|
||||
const result = await lifecycle.tick();
|
||||
assert.equal(result.status, 'pull_result');
|
||||
assert.deepEqual(calls.slice(-2), ['control:offer-running', 'pull']);
|
||||
await lifecycle.stop();
|
||||
});
|
||||
|
||||
test('fails closed before Pull when active supervision records lease loss', async () => {
|
||||
let lists = 0;
|
||||
const { calls, lifecycle } = fixture({
|
||||
journal: {
|
||||
async acquireOwnership() { calls.push('acquire'); },
|
||||
async releaseOwnership() { calls.push('release'); },
|
||||
async listOffers() {
|
||||
lists += 1;
|
||||
return lists === 1
|
||||
? { records: [] }
|
||||
: { records: [record('offer-lost', 'running_acknowledged')] };
|
||||
},
|
||||
},
|
||||
control: {
|
||||
async reconcile(offerId) {
|
||||
calls.push(`control:${offerId}`);
|
||||
return {
|
||||
status: 'lease_expired', offerId,
|
||||
stop: { status: 'stopped', signal: 'SIGTERM' },
|
||||
recoveryReason: 'lease_lost_local_execution_stopped',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await lifecycle.start();
|
||||
await lifecycle.tick();
|
||||
assert.deepEqual(await lifecycle.tick(), {
|
||||
status: 'recovery_required', offerId: 'offer-lost',
|
||||
});
|
||||
assert.equal(calls.includes('pull'), false);
|
||||
assert.deepEqual(await lifecycle.tick(), {
|
||||
status: 'recovery_required', offerId: 'offer-lost',
|
||||
});
|
||||
await lifecycle.stop();
|
||||
});
|
||||
|
||||
test('fails closed on durable recovery evidence and never pulls again', async () => {
|
||||
const { calls, lifecycle } = fixture({
|
||||
journal: {
|
||||
async acquireOwnership() { calls.push('acquire'); },
|
||||
async releaseOwnership() { calls.push('release'); },
|
||||
async listOffers() {
|
||||
calls.push('list');
|
||||
return { records: [record('offer-unsafe', 'recovery_required')] };
|
||||
},
|
||||
},
|
||||
});
|
||||
await lifecycle.start();
|
||||
assert.deepEqual(await lifecycle.tick(), {
|
||||
status: 'recovery_required',
|
||||
offerId: 'offer-unsafe',
|
||||
});
|
||||
assert.deepEqual(await lifecycle.tick(), {
|
||||
status: 'recovery_required',
|
||||
offerId: 'offer-unsafe',
|
||||
});
|
||||
assert.equal(calls.includes('pull'), false);
|
||||
await lifecycle.stop();
|
||||
});
|
||||
|
||||
test('coalesces ticks and aborts a pending pull before releasing ownership', async () => {
|
||||
const events = [];
|
||||
const { lifecycle } = fixture({
|
||||
journal: {
|
||||
async acquireOwnership() { events.push('acquire'); },
|
||||
async releaseOwnership() { events.push('release'); },
|
||||
async listOffers() { return { records: [] }; },
|
||||
},
|
||||
offers: {
|
||||
pull(_session, signal) {
|
||||
events.push('pull');
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
events.push('aborted');
|
||||
reject(signal.reason);
|
||||
}, { once: true });
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
await lifecycle.start();
|
||||
await lifecycle.tick();
|
||||
const first = lifecycle.tick();
|
||||
const second = lifecycle.tick();
|
||||
assert.strictEqual(first, second);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const stopping = lifecycle.stop();
|
||||
await assert.rejects(first, /stopping/);
|
||||
await stopping;
|
||||
assert.deepEqual(events, ['acquire', 'pull', 'aborted', 'release']);
|
||||
});
|
||||
|
||||
test('draining aborts Pull but keeps ownership until final stop', async () => {
|
||||
const events = [];
|
||||
const { lifecycle } = fixture({
|
||||
journal: {
|
||||
async acquireOwnership() { events.push('acquire'); },
|
||||
async releaseOwnership() { events.push('release'); },
|
||||
async listOffers() { events.push('list'); return { records: [] }; },
|
||||
},
|
||||
offers: {
|
||||
pull(_session, signal) {
|
||||
events.push('pull');
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
events.push('aborted');
|
||||
reject(signal.reason);
|
||||
}, { once: true });
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
await lifecycle.start();
|
||||
await lifecycle.tick();
|
||||
const pulling = lifecycle.tick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await lifecycle.beginDrain();
|
||||
await assert.rejects(pulling, /draining/);
|
||||
assert.equal(events.includes('release'), false);
|
||||
assert.deepEqual(await lifecycle.tick(), { status: 'draining' });
|
||||
assert.equal(events.filter((event) => event === 'pull').length, 1);
|
||||
await lifecycle.beginDrain();
|
||||
await lifecycle.stop();
|
||||
assert.equal(events.at(-1), 'release');
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
require('reflect-metadata');
|
||||
|
||||
const { randomBytes, webcrypto } = require('node:crypto');
|
||||
const {
|
||||
BasicConstraintsExtension,
|
||||
ExtendedKeyUsage,
|
||||
ExtendedKeyUsageExtension,
|
||||
KeyUsageFlags,
|
||||
KeyUsagesExtension,
|
||||
Pkcs10CertificateRequest,
|
||||
SubjectKeyIdentifierExtension,
|
||||
X509CertificateGenerator,
|
||||
} = require('@peculiar/x509');
|
||||
|
||||
const algorithm = Object.freeze({
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
hash: 'SHA-256',
|
||||
});
|
||||
|
||||
async function createCertificateAuthority(options = {}) {
|
||||
const now = options.now ?? Date.now();
|
||||
const keys = await webcrypto.subtle.generateKey(algorithm, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
const name = 'CN=QingLong Worker Test CA';
|
||||
const certificate = await X509CertificateGenerator.createSelfSigned(
|
||||
{
|
||||
serialNumber: randomBytes(16).toString('hex'),
|
||||
name,
|
||||
notBefore: new Date(now - 60_000),
|
||||
notAfter: new Date(now + 365 * 24 * 60 * 60_000),
|
||||
signingAlgorithm: algorithm,
|
||||
keys,
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(true, 1, true),
|
||||
new KeyUsagesExtension(
|
||||
KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign,
|
||||
true,
|
||||
),
|
||||
await SubjectKeyIdentifierExtension.create(
|
||||
keys.publicKey,
|
||||
false,
|
||||
webcrypto,
|
||||
),
|
||||
],
|
||||
},
|
||||
webcrypto,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
certificatePem: certificate.toString('pem'),
|
||||
async issue(certificateSigningRequestPem, issueOptions = {}) {
|
||||
const request = new Pkcs10CertificateRequest(
|
||||
certificateSigningRequestPem,
|
||||
);
|
||||
if (!(await request.verify(webcrypto))) {
|
||||
throw new Error('test CSR signature is invalid');
|
||||
}
|
||||
const notBeforeMs = issueOptions.notBeforeMs ?? now - 60_000;
|
||||
const notAfterMs = issueOptions.notAfterMs ?? now + 30 * 24 * 60 * 60_000;
|
||||
const extensions = [
|
||||
new BasicConstraintsExtension(false, undefined, true),
|
||||
new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true),
|
||||
await SubjectKeyIdentifierExtension.create(
|
||||
request.publicKey,
|
||||
false,
|
||||
webcrypto,
|
||||
),
|
||||
];
|
||||
if (issueOptions.clientAuth !== false) {
|
||||
extensions.splice(
|
||||
1,
|
||||
0,
|
||||
new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], true),
|
||||
);
|
||||
}
|
||||
const leaf = await X509CertificateGenerator.create(
|
||||
{
|
||||
serialNumber: randomBytes(16).toString('hex'),
|
||||
subject: request.subject,
|
||||
issuer: name,
|
||||
notBefore: new Date(notBeforeMs),
|
||||
notAfter: new Date(notAfterMs),
|
||||
signingAlgorithm: algorithm,
|
||||
publicKey: request.publicKey,
|
||||
signingKey: keys.privateKey,
|
||||
extensions,
|
||||
},
|
||||
webcrypto,
|
||||
);
|
||||
return leaf.toString('pem');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createCertificateAuthority };
|
||||
@@ -0,0 +1,209 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createProductionWorkerHeadlessExecutionStack,
|
||||
startProductionWorkerHeadlessApplication,
|
||||
} = require('@qinglong/worker-runtime/production');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
|
||||
async function temporaryStorage() {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql3-worker-production-'),
|
||||
);
|
||||
return {
|
||||
root,
|
||||
storage: {
|
||||
journalRoot: path.join(root, 'journal'),
|
||||
logRoot: path.join(root, 'logs'),
|
||||
receiptRoot: path.join(root, 'receipts'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function options(storage, session, overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: 'edge',
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: {
|
||||
async load() {
|
||||
throw new Error('credentials must remain lazy');
|
||||
},
|
||||
},
|
||||
session,
|
||||
storage,
|
||||
cadenceMs: 60_000,
|
||||
drainTimeoutMs: 1_000,
|
||||
drainPollMs: 25,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionLifecycle() {
|
||||
let status = 'available';
|
||||
let drains = 0;
|
||||
return {
|
||||
current() {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
sessionId: SESSION_ID,
|
||||
generation: 1,
|
||||
status,
|
||||
leaseExpiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
},
|
||||
async beginDrain() {
|
||||
drains += 1;
|
||||
status = 'draining';
|
||||
},
|
||||
drains() {
|
||||
return drains;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('disabled production Worker is resource-free before option access', async () => {
|
||||
const candidate = { enabled: false };
|
||||
Object.defineProperty(candidate, 'profile', {
|
||||
get() {
|
||||
throw new Error('disabled path inspected profile');
|
||||
},
|
||||
});
|
||||
const application = await startProductionWorkerHeadlessApplication(candidate);
|
||||
assert.equal(application.status, 'disabled');
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
});
|
||||
|
||||
test('the concrete execution factory requires an explicit enabled authority', () => {
|
||||
assert.throws(
|
||||
() => createProductionWorkerHeadlessExecutionStack({ enabled: false }),
|
||||
/invalid_configuration/,
|
||||
);
|
||||
});
|
||||
|
||||
test('assembles one concrete execution plane and drains before owner release', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
const session = sessionLifecycle();
|
||||
try {
|
||||
const application = await startProductionWorkerHeadlessApplication(
|
||||
options(temporary.storage, session),
|
||||
);
|
||||
assert.equal(application.status, 'active');
|
||||
const journal = await fs.stat(temporary.storage.journalRoot);
|
||||
const offers = await fs.stat(
|
||||
path.join(temporary.storage.journalRoot, 'offers'),
|
||||
);
|
||||
assert.equal(journal.isDirectory(), true);
|
||||
assert.equal(offers.isDirectory(), true);
|
||||
await assert.rejects(fs.stat(temporary.storage.logRoot), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await assert.rejects(fs.stat(temporary.storage.receiptRoot), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(session.drains(), 1);
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects wrong Profile and overlapping authorities before filesystem use', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
const session = sessionLifecycle();
|
||||
try {
|
||||
await assert.rejects(
|
||||
startProductionWorkerHeadlessApplication(
|
||||
options(temporary.storage, session, { profile: 'cluster-control' }),
|
||||
),
|
||||
/invalid_configuration/,
|
||||
);
|
||||
const overlapping = {
|
||||
journalRoot: path.join(temporary.root, 'state'),
|
||||
logRoot: path.join(temporary.root, 'state', 'logs'),
|
||||
receiptRoot: path.join(temporary.root, 'receipts'),
|
||||
};
|
||||
await assert.rejects(
|
||||
startProductionWorkerHeadlessApplication(options(overlapping, session)),
|
||||
/invalid_configuration/,
|
||||
);
|
||||
await assert.rejects(fs.stat(overlapping.journalRoot), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed Session drain keeps the application owned and retryable', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
let attempts = 0;
|
||||
let status = 'available';
|
||||
const session = {
|
||||
current() {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
sessionId: SESSION_ID,
|
||||
generation: 1,
|
||||
status,
|
||||
leaseExpiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
},
|
||||
async beginDrain() {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('drain unavailable');
|
||||
status = 'draining';
|
||||
},
|
||||
};
|
||||
try {
|
||||
const application = await startProductionWorkerHeadlessApplication(
|
||||
options(temporary.storage, session),
|
||||
);
|
||||
await assert.rejects(application.stop(), /drain unavailable/);
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(attempts, 2);
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('production execution graph is reachable only through its explicit subpath', () => {
|
||||
const packageDirectory = path.resolve(__dirname, '..');
|
||||
const inspect = (specifier) => {
|
||||
const script = `
|
||||
const exported = require(${JSON.stringify(specifier)});
|
||||
const loaded = Object.keys(require.cache).map((file) => file.replaceAll('\\\\', '/'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
hasProduction: typeof exported.startProductionWorkerHeadlessApplication === 'function',
|
||||
loadedJournal: loaded.some((file) => file.includes('/remoteOfferFileJournal.js')),
|
||||
loadedLock: loaded.some((file) => file.includes('/proper-lockfile/')),
|
||||
loadedCluster: loaded.some((file) => file.includes('/ql3-cluster-')),
|
||||
}));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
cwd: packageDirectory,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
assert.deepEqual(inspect('@qinglong/worker-runtime'), {
|
||||
hasProduction: false,
|
||||
loadedJournal: false,
|
||||
loadedLock: false,
|
||||
loadedCluster: false,
|
||||
});
|
||||
assert.deepEqual(inspect('@qinglong/worker-runtime/production'), {
|
||||
hasProduction: true,
|
||||
loadedJournal: true,
|
||||
loadedLock: true,
|
||||
loadedCluster: false,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const https = require('node:https');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
startProductionWorkerApplication,
|
||||
} = require('@qinglong/worker-runtime/product');
|
||||
const {
|
||||
startProductionWorkerHeadlessApplicationWithStack,
|
||||
} = require('@qinglong/worker-runtime/production');
|
||||
|
||||
const AUTHORIZATION = `Worker ql3w_worker_primary_${Buffer.alloc(
|
||||
32,
|
||||
7,
|
||||
).toString('base64url')}`;
|
||||
const fixtures = path.resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls',
|
||||
);
|
||||
|
||||
async function material(name) {
|
||||
return fs.readFile(path.join(fixtures, name));
|
||||
}
|
||||
|
||||
async function temporaryStorage() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-worker-product-'));
|
||||
return {
|
||||
root,
|
||||
storage: {
|
||||
journalRoot: path.join(root, 'journal'),
|
||||
logRoot: path.join(root, 'logs'),
|
||||
receiptRoot: path.join(root, 'receipts'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function capabilities() {
|
||||
return {
|
||||
architecture: 'x64',
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
runtimes: [{ name: 'node', version: '24.14.0' }],
|
||||
labels: {},
|
||||
capacity: { cpuCores: 1, memoryBytes: 256 * 1024 * 1024 },
|
||||
features: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('owns one TLS Agent across register, drain and offline', async () => {
|
||||
const [ca, serverCertificate, serverKey, clientCertificate, clientKey] =
|
||||
await Promise.all([
|
||||
material('ca-cert.pem'),
|
||||
material('server-cert.pem'),
|
||||
material('server-key.pem'),
|
||||
material('client-cert.pem'),
|
||||
material('client-key.pem'),
|
||||
]);
|
||||
const temporary = await temporaryStorage();
|
||||
const observations = [];
|
||||
const sockets = new Set();
|
||||
let version = -1;
|
||||
const server = https.createServer(
|
||||
{
|
||||
ca,
|
||||
cert: serverCertificate,
|
||||
key: serverKey,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
(request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
const match = request.url.match(
|
||||
/^\/api\/v3\/worker-ingress\/workers\/edge-1\/sessions\/([^/]+)\/(register|transition)$/,
|
||||
);
|
||||
assert.ok(match);
|
||||
sockets.add(request.socket);
|
||||
observations.push({
|
||||
operation: match[2],
|
||||
status: body.status,
|
||||
availableSlots: body.availableSlots,
|
||||
authorized: request.socket.authorized,
|
||||
protocol: request.socket.getProtocol(),
|
||||
});
|
||||
version += 1;
|
||||
const status = match[2] === 'register' ? 'online' : body.status;
|
||||
const payload = {
|
||||
schema: body.schema,
|
||||
workerId: 'edge-1',
|
||||
sessionId: match[1],
|
||||
generation: 1,
|
||||
version,
|
||||
status,
|
||||
leaseExpiresAtMs: 46_000,
|
||||
...(match[2] === 'register' ? { replacedSession: false } : {}),
|
||||
};
|
||||
const encoded = JSON.stringify(payload);
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(encoded)),
|
||||
});
|
||||
response.end(encoded);
|
||||
});
|
||||
},
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
try {
|
||||
const application = await startProductionWorkerApplication({
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: 'edge',
|
||||
origin: `https://127.0.0.1:${address.port}`,
|
||||
credentials: {
|
||||
async load() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: clientCertificate,
|
||||
privateKeyPem: clientKey,
|
||||
trustAnchors: [ca],
|
||||
};
|
||||
},
|
||||
},
|
||||
workerId: 'edge-1',
|
||||
capabilities: capabilities(),
|
||||
maxConcurrentRuns: 2,
|
||||
storage: temporary.storage,
|
||||
cadenceMs: 60_000,
|
||||
drainTimeoutMs: 1_000,
|
||||
drainPollMs: 25,
|
||||
now: () => 1_000,
|
||||
});
|
||||
assert.equal(application.status, 'active');
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(sockets.size, 1);
|
||||
assert.deepEqual(observations, [
|
||||
{
|
||||
operation: 'register',
|
||||
status: undefined,
|
||||
availableSlots: 2,
|
||||
authorized: true,
|
||||
protocol: 'TLSv1.3',
|
||||
},
|
||||
{
|
||||
operation: 'transition',
|
||||
status: 'draining',
|
||||
availableSlots: undefined,
|
||||
authorized: true,
|
||||
protocol: 'TLSv1.3',
|
||||
},
|
||||
{
|
||||
operation: 'transition',
|
||||
status: 'offline',
|
||||
availableSlots: undefined,
|
||||
authorized: true,
|
||||
protocol: 'TLSv1.3',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an unsettled startup journal before Session registration', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
let registers = 0;
|
||||
let releases = 0;
|
||||
const options = {
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: 'edge',
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: {
|
||||
async load() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
session: {
|
||||
current() {
|
||||
return undefined;
|
||||
},
|
||||
async register() {
|
||||
registers += 1;
|
||||
},
|
||||
async beginDrain() {},
|
||||
},
|
||||
storage: temporary.storage,
|
||||
};
|
||||
const stack = {
|
||||
journal: {
|
||||
async listOffers() {
|
||||
return {
|
||||
records: [{ state: 'accepted', offer: { offerId: 'offer-1' } }],
|
||||
};
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
async start() {
|
||||
return 'started';
|
||||
},
|
||||
async stop() {
|
||||
releases += 1;
|
||||
},
|
||||
},
|
||||
client: { close() {} },
|
||||
offerTransport: { close() {} },
|
||||
ownsClient: false,
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
startProductionWorkerHeadlessApplicationWithStack(options, stack),
|
||||
/startup_recovery_required/,
|
||||
);
|
||||
assert.equal(registers, 0);
|
||||
assert.equal(releases, 1);
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects unavailable startup identity before Session registration', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
let registers = 0;
|
||||
let fences = 0;
|
||||
let releases = 0;
|
||||
let transportCloses = 0;
|
||||
const options = {
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: 'edge',
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: {
|
||||
async load() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
certificateRenewal: {
|
||||
async run() {
|
||||
return { status: 'unavailable', nextAttemptAtMs: 2_000 };
|
||||
},
|
||||
},
|
||||
session: {
|
||||
current() {
|
||||
return undefined;
|
||||
},
|
||||
async register() {
|
||||
registers += 1;
|
||||
},
|
||||
failClosed() {
|
||||
fences += 1;
|
||||
},
|
||||
async beginDrain() {},
|
||||
},
|
||||
storage: temporary.storage,
|
||||
};
|
||||
const stack = {
|
||||
journal: {
|
||||
async listOffers() {
|
||||
return { records: [] };
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
async start() {},
|
||||
async tick() {
|
||||
return { status: 'reconciled', processed: 0 };
|
||||
},
|
||||
async stop() {
|
||||
releases += 1;
|
||||
},
|
||||
},
|
||||
client: { close() {} },
|
||||
offerTransport: {
|
||||
close() {
|
||||
transportCloses += 1;
|
||||
},
|
||||
},
|
||||
ownsClient: false,
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
startProductionWorkerHeadlessApplicationWithStack(options, stack),
|
||||
/certificate_unavailable/,
|
||||
);
|
||||
assert.equal(registers, 0);
|
||||
assert.equal(fences, 1);
|
||||
assert.equal(releases, 1);
|
||||
assert.equal(transportCloses, 1);
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('drives Session and execution in one cadence and releases ownership last', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
const events = [];
|
||||
let status;
|
||||
let draining = false;
|
||||
const options = {
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: 'edge',
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: {
|
||||
async load() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
session: {
|
||||
current() {
|
||||
return status === undefined
|
||||
? undefined
|
||||
: {
|
||||
workerId: 'edge-1',
|
||||
sessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
generation: 1,
|
||||
status,
|
||||
leaseExpiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
},
|
||||
async register() {
|
||||
events.push('session:register');
|
||||
status = 'available';
|
||||
},
|
||||
async tick() {
|
||||
events.push('session:tick');
|
||||
},
|
||||
async beginDrain() {
|
||||
events.push('session:drain');
|
||||
status = 'draining';
|
||||
},
|
||||
async disconnect() {
|
||||
events.push('session:offline');
|
||||
status = 'offline';
|
||||
},
|
||||
},
|
||||
storage: temporary.storage,
|
||||
cadenceMs: 60_000,
|
||||
drainTimeoutMs: 1_000,
|
||||
drainPollMs: 25,
|
||||
};
|
||||
let startup = true;
|
||||
const stack = {
|
||||
journal: {
|
||||
async listOffers() {
|
||||
return { records: [] };
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
async start() {
|
||||
events.push('execution:start');
|
||||
},
|
||||
async tick() {
|
||||
if (startup) {
|
||||
startup = false;
|
||||
events.push('execution:reconcile');
|
||||
return { status: 'reconciled', processed: 0 };
|
||||
}
|
||||
events.push('execution:tick');
|
||||
return draining
|
||||
? { status: 'draining' }
|
||||
: { status: 'session_unavailable' };
|
||||
},
|
||||
async beginDrain() {
|
||||
events.push('execution:drain');
|
||||
draining = true;
|
||||
},
|
||||
async stop() {
|
||||
events.push('execution:release');
|
||||
},
|
||||
},
|
||||
client: {
|
||||
close() {
|
||||
events.push('client:close');
|
||||
},
|
||||
},
|
||||
offerTransport: {
|
||||
close() {
|
||||
events.push('transport:close');
|
||||
},
|
||||
},
|
||||
ownsClient: false,
|
||||
};
|
||||
try {
|
||||
const application = await startProductionWorkerHeadlessApplicationWithStack(
|
||||
options,
|
||||
stack,
|
||||
);
|
||||
await application.tick();
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.deepEqual(events, [
|
||||
'execution:start',
|
||||
'execution:reconcile',
|
||||
'session:register',
|
||||
'session:tick',
|
||||
'execution:tick',
|
||||
'execution:drain',
|
||||
'session:drain',
|
||||
'session:tick',
|
||||
'execution:tick',
|
||||
'session:offline',
|
||||
'execution:release',
|
||||
'transport:close',
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('runs certificate renewal in the existing cadence and fences admission', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
const events = [];
|
||||
const diagnostics = [];
|
||||
let renewalRuns = 0;
|
||||
let sessionAvailable = true;
|
||||
const options = {
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: 'edge',
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: {
|
||||
async load() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
certificateRenewal: {
|
||||
async run() {
|
||||
renewalRuns += 1;
|
||||
events.push(`certificate:${renewalRuns}`);
|
||||
if (renewalRuns === 1) {
|
||||
return { status: 'not_due', identity: {}, renewAtMs: 10_000 };
|
||||
}
|
||||
return { status: 'unavailable', nextAttemptAtMs: 20_000 };
|
||||
},
|
||||
},
|
||||
session: {
|
||||
current() {
|
||||
return sessionAvailable
|
||||
? {
|
||||
workerId: 'edge-1',
|
||||
sessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
generation: 1,
|
||||
status: 'available',
|
||||
leaseExpiresAtMs: Date.now() + 60_000,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
async register() {
|
||||
events.push('session:register');
|
||||
},
|
||||
async tick() {
|
||||
events.push('session:tick');
|
||||
},
|
||||
failClosed() {
|
||||
events.push('session:fail-closed');
|
||||
sessionAvailable = false;
|
||||
},
|
||||
async beginDrain() {
|
||||
events.push('session:drain');
|
||||
},
|
||||
},
|
||||
storage: temporary.storage,
|
||||
cadenceMs: 60_000,
|
||||
drainTimeoutMs: 1_000,
|
||||
drainPollMs: 25,
|
||||
diagnostic(fact) {
|
||||
diagnostics.push(fact.code);
|
||||
},
|
||||
};
|
||||
let startup = true;
|
||||
const stack = {
|
||||
journal: {
|
||||
async listOffers() {
|
||||
return { records: [] };
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
async start() {
|
||||
events.push('execution:start');
|
||||
},
|
||||
async tick() {
|
||||
if (startup) {
|
||||
startup = false;
|
||||
events.push('execution:reconcile');
|
||||
return { status: 'reconciled', processed: 0 };
|
||||
}
|
||||
events.push('execution:tick');
|
||||
return { status: 'session_unavailable' };
|
||||
},
|
||||
async beginDrain() {
|
||||
events.push('execution:drain');
|
||||
},
|
||||
async stop() {
|
||||
events.push('execution:release');
|
||||
},
|
||||
},
|
||||
client: { close() {} },
|
||||
offerTransport: {
|
||||
close() {
|
||||
events.push('transport:close');
|
||||
},
|
||||
},
|
||||
ownsClient: false,
|
||||
};
|
||||
try {
|
||||
const application = await startProductionWorkerHeadlessApplicationWithStack(
|
||||
options,
|
||||
stack,
|
||||
);
|
||||
assert.deepEqual(events.slice(0, 4), [
|
||||
'execution:start',
|
||||
'execution:reconcile',
|
||||
'certificate:1',
|
||||
'session:register',
|
||||
]);
|
||||
assert.deepEqual(await application.tick(), {
|
||||
status: 'session_unavailable',
|
||||
});
|
||||
assert.equal(events.includes('session:tick'), false);
|
||||
assert.deepEqual(events.slice(4), [
|
||||
'certificate:2',
|
||||
'session:fail-closed',
|
||||
'execution:tick',
|
||||
]);
|
||||
assert.deepEqual(await application.tick(), {
|
||||
status: 'session_unavailable',
|
||||
});
|
||||
assert.equal(
|
||||
events.filter((event) => event === 'session:fail-closed').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(diagnostics, ['certificate_unavailable']);
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('retries owner release after Session is already durably offline', async () => {
|
||||
const temporary = await temporaryStorage();
|
||||
let status;
|
||||
let releases = 0;
|
||||
let transportCloses = 0;
|
||||
const options = {
|
||||
enabled: true,
|
||||
profile: 'worker',
|
||||
capacityProfile: 'edge',
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: {
|
||||
async load() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
},
|
||||
session: {
|
||||
current() {
|
||||
return status === undefined
|
||||
? undefined
|
||||
: {
|
||||
workerId: 'edge-1',
|
||||
sessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
generation: 1,
|
||||
status,
|
||||
leaseExpiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
},
|
||||
async register() {
|
||||
status = 'available';
|
||||
},
|
||||
async tick() {},
|
||||
async beginDrain() {
|
||||
if (status !== 'offline') status = 'draining';
|
||||
},
|
||||
async disconnect() {
|
||||
if (status !== 'offline') status = 'offline';
|
||||
},
|
||||
},
|
||||
storage: temporary.storage,
|
||||
cadenceMs: 60_000,
|
||||
drainTimeoutMs: 1_000,
|
||||
drainPollMs: 25,
|
||||
};
|
||||
let startup = true;
|
||||
const stack = {
|
||||
journal: {
|
||||
async listOffers() {
|
||||
return { records: [] };
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
async start() {},
|
||||
async tick() {
|
||||
if (startup) {
|
||||
startup = false;
|
||||
return { status: 'reconciled', processed: 0 };
|
||||
}
|
||||
return { status: 'draining' };
|
||||
},
|
||||
async beginDrain() {},
|
||||
async stop() {
|
||||
releases += 1;
|
||||
if (releases === 1) throw new Error('owner release unavailable');
|
||||
},
|
||||
},
|
||||
client: { close() {} },
|
||||
offerTransport: {
|
||||
close() {
|
||||
transportCloses += 1;
|
||||
},
|
||||
},
|
||||
ownsClient: false,
|
||||
};
|
||||
try {
|
||||
const application = await startProductionWorkerHeadlessApplicationWithStack(
|
||||
options,
|
||||
stack,
|
||||
);
|
||||
await assert.rejects(application.stop(), /owner release unavailable/);
|
||||
assert.equal(status, 'offline');
|
||||
assert.equal(transportCloses, 0);
|
||||
assert.equal(await application.stop(), 'stopped');
|
||||
assert.equal(releases, 2);
|
||||
assert.equal(transportCloses, 1);
|
||||
} finally {
|
||||
await fs.rm(temporary.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { PassThrough } = require('node:stream');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerRemoteExecutionHttpsActivationClient,
|
||||
WorkerRemoteOfferHttpsTransport,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const AUTHORIZATION =
|
||||
`Worker ql3w_worker_primary_${Buffer.alloc(32, 7).toString('base64url')}`;
|
||||
|
||||
function response(body) {
|
||||
const serialized = JSON.stringify(body);
|
||||
const stream = new PassThrough();
|
||||
stream.statusCode = 200;
|
||||
stream.headers = {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(serialized)),
|
||||
};
|
||||
queueMicrotask(() => stream.end(serialized));
|
||||
return stream;
|
||||
}
|
||||
|
||||
function responseBody(overrides = {}) {
|
||||
return {
|
||||
schema: 'qinglong/remote-run-activation@v1',
|
||||
status: 'applied',
|
||||
snapshot: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
runStatus: 'dispatching',
|
||||
attemptStatus: 'starting',
|
||||
leaseVersion: 4,
|
||||
leaseGeneration: 3,
|
||||
callbackSequence: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function responseBodyForPath(requestPath) {
|
||||
if (requestPath.endsWith('/offers')) return { status: 'idle' };
|
||||
if (requestPath.endsWith('/running')) {
|
||||
return responseBody({
|
||||
snapshot: {
|
||||
...responseBody().snapshot,
|
||||
runStatus: 'running',
|
||||
attemptStatus: 'running',
|
||||
callbackSequence: 1,
|
||||
executorHandle: 'remote:handle-1',
|
||||
startedAtMs: 20_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (requestPath.endsWith('/start-failure')) {
|
||||
return responseBody({
|
||||
snapshot: {
|
||||
...responseBody().snapshot,
|
||||
runStatus: 'failed',
|
||||
attemptStatus: 'failed',
|
||||
leaseVersion: 5,
|
||||
callbackSequence: 1,
|
||||
finishedAtMs: 20_000,
|
||||
errorCode: 'EXECUTOR_START_FAILED',
|
||||
},
|
||||
});
|
||||
}
|
||||
return responseBody();
|
||||
}
|
||||
|
||||
function requestFactory(observations, responseFactory = responseBodyForPath) {
|
||||
return (options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.setTimeout = () => request;
|
||||
request.destroy = (error) => {
|
||||
if (error) queueMicrotask(() => request.emit('error', error));
|
||||
};
|
||||
request.end = (body) => {
|
||||
observations.push({
|
||||
agent: options.agent,
|
||||
path: options.path,
|
||||
body: JSON.parse(Buffer.from(body).toString('utf8')),
|
||||
});
|
||||
queueMicrotask(() => callback(response(responseFactory(options.path))));
|
||||
};
|
||||
return request;
|
||||
};
|
||||
}
|
||||
|
||||
function credentials() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: 'client certificate',
|
||||
privateKeyPem: 'client private key',
|
||||
trustAnchors: ['trusted ca'],
|
||||
};
|
||||
}
|
||||
|
||||
function command() {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
expectedLeaseVersion: 4,
|
||||
};
|
||||
}
|
||||
|
||||
test('shares one Agent and credential authority across offer and activation calls', async () => {
|
||||
const observations = [];
|
||||
const shared = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example:7443',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestFactory: requestFactory(observations),
|
||||
});
|
||||
const offers = new WorkerRemoteOfferHttpsTransport({ client: shared });
|
||||
const activation = new WorkerRemoteExecutionHttpsActivationClient({
|
||||
client: shared,
|
||||
});
|
||||
try {
|
||||
await offers.exchange({
|
||||
path: `/api/v3/worker-ingress/workers/edge-1/sessions/${SESSION_ID}/offers`,
|
||||
body: {
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseToken: command().leaseToken,
|
||||
},
|
||||
maximumResponseBytes: 1024,
|
||||
});
|
||||
offers.close();
|
||||
await activation.acknowledgeStarting({
|
||||
...command(),
|
||||
eventId: '018f0000-0000-7000-8000-000000000002',
|
||||
});
|
||||
await activation.acknowledgeRunning({
|
||||
...command(),
|
||||
attemptEventId: '018f0000-0000-7000-8000-000000000003',
|
||||
runEventId: '018f0000-0000-7000-8000-000000000004',
|
||||
executorHandle: 'remote:handle-1',
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: 'a'.repeat(64),
|
||||
});
|
||||
await activation.failStart({
|
||||
...command(),
|
||||
attemptEventId: '018f0000-0000-7000-8000-000000000005',
|
||||
runEventId: '018f0000-0000-7000-8000-000000000006',
|
||||
});
|
||||
await shared.postJson({
|
||||
path: `/api/v3/worker-ingress/workers/edge-1/sessions/${SESSION_ID}/secrets`,
|
||||
body: { probe: true },
|
||||
maximumResponseBytes: 16 * 1024,
|
||||
});
|
||||
assert.equal(new Set(observations.map((item) => item.agent)).size, 1);
|
||||
assert.deepEqual(observations.map((item) => item.path.split('/').at(-1)), [
|
||||
'offers', 'starting', 'running', 'start-failure', 'secrets',
|
||||
]);
|
||||
assert.equal('eventId' in observations[1].body, false);
|
||||
assert.equal('workerId' in observations[1].body, false);
|
||||
assert.equal('workerSessionId' in observations[1].body, false);
|
||||
assert.equal(observations[2].body.logArtifactId, null);
|
||||
} finally {
|
||||
shared.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a response whose run authority does not match the request', async () => {
|
||||
const shared = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestFactory: requestFactory([], () => responseBody({
|
||||
snapshot: { ...responseBody().snapshot, runId: 'run-other' },
|
||||
})),
|
||||
});
|
||||
const activation = new WorkerRemoteExecutionHttpsActivationClient({
|
||||
client: shared,
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
activation.acknowledgeStarting({
|
||||
...command(),
|
||||
eventId: '018f0000-0000-7000-8000-000000000002',
|
||||
}),
|
||||
/response_invalid/,
|
||||
);
|
||||
} finally {
|
||||
shared.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps 4 KiB as the default body cap and permits bounded Secret batches explicitly', async () => {
|
||||
const observations = [];
|
||||
const shared = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestFactory: requestFactory(observations, () => ({ ok: true })),
|
||||
});
|
||||
const path = `/api/v3/worker-ingress/workers/edge-1/sessions/${SESSION_ID}/secrets`;
|
||||
const body = { value: 'x'.repeat(5 * 1024) };
|
||||
try {
|
||||
await assert.rejects(
|
||||
shared.postJson({ path, body, maximumResponseBytes: 1024 }),
|
||||
/request_rejected/,
|
||||
);
|
||||
await shared.postJson({
|
||||
path, body, maximumRequestBytes: 64 * 1024, maximumResponseBytes: 1024,
|
||||
});
|
||||
assert.equal(observations.length, 1);
|
||||
} finally {
|
||||
shared.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFile } = require('node:fs/promises');
|
||||
const https = require('node:https');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerRemoteExecutionHttpsActivationClient,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const AUTHORIZATION =
|
||||
`Worker ql3w_worker_primary_${Buffer.alloc(32, 7).toString('base64url')}`;
|
||||
const fixtures = path.resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls',
|
||||
);
|
||||
|
||||
async function material(name) {
|
||||
return readFile(path.join(fixtures, name));
|
||||
}
|
||||
|
||||
test('completes a real TLS 1.3 mutual-auth activation exchange', async () => {
|
||||
const [ca, serverCertificate, serverKey, clientCertificate, clientKey] =
|
||||
await Promise.all([
|
||||
material('ca-cert.pem'),
|
||||
material('server-cert.pem'),
|
||||
material('server-key.pem'),
|
||||
material('client-cert.pem'),
|
||||
material('client-key.pem'),
|
||||
]);
|
||||
const observations = [];
|
||||
const server = https.createServer({
|
||||
ca,
|
||||
cert: serverCertificate,
|
||||
key: serverKey,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
}, (request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
observations.push({
|
||||
authorized: request.socket.authorized,
|
||||
protocol: request.socket.getProtocol(),
|
||||
authorization: request.headers.authorization,
|
||||
path: request.url,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
schema: 'qinglong/remote-run-activation@v1',
|
||||
status: 'applied',
|
||||
snapshot: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
runStatus: 'dispatching',
|
||||
attemptStatus: 'starting',
|
||||
leaseVersion: 4,
|
||||
leaseGeneration: 3,
|
||||
callbackSequence: 0,
|
||||
},
|
||||
});
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(body)),
|
||||
});
|
||||
response.end(body);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const shared = new WorkerIngressHttpsClient({
|
||||
origin: `https://127.0.0.1:${address.port}`,
|
||||
credentials: {
|
||||
async load() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: clientCertificate,
|
||||
privateKeyPem: clientKey,
|
||||
trustAnchors: [ca],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const activation = new WorkerRemoteExecutionHttpsActivationClient({
|
||||
client: shared,
|
||||
});
|
||||
const result = await activation.acknowledgeStarting({
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
expectedLeaseVersion: 4,
|
||||
eventId: '018f0000-0000-7000-8000-000000000002',
|
||||
});
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.deepEqual(observations, [{
|
||||
authorized: true,
|
||||
protocol: 'TLSv1.3',
|
||||
authorization: AUTHORIZATION,
|
||||
path: `/api/v3/worker-ingress/workers/edge-1/sessions/${SESSION_ID}/starting`,
|
||||
body: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
expectedLeaseVersion: 4,
|
||||
},
|
||||
}]);
|
||||
} finally {
|
||||
shared.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { mkdtemp, lstat, rm } = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/cluster-execution-revision');
|
||||
const {
|
||||
createClusterRemoteExecutionOffer,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
const {
|
||||
createRemoteExecutionOfferPullBody,
|
||||
} = require('@qinglong/runtime-core/remote-offer-delivery');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('@qinglong/runtime-core');
|
||||
const {
|
||||
WorkerRemoteOfferFileJournal,
|
||||
WorkerRemoteOfferPullCoordinator,
|
||||
createWorkerRemoteOfferClaimRecord,
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const SESSION = '018f0000-0000-7000-8000-000000000001';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
const STATS = Object.freeze({
|
||||
pages: 1,
|
||||
candidates: 1,
|
||||
plansUnavailable: 0,
|
||||
placementMismatches: 0,
|
||||
claimAttempts: 1,
|
||||
claimRaces: 0,
|
||||
});
|
||||
|
||||
const session = Object.freeze({
|
||||
workerId: 'edge-1',
|
||||
sessionId: SESSION,
|
||||
generation: 2,
|
||||
});
|
||||
|
||||
function executionRevision() {
|
||||
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: '/bin/true', args: [] },
|
||||
environment: [],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function offerFromRequest(request, version = 0) {
|
||||
const revision = executionRevision();
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: request.body.offerId,
|
||||
deliveryKind: version === 0 ? 'new_claim' : 'lease_recovery',
|
||||
executionDigest: revision.contentDigest,
|
||||
candidate: {
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
priority: 1,
|
||||
queuedAtMs: 10,
|
||||
attemptCreatedAtMs: 11,
|
||||
attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
},
|
||||
worker: {
|
||||
workerId: session.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
},
|
||||
lease: {
|
||||
attemptId: 'attempt-1',
|
||||
runId: 'run-1',
|
||||
status: 'leased',
|
||||
version,
|
||||
leaseGeneration: 1,
|
||||
workerId: session.workerId,
|
||||
workerSessionId: session.sessionId,
|
||||
workerGeneration: session.generation,
|
||||
leaseTokenDigest: digestRunDispatchLeaseToken(request.body.leaseToken),
|
||||
acquiredAtMs: 20,
|
||||
renewedAtMs: 20 + version,
|
||||
expiresAtMs: 30_020 + version,
|
||||
updatedAtMs: 20 + version,
|
||||
},
|
||||
leaseToken: request.body.leaseToken,
|
||||
executionRevision: revision,
|
||||
placementScore: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function journalFixture(t, maximumEntries = 64) {
|
||||
const parent = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-offer-'));
|
||||
t.after(() => rm(parent, { recursive: true, force: true }));
|
||||
const rootDirectory = path.join(parent, 'inbox');
|
||||
const journal = new WorkerRemoteOfferFileJournal({
|
||||
rootDirectory,
|
||||
maximumEntries,
|
||||
ownershipStaleMs: 5_000,
|
||||
});
|
||||
await journal.acquireOwnership();
|
||||
t.after(() => journal.releaseOwnership().catch(() => undefined));
|
||||
return { journal, rootDirectory };
|
||||
}
|
||||
|
||||
test('persists the claim intent and accepted capability in private atomic files', async (t) => {
|
||||
const { journal, rootDirectory } = await journalFixture(t);
|
||||
const claim = createWorkerRemoteOfferClaimRecord({
|
||||
workerId: session.workerId,
|
||||
workerSessionId: session.sessionId,
|
||||
workerGeneration: session.generation,
|
||||
offerId: 'offer-1',
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
}, 1_000);
|
||||
await journal.createPendingClaim(claim);
|
||||
assert.equal((await journal.readPendingClaim()).offerId, 'offer-1');
|
||||
|
||||
const request = {
|
||||
body: {
|
||||
offerId: claim.offerId,
|
||||
leaseToken: claim.leaseToken,
|
||||
},
|
||||
};
|
||||
const accepted = await journal.acceptOffer(offerFromRequest(request), 1_001);
|
||||
assert.equal(accepted.status, 'accepted');
|
||||
const replayed = await journal.acceptOffer(offerFromRequest(request), 1_002);
|
||||
assert.equal(replayed.status, 'replayed');
|
||||
assert.equal(replayed.record.revision, 0);
|
||||
assert.equal(
|
||||
(await lstat(path.join(rootDirectory, 'offers', 'offer-1.json'))).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assert.equal((await lstat(rootDirectory)).mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
test('uses one revision-fenced inbox record through ACK and spawn barriers', async (t) => {
|
||||
const { journal } = await journalFixture(t);
|
||||
const request = {
|
||||
body: {
|
||||
offerId: 'offer-state-machine-1',
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000002',
|
||||
},
|
||||
};
|
||||
let record = (await journal.acceptOffer(offerFromRequest(request), 1_000)).record;
|
||||
assert.equal(record.state, 'accepted');
|
||||
|
||||
const advance = async (patch) => {
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...record,
|
||||
...patch,
|
||||
revision: record.revision + 1,
|
||||
updatedAtMs: record.updatedAtMs + 1,
|
||||
});
|
||||
await journal.replaceOffer(next, record.revision);
|
||||
record = await journal.readOffer(record.offer.offerId);
|
||||
};
|
||||
|
||||
await advance({ state: 'starting_acknowledged' });
|
||||
await advance({
|
||||
state: 'launching',
|
||||
executorStartedAtMs: 1_002,
|
||||
logArtifactId: 'log-artifact-1',
|
||||
completionReceiptCallbackSequence: 1,
|
||||
completionReceiptTokenDigest: 'b'.repeat(64),
|
||||
});
|
||||
await advance({
|
||||
state: 'started',
|
||||
executorHandle: 'pid:123:boot:abc',
|
||||
executorStartedAtMs: 1_002,
|
||||
logArtifactId: 'log-artifact-1',
|
||||
});
|
||||
await advance({ state: 'running_acknowledged' });
|
||||
|
||||
assert.equal(record.state, 'running_acknowledged');
|
||||
assert.equal(record.revision, 4);
|
||||
assert.equal(record.offer.leaseToken, request.body.leaseToken);
|
||||
assert.equal(record.executorHandle, 'pid:123:boot:abc');
|
||||
assert.equal(record.completionReceiptTokenDigest, 'b'.repeat(64));
|
||||
|
||||
const regressed = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
schemaVersion: 1,
|
||||
revision: record.revision + 1,
|
||||
state: 'starting_acknowledged',
|
||||
offer: record.offer,
|
||||
acceptedAtMs: record.acceptedAtMs,
|
||||
updatedAtMs: record.updatedAtMs + 1,
|
||||
});
|
||||
await assert.rejects(
|
||||
journal.replaceOffer(regressed, record.revision),
|
||||
/invalid_transition/,
|
||||
);
|
||||
await assert.rejects(
|
||||
journal.replaceOffer({ ...record, revision: record.revision + 2 }, record.revision),
|
||||
/offer_revision_conflict/,
|
||||
);
|
||||
assert.equal((await journal.readOffer(record.offer.offerId)).revision, 4);
|
||||
});
|
||||
|
||||
test('lists the single execution inbox authority with a stable bounded cursor', async (t) => {
|
||||
const { journal } = await journalFixture(t);
|
||||
for (const offerId of ['offer-page-a', 'offer-page-b', 'offer-page-c']) {
|
||||
await journal.acceptOffer(offerFromRequest({
|
||||
body: {
|
||||
offerId,
|
||||
leaseToken: `worker_generated_lease_capability_${offerId}`,
|
||||
},
|
||||
}), 1_000);
|
||||
}
|
||||
const first = await journal.listOffers({ limit: 2 });
|
||||
assert.deepEqual(
|
||||
first.records.map((record) => record.offer.offerId),
|
||||
['offer-page-a', 'offer-page-b'],
|
||||
);
|
||||
assert.equal(first.nextAfterOfferId, 'offer-page-b');
|
||||
const second = await journal.listOffers({
|
||||
afterOfferId: first.nextAfterOfferId,
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
second.records.map((record) => record.offer.offerId),
|
||||
['offer-page-c'],
|
||||
);
|
||||
assert.equal(second.nextAfterOfferId, undefined);
|
||||
await assert.rejects(journal.listOffers({ limit: 65 }), /invalid_configuration/);
|
||||
});
|
||||
|
||||
test('keeps one stable claim through transport loss, bounded backoff and restart', async (t) => {
|
||||
const { journal, rootDirectory } = await journalFixture(t);
|
||||
let now = 1_000;
|
||||
const firstRequests = [];
|
||||
const first = new WorkerRemoteOfferPullCoordinator({
|
||||
journal,
|
||||
currentSession: () => session,
|
||||
now: () => now,
|
||||
random: () => 0.5,
|
||||
backoffBaseMs: 1_000,
|
||||
transport: {
|
||||
async exchange(request) {
|
||||
firstRequests.push(request);
|
||||
throw new Error('response lost');
|
||||
},
|
||||
},
|
||||
});
|
||||
const unavailable = await first.pull(session);
|
||||
assert.equal(unavailable.status, 'unavailable');
|
||||
assert.equal(unavailable.nextAttemptAtMs, 1_500);
|
||||
assert.equal(firstRequests.length, 1);
|
||||
|
||||
now = 1_400;
|
||||
const suppressed = await first.pull(session);
|
||||
assert.equal(suppressed.status, 'backoff');
|
||||
assert.equal(firstRequests.length, 1);
|
||||
|
||||
await journal.releaseOwnership();
|
||||
const resumedJournal = new WorkerRemoteOfferFileJournal({
|
||||
rootDirectory,
|
||||
ownershipStaleMs: 5_000,
|
||||
});
|
||||
await resumedJournal.acquireOwnership();
|
||||
t.after(() => resumedJournal.releaseOwnership().catch(() => undefined));
|
||||
now = 1_500;
|
||||
let resumedRequest;
|
||||
const resumed = new WorkerRemoteOfferPullCoordinator({
|
||||
journal: resumedJournal,
|
||||
currentSession: () => session,
|
||||
now: () => now,
|
||||
random: () => 0,
|
||||
transport: {
|
||||
async exchange(request) {
|
||||
resumedRequest = request;
|
||||
return JSON.stringify(createRemoteExecutionOfferPullBody({
|
||||
status: 'offered',
|
||||
offer: offerFromRequest(request),
|
||||
stats: STATS,
|
||||
truncated: false,
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await resumed.pull(session);
|
||||
assert.equal(result.status, 'accepted');
|
||||
assert.equal(resumedRequest.body.offerId, firstRequests[0].body.offerId);
|
||||
assert.equal(resumedRequest.body.leaseToken, firstRequests[0].body.leaseToken);
|
||||
assert.equal(await resumedJournal.readPendingClaim(), undefined);
|
||||
assert.equal(
|
||||
(await resumedJournal.readOffer(resumedRequest.body.offerId)).offer.leaseToken,
|
||||
resumedRequest.body.leaseToken,
|
||||
);
|
||||
});
|
||||
|
||||
test('writes the inbox before clearing the pending claim and rejects target drift', async (t) => {
|
||||
const events = [];
|
||||
let stored;
|
||||
let pending;
|
||||
const journal = {
|
||||
async readPendingClaim() { return pending; },
|
||||
async createPendingClaim(record) { pending = record; return record; },
|
||||
async replacePendingClaim(record) { pending = record; return record; },
|
||||
async clearPendingClaim() { events.push('clear'); pending = undefined; },
|
||||
async acceptOffer(offer, acceptedAtMs) {
|
||||
events.push('accept');
|
||||
stored = { schemaVersion: 1, revision: 0, state: 'accepted', offer, acceptedAtMs, updatedAtMs: acceptedAtMs };
|
||||
return { status: 'accepted', record: stored };
|
||||
},
|
||||
async readOffer() { return stored; },
|
||||
};
|
||||
const coordinator = new WorkerRemoteOfferPullCoordinator({
|
||||
journal,
|
||||
currentSession: () => session,
|
||||
now: () => 1_000,
|
||||
transport: {
|
||||
async exchange(request) {
|
||||
return JSON.stringify(createRemoteExecutionOfferPullBody({
|
||||
status: 'offered',
|
||||
offer: offerFromRequest(request),
|
||||
stats: STATS,
|
||||
truncated: false,
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal((await coordinator.pull(session)).status, 'accepted');
|
||||
assert.deepEqual(events, ['accept', 'clear']);
|
||||
});
|
||||
|
||||
test('retains the old claim without accepting when the current Session changes', async () => {
|
||||
let current = session;
|
||||
let pending;
|
||||
let accepted = false;
|
||||
const journal = {
|
||||
async readPendingClaim() { return pending; },
|
||||
async createPendingClaim(record) { pending = record; return record; },
|
||||
async replacePendingClaim(record) { pending = record; return record; },
|
||||
async clearPendingClaim() { pending = undefined; },
|
||||
async acceptOffer() { accepted = true; throw new Error('must not accept'); },
|
||||
async readOffer() { return undefined; },
|
||||
};
|
||||
const coordinator = new WorkerRemoteOfferPullCoordinator({
|
||||
journal,
|
||||
currentSession: () => current,
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
transport: {
|
||||
async exchange(request) {
|
||||
current = { ...session, generation: 3 };
|
||||
return JSON.stringify(createRemoteExecutionOfferPullBody({
|
||||
status: 'offered',
|
||||
offer: offerFromRequest(request),
|
||||
stats: STATS,
|
||||
truncated: false,
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await coordinator.pull(session);
|
||||
assert.equal(result.status, 'unavailable');
|
||||
assert.equal(accepted, false);
|
||||
assert.equal(pending.workerGeneration, 2);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { PassThrough } = require('node:stream');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerRemoteOfferHttpsTransport,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const AUTHORIZATION =
|
||||
`Worker ql3w_worker_primary_${Buffer.alloc(32, 7).toString('base64url')}`;
|
||||
const PATH =
|
||||
'/api/v3/worker-ingress/workers/edge-1/sessions/018f0000-0000-7000-8000-000000000001/offers';
|
||||
|
||||
function requestFactory(responseFactory, observations) {
|
||||
return (options, callback) => {
|
||||
observations.options = options;
|
||||
const request = new EventEmitter();
|
||||
request.setTimeout = (timeout, handler) => {
|
||||
observations.timeout = timeout;
|
||||
observations.timeoutHandler = handler;
|
||||
return request;
|
||||
};
|
||||
request.destroy = (error) => {
|
||||
if (error) queueMicrotask(() => request.emit('error', error));
|
||||
};
|
||||
request.end = (body) => {
|
||||
observations.body = Buffer.from(body);
|
||||
const response = responseFactory();
|
||||
queueMicrotask(() => callback(response));
|
||||
};
|
||||
return request;
|
||||
};
|
||||
}
|
||||
|
||||
function response(body, headers = {}) {
|
||||
const stream = new PassThrough();
|
||||
stream.statusCode = 200;
|
||||
stream.headers = {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(body)),
|
||||
...headers,
|
||||
};
|
||||
queueMicrotask(() => stream.end(body));
|
||||
return stream;
|
||||
}
|
||||
|
||||
function credentials() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: 'client certificate',
|
||||
privateKeyPem: 'client private key',
|
||||
trustAnchors: ['trusted ca'],
|
||||
};
|
||||
}
|
||||
|
||||
function request() {
|
||||
return {
|
||||
path: PATH,
|
||||
body: {
|
||||
workerGeneration: 2,
|
||||
offerId: 'offer-1',
|
||||
leaseToken: 'worker_generated_lease_capability_0000000000000001',
|
||||
},
|
||||
maximumResponseBytes: 1024,
|
||||
};
|
||||
}
|
||||
|
||||
test('uses one bounded TLS 1.3 mTLS POST with the Worker credential', async () => {
|
||||
const observations = {};
|
||||
const transport = new WorkerRemoteOfferHttpsTransport({
|
||||
origin: 'https://cluster.example:7443',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestTimeoutMs: 5_000,
|
||||
requestFactory: requestFactory(
|
||||
() => response('{"status":"idle"}'),
|
||||
observations,
|
||||
),
|
||||
});
|
||||
try {
|
||||
const result = await transport.exchange(request());
|
||||
assert.equal(Buffer.from(result).toString('utf8'), '{"status":"idle"}');
|
||||
assert.equal(observations.options.protocol, 'https:');
|
||||
assert.equal(observations.options.hostname, 'cluster.example');
|
||||
assert.equal(observations.options.port, '7443');
|
||||
assert.equal(observations.options.minVersion, 'TLSv1.3');
|
||||
assert.equal(observations.options.rejectUnauthorized, true);
|
||||
assert.equal(observations.options.headers.authorization, AUTHORIZATION);
|
||||
assert.equal(observations.options.path, PATH);
|
||||
assert.equal(observations.timeout, 5_000);
|
||||
assert.deepEqual(JSON.parse(observations.body.toString('utf8')), request().body);
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plaintext origins, malformed credentials and oversized responses', async () => {
|
||||
assert.throws(
|
||||
() => new WorkerRemoteOfferHttpsTransport({
|
||||
origin: 'http://cluster.example',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
}),
|
||||
/invalid_configuration/,
|
||||
);
|
||||
|
||||
const malformed = new WorkerRemoteOfferHttpsTransport({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: {
|
||||
async load() { return { ...credentials(), authorization: 'Bearer token' }; },
|
||||
},
|
||||
requestFactory: requestFactory(
|
||||
() => response('{}'),
|
||||
{},
|
||||
),
|
||||
});
|
||||
await assert.rejects(malformed.exchange(request()), /credentials_unavailable/);
|
||||
malformed.close();
|
||||
|
||||
const observations = {};
|
||||
const oversized = new WorkerRemoteOfferHttpsTransport({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestFactory: requestFactory(() => {
|
||||
const stream = new PassThrough();
|
||||
stream.statusCode = 200;
|
||||
stream.headers = { 'content-type': 'application/json' };
|
||||
queueMicrotask(() => stream.end(Buffer.alloc(1025, 1)));
|
||||
return stream;
|
||||
}, observations),
|
||||
});
|
||||
await assert.rejects(oversized.exchange(request()), /response_too_large/);
|
||||
oversized.close();
|
||||
});
|
||||
|
||||
test('propagates caller cancellation and refuses work after close', async () => {
|
||||
const transport = new WorkerRemoteOfferHttpsTransport({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestFactory: requestFactory(() => response('{}'), {}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('shutdown'));
|
||||
await assert.rejects(
|
||||
transport.exchange({ ...request(), signal: controller.signal }),
|
||||
/shutdown/,
|
||||
);
|
||||
transport.close();
|
||||
await assert.rejects(transport.exchange(request()), /closed/);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/cluster-execution-revision');
|
||||
const {
|
||||
createClusterRemoteExecutionOffer,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('@qinglong/runtime-core/run-dispatch-lease');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
WorkerRemoteSecretHttpsProvider,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const SECRET_REF = createSecretRef({ projectId: 'project-1', name: 'token' });
|
||||
|
||||
function acceptedOffer() {
|
||||
const executionRevision = 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: '/bin/true', args: [] },
|
||||
environment: [{ name: 'TOKEN', kind: 'secret', secretRef: SECRET_REF }],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: 'offer-1', deliveryKind: 'new_claim',
|
||||
executionDigest: executionRevision.contentDigest,
|
||||
candidate: {
|
||||
runId: 'run-1', attemptId: 'attempt-1', projectId: 'project-1',
|
||||
taskId: 'task-1', taskRevision: TASK_REVISION, priority: 1,
|
||||
queuedAtMs: 10, attemptCreatedAtMs: 11, attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
},
|
||||
worker: { workerId: 'edge-1', sessionId: SESSION_ID, generation: 2 },
|
||||
lease: {
|
||||
attemptId: 'attempt-1', runId: 'run-1', status: 'leased', version: 4,
|
||||
leaseGeneration: 3, workerId: 'edge-1', workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2, leaseTokenDigest: digestRunDispatchLeaseToken(LEASE_TOKEN),
|
||||
acquiredAtMs: 20, renewedAtMs: 20, expiresAtMs: 30_020,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
leaseToken: LEASE_TOKEN, executionRevision, placementScore: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function requestFor(offer) {
|
||||
return {
|
||||
projectId: offer.candidate.projectId,
|
||||
taskId: offer.candidate.taskId,
|
||||
taskRevision: offer.candidate.taskRevision,
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
offerId: offer.offerId,
|
||||
executionDigest: offer.executionDigest,
|
||||
secretRefs: [SECRET_REF],
|
||||
};
|
||||
}
|
||||
|
||||
test('rehydrates lease authority from inbox and delivers one exact Secret batch', async () => {
|
||||
const offer = acceptedOffer();
|
||||
let transport;
|
||||
const provider = new WorkerRemoteSecretHttpsProvider({
|
||||
inbox: {
|
||||
async readOffer(offerId) {
|
||||
assert.equal(offerId, offer.offerId);
|
||||
return { state: 'starting_acknowledged', offer };
|
||||
},
|
||||
},
|
||||
client: {
|
||||
async postJson(request) {
|
||||
transport = request;
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: 'qinglong/remote-secret-delivery@v1',
|
||||
runId: 'run-1', attemptId: 'attempt-1', offerId: 'offer-1',
|
||||
executionDigest: offer.executionDigest,
|
||||
values: [{ secretRef: SECRET_REF, value: 'resolved-value' }],
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolution = await provider.resolve(requestFor(offer));
|
||||
assert.deepEqual(resolution.values, [
|
||||
{ secretRef: SECRET_REF, value: 'resolved-value' },
|
||||
]);
|
||||
assert.equal(transport.path.endsWith(`/sessions/${SESSION_ID}/secrets`), true);
|
||||
assert.equal(transport.body.leaseToken, LEASE_TOKEN);
|
||||
assert.equal(transport.maximumRequestBytes, 64 * 1024);
|
||||
assert.equal(JSON.stringify(requestFor(offer)).includes(LEASE_TOKEN), false);
|
||||
});
|
||||
|
||||
test('rejects a stale inbox identity before sending the capability', async () => {
|
||||
const offer = acceptedOffer();
|
||||
let calls = 0;
|
||||
const provider = new WorkerRemoteSecretHttpsProvider({
|
||||
inbox: {
|
||||
async readOffer() { return { state: 'starting_acknowledged', offer }; },
|
||||
},
|
||||
client: { async postJson() { calls += 1; } },
|
||||
});
|
||||
await assert.rejects(
|
||||
provider.resolve({ ...requestFor(offer), executionDigest: 'b'.repeat(64) }),
|
||||
/authority_mismatch/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('rejects response authority drift and does not return plaintext', async () => {
|
||||
const offer = acceptedOffer();
|
||||
const provider = new WorkerRemoteSecretHttpsProvider({
|
||||
inbox: {
|
||||
async readOffer() { return { state: 'starting_acknowledged', offer }; },
|
||||
},
|
||||
client: {
|
||||
async postJson() {
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: 'qinglong/remote-secret-delivery@v1',
|
||||
runId: 'run-other', attemptId: 'attempt-1', offerId: 'offer-1',
|
||||
executionDigest: offer.executionDigest,
|
||||
values: [{ secretRef: SECRET_REF, value: 'must-not-escape' }],
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(provider.resolve(requestFor(offer)), /response_invalid/);
|
||||
});
|
||||
|
||||
test('does not fetch Secrets before starting ACK or after the launch barrier', async () => {
|
||||
const offer = acceptedOffer();
|
||||
for (const state of ['accepted', 'launching']) {
|
||||
let calls = 0;
|
||||
const provider = new WorkerRemoteSecretHttpsProvider({
|
||||
inbox: { async readOffer() { return { state, offer }; } },
|
||||
client: { async postJson() { calls += 1; } },
|
||||
});
|
||||
await assert.rejects(provider.resolve(requestFor(offer)), /offer_unavailable/);
|
||||
assert.equal(calls, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createRemoteWorkerArtifactUploadResponseBody,
|
||||
createRemoteWorkerCompletionResponseBody,
|
||||
parseRemoteWorkerArtifactUploadHeader,
|
||||
} = require('@qinglong/runtime-core/remote-worker-completion');
|
||||
const {
|
||||
WorkerRemoteArtifactHttpsUploader,
|
||||
WorkerRemoteCompletionHttpsError,
|
||||
WorkerRemoteExecutionHttpsCompletionClient,
|
||||
} = require('@qinglong/worker-runtime/completion-transport');
|
||||
|
||||
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_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) {
|
||||
return {
|
||||
...fence(),
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
truncated: false,
|
||||
content: (async function* () { yield content; })(),
|
||||
};
|
||||
}
|
||||
|
||||
function completionCommand(content, overrides = {}) {
|
||||
return {
|
||||
...fence(),
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: CALLBACK_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,
|
||||
},
|
||||
executorType: 'remote_worker',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('uploads one framed Artifact and verifies exact response authority', async () => {
|
||||
const content = Buffer.from('worker-log');
|
||||
let observed;
|
||||
const uploader = new WorkerRemoteArtifactHttpsUploader({
|
||||
client: {
|
||||
async postStream(request) {
|
||||
const chunks = [];
|
||||
for await (const chunk of request.body) chunks.push(Buffer.from(chunk));
|
||||
const envelope = Buffer.concat(chunks);
|
||||
const headerLength = envelope.readUInt32BE(0);
|
||||
const header = parseRemoteWorkerArtifactUploadHeader(
|
||||
envelope.subarray(4, 4 + headerLength),
|
||||
{ workerId: 'worker-1', workerSessionId: SESSION_ID },
|
||||
);
|
||||
observed = { request, envelope, header, headerLength };
|
||||
return Buffer.from(JSON.stringify(
|
||||
createRemoteWorkerArtifactUploadResponseBody({
|
||||
status: 'stored',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
sha256: createHash('sha256').update(content).digest('hex'),
|
||||
truncated: false,
|
||||
}),
|
||||
));
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await uploader.upload(uploadCommand(content));
|
||||
assert.equal(result.status, 'stored');
|
||||
assert.equal(result.sha256, createHash('sha256').update(content).digest('hex'));
|
||||
assert.equal(
|
||||
observed.request.path,
|
||||
`/api/v3/worker-ingress/workers/worker-1/sessions/${SESSION_ID}/artifacts`,
|
||||
);
|
||||
assert.equal(observed.request.byteLength, observed.envelope.byteLength);
|
||||
assert.deepEqual(
|
||||
observed.envelope.subarray(4 + observed.headerLength),
|
||||
content,
|
||||
);
|
||||
assert.equal(observed.header.leaseToken, LEASE_TOKEN);
|
||||
assert.equal(observed.header.logArtifactId, LOG_ARTIFACT_ID);
|
||||
});
|
||||
|
||||
test('rejects Artifact receipt authority drift', async () => {
|
||||
const content = Buffer.from('log');
|
||||
const uploader = new WorkerRemoteArtifactHttpsUploader({
|
||||
client: {
|
||||
async postStream(request) {
|
||||
for await (const _chunk of request.body) { /* consume */ }
|
||||
return Buffer.from(JSON.stringify(
|
||||
createRemoteWorkerArtifactUploadResponseBody({
|
||||
status: 'stored',
|
||||
projectId: 'project-other',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
sha256: 'c'.repeat(64),
|
||||
truncated: false,
|
||||
}),
|
||||
));
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
uploader.upload(uploadCommand(content)),
|
||||
(error) =>
|
||||
error instanceof WorkerRemoteCompletionHttpsError &&
|
||||
error.reason === 'response_invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('posts exact completion JSON and binds the response to the receipt', async () => {
|
||||
const content = Buffer.from('worker-log');
|
||||
let observed;
|
||||
const client = new WorkerRemoteExecutionHttpsCompletionClient({
|
||||
client: {
|
||||
async postJson(request) {
|
||||
observed = request;
|
||||
return Buffer.from(JSON.stringify(
|
||||
createRemoteWorkerCompletionResponseBody({
|
||||
status: 'applied',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
callbackSequence: 1,
|
||||
}),
|
||||
));
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await client.complete(completionCommand(content)), {
|
||||
status: 'applied',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
callbackSequence: 1,
|
||||
});
|
||||
assert.equal(
|
||||
observed.path,
|
||||
`/api/v3/worker-ingress/workers/worker-1/sessions/${SESSION_ID}/completion`,
|
||||
);
|
||||
assert.equal(observed.body.schema, 'qinglong/remote-worker-completion@v1');
|
||||
assert.equal('workerId' in observed.body, false);
|
||||
assert.equal('workerSessionId' in observed.body, false);
|
||||
assert.equal(observed.body.callbackTokenDigest, CALLBACK_DIGEST);
|
||||
assert.equal(observed.body.leaseToken, LEASE_TOKEN);
|
||||
});
|
||||
|
||||
test('rejects non-Worker execution and response authority drift', async () => {
|
||||
const content = Buffer.from('log');
|
||||
let calls = 0;
|
||||
const client = new WorkerRemoteExecutionHttpsCompletionClient({
|
||||
client: {
|
||||
async postJson() {
|
||||
calls += 1;
|
||||
return Buffer.from(JSON.stringify(
|
||||
createRemoteWorkerCompletionResponseBody({
|
||||
status: 'applied',
|
||||
runId: 'run-other',
|
||||
attemptId: 'attempt-1',
|
||||
callbackSequence: 1,
|
||||
}),
|
||||
));
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
client.complete(completionCommand(content, { executorType: 'local_process' })),
|
||||
(error) =>
|
||||
error instanceof WorkerRemoteCompletionHttpsError &&
|
||||
error.reason === 'request_invalid',
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
await assert.rejects(
|
||||
client.complete(completionCommand(content)),
|
||||
(error) =>
|
||||
error instanceof WorkerRemoteCompletionHttpsError &&
|
||||
error.reason === 'response_invalid',
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { readFile } = require('node:fs/promises');
|
||||
const https = require('node:https');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
|
||||
createRemoteWorkerArtifactUploadResponseBody,
|
||||
createRemoteWorkerCompletionResponseBody,
|
||||
parseRemoteWorkerArtifactUploadHeader,
|
||||
parseRemoteWorkerCompletionRequestBody,
|
||||
} = require('@qinglong/runtime-core/remote-worker-completion');
|
||||
const {
|
||||
createRemoteWorkerLeaseControlResponseBody,
|
||||
parseRemoteWorkerLeaseControlRequestBody,
|
||||
} = require('@qinglong/runtime-core/remote-worker-lease-control');
|
||||
const {
|
||||
WorkerIngressHttpsClient,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
const {
|
||||
WorkerRemoteArtifactHttpsUploader,
|
||||
WorkerRemoteExecutionHttpsCompletionClient,
|
||||
} = require('../dist/remote-execution/transport/remoteWorkerCompletionHttpsClient');
|
||||
const {
|
||||
WorkerRemoteLeaseControlHttpsClient,
|
||||
} = require('../dist/remote-execution/transport/remoteWorkerLeaseControlHttpsClient');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const AUTHORIZATION =
|
||||
`Worker ql3w_worker_primary_${Buffer.alloc(32, 7).toString('base64url')}`;
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const LOG_ARTIFACT_ID = `wlog-${'a'.repeat(30)}`;
|
||||
const CALLBACK_DIGEST = 'b'.repeat(64);
|
||||
const fixtures = path.resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls',
|
||||
);
|
||||
|
||||
async function material(name) {
|
||||
return readFile(path.join(fixtures, name));
|
||||
}
|
||||
|
||||
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 json(response, body) {
|
||||
const serialized = Buffer.from(JSON.stringify(body));
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(serialized.byteLength),
|
||||
});
|
||||
response.end(serialized);
|
||||
}
|
||||
|
||||
test('streams Artifact, completion and lease control over one TLS 1.3 mTLS client', async () => {
|
||||
const [ca, serverCertificate, serverKey, clientCertificate, clientKey] =
|
||||
await Promise.all([
|
||||
material('ca-cert.pem'),
|
||||
material('server-cert.pem'),
|
||||
material('server-key.pem'),
|
||||
material('client-cert.pem'),
|
||||
material('client-key.pem'),
|
||||
]);
|
||||
const content = Buffer.from('first log frame\nsecond log frame\n');
|
||||
const digest = createHash('sha256').update(content).digest('hex');
|
||||
const observations = [];
|
||||
const server = https.createServer({
|
||||
ca,
|
||||
cert: serverCertificate,
|
||||
key: serverKey,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
}, (request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const common = {
|
||||
authorized: request.socket.authorized,
|
||||
protocol: request.socket.getProtocol(),
|
||||
authorization: request.headers.authorization,
|
||||
contentLength: request.headers['content-length'],
|
||||
contentType: request.headers['content-type'],
|
||||
path: request.url,
|
||||
};
|
||||
if (request.url.endsWith('/artifacts')) {
|
||||
const headerLength = body.readUInt32BE(0);
|
||||
const header = parseRemoteWorkerArtifactUploadHeader(
|
||||
body.subarray(4, 4 + headerLength),
|
||||
{ workerId: 'worker-1', workerSessionId: SESSION_ID },
|
||||
);
|
||||
const artifact = body.subarray(4 + headerLength);
|
||||
observations.push({ ...common, header, artifact: artifact.toString() });
|
||||
json(response, createRemoteWorkerArtifactUploadResponseBody({
|
||||
status: 'stored',
|
||||
projectId: header.projectId,
|
||||
runId: header.runId,
|
||||
attemptId: header.attemptId,
|
||||
logArtifactId: header.logArtifactId,
|
||||
byteLength: artifact.byteLength,
|
||||
sha256: createHash('sha256').update(artifact).digest('hex'),
|
||||
truncated: header.truncated,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (request.url.endsWith('/lease-control')) {
|
||||
const control = parseRemoteWorkerLeaseControlRequestBody(
|
||||
JSON.parse(body.toString('utf8')),
|
||||
{ workerId: 'worker-1', workerSessionId: SESSION_ID },
|
||||
);
|
||||
observations.push({ ...common, control });
|
||||
json(response, createRemoteWorkerLeaseControlResponseBody({
|
||||
status: 'renewed',
|
||||
projectId: control.projectId,
|
||||
runId: control.runId,
|
||||
attemptId: control.attemptId,
|
||||
offerId: control.offerId,
|
||||
leaseGeneration: control.leaseGeneration,
|
||||
leaseVersion: control.expectedLeaseVersion + 1,
|
||||
renewedAtMs: 1_000,
|
||||
expiresAtMs: 31_000,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const completion = parseRemoteWorkerCompletionRequestBody(
|
||||
JSON.parse(body.toString('utf8')),
|
||||
{ workerId: 'worker-1', workerSessionId: SESSION_ID },
|
||||
);
|
||||
observations.push({ ...common, completion });
|
||||
json(response, createRemoteWorkerCompletionResponseBody({
|
||||
status: 'applied',
|
||||
runId: completion.runId,
|
||||
attemptId: completion.attemptId,
|
||||
callbackSequence: completion.callbackSequence,
|
||||
}));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const shared = new WorkerIngressHttpsClient({
|
||||
origin: `https://127.0.0.1:${address.port}`,
|
||||
credentials: {
|
||||
async load() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: clientCertificate,
|
||||
privateKeyPem: clientKey,
|
||||
trustAnchors: [ca],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const uploader = new WorkerRemoteArtifactHttpsUploader({ client: shared });
|
||||
const completion = new WorkerRemoteExecutionHttpsCompletionClient({
|
||||
client: shared,
|
||||
});
|
||||
const leaseControl = new WorkerRemoteLeaseControlHttpsClient({
|
||||
client: shared,
|
||||
});
|
||||
const artifact = await uploader.upload({
|
||||
...fence(),
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
truncated: false,
|
||||
content: (async function* () {
|
||||
yield content.subarray(0, 7);
|
||||
yield content.subarray(7);
|
||||
})(),
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
status: 'stored',
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
sha256: digest,
|
||||
});
|
||||
assert.deepEqual(await completion.complete({
|
||||
...fence(),
|
||||
callbackSequence: 1,
|
||||
callbackTokenDigest: CALLBACK_DIGEST,
|
||||
result: {
|
||||
outcome: 'succeeded',
|
||||
startedAtMs: 100,
|
||||
finishedAtMs: 200,
|
||||
exitCode: 0,
|
||||
},
|
||||
artifact: {
|
||||
logArtifactId: LOG_ARTIFACT_ID,
|
||||
byteLength: content.byteLength,
|
||||
sha256: digest,
|
||||
truncated: false,
|
||||
},
|
||||
executorType: 'remote_worker',
|
||||
}), {
|
||||
status: 'applied',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
callbackSequence: 1,
|
||||
});
|
||||
assert.deepEqual(await leaseControl.control(fence()), {
|
||||
status: 'renewed',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 3,
|
||||
leaseVersion: 5,
|
||||
renewedAtMs: 1_000,
|
||||
expiresAtMs: 31_000,
|
||||
});
|
||||
assert.equal(observations.length, 3);
|
||||
assert.deepEqual(observations.map((value) => ({
|
||||
authorized: value.authorized,
|
||||
protocol: value.protocol,
|
||||
authorization: value.authorization,
|
||||
})), [
|
||||
{ authorized: true, protocol: 'TLSv1.3', authorization: AUTHORIZATION },
|
||||
{ authorized: true, protocol: 'TLSv1.3', authorization: AUTHORIZATION },
|
||||
{ authorized: true, protocol: 'TLSv1.3', authorization: AUTHORIZATION },
|
||||
]);
|
||||
assert.equal(
|
||||
observations[0].path,
|
||||
`/api/v3/worker-ingress/workers/worker-1/sessions/${SESSION_ID}/artifacts`,
|
||||
);
|
||||
assert.equal(
|
||||
observations[0].contentType,
|
||||
REMOTE_WORKER_ARTIFACT_CONTENT_TYPE,
|
||||
);
|
||||
assert.equal(observations[0].artifact, content.toString());
|
||||
assert.equal(observations[0].header.leaseToken, LEASE_TOKEN);
|
||||
assert.equal(
|
||||
observations[1].path,
|
||||
`/api/v3/worker-ingress/workers/worker-1/sessions/${SESSION_ID}/completion`,
|
||||
);
|
||||
assert.equal(observations[1].contentType, 'application/json');
|
||||
assert.equal(observations[1].completion.callbackTokenDigest, CALLBACK_DIGEST);
|
||||
assert.equal(observations[1].completion.artifact.sha256, digest);
|
||||
assert.equal(
|
||||
observations[2].path,
|
||||
`/api/v3/worker-ingress/workers/worker-1/sessions/${SESSION_ID}/lease-control`,
|
||||
);
|
||||
assert.equal(observations[2].control.leaseToken, LEASE_TOKEN);
|
||||
} finally {
|
||||
shared.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { PassThrough } = require('node:stream');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerRemoteLeaseControlHttpsClient,
|
||||
} = require('../dist/remote-execution/remoteOfferDeliveryEntrypoint');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000001';
|
||||
const AUTHORIZATION =
|
||||
`Worker ql3w_worker_primary_${Buffer.alloc(32, 7).toString('base64url')}`;
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
workerId: 'edge-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, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function responseBody(overrides = {}) {
|
||||
return {
|
||||
schema: 'qinglong/remote-worker-lease-control@v1',
|
||||
status: 'renewed', projectId: 'project-1', runId: 'run-1',
|
||||
attemptId: 'attempt-1', offerId: 'offer-1', leaseGeneration: 3,
|
||||
leaseVersion: 5, renewedAtMs: 10_000, expiresAtMs: 40_000,
|
||||
stop: null, terminalStatus: null, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function response(value) {
|
||||
const serialized = JSON.stringify(value);
|
||||
const stream = new PassThrough();
|
||||
stream.statusCode = 200;
|
||||
stream.headers = {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(serialized)),
|
||||
};
|
||||
queueMicrotask(() => stream.end(serialized));
|
||||
return stream;
|
||||
}
|
||||
|
||||
function fixture(responseFactory = () => responseBody()) {
|
||||
const observations = [];
|
||||
const shared = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example:7443',
|
||||
credentials: { async load() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: 'client certificate',
|
||||
privateKeyPem: 'client private key',
|
||||
trustAnchors: ['trusted ca'],
|
||||
};
|
||||
} },
|
||||
requestFactory(options, callback) {
|
||||
const request = new EventEmitter();
|
||||
request.setTimeout = () => request;
|
||||
request.destroy = (error) => {
|
||||
if (error) queueMicrotask(() => request.emit('error', error));
|
||||
};
|
||||
request.end = (body) => {
|
||||
observations.push({
|
||||
path: options.path,
|
||||
body: JSON.parse(Buffer.from(body).toString('utf8')),
|
||||
});
|
||||
queueMicrotask(() => callback(response(responseFactory())));
|
||||
};
|
||||
return request;
|
||||
},
|
||||
});
|
||||
return {
|
||||
observations,
|
||||
shared,
|
||||
client: new WorkerRemoteLeaseControlHttpsClient({ client: shared }),
|
||||
};
|
||||
}
|
||||
|
||||
test('posts a path-bound fence and accepts only the next lease version', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
assert.deepEqual(await f.client.control(command()), {
|
||||
status: 'renewed', projectId: 'project-1', runId: 'run-1',
|
||||
attemptId: 'attempt-1', offerId: 'offer-1', leaseGeneration: 3,
|
||||
leaseVersion: 5, renewedAtMs: 10_000, expiresAtMs: 40_000,
|
||||
});
|
||||
assert.equal(f.observations[0].path,
|
||||
`/api/v3/worker-ingress/workers/edge-1/sessions/${SESSION_ID}/lease-control`);
|
||||
assert.equal('workerId' in f.observations[0].body, false);
|
||||
assert.equal('workerSessionId' in f.observations[0].body, false);
|
||||
assert.equal(f.observations[0].body.leaseToken, LEASE_TOKEN);
|
||||
} finally { f.shared.close(); }
|
||||
});
|
||||
|
||||
test('accepts a durable stop request after the lease is renewed', async () => {
|
||||
const f = fixture(() => responseBody({
|
||||
status: 'stop_requested',
|
||||
stop: { reason: 'user', requestedAtMs: 9_000 },
|
||||
}));
|
||||
try {
|
||||
const result = await f.client.control(command());
|
||||
assert.equal(result.status, 'stop_requested');
|
||||
assert.deepEqual(result.stop, { reason: 'user', requestedAtMs: 9_000 });
|
||||
} finally { f.shared.close(); }
|
||||
});
|
||||
|
||||
test('rejects response identity or lease-version drift', async () => {
|
||||
for (const drift of [
|
||||
{ runId: 'run-other' },
|
||||
{ leaseVersion: 6 },
|
||||
]) {
|
||||
const f = fixture(() => responseBody(drift));
|
||||
try {
|
||||
await assert.rejects(f.client.control(command()), /response_invalid/);
|
||||
} finally { f.shared.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects invalid requests before any transport access', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await assert.rejects(
|
||||
f.client.control(command({ leaseToken: 'short' })),
|
||||
/request_invalid/,
|
||||
);
|
||||
assert.equal(f.observations.length, 0);
|
||||
} finally { f.shared.close(); }
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { mkdtemp, rm } = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
WorkerCertificateFileStore,
|
||||
} = require('../dist/credential/workerCertificateStore');
|
||||
const {
|
||||
WorkerCertificateRenewalCoordinator,
|
||||
} = require('../dist/credential/workerCertificateRenewal');
|
||||
const {
|
||||
createCertificateAuthority,
|
||||
} = require('./helpers/certificateAuthority.cjs');
|
||||
|
||||
const HOUR_MS = 60 * 60_000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
async function fixture(t) {
|
||||
const now = Date.now();
|
||||
const parent = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-renewal-'));
|
||||
t.after(() => rm(parent, { recursive: true, force: true }));
|
||||
return {
|
||||
now,
|
||||
ca: await createCertificateAuthority({ now }),
|
||||
store: new WorkerCertificateFileStore({
|
||||
rootDirectory: path.join(parent, 'identity'),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function coordinator(options) {
|
||||
return new WorkerCertificateRenewalCoordinator({
|
||||
workerId: 'worker-renewal-01',
|
||||
store: options.store,
|
||||
issuer: options.issuer,
|
||||
trustAnchors: { load: async () => [options.ca.certificatePem] },
|
||||
now: () => options.now,
|
||||
random: () => 0,
|
||||
policy: {
|
||||
renewBeforeMs: HOUR_MS,
|
||||
minimumIssuedValidityMs: 2 * HOUR_MS,
|
||||
backoffBaseMs: 10_000,
|
||||
backoffMaximumMs: 60_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('coalesces enrollment and leaves a fresh identity timer-free', async (t) => {
|
||||
const context = await fixture(t);
|
||||
let issueCalls = 0;
|
||||
const renewal = coordinator({
|
||||
...context,
|
||||
issuer: {
|
||||
async issue({ certificateSigningRequestPem }) {
|
||||
issueCalls += 1;
|
||||
return {
|
||||
certificateChainPem: await context.ca.issue(
|
||||
certificateSigningRequestPem,
|
||||
{ notAfterMs: context.now + 30 * DAY_MS },
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstRun = renewal.run();
|
||||
const coalescedRun = renewal.run();
|
||||
assert.equal(firstRun, coalescedRun);
|
||||
const result = await firstRun;
|
||||
|
||||
assert.equal(result.status, 'renewed');
|
||||
assert.equal(issueCalls, 1);
|
||||
const next = await renewal.run();
|
||||
assert.equal(next.status, 'not_due');
|
||||
assert.equal(issueCalls, 1);
|
||||
});
|
||||
|
||||
test('persists bounded backoff and suppresses repeated CA attempts', async (t) => {
|
||||
const context = await fixture(t);
|
||||
let issueCalls = 0;
|
||||
const renewal = coordinator({
|
||||
...context,
|
||||
issuer: {
|
||||
async issue() {
|
||||
issueCalls += 1;
|
||||
throw new Error('CA unavailable');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await renewal.run();
|
||||
assert.equal(failed.status, 'unavailable');
|
||||
assert.equal(failed.nextAttemptAtMs, context.now + 5_000);
|
||||
assert.equal(issueCalls, 1);
|
||||
|
||||
const suppressed = await renewal.run();
|
||||
assert.equal(suppressed.status, 'unavailable');
|
||||
assert.equal(suppressed.nextAttemptAtMs, context.now + 5_000);
|
||||
assert.equal(issueCalls, 1);
|
||||
assert.deepEqual(await context.store.readRenewalState(), {
|
||||
consecutiveFailures: 1,
|
||||
nextAttemptAtMs: context.now + 5_000,
|
||||
lastAttemptAtMs: context.now,
|
||||
lastSuccessAtMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('does not convert caller cancellation into a renewal failure', async (t) => {
|
||||
const context = await fixture(t);
|
||||
const renewal = coordinator({
|
||||
...context,
|
||||
issuer: {
|
||||
async issue() {
|
||||
throw new Error('must not run');
|
||||
},
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('shutdown'));
|
||||
|
||||
await assert.rejects(renewal.run(controller.signal), /shutdown/);
|
||||
assert.deepEqual(await context.store.readRenewalState(), {
|
||||
consecutiveFailures: 0,
|
||||
nextAttemptAtMs: null,
|
||||
lastAttemptAtMs: null,
|
||||
lastSuccessAtMs: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/cluster-execution-revision');
|
||||
const {
|
||||
createClusterRemoteExecutionOffer,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('@qinglong/runtime-core/run-dispatch-lease');
|
||||
const {
|
||||
assertWorkerRemoteExecutionInboxTransition,
|
||||
createWorkerRemoteExecutionInboxRecord,
|
||||
} = require('../dist/remote-execution/executionInbox');
|
||||
const {
|
||||
WorkerRemoteCompletionCoordinator,
|
||||
} = require('../dist/execution/workerCompletionCoordinator');
|
||||
|
||||
const RUN_ID = '019f70e0-0000-7000-8000-000000000201';
|
||||
const ATTEMPT_ID = '019f70e0-0000-7000-8000-000000000202';
|
||||
const SESSION_ID = '019f70e0-0000-7000-8000-000000000203';
|
||||
const TOKEN = Buffer.alloc(32, 0x41);
|
||||
const TOKEN_DIGEST = createHash('sha256').update(TOKEN).digest('hex');
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000009';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
const LOG_ID = `wlog-${'b'.repeat(30)}`;
|
||||
|
||||
function offer() {
|
||||
const executionRevision = 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: '/bin/true', args: [] },
|
||||
environment: [],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: 'offer-completion-1',
|
||||
deliveryKind: 'new_claim',
|
||||
executionDigest: executionRevision.contentDigest,
|
||||
candidate: {
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: TASK_REVISION,
|
||||
priority: 1,
|
||||
queuedAtMs: 10,
|
||||
attemptCreatedAtMs: 11,
|
||||
attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
},
|
||||
worker: { workerId: 'edge-1', sessionId: SESSION_ID, generation: 2 },
|
||||
lease: {
|
||||
attemptId: ATTEMPT_ID,
|
||||
runId: RUN_ID,
|
||||
status: 'leased',
|
||||
version: 0,
|
||||
leaseGeneration: 1,
|
||||
workerId: 'edge-1',
|
||||
workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2,
|
||||
leaseTokenDigest: digestRunDispatchLeaseToken(LEASE_TOKEN),
|
||||
acquiredAtMs: 20,
|
||||
renewedAtMs: 20,
|
||||
expiresAtMs: 30_020,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
leaseToken: LEASE_TOKEN,
|
||||
executionRevision,
|
||||
placementScore: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function launchingRecord() {
|
||||
const accepted = createWorkerRemoteExecutionInboxRecord(offer(), 100);
|
||||
const starting = {
|
||||
...accepted,
|
||||
revision: 1,
|
||||
state: 'starting_acknowledged',
|
||||
updatedAtMs: 101,
|
||||
};
|
||||
const launching = {
|
||||
...starting,
|
||||
revision: 2,
|
||||
state: 'launching',
|
||||
updatedAtMs: 102,
|
||||
executorStartedAtMs: 100,
|
||||
logArtifactId: LOG_ID,
|
||||
completionReceiptCallbackSequence: 1,
|
||||
completionReceiptTokenDigest: TOKEN_DIGEST,
|
||||
};
|
||||
assertWorkerRemoteExecutionInboxTransition(accepted, starting);
|
||||
assertWorkerRemoteExecutionInboxTransition(starting, launching);
|
||||
return launching;
|
||||
}
|
||||
|
||||
function harness(overrides = {}) {
|
||||
let record = launchingRecord();
|
||||
let removed = 0;
|
||||
let uploaded = false;
|
||||
let completed = false;
|
||||
let artifactClosed = false;
|
||||
const receipt = {
|
||||
schemaVersion: 1,
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
callbackSequence: 1,
|
||||
token: TOKEN.toString('base64url'),
|
||||
startedAtMs: 100,
|
||||
finishedAtMs: 200,
|
||||
exitCode: 0,
|
||||
...overrides.receipt,
|
||||
};
|
||||
const inbox = {
|
||||
async readOffer(id) { return id === record.offer.offerId ? record : undefined; },
|
||||
async replaceOffer(next, expectedRevision) {
|
||||
assert.equal(expectedRevision, record.revision);
|
||||
assertWorkerRemoteExecutionInboxTransition(record, next);
|
||||
record = next;
|
||||
},
|
||||
};
|
||||
const receipts = {
|
||||
async read() {
|
||||
if (overrides.readReceipt) return overrides.readReceipt();
|
||||
return receipt;
|
||||
},
|
||||
async remove() {
|
||||
assert.equal(record.state, 'completion_acknowledged');
|
||||
removed += 1;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const artifacts = {
|
||||
async open() {
|
||||
return {
|
||||
logArtifactId: LOG_ID,
|
||||
byteLength: 3,
|
||||
truncated: false,
|
||||
async *chunks() { yield Buffer.from('log'); },
|
||||
async close() { artifactClosed = true; },
|
||||
};
|
||||
},
|
||||
};
|
||||
const uploader = {
|
||||
async upload(command) {
|
||||
assert.equal(command.workerId, 'edge-1');
|
||||
assert.equal(command.workerSessionId, SESSION_ID);
|
||||
assert.equal(command.workerGeneration, 2);
|
||||
assert.equal(command.offerId, 'offer-completion-1');
|
||||
assert.equal(command.leaseGeneration, 1);
|
||||
assert.equal(command.leaseToken, LEASE_TOKEN);
|
||||
assert.equal(command.expectedLeaseVersion, 0);
|
||||
const chunks = [];
|
||||
for await (const chunk of command.content) chunks.push(chunk);
|
||||
const body = Buffer.concat(chunks);
|
||||
assert.equal(body.toString(), 'log');
|
||||
uploaded = true;
|
||||
if (overrides.upload) return overrides.upload(command, body);
|
||||
return {
|
||||
status: 'stored',
|
||||
logArtifactId: command.logArtifactId,
|
||||
byteLength: body.length,
|
||||
sha256: createHash('sha256').update(body).digest('hex'),
|
||||
};
|
||||
},
|
||||
};
|
||||
const completion = {
|
||||
async complete(command) {
|
||||
assert.equal(uploaded, true);
|
||||
assert.equal(record.state, 'launching');
|
||||
assert.equal(removed, 0);
|
||||
assert.equal(command.callbackTokenDigest, TOKEN_DIGEST);
|
||||
assert.equal(command.artifact.logArtifactId, LOG_ID);
|
||||
completed = true;
|
||||
return overrides.complete?.(command) ?? {
|
||||
status: 'applied',
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
callbackSequence: 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
const coordinator = new WorkerRemoteCompletionCoordinator(
|
||||
inbox,
|
||||
receipts,
|
||||
artifacts,
|
||||
uploader,
|
||||
completion,
|
||||
{
|
||||
currentSession: () => ({
|
||||
workerId: 'edge-1',
|
||||
sessionId: SESSION_ID,
|
||||
generation: 2,
|
||||
status: 'available',
|
||||
leaseExpiresAtMs: 30_000,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
},
|
||||
);
|
||||
return {
|
||||
coordinator,
|
||||
record: () => record,
|
||||
removed: () => removed,
|
||||
uploaded: () => uploaded,
|
||||
completed: () => completed,
|
||||
artifactClosed: () => artifactClosed,
|
||||
};
|
||||
}
|
||||
|
||||
test('uploads before completion and deletes the receipt only after durable ACK', async () => {
|
||||
const fixture = harness();
|
||||
const result = await fixture.coordinator.recover('offer-completion-1');
|
||||
assert.deepEqual(result, {
|
||||
offerId: 'offer-completion-1',
|
||||
status: 'completion_acknowledged',
|
||||
receiptCleanup: 'removed',
|
||||
});
|
||||
assert.equal(fixture.record().state, 'completion_acknowledged');
|
||||
assert.equal(fixture.uploaded(), true);
|
||||
assert.equal(fixture.completed(), true);
|
||||
assert.equal(fixture.removed(), 1);
|
||||
assert.equal(fixture.artifactClosed(), true);
|
||||
});
|
||||
|
||||
test('recovers a receipt from the durable launching crash window', async () => {
|
||||
const fixture = harness();
|
||||
await fixture.coordinator.recover('offer-completion-1');
|
||||
assert.equal(fixture.record().executorHandle, undefined);
|
||||
assert.equal(fixture.record().executorStartedAtMs, 100);
|
||||
assert.equal(fixture.record().state, 'completion_acknowledged');
|
||||
});
|
||||
|
||||
test('rejects a non-matching raw capability before upload', async () => {
|
||||
const fixture = harness({
|
||||
receipt: { token: Buffer.alloc(32, 0x42).toString('base64url') },
|
||||
});
|
||||
assert.deepEqual(await fixture.coordinator.recover('offer-completion-1'), {
|
||||
offerId: 'offer-completion-1',
|
||||
status: 'receipt_invalid',
|
||||
});
|
||||
assert.equal(fixture.uploaded(), false);
|
||||
assert.equal(fixture.completed(), false);
|
||||
assert.equal(fixture.removed(), 0);
|
||||
});
|
||||
|
||||
test('distinguishes unavailable receipt storage from invalid evidence', async () => {
|
||||
const fixture = harness({
|
||||
readReceipt() { throw new Error('storage unavailable'); },
|
||||
});
|
||||
assert.deepEqual(await fixture.coordinator.recover('offer-completion-1'), {
|
||||
offerId: 'offer-completion-1',
|
||||
status: 'receipt_unavailable',
|
||||
});
|
||||
assert.equal(fixture.uploaded(), false);
|
||||
assert.equal(fixture.removed(), 0);
|
||||
});
|
||||
|
||||
test('keeps durable evidence when upload fails', async () => {
|
||||
const fixture = harness({
|
||||
upload() { throw new Error('network unavailable'); },
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.coordinator.recover('offer-completion-1'),
|
||||
/network unavailable/,
|
||||
);
|
||||
assert.equal(fixture.record().state, 'launching');
|
||||
assert.equal(fixture.completed(), false);
|
||||
assert.equal(fixture.removed(), 0);
|
||||
assert.equal(fixture.artifactClosed(), true);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerExecutionCapacityOracle,
|
||||
} = require('../dist/session/workerExecutionCapacityOracle');
|
||||
|
||||
function record(offerId, state) {
|
||||
return { state, offer: { offerId } };
|
||||
}
|
||||
|
||||
function fixture(records = [], pending) {
|
||||
const journal = {
|
||||
async listOffers() {
|
||||
return { records, nextAfterOfferId: undefined };
|
||||
},
|
||||
async readPendingClaim() { return pending; },
|
||||
};
|
||||
return new WorkerExecutionCapacityOracle({
|
||||
journal,
|
||||
maxConcurrentRuns: 4,
|
||||
});
|
||||
}
|
||||
|
||||
test('publishes zero until startup reconciliation authorizes registration', async () => {
|
||||
const oracle = fixture();
|
||||
assert.equal(oracle.mode(), 'reconciling');
|
||||
assert.equal(await oracle.availableSlots(), 0);
|
||||
oracle.prepareRegistration();
|
||||
assert.equal(await oracle.availableSlots(), 4);
|
||||
oracle.activate();
|
||||
assert.equal(await oracle.availableSlots(), 4);
|
||||
});
|
||||
|
||||
test('subtracts durable active records and the current pull reservation', async () => {
|
||||
const oracle = fixture([
|
||||
record('offer-1', 'running_acknowledged'),
|
||||
record('offer-2', 'completion_acknowledged'),
|
||||
], { offerId: 'offer-3' });
|
||||
oracle.prepareRegistration();
|
||||
assert.equal(await oracle.availableSlots(), 2);
|
||||
});
|
||||
|
||||
test('does not double count a reservation already admitted to the inbox', async () => {
|
||||
const oracle = fixture([
|
||||
record('offer-1', 'accepted'),
|
||||
], { offerId: 'offer-1' });
|
||||
oracle.prepareRegistration();
|
||||
assert.equal(await oracle.availableSlots(), 3);
|
||||
});
|
||||
|
||||
test('fails closed on recovery and remains zero throughout drain', async () => {
|
||||
const recovery = fixture([record('offer-1', 'recovery_required')]);
|
||||
recovery.prepareRegistration();
|
||||
assert.equal(await recovery.availableSlots(), 0);
|
||||
assert.equal(recovery.mode(), 'recovery_required');
|
||||
|
||||
const draining = fixture();
|
||||
draining.prepareRegistration();
|
||||
draining.activate();
|
||||
draining.beginDrain();
|
||||
assert.equal(await draining.availableSlots(), 0);
|
||||
draining.offline();
|
||||
assert.equal(await draining.availableSlots(), 0);
|
||||
draining.beginDrain();
|
||||
draining.offline();
|
||||
assert.equal(draining.mode(), 'offline');
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createClusterTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/cluster-execution-revision');
|
||||
const {
|
||||
createClusterRemoteExecutionOffer,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
const {
|
||||
digestRunDispatchLeaseToken,
|
||||
} = require('@qinglong/runtime-core/run-dispatch-lease');
|
||||
const {
|
||||
assertWorkerRemoteExecutionInboxTransition,
|
||||
createWorkerRemoteExecutionInboxRecord,
|
||||
} = require('../dist/remote-execution/executionInbox');
|
||||
const {
|
||||
WorkerRemoteExecutionControlCoordinator,
|
||||
} = require('../dist/execution/workerExecutionControlCoordinator');
|
||||
|
||||
const RUN_ID = '019f70e0-0000-7000-8000-000000000301';
|
||||
const ATTEMPT_ID = '019f70e0-0000-7000-8000-000000000302';
|
||||
const SESSION_ID = '019f70e0-0000-7000-8000-000000000303';
|
||||
const LEASE_TOKEN = 'worker_generated_lease_capability_0000000000000010';
|
||||
const SOURCE_DIGEST = 'a'.repeat(64);
|
||||
const TASK_REVISION = `qltd:v1:1:${SOURCE_DIGEST}`;
|
||||
const LOG_ID = `wlog-${'b'.repeat(30)}`;
|
||||
const RECEIPT_DIGEST = createHash('sha256').update(Buffer.alloc(32, 1)).digest('hex');
|
||||
|
||||
function offer(expiresAtMs = 30_020) {
|
||||
const executionRevision = 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: '/bin/true', args: [] },
|
||||
environment: [], createdAtMs: 1,
|
||||
});
|
||||
return createClusterRemoteExecutionOffer({
|
||||
offerId: 'offer-control-1', deliveryKind: 'new_claim',
|
||||
executionDigest: executionRevision.contentDigest,
|
||||
candidate: {
|
||||
runId: RUN_ID, attemptId: ATTEMPT_ID, projectId: 'project-1',
|
||||
taskId: 'task-1', taskRevision: TASK_REVISION, priority: 1,
|
||||
queuedAtMs: 10, attemptCreatedAtMs: 11, attemptNumber: 1,
|
||||
executorType: 'remote_worker',
|
||||
},
|
||||
worker: { workerId: 'edge-1', sessionId: SESSION_ID, generation: 2 },
|
||||
lease: {
|
||||
attemptId: ATTEMPT_ID, runId: RUN_ID, status: 'leased', version: 0,
|
||||
leaseGeneration: 1, workerId: 'edge-1', workerSessionId: SESSION_ID,
|
||||
workerGeneration: 2, leaseTokenDigest: digestRunDispatchLeaseToken(LEASE_TOKEN),
|
||||
acquiredAtMs: 20, renewedAtMs: 20, expiresAtMs, updatedAtMs: 20,
|
||||
},
|
||||
leaseToken: LEASE_TOKEN, executionRevision, placementScore: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function runningRecord(expiresAtMs) {
|
||||
const accepted = createWorkerRemoteExecutionInboxRecord(offer(expiresAtMs), 100);
|
||||
const starting = { ...accepted, revision: 1, state: 'starting_acknowledged', updatedAtMs: 101 };
|
||||
const launching = {
|
||||
...starting, revision: 2, state: 'launching', updatedAtMs: 102,
|
||||
executorStartedAtMs: 100, logArtifactId: LOG_ID,
|
||||
completionReceiptCallbackSequence: 1,
|
||||
completionReceiptTokenDigest: RECEIPT_DIGEST,
|
||||
};
|
||||
const started = {
|
||||
...launching, revision: 3, state: 'started', updatedAtMs: 103,
|
||||
executorHandle: 'ql3lp1.durable-handle',
|
||||
};
|
||||
const running = { ...started, revision: 4, state: 'running_acknowledged', updatedAtMs: 104 };
|
||||
assertWorkerRemoteExecutionInboxTransition(accepted, starting);
|
||||
assertWorkerRemoteExecutionInboxTransition(starting, launching);
|
||||
assertWorkerRemoteExecutionInboxTransition(launching, started);
|
||||
assertWorkerRemoteExecutionInboxTransition(started, running);
|
||||
return running;
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
let record = runningRecord(overrides.expiresAtMs ?? 30_020);
|
||||
const calls = [];
|
||||
const inbox = {
|
||||
async readOffer(id) { return id === record.offer.offerId ? record : undefined; },
|
||||
async replaceOffer(next, expectedRevision) {
|
||||
assert.equal(expectedRevision, record.revision);
|
||||
assertWorkerRemoteExecutionInboxTransition(record, next);
|
||||
record = next;
|
||||
calls.push(`persist:${record.offer.lease.version}:${record.state}`);
|
||||
},
|
||||
};
|
||||
const completion = {
|
||||
async recover() {
|
||||
calls.push('completion');
|
||||
return overrides.completionResult ?? {
|
||||
offerId: record.offer.offerId, status: 'receipt_missing',
|
||||
};
|
||||
},
|
||||
};
|
||||
const leaseControl = {
|
||||
async control(command) {
|
||||
calls.push(`control:${command.expectedLeaseVersion}`);
|
||||
if (overrides.control) return overrides.control(command, () => record);
|
||||
return {
|
||||
status: 'renewed', projectId: command.projectId, runId: command.runId,
|
||||
attemptId: command.attemptId, offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseVersion: command.expectedLeaseVersion + 1,
|
||||
renewedAtMs: 1_000, expiresAtMs: 31_000,
|
||||
};
|
||||
},
|
||||
};
|
||||
const processes = {
|
||||
async stop(handle) {
|
||||
calls.push(`stop:${handle}:v${record.offer.lease.version}`);
|
||||
return overrides.stopResult ?? { status: 'stopped', signal: 'SIGTERM' };
|
||||
},
|
||||
};
|
||||
const coordinator = new WorkerRemoteExecutionControlCoordinator(
|
||||
inbox, completion, leaseControl, processes,
|
||||
{
|
||||
currentSession: () => overrides.session === null ? undefined : {
|
||||
workerId: 'edge-1', sessionId: SESSION_ID, generation: 2,
|
||||
status: 'available', leaseExpiresAtMs: 60_000,
|
||||
...overrides.session,
|
||||
},
|
||||
now: () => overrides.now ?? 500,
|
||||
},
|
||||
);
|
||||
return { coordinator, calls, record: () => record };
|
||||
}
|
||||
|
||||
test('replays completion first, renews authority, then persists the next lease version', async () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(await f.coordinator.reconcile('offer-control-1'), {
|
||||
offerId: 'offer-control-1', status: 'renewed', leaseVersion: 1,
|
||||
expiresAtMs: 31_000, completionStatus: 'receipt_missing',
|
||||
});
|
||||
assert.deepEqual(f.calls, ['completion', 'control:0', 'persist:1:running_acknowledged']);
|
||||
assert.equal(f.record().offer.lease.renewedAtMs, 1_000);
|
||||
});
|
||||
|
||||
test('persists stop-request lease authority before stopping the exact process', async () => {
|
||||
const f = fixture({
|
||||
control(command) {
|
||||
return {
|
||||
status: 'stop_requested', projectId: command.projectId,
|
||||
runId: command.runId, attemptId: command.attemptId,
|
||||
offerId: command.offerId, leaseGeneration: command.leaseGeneration,
|
||||
leaseVersion: 1, renewedAtMs: 1_000, expiresAtMs: 31_000,
|
||||
stop: { reason: 'timeout', requestedAtMs: 900 },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await f.coordinator.reconcile('offer-control-1');
|
||||
assert.equal(result.status, 'stop_requested');
|
||||
assert.equal(result.reason, 'timeout');
|
||||
assert.deepEqual(f.calls, [
|
||||
'completion', 'control:0', 'persist:1:running_acknowledged',
|
||||
'stop:ql3lp1.durable-handle:v1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('stops locally and records conclusive recovery after lease expiry', async () => {
|
||||
const f = fixture({ expiresAtMs: 400, now: 500 });
|
||||
const result = await f.coordinator.reconcile('offer-control-1');
|
||||
assert.equal(result.status, 'lease_expired');
|
||||
assert.equal(result.recoveryReason, 'lease_lost_local_execution_stopped');
|
||||
assert.equal(f.record().state, 'recovery_required');
|
||||
assert.equal(f.record().recoveryReason, 'lease_lost_local_execution_stopped');
|
||||
assert.equal(f.calls.some((value) => value.startsWith('control:')), false);
|
||||
});
|
||||
|
||||
test('keeps inconclusive stop evidence distinct after lease expiry', async () => {
|
||||
const f = fixture({
|
||||
expiresAtMs: 400, now: 500,
|
||||
stopResult: { status: 'unknown', reason: 'provider_unavailable' },
|
||||
});
|
||||
const result = await f.coordinator.reconcile('offer-control-1');
|
||||
assert.equal(result.recoveryReason, 'lease_lost_local_execution_unverified');
|
||||
assert.equal(f.record().recoveryReason, 'lease_lost_local_execution_unverified');
|
||||
});
|
||||
|
||||
test('does not contact control after completion acknowledgement', async () => {
|
||||
const f = fixture({
|
||||
completionResult: { offerId: 'offer-control-1', status: 'completion_acknowledged' },
|
||||
});
|
||||
assert.equal((await f.coordinator.reconcile('offer-control-1')).status,
|
||||
'completion_acknowledged');
|
||||
assert.deepEqual(f.calls, ['completion']);
|
||||
});
|
||||
|
||||
test('waits for the bound Worker Session while local lease authority remains live', async () => {
|
||||
const f = fixture({ session: null });
|
||||
const result = await f.coordinator.reconcile('offer-control-1');
|
||||
assert.equal(result.status, 'session_unavailable');
|
||||
assert.equal(f.calls.some((value) => value.startsWith('control:')), false);
|
||||
assert.equal(f.calls.some((value) => value.startsWith('stop:')), false);
|
||||
});
|
||||
|
||||
test('stops and quarantines execution when the control plane is terminal', async () => {
|
||||
const f = fixture({
|
||||
control(command) {
|
||||
return {
|
||||
status: 'terminal', projectId: command.projectId, runId: command.runId,
|
||||
attemptId: command.attemptId, offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration, terminalStatus: 'cancelled',
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await f.coordinator.reconcile('offer-control-1');
|
||||
assert.equal(result.status, 'terminal');
|
||||
assert.equal(result.terminalStatus, 'cancelled');
|
||||
assert.equal(f.record().recoveryReason, 'control_plane_terminal');
|
||||
});
|
||||
|
||||
test('coalesces concurrent supervision for the same offer', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const f = fixture({
|
||||
async control(command) {
|
||||
await gate;
|
||||
return {
|
||||
status: 'renewed', projectId: command.projectId, runId: command.runId,
|
||||
attemptId: command.attemptId, offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration, leaseVersion: 1,
|
||||
renewedAtMs: 1_000, expiresAtMs: 31_000,
|
||||
};
|
||||
},
|
||||
});
|
||||
const first = f.coordinator.reconcile('offer-control-1');
|
||||
const second = f.coordinator.reconcile('offer-control-1');
|
||||
assert.equal(first, second);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(f.calls.filter((value) => value.startsWith('control:')).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerFileLogArtifactAllocator,
|
||||
createWorkerRemoteLogArtifactId,
|
||||
workerRemoteLogArtifactPolicy,
|
||||
} = require('../dist/execution/workerFileLogArtifactAllocator');
|
||||
|
||||
const REQUEST = Object.freeze({
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
offerId: 'offer-1',
|
||||
});
|
||||
|
||||
async function temporaryRoot(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-worker-log-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
function policy(overrides = {}) {
|
||||
return {
|
||||
maximumAttemptBytes: 16,
|
||||
minimumFreeBytes: 32,
|
||||
maximumWriteChunkBytes: 8,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function capacity(availableBytes = 1_000_000n) {
|
||||
return { async availableBytes() { return availableBytes; } };
|
||||
}
|
||||
|
||||
function artifactPath(root, artifactId) {
|
||||
return path.join(root, artifactId.slice(5, 7), `${artifactId}.log`);
|
||||
}
|
||||
|
||||
test('provides explicit edge and node capacity policies', () => {
|
||||
assert.deepEqual(workerRemoteLogArtifactPolicy('edge'), {
|
||||
maximumAttemptBytes: 4 * 1024 * 1024,
|
||||
minimumFreeBytes: 32 * 1024 * 1024,
|
||||
maximumWriteChunkBytes: 1024 * 1024,
|
||||
});
|
||||
assert.deepEqual(workerRemoteLogArtifactPolicy('node'), {
|
||||
maximumAttemptBytes: 64 * 1024 * 1024,
|
||||
minimumFreeBytes: 256 * 1024 * 1024,
|
||||
maximumWriteChunkBytes: 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
test('derives one opaque log identity per exact offer authority', () => {
|
||||
const first = createWorkerRemoteLogArtifactId(REQUEST);
|
||||
assert.equal(first, createWorkerRemoteLogArtifactId({ ...REQUEST }));
|
||||
assert.notEqual(first, createWorkerRemoteLogArtifactId({
|
||||
...REQUEST,
|
||||
offerId: 'offer-2',
|
||||
}));
|
||||
assert.match(first, /^wlog-[a-f0-9]{30}$/);
|
||||
assert.equal(first.length, 35);
|
||||
assert.equal(first.includes(REQUEST.runId), false);
|
||||
});
|
||||
|
||||
test('hands off once, appends both streams, and keeps private ownership', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: policy(),
|
||||
capacity: capacity(),
|
||||
});
|
||||
const prepared = await allocator.prepare(REQUEST);
|
||||
const output = prepared.takeOutput();
|
||||
assert.throws(() => prepared.takeOutput(), /closed/);
|
||||
await prepared.release();
|
||||
const mutable = Buffer.from('abc');
|
||||
const firstWrite = output.write({
|
||||
stream: 'stdout',
|
||||
chunk: mutable,
|
||||
observedAtMs: 1,
|
||||
});
|
||||
mutable.fill(0x7a);
|
||||
await firstWrite;
|
||||
await output.write({
|
||||
stream: 'stderr',
|
||||
chunk: Buffer.from('def'),
|
||||
observedAtMs: 2,
|
||||
});
|
||||
await output.close();
|
||||
await output.close();
|
||||
await assert.rejects(
|
||||
output.write({ stream: 'stdout', chunk: Buffer.from('x'), observedAtMs: 3 }),
|
||||
/closed/,
|
||||
);
|
||||
const file = artifactPath(root, prepared.logArtifactId);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'abcdef');
|
||||
assert.equal((await fs.stat(root)).mode & 0o777, 0o700);
|
||||
assert.equal((await fs.stat(path.dirname(file))).mode & 0o777, 0o700);
|
||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('preserves an accepted prefix across reopen without truncation', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: policy(),
|
||||
capacity: capacity(),
|
||||
});
|
||||
const first = await allocator.prepare(REQUEST);
|
||||
const firstOutput = first.takeOutput();
|
||||
await firstOutput.write({
|
||||
stream: 'stdout',
|
||||
chunk: Buffer.from('before-'),
|
||||
observedAtMs: 1,
|
||||
});
|
||||
const replay = await allocator.prepare(REQUEST);
|
||||
const replayOutput = replay.takeOutput();
|
||||
await replayOutput.write({
|
||||
stream: 'stdout',
|
||||
chunk: Buffer.from('after'),
|
||||
observedAtMs: 2,
|
||||
});
|
||||
await Promise.all([firstOutput.close(), replayOutput.close()]);
|
||||
assert.equal(first.logArtifactId, replay.logArtifactId);
|
||||
assert.equal(
|
||||
await fs.readFile(artifactPath(root, first.logArtifactId), 'utf8'),
|
||||
'before-after',
|
||||
);
|
||||
});
|
||||
|
||||
test('streams a bounded Artifact and authenticates its truncation fact', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const streamingPolicy = policy({ maximumWriteChunkBytes: 16 });
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: streamingPolicy,
|
||||
capacity: capacity(),
|
||||
});
|
||||
const prepared = await allocator.prepare(REQUEST);
|
||||
const output = prepared.takeOutput();
|
||||
await output.write({
|
||||
stream: 'stdout',
|
||||
chunk: Buffer.from('streamed-log'),
|
||||
observedAtMs: 1,
|
||||
});
|
||||
await output.close();
|
||||
const file = artifactPath(root, prepared.logArtifactId);
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(file), `.${prepared.logArtifactId}.log.truncated.json`),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
runId: REQUEST.runId,
|
||||
attemptId: REQUEST.attemptId,
|
||||
logArtifactId: prepared.logArtifactId,
|
||||
maximumBytes: streamingPolicy.maximumAttemptBytes,
|
||||
quotaReached: false,
|
||||
observedAtMs: 2,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const lease = await allocator.open({
|
||||
runId: REQUEST.runId,
|
||||
attemptId: REQUEST.attemptId,
|
||||
logArtifactId: prepared.logArtifactId,
|
||||
});
|
||||
assert.ok(lease);
|
||||
assert.equal(lease.byteLength, 12);
|
||||
assert.equal(lease.truncated, false);
|
||||
const chunks = [];
|
||||
for await (const chunk of lease.chunks()) chunks.push(chunk);
|
||||
assert.equal(Buffer.concat(chunks).toString(), 'streamed-log');
|
||||
await lease.close();
|
||||
assert.throws(() => lease.chunks(), /closed/);
|
||||
});
|
||||
|
||||
test('writes only the remaining prefix and then enforces the hard quota', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: policy({ maximumAttemptBytes: 5 }),
|
||||
capacity: capacity(),
|
||||
});
|
||||
const prepared = await allocator.prepare(REQUEST);
|
||||
const output = prepared.takeOutput();
|
||||
await output.write({
|
||||
stream: 'stdout',
|
||||
chunk: Buffer.from('abc'),
|
||||
observedAtMs: 1,
|
||||
});
|
||||
await assert.rejects(
|
||||
output.write({ stream: 'stderr', chunk: Buffer.from('defg'), observedAtMs: 2 }),
|
||||
/quota_exceeded/,
|
||||
);
|
||||
await output.close();
|
||||
assert.equal(
|
||||
await fs.readFile(artifactPath(root, prepared.logArtifactId), 'utf8'),
|
||||
'abcde',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects oversized write chunks without changing the Artifact', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: policy({ maximumWriteChunkBytes: 3 }),
|
||||
capacity: capacity(),
|
||||
});
|
||||
const prepared = await allocator.prepare(REQUEST);
|
||||
const output = prepared.takeOutput();
|
||||
await assert.rejects(
|
||||
output.write({ stream: 'stdout', chunk: Buffer.from('four'), observedAtMs: 1 }),
|
||||
/invalid_output/,
|
||||
);
|
||||
await output.close();
|
||||
assert.equal((await fs.stat(artifactPath(root, prepared.logArtifactId))).size, 0);
|
||||
});
|
||||
|
||||
test('fails capacity admission before creating a shard or output file', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: policy(),
|
||||
capacity: capacity(47n),
|
||||
});
|
||||
await assert.rejects(allocator.prepare(REQUEST), /capacity_unavailable/);
|
||||
assert.deepEqual(await fs.readdir(root), []);
|
||||
});
|
||||
|
||||
test('fails closed when the deterministic output target is a symlink', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const artifactId = createWorkerRemoteLogArtifactId(REQUEST);
|
||||
const directory = path.dirname(artifactPath(root, artifactId));
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const victim = path.join(root, 'victim');
|
||||
await fs.writeFile(victim, 'unchanged', { mode: 0o600 });
|
||||
await fs.symlink(victim, artifactPath(root, artifactId));
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: policy(),
|
||||
capacity: capacity(),
|
||||
});
|
||||
await assert.rejects(allocator.prepare(REQUEST), /unsafe_path/);
|
||||
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
|
||||
});
|
||||
|
||||
test('release closes an unclaimed preparation and prevents later handoff', async (t) => {
|
||||
const root = await temporaryRoot(t);
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root,
|
||||
policy: policy(),
|
||||
capacity: capacity(),
|
||||
});
|
||||
const prepared = await allocator.prepare(REQUEST);
|
||||
await prepared.release();
|
||||
await prepared.release();
|
||||
assert.throws(() => prepared.takeOutput(), /closed/);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { PassThrough } = require('node:stream');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WORKER_INGRESS_ARTIFACT_CONTENT_TYPE,
|
||||
WorkerIngressHttpsClient,
|
||||
} = require('../dist/remote-execution/transport/workerIngressHttpsClient');
|
||||
|
||||
const SESSION_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
const ARTIFACT_PATH =
|
||||
`/api/v3/worker-ingress/workers/worker-1/sessions/${SESSION_ID}/artifacts`;
|
||||
const COMPLETION_PATH =
|
||||
`/api/v3/worker-ingress/workers/worker-1/sessions/${SESSION_ID}/completion`;
|
||||
const AUTHORIZATION =
|
||||
`Worker ql3w_worker_primary_${Buffer.alloc(32, 7).toString('base64url')}`;
|
||||
|
||||
function credentials() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: 'client certificate',
|
||||
privateKeyPem: 'client private key',
|
||||
trustAnchors: ['trusted ca'],
|
||||
};
|
||||
}
|
||||
|
||||
function response(body = '{"stored":true}') {
|
||||
const stream = new PassThrough();
|
||||
stream.statusCode = 200;
|
||||
stream.headers = {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(body)),
|
||||
};
|
||||
queueMicrotask(() => stream.end(body));
|
||||
return stream;
|
||||
}
|
||||
|
||||
function requestFactory(observation, options = {}) {
|
||||
return (requestOptions, callback) => {
|
||||
observation.options = requestOptions;
|
||||
observation.chunks = [];
|
||||
const outgoing = new EventEmitter();
|
||||
let writes = 0;
|
||||
outgoing.setTimeout = (timeout, handler) => {
|
||||
observation.timeout = timeout;
|
||||
observation.timeoutHandler = handler;
|
||||
return outgoing;
|
||||
};
|
||||
outgoing.write = (chunk) => {
|
||||
observation.chunks.push(Buffer.from(chunk));
|
||||
writes += 1;
|
||||
if (options.backpressure && writes === 1) {
|
||||
queueMicrotask(() => outgoing.emit('drain'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
outgoing.end = () => {
|
||||
observation.ended = true;
|
||||
queueMicrotask(() => callback(response()));
|
||||
};
|
||||
outgoing.destroy = (error) => {
|
||||
observation.destroyed = true;
|
||||
if (error) queueMicrotask(() => outgoing.emit('error', error));
|
||||
};
|
||||
return outgoing;
|
||||
};
|
||||
}
|
||||
|
||||
function client(observation, options = {}) {
|
||||
return new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example:7443',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestTimeoutMs: 5_000,
|
||||
requestFactory: requestFactory(observation, options),
|
||||
});
|
||||
}
|
||||
|
||||
test('streams exact Artifact bytes with bounded backpressure over shared mTLS', async () => {
|
||||
const observation = {};
|
||||
const transport = client(observation, { backpressure: true });
|
||||
const prefix = Buffer.from('header');
|
||||
const content = Buffer.from('worker-log');
|
||||
try {
|
||||
const result = await transport.postStream({
|
||||
path: ARTIFACT_PATH,
|
||||
body: (async function* () {
|
||||
yield prefix;
|
||||
yield content;
|
||||
})(),
|
||||
byteLength: prefix.byteLength + content.byteLength,
|
||||
maximumResponseBytes: 1024,
|
||||
});
|
||||
assert.equal(Buffer.from(result).toString('utf8'), '{"stored":true}');
|
||||
assert.equal(observation.options.protocol, 'https:');
|
||||
assert.equal(observation.options.hostname, 'cluster.example');
|
||||
assert.equal(observation.options.port, '7443');
|
||||
assert.equal(observation.options.minVersion, 'TLSv1.3');
|
||||
assert.equal(observation.options.rejectUnauthorized, true);
|
||||
assert.equal(observation.options.headers.authorization, AUTHORIZATION);
|
||||
assert.equal(
|
||||
observation.options.headers['content-type'],
|
||||
WORKER_INGRESS_ARTIFACT_CONTENT_TYPE,
|
||||
);
|
||||
assert.equal(
|
||||
observation.options.headers['content-length'],
|
||||
String(prefix.byteLength + content.byteLength),
|
||||
);
|
||||
assert.equal(Buffer.concat(observation.chunks).toString(), 'headerworker-log');
|
||||
assert.equal(observation.ended, true);
|
||||
assert.equal(observation.timeout, 5_000);
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects short, overlong and route-confused stream bodies', async () => {
|
||||
const shortObservation = {};
|
||||
const short = client(shortObservation);
|
||||
try {
|
||||
await assert.rejects(
|
||||
short.postStream({
|
||||
path: ARTIFACT_PATH,
|
||||
body: (async function* () { yield Buffer.from('short'); })(),
|
||||
byteLength: 6,
|
||||
maximumResponseBytes: 1024,
|
||||
}),
|
||||
/request_rejected/,
|
||||
);
|
||||
assert.equal(shortObservation.destroyed, true);
|
||||
} finally {
|
||||
short.close();
|
||||
}
|
||||
|
||||
const longObservation = {};
|
||||
const long = client(longObservation);
|
||||
try {
|
||||
await assert.rejects(
|
||||
long.postStream({
|
||||
path: ARTIFACT_PATH,
|
||||
body: (async function* () { yield Buffer.from('too-long'); })(),
|
||||
byteLength: 3,
|
||||
maximumResponseBytes: 1024,
|
||||
}),
|
||||
/request_rejected/,
|
||||
);
|
||||
await assert.rejects(
|
||||
long.postStream({
|
||||
path: COMPLETION_PATH,
|
||||
body: (async function* () { yield Buffer.from('{}'); })(),
|
||||
byteLength: 2,
|
||||
maximumResponseBytes: 1024,
|
||||
}),
|
||||
/request_rejected/,
|
||||
);
|
||||
await assert.rejects(
|
||||
long.postJson({
|
||||
path: ARTIFACT_PATH,
|
||||
body: {},
|
||||
maximumResponseBytes: 1024,
|
||||
}),
|
||||
/request_rejected/,
|
||||
);
|
||||
} finally {
|
||||
long.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('permits completion JSON while keeping Artifact transport stream-only', async () => {
|
||||
const observation = {};
|
||||
const transport = client(observation);
|
||||
const originalFactory = observation;
|
||||
try {
|
||||
// A separate JSON-capable fake keeps this assertion focused on route policy.
|
||||
const json = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestFactory(options, callback) {
|
||||
originalFactory.options = options;
|
||||
const outgoing = new EventEmitter();
|
||||
outgoing.setTimeout = () => outgoing;
|
||||
outgoing.destroy = (error) => {
|
||||
if (error) queueMicrotask(() => outgoing.emit('error', error));
|
||||
};
|
||||
outgoing.end = (body) => {
|
||||
originalFactory.body = Buffer.from(body);
|
||||
queueMicrotask(() => callback(response('{"status":"applied"}')));
|
||||
};
|
||||
return outgoing;
|
||||
},
|
||||
});
|
||||
try {
|
||||
await json.postJson({
|
||||
path: COMPLETION_PATH,
|
||||
body: { schema: 'qinglong/remote-worker-completion@v1' },
|
||||
maximumResponseBytes: 1024,
|
||||
});
|
||||
assert.equal(originalFactory.options.path, COMPLETION_PATH);
|
||||
} finally {
|
||||
json.close();
|
||||
}
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disposes provider-owned credential material on success and rejection', async () => {
|
||||
const certificate = Buffer.from('client certificate');
|
||||
const privateKey = Buffer.from('client private key');
|
||||
const trust = Buffer.from('trusted ca');
|
||||
let disposals = 0;
|
||||
const observation = {};
|
||||
const transport = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: {
|
||||
async load() {
|
||||
return {
|
||||
authorization: AUTHORIZATION,
|
||||
certificateChainPem: certificate,
|
||||
privateKeyPem: privateKey,
|
||||
trustAnchors: [trust],
|
||||
dispose() {
|
||||
disposals += 1;
|
||||
certificate.fill(0);
|
||||
privateKey.fill(0);
|
||||
trust.fill(0);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
requestFactory: requestFactory(observation),
|
||||
});
|
||||
try {
|
||||
await transport.postJson({
|
||||
path: COMPLETION_PATH,
|
||||
body: { schema: 'qinglong/remote-worker-completion@v1' },
|
||||
maximumResponseBytes: 1024,
|
||||
});
|
||||
assert.equal(disposals, 1);
|
||||
assert.equal(certificate.equals(Buffer.alloc(certificate.length)), true);
|
||||
assert.equal(privateKey.equals(Buffer.alloc(privateKey.length)), true);
|
||||
assert.equal(trust.equals(Buffer.alloc(trust.length)), true);
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
|
||||
let rejectedDisposals = 0;
|
||||
const rejected = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: {
|
||||
async load() {
|
||||
return {
|
||||
authorization: 'invalid',
|
||||
certificateChainPem: 'certificate',
|
||||
privateKeyPem: 'key',
|
||||
trustAnchors: ['trust'],
|
||||
dispose() { rejectedDisposals += 1; },
|
||||
};
|
||||
},
|
||||
},
|
||||
requestFactory() { throw new Error('request must not start'); },
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
rejected.postJson({
|
||||
path: COMPLETION_PATH,
|
||||
body: {},
|
||||
maximumResponseBytes: 1024,
|
||||
}),
|
||||
/credentials_unavailable/,
|
||||
);
|
||||
assert.equal(rejectedDisposals, 1);
|
||||
} finally {
|
||||
rejected.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('exposes only the low-sensitive non-success HTTP status class', async () => {
|
||||
const transport = new WorkerIngressHttpsClient({
|
||||
origin: 'https://cluster.example',
|
||||
credentials: { async load() { return credentials(); } },
|
||||
requestFactory(_options, callback) {
|
||||
const outgoing = new EventEmitter();
|
||||
outgoing.setTimeout = () => outgoing;
|
||||
outgoing.destroy = (error) => {
|
||||
if (error) queueMicrotask(() => outgoing.emit('error', error));
|
||||
};
|
||||
outgoing.end = () => {
|
||||
const denied = response('{"code":"must-not-be-read"}');
|
||||
denied.statusCode = 401;
|
||||
queueMicrotask(() => callback(denied));
|
||||
};
|
||||
return outgoing;
|
||||
},
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
transport.postJson({
|
||||
path: COMPLETION_PATH,
|
||||
body: {},
|
||||
maximumResponseBytes: 1024,
|
||||
}),
|
||||
(error) =>
|
||||
error.reason === 'response_rejected' && error.httpStatus === 401,
|
||||
);
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { CompletionReceiptFileStore } = require('@qinglong/local-process');
|
||||
const {
|
||||
WorkerFileLogArtifactAllocator,
|
||||
workerRemoteLogArtifactPolicy,
|
||||
} = require('../dist/execution/workerFileLogArtifactAllocator');
|
||||
const {
|
||||
WorkerPosixExecutionExecutor,
|
||||
} = require('../dist/execution/workerPosixExecutionExecutor');
|
||||
|
||||
const RUN_ID = '019f70e0-0000-7000-8000-000000000101';
|
||||
const ATTEMPT_ID = '019f70e0-0000-7000-8000-000000000102';
|
||||
const TOKEN = Buffer.alloc(32, 0x5a);
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-worker-posix-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
return {
|
||||
root,
|
||||
artifactRoot: path.join(root, 'artifacts'),
|
||||
receiptRoot: path.join(root, 'receipts'),
|
||||
};
|
||||
}
|
||||
|
||||
function identityProvider() {
|
||||
return {
|
||||
async capture(pid) {
|
||||
return {
|
||||
platform: 'linux',
|
||||
bootId: '11111111-2222-3333-4444-555555555555',
|
||||
pid,
|
||||
processGroupId: pid,
|
||||
startTimeTicks: '1',
|
||||
};
|
||||
},
|
||||
async inspect(identity) {
|
||||
return { status: 'running', identityPid: identity.pid };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function preparedOutput(artifactRoot, offerId = 'offer-posix-1') {
|
||||
const allocator = new WorkerFileLogArtifactAllocator({
|
||||
root: artifactRoot,
|
||||
policy: workerRemoteLogArtifactPolicy('edge'),
|
||||
capacity: { async availableBytes() { return 1024n ** 4n; } },
|
||||
});
|
||||
const prepared = await allocator.prepare({
|
||||
projectId: 'project-1',
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
offerId,
|
||||
});
|
||||
return { prepared, output: prepared.takeOutput() };
|
||||
}
|
||||
|
||||
function launch(prepared, output, overrides = {}) {
|
||||
return {
|
||||
offerId: 'offer-posix-1',
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
executorStartedAtMs: 100,
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: process.execPath,
|
||||
args: [
|
||||
'-e',
|
||||
"process.stdout.write(process.env.QL3_RECEIPT_CALLBACK_TOKEN ? 'leaked' : 'worker-output')",
|
||||
],
|
||||
},
|
||||
environment: [],
|
||||
logArtifactId: prepared.logArtifactId,
|
||||
output,
|
||||
completionCallback: { sequence: 1, token: Buffer.from(TOKEN) },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForReceipt(root) {
|
||||
const store = new CompletionReceiptFileStore(root);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const receipt = await store.read(ATTEMPT_ID);
|
||||
if (receipt) return receipt;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error('Worker completion receipt was not published');
|
||||
}
|
||||
|
||||
test('verifies the Worker barrier, launches through the reviewed fd and writes a receipt', async (t) => {
|
||||
const roots = await fixture(t);
|
||||
const { prepared, output } = await preparedOutput(roots.artifactRoot);
|
||||
let barrier;
|
||||
const executor = new WorkerPosixExecutionExecutor({
|
||||
barrier: { async verify(input) { barrier = input; } },
|
||||
receiptRoot: roots.receiptRoot,
|
||||
identityProvider: identityProvider(),
|
||||
clock: { now: () => 100 },
|
||||
createHandleId: () => 'worker-handle-1',
|
||||
});
|
||||
const result = await executor.start(launch(prepared, output));
|
||||
assert.equal(result.status, 'started');
|
||||
assert.match(result.executorHandle, /^ql3lp1\./);
|
||||
assert.equal(barrier.logArtifactId, prepared.logArtifactId);
|
||||
assert.equal(barrier.executorStartedAtMs, 100);
|
||||
assert.match(barrier.callbackTokenDigest, /^[a-f0-9]{64}$/);
|
||||
const receipt = await waitForReceipt(roots.receiptRoot);
|
||||
assert.equal(receipt.runId, RUN_ID);
|
||||
assert.equal(receipt.attemptId, ATTEMPT_ID);
|
||||
assert.equal(receipt.callbackSequence, 1);
|
||||
assert.equal(receipt.token, TOKEN.toString('base64url'));
|
||||
assert.equal(receipt.exitCode, 0);
|
||||
const outputPath = path.join(
|
||||
roots.artifactRoot,
|
||||
prepared.logArtifactId.slice(5, 7),
|
||||
`${prepared.logArtifactId}.log`,
|
||||
);
|
||||
assert.equal(await fs.readFile(outputPath, 'utf8'), 'worker-output');
|
||||
});
|
||||
|
||||
test('does not spawn when the durable Worker barrier rejects authority', async (t) => {
|
||||
const roots = await fixture(t);
|
||||
const marker = path.join(roots.root, 'spawned');
|
||||
const { prepared, output } = await preparedOutput(roots.artifactRoot);
|
||||
const executor = new WorkerPosixExecutionExecutor({
|
||||
barrier: { async verify() { throw new Error('stale inbox'); } },
|
||||
receiptRoot: roots.receiptRoot,
|
||||
identityProvider: identityProvider(),
|
||||
});
|
||||
const result = await executor.start(launch(prepared, output, {
|
||||
command: { kind: 'argv', file: '/usr/bin/touch', args: [marker] },
|
||||
}));
|
||||
assert.deepEqual(result, { status: 'rejected' });
|
||||
await assert.rejects(fs.stat(marker), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('rejects timeout without durable control-plane deadline before spawn', async (t) => {
|
||||
const roots = await fixture(t);
|
||||
const { prepared, output } = await preparedOutput(roots.artifactRoot);
|
||||
let barriers = 0;
|
||||
const executor = new WorkerPosixExecutionExecutor({
|
||||
barrier: { async verify() { barriers += 1; } },
|
||||
receiptRoot: roots.receiptRoot,
|
||||
identityProvider: identityProvider(),
|
||||
});
|
||||
const result = await executor.start(launch(prepared, output, {
|
||||
timeoutMs: 1_000,
|
||||
}));
|
||||
assert.deepEqual(result, { status: 'rejected' });
|
||||
assert.equal(barriers, 0);
|
||||
});
|
||||
|
||||
test('accepts timeout only when starting ACK supplied a durable deadline', async (t) => {
|
||||
const roots = await fixture(t);
|
||||
const { prepared, output } = await preparedOutput(roots.artifactRoot);
|
||||
let barriers = 0;
|
||||
const executor = new WorkerPosixExecutionExecutor({
|
||||
barrier: { async verify() { barriers += 1; } },
|
||||
receiptRoot: roots.receiptRoot,
|
||||
identityProvider: identityProvider(),
|
||||
});
|
||||
const result = await executor.start(launch(prepared, output, {
|
||||
timeoutMs: 1_000,
|
||||
executionDeadlineAtMs: 2_000,
|
||||
}));
|
||||
assert.equal(result.status, 'started');
|
||||
assert.equal(barriers, 1);
|
||||
});
|
||||
|
||||
test('propagates unknown outcome when durable identity capture fails after spawn', async (t) => {
|
||||
const roots = await fixture(t);
|
||||
const { prepared, output } = await preparedOutput(roots.artifactRoot);
|
||||
const executor = new WorkerPosixExecutionExecutor({
|
||||
barrier: { async verify() {} },
|
||||
receiptRoot: roots.receiptRoot,
|
||||
identityProvider: {
|
||||
async capture() { throw new Error('procfs unavailable'); },
|
||||
async inspect() { return { status: 'unknown' }; },
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
executor.start(launch(prepared, output, {
|
||||
command: { kind: 'shell', command: 'sleep 5', shell: '/bin/sh' },
|
||||
})),
|
||||
(error) => error?.spawnOutcome === 'unknown',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
rm,
|
||||
writeFile,
|
||||
} = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerProcessError,
|
||||
runProductionWorkerProcess,
|
||||
} = require('@qinglong/worker-runtime/process');
|
||||
|
||||
async function environment(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-process-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const capabilities = path.join(root, 'capabilities.json');
|
||||
await writeFile(capabilities, JSON.stringify({
|
||||
architecture: 'x64',
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
}));
|
||||
await chmod(capabilities, 0o444);
|
||||
return {
|
||||
QL3_WORKER_RUNTIME_ENABLED: 'true',
|
||||
QL_DEPLOYMENT_PROFILE: 'worker',
|
||||
QL3_WORKER_CAPACITY_PROFILE: 'node',
|
||||
QL3_WORKER_ID: 'node-worker-1',
|
||||
QL3_WORKER_CONTROL_ORIGIN: 'https://control.internal:5801',
|
||||
QL3_WORKER_CAPABILITIES_FILE: capabilities,
|
||||
QL3_WORKER_JOURNAL_ROOT: path.join(root, 'journal'),
|
||||
QL3_WORKER_LOG_ROOT: path.join(root, 'logs'),
|
||||
QL3_WORKER_RECEIPT_ROOT: path.join(root, 'receipts'),
|
||||
QL3_WORKER_CERTIFICATE_STORE_ROOT: path.join(root, 'identity'),
|
||||
QL3_WORKER_TRUST_ANCHOR_FILE: path.join(root, 'ca.pem'),
|
||||
QL3_WORKER_CREDENTIAL_TOKEN_FILE: path.join(root, 'token'),
|
||||
QL3_WORKER_DRAIN_TIMEOUT_MS: '1000',
|
||||
};
|
||||
}
|
||||
|
||||
function signals(events) {
|
||||
return {
|
||||
subscribe(listener) {
|
||||
events.push('subscribe');
|
||||
queueMicrotask(() => listener('SIGTERM'));
|
||||
return () => events.push('unsubscribe');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('assembles one product runtime and preserves authority across deferred drain', async (t) => {
|
||||
const events = [];
|
||||
const facts = [];
|
||||
const configured = await environment(t);
|
||||
let stopCalls = 0;
|
||||
const certificateRenewal = { async run() { return { status: 'not_due' }; } };
|
||||
const result = await runProductionWorkerProcess({
|
||||
environment: configured,
|
||||
signals: signals(events),
|
||||
emit(fact) {
|
||||
facts.push(fact);
|
||||
},
|
||||
async createCredentials(identity) {
|
||||
events.push(`credentials:${identity.certificateStoreRoot}`);
|
||||
return { async load() { throw new Error('not used'); } };
|
||||
},
|
||||
async createCertificateRenewal(config, credentials) {
|
||||
events.push(`renewal:${config.workerId}`);
|
||||
assert.equal(typeof credentials.load, 'function');
|
||||
return certificateRenewal;
|
||||
},
|
||||
async start(options) {
|
||||
events.push('start');
|
||||
assert.equal(options.enabled, true);
|
||||
assert.equal(options.profile, 'worker');
|
||||
assert.equal(options.capacityProfile, 'node');
|
||||
assert.equal(options.workerId, 'node-worker-1');
|
||||
assert.equal(options.maxConcurrentRuns, 8);
|
||||
assert.equal(options.heartbeatIntervalMs, 10_000);
|
||||
assert.equal(options.certificateRenewal, certificateRenewal);
|
||||
options.diagnostic({ code: 'certificate_renewal_failed' });
|
||||
return {
|
||||
status: 'active',
|
||||
async tick() {},
|
||||
async stop() {
|
||||
stopCalls += 1;
|
||||
events.push(`stop:${stopCalls}`);
|
||||
return stopCalls === 1 ? 'drain_timed_out' : 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
async waitBeforeStopRetry() {
|
||||
events.push('wait');
|
||||
},
|
||||
});
|
||||
assert.equal(result, 'stopped');
|
||||
assert.deepEqual(events, [
|
||||
'subscribe',
|
||||
`credentials:${path.dirname(configured.QL3_WORKER_JOURNAL_ROOT)}/identity`,
|
||||
'renewal:node-worker-1',
|
||||
'start',
|
||||
'stop:1',
|
||||
'wait',
|
||||
'stop:2',
|
||||
'unsubscribe',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
facts.map((fact) => fact.event),
|
||||
[
|
||||
'starting',
|
||||
'runtime_diagnostic',
|
||||
'active',
|
||||
'shutdown_requested',
|
||||
'shutdown_deferred',
|
||||
'stopped',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(facts).includes(configured.QL3_WORKER_CREDENTIAL_TOKEN_FILE),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('retains the production process until an OS shutdown signal arrives', async (t) => {
|
||||
const configured = await environment(t);
|
||||
const childSource = String.raw`
|
||||
'use strict';
|
||||
const { runProductionWorkerProcess } = require(
|
||||
process.env.QL3_TEST_WORKER_RUNTIME_PATH
|
||||
);
|
||||
void runProductionWorkerProcess({
|
||||
environment: JSON.parse(process.env.QL3_TEST_WORKER_ENV),
|
||||
signals: {
|
||||
subscribe(listener) {
|
||||
const stop = () => listener('SIGTERM');
|
||||
process.once('SIGTERM', stop);
|
||||
return () => process.off('SIGTERM', stop);
|
||||
},
|
||||
},
|
||||
emit(event) {
|
||||
process.stdout.write(event.event + '\n');
|
||||
},
|
||||
async createCredentials() {
|
||||
return { async load() { throw new Error('not used'); } };
|
||||
},
|
||||
async start() {
|
||||
return {
|
||||
status: 'active',
|
||||
async tick() {},
|
||||
async stop() { return 'stopped'; },
|
||||
};
|
||||
},
|
||||
}).catch((error) => {
|
||||
process.stderr.write(String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`;
|
||||
const child = spawn(process.execPath, ['-e', childSource], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
QL3_TEST_WORKER_ENV: JSON.stringify(configured),
|
||||
QL3_TEST_WORKER_RUNTIME_PATH: require.resolve(
|
||||
'@qinglong/worker-runtime/process',
|
||||
),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
const activeDeadline = Date.now() + 5_000;
|
||||
while (!stdout.includes('active\n') && Date.now() < activeDeadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.match(stdout, /active\n/);
|
||||
|
||||
const retained = await Promise.race([
|
||||
once(child, 'exit').then(() => false),
|
||||
new Promise((resolve) => setTimeout(() => resolve(true), 250)),
|
||||
]);
|
||||
assert.equal(retained, true);
|
||||
|
||||
const exited = once(child, 'exit');
|
||||
assert.equal(child.kill('SIGTERM'), true);
|
||||
const [exitCode, signal] = await exited;
|
||||
assert.equal(exitCode, 0);
|
||||
assert.equal(signal, null);
|
||||
assert.match(stdout, /shutdown_requested\nstopped\n/);
|
||||
});
|
||||
|
||||
test('disabled process never creates credentials or starts the product runtime', async () => {
|
||||
let credentials = 0;
|
||||
let starts = 0;
|
||||
await assert.rejects(
|
||||
runProductionWorkerProcess({
|
||||
environment: {
|
||||
QL3_WORKER_RUNTIME_ENABLED: 'false',
|
||||
QL_DEPLOYMENT_PROFILE: 'edge',
|
||||
},
|
||||
signals: { subscribe() { return () => {}; } },
|
||||
emit() {},
|
||||
async createCredentials() {
|
||||
credentials += 1;
|
||||
return { async load() {} };
|
||||
},
|
||||
async start() {
|
||||
starts += 1;
|
||||
return { status: 'disabled', async stop() { return 'stopped'; } };
|
||||
},
|
||||
}),
|
||||
WorkerProcessError,
|
||||
);
|
||||
assert.equal(credentials, 0);
|
||||
assert.equal(starts, 0);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
rm,
|
||||
writeFile,
|
||||
} = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerProcessConfigError,
|
||||
loadWorkerProcessConfig,
|
||||
} = require('@qinglong/worker-runtime/process-config');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-config-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const capabilitiesFile = path.join(root, 'capabilities.json');
|
||||
await writeFile(
|
||||
capabilitiesFile,
|
||||
JSON.stringify({
|
||||
architecture: 'arm64',
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
labels: { site: 'edge-a' },
|
||||
capacity: {
|
||||
cpuCores: 2,
|
||||
memoryBytes: 512 * 1024 * 1024,
|
||||
},
|
||||
features: [],
|
||||
}),
|
||||
);
|
||||
await chmod(capabilitiesFile, 0o444);
|
||||
return {
|
||||
root,
|
||||
capabilitiesFile,
|
||||
environment: {
|
||||
QL3_WORKER_RUNTIME_ENABLED: 'true',
|
||||
QL_DEPLOYMENT_PROFILE: 'worker',
|
||||
QL3_WORKER_CAPACITY_PROFILE: 'edge',
|
||||
QL3_WORKER_ID: 'router-worker-1',
|
||||
QL3_WORKER_CONTROL_ORIGIN: 'https://control.example.internal:5801',
|
||||
QL3_WORKER_CAPABILITIES_FILE: capabilitiesFile,
|
||||
QL3_WORKER_JOURNAL_ROOT: path.join(root, 'journal'),
|
||||
QL3_WORKER_LOG_ROOT: path.join(root, 'logs'),
|
||||
QL3_WORKER_RECEIPT_ROOT: path.join(root, 'receipts'),
|
||||
QL3_WORKER_CERTIFICATE_STORE_ROOT: path.join(root, 'identity'),
|
||||
QL3_WORKER_TRUST_ANCHOR_FILE: path.join(root, 'ca.pem'),
|
||||
QL3_WORKER_CREDENTIAL_TOKEN_FILE: path.join(root, 'token'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('disabled Worker runtime does not read paths, credentials or capabilities', async () => {
|
||||
const reads = [];
|
||||
const environment = new Proxy(
|
||||
{
|
||||
QL3_WORKER_RUNTIME_ENABLED: 'false',
|
||||
QL_DEPLOYMENT_PROFILE: 'edge',
|
||||
},
|
||||
{
|
||||
get(target, property, receiver) {
|
||||
reads.push(String(property));
|
||||
if (
|
||||
/CAPABILITIES_FILE|TOKEN|_ROOT|LAUNCHER_PATH|IDENTITY/.test(
|
||||
String(property),
|
||||
)
|
||||
) {
|
||||
throw new Error('disabled Worker read protected configuration');
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await loadWorkerProcessConfig(environment), {
|
||||
enabled: false,
|
||||
profile: 'edge',
|
||||
});
|
||||
assert.equal(
|
||||
reads.some((name) =>
|
||||
/CAPABILITIES_FILE|TOKEN|_ROOT|LAUNCHER_PATH|IDENTITY/.test(name),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('loads canonical edge defaults and bounded node overrides', async (t) => {
|
||||
const current = await fixture(t);
|
||||
const edge = await loadWorkerProcessConfig(current.environment);
|
||||
assert.equal(edge.enabled, true);
|
||||
assert.equal(edge.profile, 'worker');
|
||||
assert.equal(edge.capacityProfile, 'edge');
|
||||
assert.equal(edge.workerId, 'router-worker-1');
|
||||
assert.equal(edge.origin, 'https://control.example.internal:5801');
|
||||
assert.deepEqual(edge.capabilities, {
|
||||
architecture: 'arm64',
|
||||
executors: ['local_process'],
|
||||
operatingSystem: 'linux',
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
labels: { site: 'edge-a' },
|
||||
capacity: {
|
||||
cpuCores: 2,
|
||||
memoryBytes: 512 * 1024 * 1024,
|
||||
},
|
||||
features: [],
|
||||
});
|
||||
assert.equal(edge.maxConcurrentRuns, 1);
|
||||
assert.deepEqual(edge.lifecycle, {
|
||||
cadenceMs: 2_000,
|
||||
leaseDurationMs: 45_000,
|
||||
heartbeatIntervalMs: 10_000,
|
||||
drainTimeoutMs: 60_000,
|
||||
drainPollMs: 500,
|
||||
requestTimeoutMs: 15_000,
|
||||
maximumJournalEntries: 64,
|
||||
maximumRecordsPerTick: 4,
|
||||
maximumSupervisionRecordsPerTick: 4,
|
||||
});
|
||||
|
||||
const node = await loadWorkerProcessConfig({
|
||||
...current.environment,
|
||||
QL3_WORKER_CAPACITY_PROFILE: 'node',
|
||||
QL3_WORKER_MAX_CONCURRENT_RUNS: '32',
|
||||
QL3_WORKER_MAXIMUM_JOURNAL_ENTRIES: '512',
|
||||
QL3_WORKER_IDENTITY_BOOTSTRAP_PRIVATE_KEY_FILE:
|
||||
path.join(current.root, 'client.key'),
|
||||
QL3_WORKER_IDENTITY_BOOTSTRAP_CERTIFICATE_FILE:
|
||||
path.join(current.root, 'client.crt'),
|
||||
QL3_WORKER_EXPECTED_CREDENTIAL_ID: 'worker_primary',
|
||||
QL3_WORKER_LAUNCHER_PATH: '/usr/local/bin/ql3-launcher',
|
||||
QL3_WORKER_LAUNCHER_SHA256: 'a'.repeat(64),
|
||||
});
|
||||
assert.equal(node.maxConcurrentRuns, 32);
|
||||
assert.equal(node.lifecycle.cadenceMs, 500);
|
||||
assert.equal(node.lifecycle.maximumJournalEntries, 512);
|
||||
assert.equal(node.identity.expectedCredentialId, 'worker_primary');
|
||||
assert.equal(
|
||||
node.identity.bootstrap.privateKeyFile,
|
||||
path.join(current.root, 'client.key'),
|
||||
);
|
||||
assert.deepEqual(node.executor, {
|
||||
launcherPath: '/usr/local/bin/ql3-launcher',
|
||||
expectedLauncherSha256: 'a'.repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects widened profiles, origins, heartbeat and filesystem configuration', async (t) => {
|
||||
const current = await fixture(t);
|
||||
for (const patch of [
|
||||
{ QL_DEPLOYMENT_PROFILE: 'standalone' },
|
||||
{ QL3_WORKER_CAPACITY_PROFILE: 'cluster' },
|
||||
{ QL3_WORKER_ID: 'unsafe worker' },
|
||||
{ QL3_WORKER_CONTROL_ORIGIN: 'http://control.internal' },
|
||||
{ QL3_WORKER_CONTROL_ORIGIN: 'https://user@control.internal' },
|
||||
{ QL3_WORKER_JOURNAL_ROOT: 'relative/journal' },
|
||||
{ QL3_WORKER_HEARTBEAT_INTERVAL_MS: '30000' },
|
||||
{ QL3_WORKER_MAX_CONCURRENT_RUNS: '5' },
|
||||
{
|
||||
QL3_WORKER_IDENTITY_BOOTSTRAP_PRIVATE_KEY_FILE:
|
||||
path.join(current.root, 'client.key'),
|
||||
},
|
||||
{
|
||||
QL3_WORKER_LAUNCHER_PATH: '/usr/local/bin/ql3-launcher',
|
||||
},
|
||||
]) {
|
||||
await assert.rejects(
|
||||
loadWorkerProcessConfig({
|
||||
...current.environment,
|
||||
...patch,
|
||||
}),
|
||||
WorkerProcessConfigError,
|
||||
);
|
||||
}
|
||||
|
||||
await chmod(current.capabilitiesFile, 0o666);
|
||||
await assert.rejects(
|
||||
loadWorkerProcessConfig(current.environment),
|
||||
WorkerProcessConfigError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerProcessIdentityError,
|
||||
createWorkerProcessCredentialProvider,
|
||||
} = require('@qinglong/worker-runtime/process-identity');
|
||||
|
||||
const FIXTURES = path.resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls',
|
||||
);
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-identity-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await chmod(root, 0o700);
|
||||
const privateKeyFile = path.join(root, 'client-key.pem');
|
||||
const certificateChainFile = path.join(root, 'client-cert.pem');
|
||||
const trustAnchorFile = path.join(root, 'ca-cert.pem');
|
||||
const credentialTokenFile = path.join(root, 'credential-token');
|
||||
await Promise.all([
|
||||
copyFile(path.join(FIXTURES, 'client-key.pem'), privateKeyFile),
|
||||
copyFile(path.join(FIXTURES, 'client-cert.pem'), certificateChainFile),
|
||||
copyFile(path.join(FIXTURES, 'ca-cert.pem'), trustAnchorFile),
|
||||
writeFile(
|
||||
credentialTokenFile,
|
||||
`ql3w_worker_primary_${Buffer.alloc(32, 7).toString('base64url')}\n`,
|
||||
),
|
||||
]);
|
||||
await Promise.all([
|
||||
chmod(privateKeyFile, 0o600),
|
||||
chmod(certificateChainFile, 0o444),
|
||||
chmod(trustAnchorFile, 0o444),
|
||||
chmod(credentialTokenFile, 0o600),
|
||||
]);
|
||||
return {
|
||||
root,
|
||||
config: {
|
||||
certificateStoreRoot: path.join(root, 'store'),
|
||||
trustAnchorFile,
|
||||
credentialTokenFile,
|
||||
expectedCredentialId: 'worker_primary',
|
||||
bootstrap: {
|
||||
privateKeyFile,
|
||||
certificateChainFile,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('bootstraps one durable identity and returns disposable request credentials', async (t) => {
|
||||
const current = await fixture(t);
|
||||
const provider = await createWorkerProcessCredentialProvider(
|
||||
current.config,
|
||||
);
|
||||
const first = await provider.load();
|
||||
assert.match(first.authorization, /^Worker ql3w_worker_primary_/);
|
||||
assert.equal(Buffer.isBuffer(first.privateKeyPem), true);
|
||||
assert.equal(Buffer.isBuffer(first.certificateChainPem), true);
|
||||
assert.equal(first.trustAnchors.length, 1);
|
||||
first.dispose();
|
||||
|
||||
const generations = await readdir(
|
||||
path.join(current.config.certificateStoreRoot, 'generations'),
|
||||
);
|
||||
assert.equal(generations.length, 1);
|
||||
const reloaded = await createWorkerProcessCredentialProvider(
|
||||
current.config,
|
||||
);
|
||||
const afterReload = await reloaded.load();
|
||||
afterReload.dispose();
|
||||
assert.equal(
|
||||
(
|
||||
await readdir(
|
||||
path.join(current.config.certificateStoreRoot, 'generations'),
|
||||
)
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed for unsafe bootstrap material or absent active identity', async (t) => {
|
||||
const current = await fixture(t);
|
||||
await chmod(current.config.bootstrap.privateKeyFile, 0o644);
|
||||
await assert.rejects(
|
||||
createWorkerProcessCredentialProvider(current.config),
|
||||
WorkerProcessIdentityError,
|
||||
);
|
||||
await assert.rejects(
|
||||
createWorkerProcessCredentialProvider({
|
||||
certificateStoreRoot: path.join(current.root, 'empty-store'),
|
||||
trustAnchorFile: current.config.trustAnchorFile,
|
||||
credentialTokenFile: current.config.credentialTokenFile,
|
||||
}),
|
||||
WorkerProcessIdentityError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
generateWorkerCertificateEnrollment,
|
||||
} = require('../dist/credential/workerCertificateEnrollment');
|
||||
const {
|
||||
WorkerCertificateFileStore,
|
||||
} = require('../dist/credential/workerCertificateStore');
|
||||
const {
|
||||
WorkerProductionCredentialProvider,
|
||||
} = require('../dist/credential/workerProductionCredentialProvider');
|
||||
const {
|
||||
createCertificateAuthority,
|
||||
} = require('./helpers/certificateAuthority.cjs');
|
||||
|
||||
function ql3w(credentialId, fill) {
|
||||
return `ql3w_${credentialId}_${Buffer.alloc(32, fill).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function issueIdentity(ca, workerId, now) {
|
||||
const enrollment = await generateWorkerCertificateEnrollment({ workerId });
|
||||
try {
|
||||
return {
|
||||
privateKeyPem: Buffer.from(enrollment.privateKeyPem),
|
||||
certificateChainPem: await ca.issue(
|
||||
enrollment.certificateSigningRequestPem,
|
||||
),
|
||||
trustAnchors: [ca.certificatePem],
|
||||
now,
|
||||
};
|
||||
} finally {
|
||||
enrollment.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-credentials-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const now = Date.now();
|
||||
const ca = await createCertificateAuthority({ now });
|
||||
const store = new WorkerCertificateFileStore({
|
||||
rootDirectory: path.join(root, 'identity'),
|
||||
});
|
||||
const identity = await issueIdentity(ca, 'edge-1', now);
|
||||
await store.install(identity);
|
||||
identity.privateKeyPem.fill(0);
|
||||
const tokenFile = path.join(root, 'worker-token');
|
||||
await writeFile(tokenFile, `${ql3w('worker_primary', 7)}\n`, { mode: 0o600 });
|
||||
let trustLoads = 0;
|
||||
const provider = new WorkerProductionCredentialProvider({
|
||||
certificateStore: store,
|
||||
trustAnchors: {
|
||||
async load() {
|
||||
trustLoads += 1;
|
||||
return [ca.certificatePem];
|
||||
},
|
||||
},
|
||||
credentialTokenFile: tokenFile,
|
||||
expectedCredentialId: 'worker_primary',
|
||||
now: () => now,
|
||||
});
|
||||
return { root, now, ca, store, tokenFile, provider, trustLoads: () => trustLoads };
|
||||
}
|
||||
|
||||
test('loads and disposes the current certificate and ql3w generations', async (t) => {
|
||||
const context = await fixture(t);
|
||||
const first = await context.provider.load();
|
||||
const firstCertificate = Buffer.from(first.certificateChainPem);
|
||||
assert.equal(first.authorization, `Worker ${ql3w('worker_primary', 7)}`);
|
||||
assert.equal(Buffer.isBuffer(first.privateKeyPem), true);
|
||||
first.dispose();
|
||||
assert.equal(
|
||||
first.privateKeyPem.equals(Buffer.alloc(first.privateKeyPem.length)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
first.certificateChainPem.equals(
|
||||
Buffer.alloc(first.certificateChainPem.length),
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const replacement = `${context.tokenFile}.next`;
|
||||
await writeFile(replacement, `${ql3w('worker_primary', 8)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await rename(replacement, context.tokenFile);
|
||||
const secondIdentity = await issueIdentity(
|
||||
context.ca,
|
||||
'edge-1',
|
||||
context.now + 1_000,
|
||||
);
|
||||
await context.store.install(secondIdentity);
|
||||
secondIdentity.privateKeyPem.fill(0);
|
||||
|
||||
const second = await context.provider.load();
|
||||
try {
|
||||
assert.equal(second.authorization, `Worker ${ql3w('worker_primary', 8)}`);
|
||||
assert.equal(
|
||||
firstCertificate.equals(second.certificateChainPem),
|
||||
false,
|
||||
);
|
||||
assert.equal(context.trustLoads(), 2);
|
||||
} finally {
|
||||
firstCertificate.fill(0);
|
||||
second.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for token identity drift and broad file permissions', async (t) => {
|
||||
const context = await fixture(t);
|
||||
const drifted = new WorkerProductionCredentialProvider({
|
||||
certificateStore: context.store,
|
||||
trustAnchors: { async load() { return [context.ca.certificatePem]; } },
|
||||
credentialTokenFile: context.tokenFile,
|
||||
expectedCredentialId: 'different_credential',
|
||||
now: () => context.now,
|
||||
});
|
||||
await assert.rejects(drifted.load(), /credentials_unavailable/);
|
||||
|
||||
await chmod(context.tokenFile, 0o644);
|
||||
await assert.rejects(context.provider.load(), /credentials_unavailable/);
|
||||
});
|
||||
|
||||
test('honors pre-abort before reading trust, certificate or token authority', async () => {
|
||||
let reads = 0;
|
||||
const provider = new WorkerProductionCredentialProvider({
|
||||
certificateStore: {
|
||||
async readActive() { reads += 1; throw new Error('not reached'); },
|
||||
},
|
||||
trustAnchors: {
|
||||
async load() { reads += 1; throw new Error('not reached'); },
|
||||
},
|
||||
credentialTokenFile: '/private/ql3-worker-token',
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled');
|
||||
controller.abort(reason);
|
||||
await assert.rejects(provider.load(controller.signal), reason);
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
|
||||
test('rejects unsafe token paths and credential identifiers at construction', () => {
|
||||
const base = {
|
||||
certificateStore: { async readActive() { return undefined; } },
|
||||
trustAnchors: { async load() { return []; } },
|
||||
};
|
||||
assert.throws(
|
||||
() => new WorkerProductionCredentialProvider({
|
||||
...base,
|
||||
credentialTokenFile: 'relative-token',
|
||||
}),
|
||||
/invalid_configuration/,
|
||||
);
|
||||
assert.throws(
|
||||
() => new WorkerProductionCredentialProvider({
|
||||
...base,
|
||||
credentialTokenFile: '/private/ql3-worker-token',
|
||||
expectedCredentialId: 'invalid id',
|
||||
}),
|
||||
/invalid_configuration/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
} = require('../dist/remote-execution/transport/workerIngressHttpsClient');
|
||||
const {
|
||||
WorkerSessionHttpsClient,
|
||||
} = require('../dist/session/workerSessionHttpsClient');
|
||||
const {
|
||||
WorkerSessionCoordinator,
|
||||
} = require('../dist/session/workerSessionCoordinator');
|
||||
|
||||
const SESSION_ID = '018f5c64-9b9d-7f1a-8c2d-1234567890ac';
|
||||
|
||||
function capabilities() {
|
||||
return {
|
||||
architecture: 'x64',
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
runtimes: [{ name: 'node', version: '24.14.0' }],
|
||||
labels: {},
|
||||
capacity: { cpuCores: 1, memoryBytes: 256 * 1024 * 1024 },
|
||||
features: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
let now = 1_000;
|
||||
let version = -1;
|
||||
let status = 'online';
|
||||
let rejectionStatus;
|
||||
const calls = [];
|
||||
const transport = new WorkerIngressHttpsClient({
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: { async load() { throw new Error('not reached'); } },
|
||||
});
|
||||
transport.postJson = async (request) => {
|
||||
calls.push(request.body);
|
||||
if (rejectionStatus !== undefined) {
|
||||
throw new WorkerIngressHttpsClientError(
|
||||
'response_rejected',
|
||||
rejectionStatus,
|
||||
);
|
||||
}
|
||||
if (request.body.schema.endsWith('register@v1')) {
|
||||
version = 0;
|
||||
status = 'online';
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: request.body.schema,
|
||||
workerId: 'edge-1', sessionId: SESSION_ID,
|
||||
generation: 1, version, status,
|
||||
leaseExpiresAtMs: now + 45_000,
|
||||
replacedSession: false,
|
||||
}));
|
||||
}
|
||||
version += 1;
|
||||
if (request.body.schema.endsWith('transition@v1')) {
|
||||
status = request.body.status;
|
||||
}
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: request.body.schema,
|
||||
workerId: 'edge-1', sessionId: SESSION_ID,
|
||||
generation: 1, version, status,
|
||||
leaseExpiresAtMs: now + 45_000,
|
||||
}));
|
||||
};
|
||||
const coordinator = new WorkerSessionCoordinator({
|
||||
client: new WorkerSessionHttpsClient({ client: transport }),
|
||||
workerId: 'edge-1',
|
||||
capabilities: capabilities(),
|
||||
maxConcurrentRuns: 2,
|
||||
availableSlots: () => 1,
|
||||
leaseDurationMs: 45_000,
|
||||
heartbeatIntervalMs: 10_000,
|
||||
now: () => now,
|
||||
createSessionId: () => SESSION_ID,
|
||||
});
|
||||
return {
|
||||
coordinator,
|
||||
calls,
|
||||
advance(value) { now += value; },
|
||||
setNow(value) { now = value; },
|
||||
rejectWith(value) { rejectionStatus = value; },
|
||||
};
|
||||
}
|
||||
|
||||
test('registers canonical capabilities and exposes one live execution Session', async () => {
|
||||
const context = fixture();
|
||||
const registered = await context.coordinator.register();
|
||||
assert.equal(registered.status, 'available');
|
||||
assert.equal(context.coordinator.current().sessionId, SESSION_ID);
|
||||
assert.equal(context.calls.length, 1);
|
||||
assert.equal(context.calls[0].availableSlots, 1);
|
||||
assert.equal(
|
||||
require('node:crypto').createHash('sha256')
|
||||
.update(context.calls[0].capabilitiesJson).digest('hex'),
|
||||
context.calls[0].capabilitiesHash,
|
||||
);
|
||||
});
|
||||
|
||||
test('uses caller-driven due heartbeats without creating a timer', async () => {
|
||||
const context = fixture();
|
||||
await context.coordinator.register();
|
||||
assert.equal((await context.coordinator.tick()).status, 'not_due');
|
||||
context.advance(10_000);
|
||||
const result = await context.coordinator.tick();
|
||||
assert.equal(result.status, 'heartbeat');
|
||||
assert.equal(result.session.version, 1);
|
||||
assert.equal(context.calls.length, 2);
|
||||
});
|
||||
|
||||
test('drains with zero capacity, heartbeats, then disconnects in order', async () => {
|
||||
const context = fixture();
|
||||
await context.coordinator.register();
|
||||
await context.coordinator.beginDrain();
|
||||
assert.equal(context.coordinator.current().status, 'draining');
|
||||
context.advance(10_000);
|
||||
await context.coordinator.tick();
|
||||
assert.equal(context.calls.at(-1).availableSlots, 0);
|
||||
await context.coordinator.disconnect();
|
||||
assert.equal(context.coordinator.currentRecord().status, 'offline');
|
||||
assert.equal(context.coordinator.current().status, 'offline');
|
||||
const completedCalls = context.calls.length;
|
||||
await context.coordinator.beginDrain();
|
||||
await context.coordinator.disconnect();
|
||||
assert.equal(context.calls.length, completedCalls);
|
||||
});
|
||||
|
||||
test('fails closed locally after the observed Session lease expires', async () => {
|
||||
const context = fixture();
|
||||
await context.coordinator.register();
|
||||
context.advance(45_000);
|
||||
assert.equal(context.coordinator.current(), undefined);
|
||||
assert.equal((await context.coordinator.tick()).status, 'lease_expired');
|
||||
await assert.rejects(context.coordinator.beginDrain(), /lease_expired/);
|
||||
});
|
||||
|
||||
test('pauses Pull on credential/fence rejection and recovers the same Session', async () => {
|
||||
const context = fixture();
|
||||
await context.coordinator.register();
|
||||
context.advance(10_000);
|
||||
context.rejectWith(401);
|
||||
await assert.rejects(
|
||||
context.coordinator.tick(),
|
||||
(error) => error.reason === 'credential_rejected',
|
||||
);
|
||||
assert.equal(context.coordinator.current(), undefined);
|
||||
|
||||
context.rejectWith(undefined);
|
||||
const recovered = await context.coordinator.tick();
|
||||
assert.equal(recovered.status, 'heartbeat');
|
||||
assert.equal(context.coordinator.current().sessionId, SESSION_ID);
|
||||
|
||||
context.advance(10_000);
|
||||
context.rejectWith(409);
|
||||
await assert.rejects(
|
||||
context.coordinator.tick(),
|
||||
(error) => error.reason === 'session_fenced',
|
||||
);
|
||||
assert.equal(context.coordinator.current(), undefined);
|
||||
});
|
||||
|
||||
test('keeps certificate fail-closed until an authenticated heartbeat succeeds', async () => {
|
||||
const context = fixture();
|
||||
await context.coordinator.register();
|
||||
context.coordinator.failClosed();
|
||||
assert.equal(context.coordinator.current(), undefined);
|
||||
assert.equal((await context.coordinator.tick()).status, 'not_due');
|
||||
assert.equal(context.coordinator.current(), undefined);
|
||||
context.advance(10_000);
|
||||
assert.equal((await context.coordinator.tick()).status, 'heartbeat');
|
||||
assert.equal(context.coordinator.current().sessionId, SESSION_ID);
|
||||
});
|
||||
|
||||
test('keeps a live Session available across transient server failure', async () => {
|
||||
const context = fixture();
|
||||
await context.coordinator.register();
|
||||
context.advance(10_000);
|
||||
context.rejectWith(503);
|
||||
await assert.rejects(
|
||||
context.coordinator.tick(),
|
||||
(error) => error.reason === 'transport_unavailable',
|
||||
);
|
||||
assert.equal(context.coordinator.current().sessionId, SESSION_ID);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WORKER_SESSION_HEARTBEAT_SCHEMA,
|
||||
WORKER_SESSION_REGISTER_SCHEMA,
|
||||
WORKER_SESSION_TRANSITION_SCHEMA,
|
||||
} = require('@qinglong/runtime-core/worker-session-transport');
|
||||
const {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
} = require('../dist/remote-execution/transport/workerIngressHttpsClient');
|
||||
const {
|
||||
WorkerSessionHttpsClient,
|
||||
} = require('../dist/session/workerSessionHttpsClient');
|
||||
|
||||
const authority = {
|
||||
workerId: 'edge-1',
|
||||
sessionId: '018f5c64-9b9d-7f1a-8c2d-1234567890ac',
|
||||
};
|
||||
const capabilitiesJson = '{}';
|
||||
const capabilitiesHash =
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||
|
||||
function client(exchange) {
|
||||
const transport = new WorkerIngressHttpsClient({
|
||||
origin: 'https://worker-control.invalid',
|
||||
credentials: { async load() { throw new Error('not reached'); } },
|
||||
});
|
||||
transport.postJson = exchange;
|
||||
return new WorkerSessionHttpsClient({ client: transport });
|
||||
}
|
||||
|
||||
test('registers one exact path-bound Session over the shared client', async () => {
|
||||
let observed;
|
||||
const session = client(async (request) => {
|
||||
observed = request;
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: WORKER_SESSION_REGISTER_SCHEMA,
|
||||
...authority,
|
||||
generation: 1,
|
||||
version: 0,
|
||||
status: 'online',
|
||||
leaseExpiresAtMs: 50_000,
|
||||
replacedSession: false,
|
||||
}));
|
||||
});
|
||||
const result = await session.register({
|
||||
...authority,
|
||||
capabilitiesJson,
|
||||
capabilitiesHash,
|
||||
maxConcurrentRuns: 2,
|
||||
availableSlots: 1,
|
||||
leaseDurationMs: 30_000,
|
||||
});
|
||||
assert.equal(result.status, 'online');
|
||||
assert.match(observed.path, /\/register$/);
|
||||
assert.equal(observed.body.schema, WORKER_SESSION_REGISTER_SCHEMA);
|
||||
assert.equal('workerId' in observed.body, false);
|
||||
assert.equal(observed.maximumRequestBytes, 20 * 1024);
|
||||
});
|
||||
|
||||
test('heartbeats and transitions under exact next-version fences', async () => {
|
||||
const operations = [];
|
||||
const session = client(async (request) => {
|
||||
operations.push(request.body.schema);
|
||||
const transition = request.body.schema === WORKER_SESSION_TRANSITION_SCHEMA;
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: request.body.schema,
|
||||
...authority,
|
||||
generation: 2,
|
||||
version: request.body.expectedVersion + 1,
|
||||
status: transition ? request.body.status : 'online',
|
||||
leaseExpiresAtMs: 60_000,
|
||||
}));
|
||||
});
|
||||
const heartbeat = await session.heartbeat({
|
||||
...authority, generation: 2, expectedVersion: 3,
|
||||
availableSlots: 1, leaseDurationMs: 30_000,
|
||||
});
|
||||
assert.equal(heartbeat.version, 4);
|
||||
const drained = await session.transition({
|
||||
...authority, generation: 2, expectedVersion: 4, status: 'draining',
|
||||
});
|
||||
assert.equal(drained.status, 'draining');
|
||||
assert.deepEqual(operations, [
|
||||
WORKER_SESSION_HEARTBEAT_SCHEMA,
|
||||
WORKER_SESSION_TRANSITION_SCHEMA,
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects response authority and version drift', async () => {
|
||||
const session = client(async () => Buffer.from(JSON.stringify({
|
||||
schema: WORKER_SESSION_HEARTBEAT_SCHEMA,
|
||||
...authority,
|
||||
generation: 2,
|
||||
version: 99,
|
||||
status: 'online',
|
||||
leaseExpiresAtMs: 60_000,
|
||||
})));
|
||||
await assert.rejects(
|
||||
session.heartbeat({
|
||||
...authority, generation: 2, expectedVersion: 3,
|
||||
availableSlots: 1, leaseDurationMs: 30_000,
|
||||
}),
|
||||
/response_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('classifies credential rejection and Session fencing without error bodies', async () => {
|
||||
for (const [statusCode, reason] of [
|
||||
[401, 'credential_rejected'],
|
||||
[403, 'credential_rejected'],
|
||||
[409, 'session_fenced'],
|
||||
[503, 'transport_unavailable'],
|
||||
]) {
|
||||
const session = client(async () => {
|
||||
throw new WorkerIngressHttpsClientError(
|
||||
'response_rejected',
|
||||
statusCode,
|
||||
);
|
||||
});
|
||||
await assert.rejects(
|
||||
session.heartbeat({
|
||||
...authority, generation: 2, expectedVersion: 3,
|
||||
availableSlots: 1, leaseDurationMs: 30_000,
|
||||
}),
|
||||
(error) => error.reason === reason,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user