feat(ql3): add cluster legacy env migration plan ledger

This commit is contained in:
whyour
2026-08-24 18:05:06 +08:00
parent 54056e8bab
commit 784d9b21a0
22 changed files with 1843 additions and 56 deletions
+8
View File
@@ -260,6 +260,9 @@
"builtin-run-log-excerpt-projection": [
"dist/tool-execution/builtin-run-log-excerpt/builtInRunLogExcerptProjection.d.ts"
],
"cluster-legacy-env-migration-plan": [
"dist/migration/clusterLegacyEnvMigrationPlan.d.ts"
],
"run": [
"dist/run/run.d.ts"
],
@@ -727,6 +730,11 @@
"require": "./dist/secret/secretReference.js",
"default": "./dist/secret/secretReference.js"
},
"./cluster-legacy-env-migration-plan": {
"types": "./dist/migration/clusterLegacyEnvMigrationPlan.d.ts",
"require": "./dist/migration/clusterLegacyEnvMigrationPlan.js",
"default": "./dist/migration/clusterLegacyEnvMigrationPlan.js"
},
"./cluster-execution-revision": {
"types": "./dist/task-definition/clusterExecutionRevision.d.ts",
"require": "./dist/task-definition/clusterExecutionRevision.js",
@@ -0,0 +1,434 @@
import { createHash } from 'node:crypto';
import { parseSecretRef } from '../secret/secretReference';
export const CLUSTER_LEGACY_ENV_MIGRATION_PLAN_SCHEMA =
'qinglong/cluster-legacy-env-migration-plan@v1' as const;
export const MAX_CLUSTER_LEGACY_ENV_SOURCE_ROWS = 100_000;
export const MAX_CLUSTER_LEGACY_ENV_TASKS = 100_000;
export const MAX_CLUSTER_LEGACY_ENV_TRIGGERS = 500_000;
export const MAX_CLUSTER_LEGACY_ENV_EFFECTIVE_BYTES = 64 * 1024;
export const MAX_CLUSTER_LEGACY_ENV_MIGRATION_PLAN_JSON_BYTES = 8 * 1024;
export interface ClusterLegacyEnvMigrationSourceEvidence {
readonly reconciliationBundleDigest: string;
readonly decisionDigest: string;
readonly candidateSetDigest: string;
readonly sourceRowCount: number;
readonly activeRowCount: number;
readonly disabledRowCount: number;
readonly effectiveBindingCount: number;
}
export interface ClusterLegacyEnvMigrationTarget {
readonly secretRef: string;
readonly taskRevisionSetDigest: string;
readonly triggerRevisionSetDigest: string;
readonly taskCount: number;
readonly triggerCount: number;
readonly totalEffectiveBytes: number;
}
export interface ClusterLegacyEnvMigrationPlanIntent {
readonly planId: string;
readonly mutationId: string;
readonly projectId: string;
readonly source: Readonly<ClusterLegacyEnvMigrationSourceEvidence>;
readonly target: Readonly<ClusterLegacyEnvMigrationTarget>;
}
export interface ClusterLegacyEnvMigrationPlan
extends ClusterLegacyEnvMigrationPlanIntent {
readonly schema: typeof CLUSTER_LEGACY_ENV_MIGRATION_PLAN_SCHEMA;
readonly plannedAtMs: number;
readonly planDigest: string;
}
export interface ClusterLegacyEnvMigrationPlanRepository {
publish(intent: Readonly<ClusterLegacyEnvMigrationPlanIntent>): Promise<
Readonly<{
status: 'created' | 'existing';
plan: Readonly<ClusterLegacyEnvMigrationPlan>;
}>
>;
findByPlanId(
planId: string,
): Promise<Readonly<ClusterLegacyEnvMigrationPlan> | null>;
}
export class InvalidClusterLegacyEnvMigrationPlanError extends TypeError {
readonly code = 'CLUSTER_LEGACY_ENV_MIGRATION_PLAN_INVALID';
constructor(message: string) {
super(`Cluster Legacy Env migration plan is invalid: ${message}`);
this.name = 'InvalidClusterLegacyEnvMigrationPlanError';
}
}
export class ClusterLegacyEnvMigrationPlanConflictError extends Error {
readonly code = 'CLUSTER_LEGACY_ENV_MIGRATION_PLAN_CONFLICT';
constructor() {
super('Cluster Legacy Env migration plan conflicts with durable state');
this.name = 'ClusterLegacyEnvMigrationPlanConflictError';
}
}
export class ClusterLegacyEnvMigrationPlanUnavailableError extends Error {
readonly code = 'CLUSTER_LEGACY_ENV_MIGRATION_PLAN_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Cluster Legacy Env migration plan is unavailable', options);
this.name = 'ClusterLegacyEnvMigrationPlanUnavailableError';
}
}
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const PLAN_DIGEST_DOMAIN = Buffer.from(
'qinglong/cluster-legacy-env-migration-plan-digest@v1\0',
'utf8',
);
function invalid(message: string): never {
throw new InvalidClusterLegacyEnvMigrationPlanError(message);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
return invalid(`${label} must be an object`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
return invalid(`${label} must contain enumerable data properties`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: object,
expected: readonly string[],
label: string,
): void {
const keys = Reflect.ownKeys(value);
const canonical = [...expected].sort();
if (
keys.some((key) => typeof key !== 'string') ||
keys.length !== canonical.length ||
keys
.map(String)
.sort()
.some((key, index) => key !== canonical[index])
) {
invalid(`${label} shape is invalid`);
}
}
export function assertClusterLegacyEnvMigrationPlanIdentifier(
value: unknown,
label: 'mutationId' | 'planId' | 'projectId',
): string {
if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function digest(value: unknown, label: string): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function count(value: unknown, label: string, maximum: number): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 0 ||
(value as number) > maximum
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function timestamp(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
return invalid('plannedAtMs is invalid');
}
return value as number;
}
function sourceEvidence(
value: ClusterLegacyEnvMigrationSourceEvidence,
): Readonly<ClusterLegacyEnvMigrationSourceEvidence> {
const candidate = record(value, 'source');
exactKeys(
candidate,
[
'activeRowCount',
'candidateSetDigest',
'decisionDigest',
'disabledRowCount',
'effectiveBindingCount',
'reconciliationBundleDigest',
'sourceRowCount',
],
'source',
);
const sourceRowCount = count(
value.sourceRowCount,
'sourceRowCount',
MAX_CLUSTER_LEGACY_ENV_SOURCE_ROWS,
);
const activeRowCount = count(
value.activeRowCount,
'activeRowCount',
MAX_CLUSTER_LEGACY_ENV_SOURCE_ROWS,
);
const disabledRowCount = count(
value.disabledRowCount,
'disabledRowCount',
MAX_CLUSTER_LEGACY_ENV_SOURCE_ROWS,
);
const effectiveBindingCount = count(
value.effectiveBindingCount,
'effectiveBindingCount',
MAX_CLUSTER_LEGACY_ENV_SOURCE_ROWS,
);
if (
sourceRowCount < 1 ||
sourceRowCount !== activeRowCount + disabledRowCount ||
activeRowCount < 1 ||
effectiveBindingCount < 1 ||
effectiveBindingCount > activeRowCount
) {
return invalid('source row counts are inconsistent');
}
return Object.freeze({
reconciliationBundleDigest: digest(
value.reconciliationBundleDigest,
'reconciliationBundleDigest',
),
decisionDigest: digest(value.decisionDigest, 'decisionDigest'),
candidateSetDigest: digest(value.candidateSetDigest, 'candidateSetDigest'),
sourceRowCount,
activeRowCount,
disabledRowCount,
effectiveBindingCount,
});
}
function target(
value: ClusterLegacyEnvMigrationTarget,
projectId: string,
): Readonly<ClusterLegacyEnvMigrationTarget> {
const candidate = record(value, 'target');
exactKeys(
candidate,
[
'secretRef',
'taskCount',
'taskRevisionSetDigest',
'totalEffectiveBytes',
'triggerCount',
'triggerRevisionSetDigest',
],
'target',
);
let parsedSecretRef;
try {
parsedSecretRef = parseSecretRef(value.secretRef);
} catch {
return invalid('secretRef is invalid');
}
if (
parsedSecretRef.projectId !== projectId ||
parsedSecretRef.version === undefined
) {
return invalid('secretRef must pin a version in the same Project');
}
const taskCount = count(
value.taskCount,
'taskCount',
MAX_CLUSTER_LEGACY_ENV_TASKS,
);
if (taskCount < 1) return invalid('taskCount is invalid');
const triggerCount = count(
value.triggerCount,
'triggerCount',
MAX_CLUSTER_LEGACY_ENV_TRIGGERS,
);
const totalEffectiveBytes = count(
value.totalEffectiveBytes,
'totalEffectiveBytes',
MAX_CLUSTER_LEGACY_ENV_EFFECTIVE_BYTES,
);
if (totalEffectiveBytes < 1) {
return invalid('totalEffectiveBytes is invalid');
}
return Object.freeze({
secretRef: value.secretRef,
taskRevisionSetDigest: digest(
value.taskRevisionSetDigest,
'taskRevisionSetDigest',
),
triggerRevisionSetDigest: digest(
value.triggerRevisionSetDigest,
'triggerRevisionSetDigest',
),
taskCount,
triggerCount,
totalEffectiveBytes,
});
}
export function normalizeClusterLegacyEnvMigrationPlanIntent(
value: ClusterLegacyEnvMigrationPlanIntent,
): Readonly<ClusterLegacyEnvMigrationPlanIntent> {
const candidate = record(value, 'plan intent');
exactKeys(
candidate,
['mutationId', 'planId', 'projectId', 'source', 'target'],
'plan intent',
);
const projectId = assertClusterLegacyEnvMigrationPlanIdentifier(
value.projectId,
'projectId',
);
return Object.freeze({
planId: assertClusterLegacyEnvMigrationPlanIdentifier(
value.planId,
'planId',
),
mutationId: assertClusterLegacyEnvMigrationPlanIdentifier(
value.mutationId,
'mutationId',
),
projectId,
source: sourceEvidence(value.source),
target: target(value.target, projectId),
});
}
function unsignedFields(
value: Omit<ClusterLegacyEnvMigrationPlan, 'planDigest'>,
): object {
return {
schema: value.schema,
planId: value.planId,
mutationId: value.mutationId,
projectId: value.projectId,
source: value.source,
target: value.target,
plannedAtMs: value.plannedAtMs,
};
}
export function clusterLegacyEnvMigrationPlanDigest(
value: Omit<ClusterLegacyEnvMigrationPlan, 'planDigest'>,
): string {
return createHash('sha256')
.update(PLAN_DIGEST_DOMAIN)
.update(JSON.stringify(unsignedFields(value)), 'utf8')
.digest('hex');
}
function bounded(
value: Readonly<ClusterLegacyEnvMigrationPlan>,
): Readonly<ClusterLegacyEnvMigrationPlan> {
if (
Buffer.byteLength(JSON.stringify(value), 'utf8') >
MAX_CLUSTER_LEGACY_ENV_MIGRATION_PLAN_JSON_BYTES
) {
return invalid('encoded plan exceeds the size limit');
}
return value;
}
export function createClusterLegacyEnvMigrationPlan(
intentValue: ClusterLegacyEnvMigrationPlanIntent,
plannedAtMsValue: number,
): Readonly<ClusterLegacyEnvMigrationPlan> {
const intent = normalizeClusterLegacyEnvMigrationPlanIntent(intentValue);
const unsigned = Object.freeze({
schema: CLUSTER_LEGACY_ENV_MIGRATION_PLAN_SCHEMA,
...intent,
plannedAtMs: timestamp(plannedAtMsValue),
});
return bounded(
Object.freeze({
...unsigned,
planDigest: clusterLegacyEnvMigrationPlanDigest(unsigned),
}),
);
}
export function normalizeClusterLegacyEnvMigrationPlan(
value: ClusterLegacyEnvMigrationPlan,
): Readonly<ClusterLegacyEnvMigrationPlan> {
const candidate = record(value, 'plan');
exactKeys(
candidate,
[
'mutationId',
'planDigest',
'planId',
'plannedAtMs',
'projectId',
'schema',
'source',
'target',
],
'plan',
);
if (value.schema !== CLUSTER_LEGACY_ENV_MIGRATION_PLAN_SCHEMA) {
return invalid('schema is invalid');
}
const expected = createClusterLegacyEnvMigrationPlan(
{
planId: value.planId,
mutationId: value.mutationId,
projectId: value.projectId,
source: value.source,
target: value.target,
},
value.plannedAtMs,
);
if (
typeof value.planDigest !== 'string' ||
!DIGEST_PATTERN.test(value.planDigest) ||
value.planDigest !== expected.planDigest
) {
return invalid('planDigest does not match plan');
}
return expected;
}
export function clusterLegacyEnvMigrationPlanMatchesIntent(
planValue: ClusterLegacyEnvMigrationPlan,
intentValue: ClusterLegacyEnvMigrationPlanIntent,
): boolean {
const plan = normalizeClusterLegacyEnvMigrationPlan(planValue);
const intent = normalizeClusterLegacyEnvMigrationPlanIntent(intentValue);
return (
plan.planId === intent.planId &&
plan.mutationId === intent.mutationId &&
plan.projectId === intent.projectId &&
JSON.stringify(plan.source) === JSON.stringify(intent.source) &&
JSON.stringify(plan.target) === JSON.stringify(intent.target)
);
}
@@ -0,0 +1,177 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
CLUSTER_LEGACY_ENV_MIGRATION_PLAN_SCHEMA,
MAX_CLUSTER_LEGACY_ENV_EFFECTIVE_BYTES,
MAX_CLUSTER_LEGACY_ENV_SOURCE_ROWS,
MAX_CLUSTER_LEGACY_ENV_TASKS,
MAX_CLUSTER_LEGACY_ENV_TRIGGERS,
InvalidClusterLegacyEnvMigrationPlanError,
clusterLegacyEnvMigrationPlanMatchesIntent,
createClusterLegacyEnvMigrationPlan,
normalizeClusterLegacyEnvMigrationPlan,
normalizeClusterLegacyEnvMigrationPlanIntent,
} = require('@qinglong/runtime-core/cluster-legacy-env-migration-plan');
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
const DIGESTS = Object.freeze({
reconciliationBundleDigest: '1'.repeat(64),
decisionDigest: '2'.repeat(64),
candidateSetDigest: '3'.repeat(64),
taskRevisionSetDigest: '4'.repeat(64),
triggerRevisionSetDigest: '5'.repeat(64),
});
function intent(overrides = {}) {
const projectId = overrides.projectId ?? 'project-a';
return {
planId: 'legacy-env-plan-a',
mutationId: 'legacy-env-mutation-a',
projectId,
source: {
reconciliationBundleDigest: DIGESTS.reconciliationBundleDigest,
decisionDigest: DIGESTS.decisionDigest,
candidateSetDigest: DIGESTS.candidateSetDigest,
sourceRowCount: 4,
activeRowCount: 3,
disabledRowCount: 1,
effectiveBindingCount: 2,
},
target: {
secretRef: createSecretRef({
projectId,
name: 'legacy-env-bundle',
version: 7,
}),
taskRevisionSetDigest: DIGESTS.taskRevisionSetDigest,
triggerRevisionSetDigest: DIGESTS.triggerRevisionSetDigest,
taskCount: 2,
triggerCount: 3,
totalEffectiveBytes: 1024,
},
...overrides,
};
}
test('creates one frozen content-free migration plan with a stable digest', () => {
const value = createClusterLegacyEnvMigrationPlan(intent(), 12_345);
assert.equal(value.schema, CLUSTER_LEGACY_ENV_MIGRATION_PLAN_SCHEMA);
assert.match(value.planDigest, /^[0-9a-f]{64}$/);
assert.equal(Object.isFrozen(value), true);
assert.equal(Object.isFrozen(value.source), true);
assert.equal(Object.isFrozen(value.target), true);
assert.deepEqual(normalizeClusterLegacyEnvMigrationPlan(value), value);
assert.equal(
clusterLegacyEnvMigrationPlanMatchesIntent(value, intent()),
true,
);
assert.deepEqual(Object.keys(value).sort(), [
'mutationId',
'planDigest',
'planId',
'plannedAtMs',
'projectId',
'schema',
'source',
'target',
]);
assert.equal(JSON.stringify(value).includes('legacy-env-bundle'), false);
});
test('requires a canonical version-pinned SecretRef in the same Project', () => {
const unversioned = intent();
unversioned.target.secretRef = createSecretRef({
projectId: unversioned.projectId,
name: 'legacy-env-bundle',
});
assert.throws(
() => normalizeClusterLegacyEnvMigrationPlanIntent(unversioned),
InvalidClusterLegacyEnvMigrationPlanError,
);
const crossProject = intent();
crossProject.target.secretRef = createSecretRef({
projectId: 'project-b',
name: 'legacy-env-bundle',
version: 1,
});
assert.throws(
() => normalizeClusterLegacyEnvMigrationPlanIntent(crossProject),
InvalidClusterLegacyEnvMigrationPlanError,
);
});
test('enforces source consistency and router-safe bounded targets', () => {
const invalidValues = [
{ source: { ...intent().source, sourceRowCount: 5 } },
{
source: {
...intent().source,
sourceRowCount: MAX_CLUSTER_LEGACY_ENV_SOURCE_ROWS + 1,
},
},
{
target: {
...intent().target,
taskCount: MAX_CLUSTER_LEGACY_ENV_TASKS + 1,
},
},
{
target: {
...intent().target,
triggerCount: MAX_CLUSTER_LEGACY_ENV_TRIGGERS + 1,
},
},
{
target: {
...intent().target,
totalEffectiveBytes: MAX_CLUSTER_LEGACY_ENV_EFFECTIVE_BYTES + 1,
},
},
];
for (const invalidValue of invalidValues) {
assert.throws(
() => normalizeClusterLegacyEnvMigrationPlanIntent(intent(invalidValue)),
InvalidClusterLegacyEnvMigrationPlanError,
);
}
});
test('rejects widened shapes, accessors, symbols and tampered durable digests', () => {
const widened = intent();
widened.source.envName = 'TOKEN';
assert.throws(
() => normalizeClusterLegacyEnvMigrationPlanIntent(widened),
InvalidClusterLegacyEnvMigrationPlanError,
);
const symbol = intent();
symbol.target[Symbol('secretValue')] = 'not-storable';
assert.throws(
() => normalizeClusterLegacyEnvMigrationPlanIntent(symbol),
InvalidClusterLegacyEnvMigrationPlanError,
);
const accessor = intent();
Object.defineProperty(accessor.source, 'sourceRowCount', {
enumerable: true,
get() {
return 4;
},
});
assert.throws(
() => normalizeClusterLegacyEnvMigrationPlanIntent(accessor),
InvalidClusterLegacyEnvMigrationPlanError,
);
const plan = createClusterLegacyEnvMigrationPlan(intent(), 12_345);
assert.throws(
() =>
normalizeClusterLegacyEnvMigrationPlan({
...plan,
planDigest: 'f'.repeat(64),
}),
InvalidClusterLegacyEnvMigrationPlanError,
);
});