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,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!,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user