mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): version worker support tier admission
This commit is contained in:
@@ -527,6 +527,8 @@ if (!migrationConnectionString || !runtimeConnectionString) {
|
||||
const capabilities = canonicalRemoteWorkerCapabilities({
|
||||
architecture: 'arm64',
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: 'tier1',
|
||||
labels: { region: 'cn-east' },
|
||||
capacity: { memoryBytes: 536870912 },
|
||||
});
|
||||
@@ -1227,7 +1229,7 @@ if (!migrationConnectionString || !runtimeConnectionString) {
|
||||
);
|
||||
const sessionId = '018f0000-0000-7000-8000-000000000021';
|
||||
const capabilitiesJson =
|
||||
'{"architecture":"arm64","executors":["remote-worker"]}';
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
const response = await workerRequest(
|
||||
application.address,
|
||||
`/api/v3/worker-ingress/workers/worker-http-a/sessions/${sessionId}/register`,
|
||||
|
||||
@@ -43,6 +43,7 @@ function revision() {
|
||||
function worker() {
|
||||
const capabilities = canonicalRemoteWorkerCapabilities({
|
||||
architecture: 'arm64', executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0', supportTier: 'tier1',
|
||||
});
|
||||
return {
|
||||
workerId: 'edge-1', sessionId: SESSION, generation: 2,
|
||||
|
||||
@@ -36,6 +36,10 @@ const {
|
||||
} = require('@qinglong/runtime-core/remote-worker-lease-control');
|
||||
|
||||
const SESSION_ID = '018f5c64-9b9d-7f1a-8c2d-1234567890ac';
|
||||
const CAPABILITIES_JSON =
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
const CAPABILITIES_HASH =
|
||||
'b3d79017d91c477ffdf4a4dcc4ce9135ca053c921922ce0221920f905d8a2aa4';
|
||||
|
||||
function metadata(operation, workerId = 'edge-1') {
|
||||
return {
|
||||
@@ -80,9 +84,8 @@ function fixture(overrides = {}) {
|
||||
generation: command.generation,
|
||||
version: command.expectedVersion + 1,
|
||||
status: 'online',
|
||||
capabilitiesJson: '{}',
|
||||
capabilitiesHash:
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
|
||||
capabilitiesJson: CAPABILITIES_JSON,
|
||||
capabilitiesHash: CAPABILITIES_HASH,
|
||||
maxConcurrentRuns: 1,
|
||||
availableSlots: command.availableSlots,
|
||||
registeredAtMs: 1,
|
||||
@@ -150,9 +153,8 @@ test('uses exact versioned register and transition contracts', async () => {
|
||||
generation: 1,
|
||||
version: 0,
|
||||
status: 'online',
|
||||
capabilitiesJson: '{}',
|
||||
capabilitiesHash:
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
|
||||
capabilitiesJson: CAPABILITIES_JSON,
|
||||
capabilitiesHash: CAPABILITIES_HASH,
|
||||
maxConcurrentRuns: 1,
|
||||
availableSlots: 1,
|
||||
registeredAtMs: 1,
|
||||
@@ -188,7 +190,7 @@ test('uses exact versioned register and transition contracts', async () => {
|
||||
await context.pipeline.prepare(metadata('register'))
|
||||
).handle({
|
||||
schema: WORKER_SESSION_REGISTER_SCHEMA,
|
||||
capabilitiesJson: '{}',
|
||||
capabilitiesJson: CAPABILITIES_JSON,
|
||||
capabilitiesHash: baseRecord.capabilitiesHash,
|
||||
maxConcurrentRuns: 1,
|
||||
availableSlots: 1,
|
||||
@@ -208,6 +210,31 @@ test('uses exact versioned register and transition contracts', async () => {
|
||||
assert.equal(transition.body.status, 'draining');
|
||||
});
|
||||
|
||||
test('rejects unversioned Worker capabilities before repository registration', async () => {
|
||||
let registered = false;
|
||||
const context = fixture({
|
||||
workers: {
|
||||
async register() { registered = true; throw new Error('must not register'); },
|
||||
async heartbeatAuthenticated() { throw new Error('not used'); },
|
||||
async transitionAuthenticated() { throw new Error('not used'); },
|
||||
async findById() { return null; },
|
||||
async listAvailable() { throw new Error('not used'); },
|
||||
},
|
||||
});
|
||||
const prepared = await context.pipeline.prepare(metadata('register'));
|
||||
await assert.rejects(prepared.handle({
|
||||
schema: WORKER_SESSION_REGISTER_SCHEMA,
|
||||
capabilitiesJson: '{}',
|
||||
capabilitiesHash:
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
|
||||
maxConcurrentRuns: 1,
|
||||
availableSlots: 1,
|
||||
leaseDurationMs: 30_000,
|
||||
}), (error) =>
|
||||
error.statusCode === 400 && error.code === 'invalid_worker_request');
|
||||
assert.equal(registered, false);
|
||||
});
|
||||
|
||||
test('binds the credential Worker to the path and rejects before body access', async () => {
|
||||
const { pipeline, events } = fixture();
|
||||
await assert.rejects(
|
||||
|
||||
@@ -5851,7 +5851,7 @@ if (!migrationConnectionString) {
|
||||
assert.equal(published.state, 'published');
|
||||
|
||||
const capabilitiesJson =
|
||||
'{"architecture":"arm64","executors":["remote-worker"]}';
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
const capabilitiesHash = createHash('sha256')
|
||||
.update(capabilitiesJson)
|
||||
.digest('hex');
|
||||
@@ -6168,7 +6168,7 @@ if (!migrationConnectionString) {
|
||||
runtimeDatabase.pool,
|
||||
);
|
||||
const capabilitiesJson =
|
||||
'{"architecture":"arm64","executors":["remote-worker"]}';
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
const capabilitiesHash = createHash('sha256')
|
||||
.update(capabilitiesJson)
|
||||
.digest('hex');
|
||||
@@ -6371,7 +6371,7 @@ if (!migrationConnectionString) {
|
||||
'worker-ingress-a',
|
||||
);
|
||||
const capabilitiesJson =
|
||||
'{"architecture":"arm64","executors":["remote-worker"]}';
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
const workers = new PostgresWorkerSessionRepository(
|
||||
ingressDatabase.pool,
|
||||
);
|
||||
|
||||
+4
-2
@@ -10,7 +10,9 @@ const {
|
||||
const SESSION_ID = '019f7094-a853-72f3-82ab-dfa08e6bd1c1';
|
||||
const DELIVERY_ID = '123e4567-e89b-42d3-a456-426614174601';
|
||||
const CAPABILITIES_HASH =
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||
'b3d79017d91c477ffdf4a4dcc4ce9135ca053c921922ce0221920f905d8a2aa4';
|
||||
const CAPABILITIES_JSON =
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
|
||||
function session(version, overrides = {}) {
|
||||
return {
|
||||
@@ -19,7 +21,7 @@ function session(version, overrides = {}) {
|
||||
generation: 2,
|
||||
status: 'online',
|
||||
version,
|
||||
capabilitiesJson: '{}',
|
||||
capabilitiesJson: CAPABILITIES_JSON,
|
||||
capabilitiesHash: CAPABILITIES_HASH,
|
||||
maxConcurrentRuns: 2,
|
||||
availableSlots: 1,
|
||||
|
||||
@@ -6,7 +6,9 @@ const {
|
||||
|
||||
const SESSION_ID = '019f7094-a853-72f3-82ab-dfa08e6bd1c1';
|
||||
const CAPABILITIES_HASH =
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||
'b3d79017d91c477ffdf4a4dcc4ce9135ca053c921922ce0221920f905d8a2aa4';
|
||||
const CAPABILITIES_JSON =
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
|
||||
test('pins the shared transition status parameter to PostgreSQL varchar', async () => {
|
||||
const events = [];
|
||||
@@ -16,7 +18,7 @@ test('pins the shared transition status parameter to PostgreSQL varchar', async
|
||||
generation: 2,
|
||||
status: 'online',
|
||||
version: 3,
|
||||
capabilitiesJson: '{}',
|
||||
capabilitiesJson: CAPABILITIES_JSON,
|
||||
capabilitiesHash: CAPABILITIES_HASH,
|
||||
maxConcurrentRuns: 2,
|
||||
availableSlots: 1,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { semver } from '../versioning/pinnedSemver';
|
||||
|
||||
export const REMOTE_WORKER_PROTOCOL_VERSION = '1.0.0';
|
||||
export const REMOTE_WORKER_PROTOCOL_RANGE = '>=1.0.0 <2.0.0';
|
||||
|
||||
export const REMOTE_WORKER_SUPPORT_TIERS = [
|
||||
'tier1',
|
||||
'candidate',
|
||||
'experimental',
|
||||
'legacy-only',
|
||||
] as const;
|
||||
|
||||
export type RemoteWorkerSupportTier =
|
||||
(typeof REMOTE_WORKER_SUPPORT_TIERS)[number];
|
||||
|
||||
export const REMOTE_WORKER_ARCHITECTURES_BY_SUPPORT_TIER = Object.freeze({
|
||||
tier1: Object.freeze(['amd64', 'arm64'] as const),
|
||||
candidate: Object.freeze(['ppc64le', 's390x'] as const),
|
||||
experimental: Object.freeze(['arm/v7'] as const),
|
||||
'legacy-only': Object.freeze(['arm/v6', '386'] as const),
|
||||
});
|
||||
|
||||
export type RemoteWorkerArchitecture =
|
||||
(typeof REMOTE_WORKER_ARCHITECTURES_BY_SUPPORT_TIER)[RemoteWorkerSupportTier][number];
|
||||
|
||||
const REMOTE_WORKER_ARCHITECTURES = Object.freeze(
|
||||
Object.values(REMOTE_WORKER_ARCHITECTURES_BY_SUPPORT_TIER).flat(),
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new TypeError(
|
||||
`Remote Worker compatibility value is invalid: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeRemoteWorkerProtocolVersion(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 64 ||
|
||||
semver().valid(value) === null
|
||||
) {
|
||||
invalid('protocolVersion is not semver');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeRemoteWorkerArchitecture(
|
||||
value: unknown,
|
||||
): RemoteWorkerArchitecture {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!REMOTE_WORKER_ARCHITECTURES.includes(value as RemoteWorkerArchitecture)
|
||||
) {
|
||||
invalid('architecture is outside the release support policy');
|
||||
}
|
||||
return value as RemoteWorkerArchitecture;
|
||||
}
|
||||
|
||||
export function remoteWorkerSupportTierForArchitecture(
|
||||
value: unknown,
|
||||
): RemoteWorkerSupportTier {
|
||||
const architecture = normalizeRemoteWorkerArchitecture(value);
|
||||
for (const supportTier of REMOTE_WORKER_SUPPORT_TIERS) {
|
||||
if (
|
||||
(
|
||||
REMOTE_WORKER_ARCHITECTURES_BY_SUPPORT_TIER[
|
||||
supportTier
|
||||
] as readonly string[]
|
||||
).includes(architecture)
|
||||
) {
|
||||
return supportTier;
|
||||
}
|
||||
}
|
||||
return invalid('architecture has no supportTier');
|
||||
}
|
||||
|
||||
export function remoteWorkerArchitectureForNodeRuntime(
|
||||
nodeArchitecture: string,
|
||||
armVersion?: unknown,
|
||||
): RemoteWorkerArchitecture {
|
||||
if (nodeArchitecture === 'x64') return 'amd64';
|
||||
if (nodeArchitecture === 'arm64') return 'arm64';
|
||||
if (nodeArchitecture === 'ppc64') return 'ppc64le';
|
||||
if (nodeArchitecture === 's390x') return 's390x';
|
||||
if (nodeArchitecture === 'ia32') return '386';
|
||||
if (nodeArchitecture === 'arm' && (armVersion === 6 || armVersion === '6')) {
|
||||
return 'arm/v6';
|
||||
}
|
||||
if (nodeArchitecture === 'arm' && (armVersion === 7 || armVersion === '7')) {
|
||||
return 'arm/v7';
|
||||
}
|
||||
return invalid('Node runtime architecture is unsupported or ambiguous');
|
||||
}
|
||||
|
||||
export function normalizeRemoteWorkerProtocolVersionRange(
|
||||
value: unknown,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 128 ||
|
||||
semver().validRange(value) === null
|
||||
) {
|
||||
invalid('protocolVersionRange is not semver');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeRemoteWorkerSupportTier(
|
||||
value: unknown,
|
||||
): RemoteWorkerSupportTier {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!REMOTE_WORKER_SUPPORT_TIERS.includes(value as RemoteWorkerSupportTier)
|
||||
) {
|
||||
invalid('supportTier is unknown');
|
||||
}
|
||||
return value as RemoteWorkerSupportTier;
|
||||
}
|
||||
|
||||
export function assertRemoteWorkerCompatibilityCapability(
|
||||
value: unknown,
|
||||
): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
invalid('capabilities must be an object');
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
const architecture = normalizeRemoteWorkerArchitecture(source.architecture);
|
||||
normalizeRemoteWorkerProtocolVersion(source.protocolVersion);
|
||||
const supportTier = normalizeRemoteWorkerSupportTier(source.supportTier);
|
||||
if (remoteWorkerSupportTierForArchitecture(architecture) !== supportTier) {
|
||||
invalid('architecture does not belong to supportTier');
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteWorkerProtocolIsCompatible(
|
||||
protocolVersion: string,
|
||||
requiredRange = REMOTE_WORKER_PROTOCOL_RANGE,
|
||||
): boolean {
|
||||
const version = normalizeRemoteWorkerProtocolVersion(protocolVersion);
|
||||
const range = normalizeRemoteWorkerProtocolVersionRange(requiredRange);
|
||||
return semver().satisfies(version, range, { includePrerelease: true });
|
||||
}
|
||||
@@ -2,6 +2,20 @@ import { createHash } from 'node:crypto';
|
||||
import type { WorkerSessionRecord } from '../worker/workerSession';
|
||||
import { assertWorkerSessionRecord } from '../worker/workerSession';
|
||||
import { semver } from '../versioning/pinnedSemver';
|
||||
import {
|
||||
REMOTE_WORKER_PROTOCOL_RANGE,
|
||||
REMOTE_WORKER_SUPPORT_TIERS,
|
||||
normalizeRemoteWorkerArchitecture,
|
||||
normalizeRemoteWorkerProtocolVersion,
|
||||
normalizeRemoteWorkerProtocolVersionRange,
|
||||
normalizeRemoteWorkerSupportTier,
|
||||
remoteWorkerProtocolIsCompatible,
|
||||
remoteWorkerSupportTierForArchitecture,
|
||||
type RemoteWorkerArchitecture,
|
||||
type RemoteWorkerSupportTier,
|
||||
} from './remoteWorkerCompatibility';
|
||||
|
||||
export * from './remoteWorkerCompatibility';
|
||||
|
||||
export const REMOTE_WORKER_EXECUTOR_CAPABILITY = 'remote-worker';
|
||||
export const MAX_REMOTE_PLACEMENT_VALUES = 16;
|
||||
@@ -20,8 +34,10 @@ export interface RemoteWorkerRuntimeCapability {
|
||||
}
|
||||
|
||||
export interface RemoteWorkerCapabilities {
|
||||
readonly architecture: string;
|
||||
readonly architecture: RemoteWorkerArchitecture;
|
||||
readonly executors: readonly string[];
|
||||
readonly protocolVersion: string;
|
||||
readonly supportTier: RemoteWorkerSupportTier;
|
||||
readonly operatingSystem?: string;
|
||||
readonly runtimes?: readonly RemoteWorkerRuntimeCapability[];
|
||||
readonly labels?: Readonly<Record<string, string>>;
|
||||
@@ -45,10 +61,12 @@ export interface RemoteWorkerRuntimeRequirement {
|
||||
|
||||
export interface RemoteWorkerPlacementSpec {
|
||||
readonly required?: Readonly<{
|
||||
readonly architectures?: readonly string[];
|
||||
readonly architectures?: readonly RemoteWorkerArchitecture[];
|
||||
readonly operatingSystems?: readonly string[];
|
||||
readonly executors?: readonly string[];
|
||||
readonly protocolVersionRange?: string;
|
||||
readonly runtimes?: readonly RemoteWorkerRuntimeRequirement[];
|
||||
readonly supportTiers?: readonly RemoteWorkerSupportTier[];
|
||||
readonly labels?: Readonly<Record<string, string>>;
|
||||
readonly minMemoryBytes?: number;
|
||||
readonly minDiskBytes?: number;
|
||||
@@ -63,6 +81,8 @@ export interface RemoteWorkerPlacementSpec {
|
||||
|
||||
export type RemoteWorkerPlacementMismatch =
|
||||
| 'worker_unavailable'
|
||||
| 'support_tier'
|
||||
| 'protocol_version'
|
||||
| 'architecture'
|
||||
| 'operating_system'
|
||||
| 'executor'
|
||||
@@ -186,17 +206,19 @@ export function normalizeRemoteWorkerCapabilities(
|
||||
const source = object(value, 'capabilities');
|
||||
exactKeys(
|
||||
source,
|
||||
['architecture', 'executors'],
|
||||
['architecture', 'executors', 'protocolVersion', 'supportTier'],
|
||||
['capacity', 'features', 'labels', 'operatingSystem', 'runtimes'],
|
||||
'capabilities',
|
||||
);
|
||||
const architecture = boundedString(
|
||||
source.architecture,
|
||||
'architecture',
|
||||
32,
|
||||
CAPABILITY_NAME,
|
||||
);
|
||||
const architecture = normalizeRemoteWorkerArchitecture(source.architecture);
|
||||
const executors = sortedStrings(source.executors, 'executors', 16, false);
|
||||
const protocolVersion = normalizeRemoteWorkerProtocolVersion(
|
||||
source.protocolVersion,
|
||||
);
|
||||
const supportTier = normalizeRemoteWorkerSupportTier(source.supportTier);
|
||||
if (remoteWorkerSupportTierForArchitecture(architecture) !== supportTier) {
|
||||
invalid('architecture does not belong to supportTier');
|
||||
}
|
||||
const operatingSystem =
|
||||
source.operatingSystem === undefined
|
||||
? undefined
|
||||
@@ -330,6 +352,8 @@ export function normalizeRemoteWorkerCapabilities(
|
||||
return Object.freeze({
|
||||
architecture,
|
||||
executors,
|
||||
protocolVersion,
|
||||
supportTier,
|
||||
...(operatingSystem === undefined ? {} : { operatingSystem }),
|
||||
...(runtimes === undefined ? {} : { runtimes }),
|
||||
...(labels === undefined ? {} : { labels }),
|
||||
@@ -377,6 +401,21 @@ function optionalStringList(
|
||||
: sortedStrings(value, label, MAX_REMOTE_PLACEMENT_VALUES);
|
||||
}
|
||||
|
||||
function optionalArchitectureList(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): readonly RemoteWorkerArchitecture[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.length > MAX_REMOTE_PLACEMENT_VALUES) {
|
||||
invalid(`${label} is invalid`);
|
||||
}
|
||||
const result = value.map((item) => normalizeRemoteWorkerArchitecture(item));
|
||||
if (new Set(result).size !== result.length) {
|
||||
invalid(`${label} contains duplicates`);
|
||||
}
|
||||
return Object.freeze([...result].sort());
|
||||
}
|
||||
|
||||
export function normalizeRemoteWorkerPlacement(
|
||||
value: unknown,
|
||||
): RemoteWorkerPlacementSpec {
|
||||
@@ -397,11 +436,13 @@ export function normalizeRemoteWorkerPlacement(
|
||||
'minDiskBytes',
|
||||
'minMemoryBytes',
|
||||
'operatingSystems',
|
||||
'protocolVersionRange',
|
||||
'runtimes',
|
||||
'supportTiers',
|
||||
],
|
||||
'placement.required',
|
||||
);
|
||||
const architectures = optionalStringList(
|
||||
const architectures = optionalArchitectureList(
|
||||
candidate.architectures,
|
||||
'placement.required.architectures',
|
||||
);
|
||||
@@ -413,6 +454,23 @@ export function normalizeRemoteWorkerPlacement(
|
||||
candidate.executors,
|
||||
'placement.required.executors',
|
||||
);
|
||||
const protocolVersionRange = candidate.protocolVersionRange === undefined
|
||||
? undefined
|
||||
: normalizeRemoteWorkerProtocolVersionRange(
|
||||
candidate.protocolVersionRange,
|
||||
);
|
||||
let supportTiers: readonly RemoteWorkerSupportTier[] | undefined;
|
||||
if (candidate.supportTiers !== undefined) {
|
||||
if (
|
||||
!Array.isArray(candidate.supportTiers) ||
|
||||
candidate.supportTiers.length > REMOTE_WORKER_SUPPORT_TIERS.length
|
||||
) invalid('placement.required.supportTiers is invalid');
|
||||
const mapped = candidate.supportTiers.map((value) =>
|
||||
normalizeRemoteWorkerSupportTier(value));
|
||||
if (new Set(mapped).size !== mapped.length)
|
||||
invalid('placement.required.supportTiers contains duplicates');
|
||||
supportTiers = Object.freeze([...mapped].sort());
|
||||
}
|
||||
const features = optionalStringList(
|
||||
candidate.features,
|
||||
'placement.required.features',
|
||||
@@ -468,7 +526,9 @@ export function normalizeRemoteWorkerPlacement(
|
||||
...(architectures === undefined ? {} : { architectures }),
|
||||
...(operatingSystems === undefined ? {} : { operatingSystems }),
|
||||
...(executors === undefined ? {} : { executors }),
|
||||
...(protocolVersionRange === undefined ? {} : { protocolVersionRange }),
|
||||
...(runtimes === undefined ? {} : { runtimes }),
|
||||
...(supportTiers === undefined ? {} : { supportTiers }),
|
||||
...(candidate.labels === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -594,6 +654,20 @@ export function evaluateRemoteWorkerPlacement(
|
||||
worker.leaseExpiresAtMs <= observedAtMs
|
||||
)
|
||||
mismatches.push('worker_unavailable');
|
||||
const requiredSupportTiers = required.supportTiers ?? ['tier1'];
|
||||
if (!requiredSupportTiers.includes(capabilities.supportTier))
|
||||
mismatches.push('support_tier');
|
||||
if (
|
||||
!remoteWorkerProtocolIsCompatible(
|
||||
capabilities.protocolVersion,
|
||||
REMOTE_WORKER_PROTOCOL_RANGE,
|
||||
) ||
|
||||
(required.protocolVersionRange !== undefined &&
|
||||
!remoteWorkerProtocolIsCompatible(
|
||||
capabilities.protocolVersion,
|
||||
required.protocolVersionRange,
|
||||
))
|
||||
) mismatches.push('protocol_version');
|
||||
if (
|
||||
required.architectures?.length &&
|
||||
!required.architectures.includes(capabilities.architecture)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { assertRemoteWorkerCompatibilityCapability } from '../remote-execution/remoteWorkerCompatibility';
|
||||
|
||||
export const WORKER_SESSION_STATUSES = ['online', 'draining', 'offline'] as const;
|
||||
export type WorkerSessionStatus = (typeof WORKER_SESSION_STATUSES)[number];
|
||||
@@ -141,6 +142,11 @@ export function assertWorkerCapabilitiesSnapshot(
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
invalid('Worker capabilities snapshot must be an object');
|
||||
}
|
||||
try {
|
||||
assertRemoteWorkerCompatibilityCapability(parsed);
|
||||
} catch {
|
||||
invalid('Worker capabilities compatibility contract is invalid');
|
||||
}
|
||||
if (
|
||||
createHash('sha256').update(capabilitiesJson, 'utf8').digest('hex') !==
|
||||
capabilitiesHash
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WorkerSessionRecord,
|
||||
type WorkerSessionStatus,
|
||||
} from './workerSession';
|
||||
import { canonicalRemoteWorkerCapabilities } from '../remote-execution/remoteWorkerPlacement';
|
||||
|
||||
export const WORKER_SESSION_REGISTER_SCHEMA =
|
||||
'qinglong/worker-session-register@v1';
|
||||
@@ -153,6 +154,13 @@ function validateRegister(
|
||||
command?.capabilitiesJson,
|
||||
command?.capabilitiesHash,
|
||||
);
|
||||
const canonical = canonicalRemoteWorkerCapabilities(
|
||||
JSON.parse(command.capabilitiesJson) as unknown,
|
||||
);
|
||||
if (
|
||||
canonical.json !== command.capabilitiesJson ||
|
||||
canonical.hash !== command.capabilitiesHash
|
||||
) throw new TypeError('capabilities snapshot is not canonical');
|
||||
assertWorkerConcurrency(
|
||||
command?.maxConcurrentRuns,
|
||||
command?.availableSlots,
|
||||
|
||||
@@ -20,6 +20,8 @@ function worker() {
|
||||
const snapshot = canonicalRemoteWorkerCapabilities({
|
||||
architecture: 'arm64',
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: 'tier1',
|
||||
operatingSystem: 'linux',
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
labels: { region: 'cn-east', tier: 'edge' },
|
||||
@@ -90,7 +92,10 @@ test('rejects non-canonical snapshots and reports bounded mismatch classes', ()
|
||||
const value = worker();
|
||||
const reordered = {
|
||||
...value,
|
||||
capabilitiesJson: JSON.stringify({ executors: ['remote-worker'], architecture: 'arm64' }),
|
||||
capabilitiesJson: JSON.stringify({
|
||||
executors: ['remote-worker'], architecture: 'arm64',
|
||||
protocolVersion: '1.0.0', supportTier: 'tier1',
|
||||
}),
|
||||
};
|
||||
reordered.capabilitiesHash = require('node:crypto')
|
||||
.createHash('sha256')
|
||||
@@ -102,7 +107,7 @@ test('rejects non-canonical snapshots and reports bounded mismatch classes', ()
|
||||
);
|
||||
const decision = evaluateRemoteWorkerPlacement(
|
||||
value,
|
||||
{ required: { architectures: ['x64'], labels: { region: 'eu' } } },
|
||||
{ required: { architectures: ['amd64'], labels: { region: 'eu' } } },
|
||||
20_000,
|
||||
);
|
||||
assert.deepEqual(decision.mismatches, ['architecture', 'label']);
|
||||
|
||||
@@ -36,6 +36,8 @@ test('uses pinned SemVer for remote runtime range admission', () => {
|
||||
const candidate = worker({
|
||||
architecture: 'arm64',
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: 'tier1',
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
});
|
||||
|
||||
@@ -54,3 +56,51 @@ test('uses pinned SemVer for remote runtime range admission', () => {
|
||||
/versionRange is not semver/,
|
||||
);
|
||||
});
|
||||
|
||||
test('defaults to Tier 1 protocol v1 and requires explicit legacy placement', () => {
|
||||
const legacy = worker({
|
||||
architecture: 'arm/v6', executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0', supportTier: 'legacy-only',
|
||||
});
|
||||
assert.deepEqual(evaluateRemoteWorkerPlacement(legacy, {}, 500), {
|
||||
matches: false, score: 0, mismatches: ['support_tier'],
|
||||
});
|
||||
assert.deepEqual(evaluateRemoteWorkerPlacement(
|
||||
legacy, { required: { supportTiers: ['legacy-only'] } }, 500,
|
||||
), { matches: true, score: 0, mismatches: [] });
|
||||
const incompatible = worker({
|
||||
architecture: 'arm64', executors: ['remote-worker'],
|
||||
protocolVersion: '2.0.0', supportTier: 'tier1',
|
||||
});
|
||||
assert.deepEqual(evaluateRemoteWorkerPlacement(incompatible, {}, 500), {
|
||||
matches: false, score: 0, mismatches: ['protocol_version'],
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects unversioned capabilities and unknown support policy', () => {
|
||||
assert.throws(() => canonicalRemoteWorkerCapabilities({
|
||||
architecture: 'arm64', executors: ['remote-worker'],
|
||||
}), /shape is invalid/);
|
||||
assert.throws(() => normalizeRemoteWorkerPlacement({
|
||||
required: { supportTiers: ['unsupported'] },
|
||||
}), /supportTier is unknown/);
|
||||
assert.throws(() => normalizeRemoteWorkerPlacement({
|
||||
required: { protocolVersionRange: 'not-a-range' },
|
||||
}), /protocolVersionRange is not semver/);
|
||||
assert.throws(() => canonicalRemoteWorkerCapabilities({
|
||||
architecture: 'arm/v7', executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0', supportTier: 'tier1',
|
||||
}), /does not belong to supportTier/);
|
||||
});
|
||||
|
||||
test('keeps Worker architecture tiers aligned with the release identity', () => {
|
||||
const { REMOTE_WORKER_ARCHITECTURES_BY_SUPPORT_TIER } =
|
||||
require('../dist/remote-execution/remoteWorkerCompatibility');
|
||||
const release = require('../../../ql3-release.json');
|
||||
assert.deepEqual(REMOTE_WORKER_ARCHITECTURES_BY_SUPPORT_TIER, {
|
||||
tier1: release.architectureSupport.tier1,
|
||||
candidate: release.architectureSupport.candidates,
|
||||
experimental: release.architectureSupport.experimentalBlocked,
|
||||
'legacy-only': release.architectureSupport.legacyOnly,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
} = require('../dist');
|
||||
|
||||
function capabilities() {
|
||||
const json = '{"architecture":"arm64","executors":["remote-worker"]}';
|
||||
const json = '{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
return {
|
||||
json,
|
||||
hash: createHash('sha256').update(json).digest('hex'),
|
||||
@@ -42,6 +42,10 @@ test('rejects forged capabilities, partial status capacity and invalid time', ()
|
||||
() => assertWorkerCapabilitiesSnapshot(snapshot.json, '0'.repeat(64)),
|
||||
InvalidWorkerSessionValueError,
|
||||
);
|
||||
const unversioned = '{"architecture":"arm64","executors":["remote-worker"]}';
|
||||
assert.throws(() => assertWorkerCapabilitiesSnapshot(
|
||||
unversioned, createHash('sha256').update(unversioned).digest('hex'),
|
||||
), /compatibility contract is invalid/);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertWorkerSessionRecord({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createWorkerSessionHeartbeatRequestBody,
|
||||
@@ -21,9 +22,10 @@ const authority = Object.freeze({
|
||||
workerId: 'edge-1',
|
||||
sessionId: '018f5c64-9b9d-7f1a-8c2d-1234567890ac',
|
||||
});
|
||||
const capabilitiesJson = '{}';
|
||||
const capabilitiesHash =
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||
const capabilitiesJson =
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
const capabilitiesHash = createHash('sha256')
|
||||
.update(capabilitiesJson).digest('hex');
|
||||
|
||||
function record(overrides = {}) {
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from 'node:path';
|
||||
import {
|
||||
canonicalRemoteWorkerCapabilities,
|
||||
REMOTE_WORKER_EXECUTOR_CAPABILITY,
|
||||
remoteWorkerArchitectureForNodeRuntime,
|
||||
type RemoteWorkerCapabilities,
|
||||
} from '@qinglong/runtime-core/remote-dispatch';
|
||||
|
||||
@@ -206,7 +208,18 @@ async function capabilities(path: string): Promise<RemoteWorkerCapabilities> {
|
||||
const parsed = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||
) as unknown;
|
||||
return canonicalRemoteWorkerCapabilities(parsed).capabilities;
|
||||
const normalized = canonicalRemoteWorkerCapabilities(parsed).capabilities;
|
||||
if (!normalized.executors.includes(REMOTE_WORKER_EXECUTOR_CAPABILITY))
|
||||
throw new Error('remote-worker executor capability is required');
|
||||
const runtimeArchitecture = remoteWorkerArchitectureForNodeRuntime(
|
||||
process.arch,
|
||||
(process.config.variables as Readonly<Record<string, unknown>>)[
|
||||
'arm_version'
|
||||
],
|
||||
);
|
||||
if (normalized.architecture !== runtimeArchitecture)
|
||||
throw new Error('capability architecture does not match the Node runtime');
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
throw new WorkerProcessConfigError(
|
||||
'QL3_WORKER_CAPABILITIES_FILE is unavailable or invalid',
|
||||
|
||||
@@ -40,9 +40,11 @@ async function temporaryStorage() {
|
||||
|
||||
function capabilities() {
|
||||
return {
|
||||
architecture: 'x64',
|
||||
architecture: 'amd64',
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: 'tier1',
|
||||
runtimes: [{ name: 'node', version: '24.14.0' }],
|
||||
labels: {},
|
||||
capacity: { cpuCores: 1, memoryBytes: 256 * 1024 * 1024 },
|
||||
|
||||
@@ -16,15 +16,24 @@ const {
|
||||
WorkerProcessError,
|
||||
runProductionWorkerProcess,
|
||||
} = require('@qinglong/worker-runtime/process');
|
||||
const {
|
||||
remoteWorkerArchitectureForNodeRuntime,
|
||||
remoteWorkerSupportTierForArchitecture,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
|
||||
async function environment(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-process-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const capabilities = path.join(root, 'capabilities.json');
|
||||
const architecture = remoteWorkerArchitectureForNodeRuntime(
|
||||
process.arch, process.config.variables.arm_version,
|
||||
);
|
||||
await writeFile(capabilities, JSON.stringify({
|
||||
architecture: 'x64',
|
||||
architecture,
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: remoteWorkerSupportTierForArchitecture(architecture),
|
||||
}));
|
||||
await chmod(capabilities, 0o444);
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,16 @@ const {
|
||||
WorkerProcessConfigError,
|
||||
loadWorkerProcessConfig,
|
||||
} = require('@qinglong/worker-runtime/process-config');
|
||||
const {
|
||||
remoteWorkerArchitectureForNodeRuntime,
|
||||
remoteWorkerSupportTierForArchitecture,
|
||||
} = require('@qinglong/runtime-core/remote-dispatch');
|
||||
|
||||
const RUNTIME_ARCHITECTURE = remoteWorkerArchitectureForNodeRuntime(
|
||||
process.arch, process.config.variables.arm_version,
|
||||
);
|
||||
const RUNTIME_SUPPORT_TIER =
|
||||
remoteWorkerSupportTierForArchitecture(RUNTIME_ARCHITECTURE);
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-worker-config-'));
|
||||
@@ -22,9 +32,11 @@ async function fixture(t) {
|
||||
await writeFile(
|
||||
capabilitiesFile,
|
||||
JSON.stringify({
|
||||
architecture: 'arm64',
|
||||
architecture: RUNTIME_ARCHITECTURE,
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: RUNTIME_SUPPORT_TIER,
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
labels: { site: 'edge-a' },
|
||||
capacity: {
|
||||
@@ -97,8 +109,10 @@ test('loads canonical edge defaults and bounded node overrides', async (t) => {
|
||||
assert.equal(edge.workerId, 'router-worker-1');
|
||||
assert.equal(edge.origin, 'https://control.example.internal:5801');
|
||||
assert.deepEqual(edge.capabilities, {
|
||||
architecture: 'arm64',
|
||||
executors: ['local_process'],
|
||||
architecture: RUNTIME_ARCHITECTURE,
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: RUNTIME_SUPPORT_TIER,
|
||||
operatingSystem: 'linux',
|
||||
runtimes: [{ name: 'node', version: '24.18.0' }],
|
||||
labels: { site: 'edge-a' },
|
||||
@@ -182,3 +196,19 @@ test('rejects widened profiles, origins, heartbeat and filesystem configuration'
|
||||
WorkerProcessConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a Worker capability file that cannot satisfy remote placement', async (t) => {
|
||||
const current = await fixture(t);
|
||||
await chmod(current.capabilitiesFile, 0o644);
|
||||
await writeFile(current.capabilitiesFile, JSON.stringify({
|
||||
architecture: RUNTIME_ARCHITECTURE,
|
||||
executors: ['local_process'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: RUNTIME_SUPPORT_TIER,
|
||||
}));
|
||||
await chmod(current.capabilitiesFile, 0o444);
|
||||
await assert.rejects(
|
||||
loadWorkerProcessConfig(current.environment),
|
||||
WorkerProcessConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,9 +17,11 @@ const SESSION_ID = '018f5c64-9b9d-7f1a-8c2d-1234567890ac';
|
||||
|
||||
function capabilities() {
|
||||
return {
|
||||
architecture: 'x64',
|
||||
architecture: 'amd64',
|
||||
operatingSystem: 'linux',
|
||||
executors: ['local_process'],
|
||||
executors: ['remote-worker'],
|
||||
protocolVersion: '1.0.0',
|
||||
supportTier: 'tier1',
|
||||
runtimes: [{ name: 'node', version: '24.14.0' }],
|
||||
labels: {},
|
||||
capacity: { cpuCores: 1, memoryBytes: 256 * 1024 * 1024 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
WORKER_SESSION_HEARTBEAT_SCHEMA,
|
||||
@@ -19,9 +20,10 @@ const authority = {
|
||||
workerId: 'edge-1',
|
||||
sessionId: '018f5c64-9b9d-7f1a-8c2d-1234567890ac',
|
||||
};
|
||||
const capabilitiesJson = '{}';
|
||||
const capabilitiesHash =
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||
const capabilitiesJson =
|
||||
'{"architecture":"arm64","executors":["remote-worker"],"protocolVersion":"1.0.0","supportTier":"tier1"}';
|
||||
const capabilitiesHash = createHash('sha256')
|
||||
.update(capabilitiesJson).digest('hex');
|
||||
|
||||
function client(exchange) {
|
||||
const transport = new WorkerIngressHttpsClient({
|
||||
|
||||
Reference in New Issue
Block a user