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