feat(ql3): atomically apply cluster legacy env migration

This commit is contained in:
whyour
2026-08-24 21:13:15 +08:00
parent 4abf125ce9
commit bd8678a888
23 changed files with 3372 additions and 21 deletions
+8
View File
@@ -266,6 +266,9 @@
"cluster-legacy-env-migration-plan": [
"dist/migration/clusterLegacyEnvMigrationPlan.d.ts"
],
"cluster-legacy-env-migration-application": [
"dist/migration/clusterLegacyEnvMigrationApplication.d.ts"
],
"run": [
"dist/run/run.d.ts"
],
@@ -743,6 +746,11 @@
"require": "./dist/migration/clusterLegacyEnvMigrationPlan.js",
"default": "./dist/migration/clusterLegacyEnvMigrationPlan.js"
},
"./cluster-legacy-env-migration-application": {
"types": "./dist/migration/clusterLegacyEnvMigrationApplication.d.ts",
"require": "./dist/migration/clusterLegacyEnvMigrationApplication.js",
"default": "./dist/migration/clusterLegacyEnvMigrationApplication.js"
},
"./cluster-execution-revision": {
"types": "./dist/task-definition/clusterExecutionRevision.d.ts",
"require": "./dist/task-definition/clusterExecutionRevision.js",
@@ -0,0 +1,660 @@
import { createHash, type Hash } from 'node:crypto';
import {
MAX_CLUSTER_LEGACY_ENV_TASKS,
MAX_CLUSTER_LEGACY_ENV_TRIGGERS,
} from './clusterLegacyEnvMigrationPlan';
import { parseSecretRef } from '../secret/secretReference';
export const CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_RECEIPT_SCHEMA =
'qinglong/cluster-legacy-env-migration-application-receipt@v1' as const;
export const MAX_CLUSTER_LEGACY_ENV_MIGRATION_RECEIPT_JSON_BYTES = 8 * 1024;
export interface ClusterLegacyEnvMigrationTaskMutation {
readonly ordinal: number;
readonly taskId: string;
readonly previousRevision: number;
readonly previousContentDigest: string;
readonly mutationId: string;
}
export interface ClusterLegacyEnvMigrationTriggerMutation {
readonly ordinal: number;
readonly triggerId: string;
readonly taskId: string;
readonly previousRevision: number;
readonly previousContentDigest: string;
readonly previousTaskRevision: number;
readonly previousTaskContentDigest: string;
readonly mutationId: string;
}
export interface ClusterLegacyEnvMigrationApplicationIntent {
readonly applicationId: string;
readonly mutationId: string;
readonly projectId: string;
readonly planId: string;
readonly planDigest: string;
readonly taskMutationSetDigest: string;
readonly triggerMutationSetDigest: string;
}
export interface ClusterLegacyEnvMigrationApplicationReceipt
extends ClusterLegacyEnvMigrationApplicationIntent {
readonly schema: typeof CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_RECEIPT_SCHEMA;
readonly environmentBundleRef: string;
readonly taskRevisionSetDigest: string;
readonly triggerRevisionSetDigest: string;
readonly taskCount: number;
readonly triggerCount: number;
readonly committedAtMs: number;
readonly receiptDigest: string;
}
export interface ClusterLegacyEnvMigrationMutationStreams {
readonly taskMutations: () =>
| Iterable<ClusterLegacyEnvMigrationTaskMutation>
| AsyncIterable<ClusterLegacyEnvMigrationTaskMutation>;
readonly triggerMutations: () =>
| Iterable<ClusterLegacyEnvMigrationTriggerMutation>
| AsyncIterable<ClusterLegacyEnvMigrationTriggerMutation>;
}
export interface ClusterLegacyEnvMigrationApplicationRepository {
apply(
intent: Readonly<ClusterLegacyEnvMigrationApplicationIntent>,
streams: Readonly<ClusterLegacyEnvMigrationMutationStreams>,
): Promise<
Readonly<{
status: 'applied' | 'existing';
receipt: Readonly<ClusterLegacyEnvMigrationApplicationReceipt>;
}>
>;
findByApplicationId(
applicationId: string,
): Promise<Readonly<ClusterLegacyEnvMigrationApplicationReceipt> | null>;
}
export interface ClusterLegacyEnvMigrationTaskMutationSetDigestResult {
readonly count: number;
readonly revisionSetDigest: string;
readonly mutationSetDigest: string;
}
export interface ClusterLegacyEnvMigrationTriggerMutationSetDigestResult {
readonly count: number;
readonly revisionSetDigest: string;
readonly mutationSetDigest: string;
}
export class InvalidClusterLegacyEnvMigrationApplicationError extends TypeError {
readonly code = 'CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_INVALID';
constructor(message: string) {
super(`Cluster Legacy Env migration application is invalid: ${message}`);
this.name = 'InvalidClusterLegacyEnvMigrationApplicationError';
}
}
export class ClusterLegacyEnvMigrationApplicationConflictError extends Error {
readonly code = 'CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_CONFLICT';
constructor() {
super(
'Cluster Legacy Env migration application conflicts with durable state',
);
this.name = 'ClusterLegacyEnvMigrationApplicationConflictError';
}
}
export class ClusterLegacyEnvMigrationApplicationUnavailableError extends Error {
readonly code = 'CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Cluster Legacy Env migration application is unavailable', options);
this.name = 'ClusterLegacyEnvMigrationApplicationUnavailableError';
}
}
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const MUTATION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const TASK_REVISION_SET_DOMAIN = Buffer.from(
'qinglong/cluster-legacy-env-task-revision-set@v1\0',
'utf8',
);
const TASK_MUTATION_SET_DOMAIN = Buffer.from(
'qinglong/cluster-legacy-env-task-mutation-set@v1\0',
'utf8',
);
const TRIGGER_REVISION_SET_DOMAIN = Buffer.from(
'qinglong/cluster-legacy-env-trigger-revision-set@v1\0',
'utf8',
);
const TRIGGER_MUTATION_SET_DOMAIN = Buffer.from(
'qinglong/cluster-legacy-env-trigger-mutation-set@v1\0',
'utf8',
);
const RECEIPT_DIGEST_DOMAIN = Buffer.from(
'qinglong/cluster-legacy-env-migration-application-receipt-digest@v1\0',
'utf8',
);
function invalid(message: string): never {
throw new InvalidClusterLegacyEnvMigrationApplicationError(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 assertClusterLegacyEnvMigrationApplicationIdentifier(
value: unknown,
label: 'applicationId' | 'planId' | 'projectId',
): string {
if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function entityIdentifier(
value: unknown,
label: 'taskId' | 'triggerId',
): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
Buffer.byteLength(value, 'utf8') > 128 ||
/[\u0000-\u001f\u007f]/.test(value)
) {
return invalid(`${label} is invalid`);
}
return value;
}
function mutationId(value: unknown, label: string): string {
if (typeof value !== 'string' || !MUTATION_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 revision(value: unknown, label: string): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 1 ||
(value as number) >= 2_147_483_647
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function ordinal(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('committedAtMs is invalid');
}
return value as number;
}
export function normalizeClusterLegacyEnvMigrationTaskMutation(
value: ClusterLegacyEnvMigrationTaskMutation,
): Readonly<ClusterLegacyEnvMigrationTaskMutation> {
const candidate = record(value, 'Task mutation');
exactKeys(
candidate,
[
'mutationId',
'ordinal',
'previousContentDigest',
'previousRevision',
'taskId',
],
'Task mutation',
);
return Object.freeze({
ordinal: ordinal(
value.ordinal,
'Task mutation ordinal',
MAX_CLUSTER_LEGACY_ENV_TASKS,
),
taskId: entityIdentifier(value.taskId, 'taskId'),
previousRevision: revision(value.previousRevision, 'previousRevision'),
previousContentDigest: digest(
value.previousContentDigest,
'previousContentDigest',
),
mutationId: mutationId(value.mutationId, 'Task mutationId'),
});
}
export function normalizeClusterLegacyEnvMigrationTriggerMutation(
value: ClusterLegacyEnvMigrationTriggerMutation,
): Readonly<ClusterLegacyEnvMigrationTriggerMutation> {
const candidate = record(value, 'Trigger mutation');
exactKeys(
candidate,
[
'mutationId',
'ordinal',
'previousContentDigest',
'previousRevision',
'previousTaskContentDigest',
'previousTaskRevision',
'taskId',
'triggerId',
],
'Trigger mutation',
);
return Object.freeze({
ordinal: ordinal(
value.ordinal,
'Trigger mutation ordinal',
MAX_CLUSTER_LEGACY_ENV_TRIGGERS,
),
triggerId: entityIdentifier(value.triggerId, 'triggerId'),
taskId: entityIdentifier(value.taskId, 'taskId'),
previousRevision: revision(value.previousRevision, 'previousRevision'),
previousContentDigest: digest(
value.previousContentDigest,
'previousContentDigest',
),
previousTaskRevision: revision(
value.previousTaskRevision,
'previousTaskRevision',
),
previousTaskContentDigest: digest(
value.previousTaskContentDigest,
'previousTaskContentDigest',
),
mutationId: mutationId(value.mutationId, 'Trigger mutationId'),
});
}
export function normalizeClusterLegacyEnvMigrationApplicationIntent(
value: ClusterLegacyEnvMigrationApplicationIntent,
): Readonly<ClusterLegacyEnvMigrationApplicationIntent> {
const candidate = record(value, 'application intent');
exactKeys(
candidate,
[
'applicationId',
'mutationId',
'planDigest',
'planId',
'projectId',
'taskMutationSetDigest',
'triggerMutationSetDigest',
],
'application intent',
);
return Object.freeze({
applicationId: assertClusterLegacyEnvMigrationApplicationIdentifier(
value.applicationId,
'applicationId',
),
mutationId: mutationId(value.mutationId, 'mutationId'),
projectId: assertClusterLegacyEnvMigrationApplicationIdentifier(
value.projectId,
'projectId',
),
planId: assertClusterLegacyEnvMigrationApplicationIdentifier(
value.planId,
'planId',
),
planDigest: digest(value.planDigest, 'planDigest'),
taskMutationSetDigest: digest(
value.taskMutationSetDigest,
'taskMutationSetDigest',
),
triggerMutationSetDigest: digest(
value.triggerMutationSetDigest,
'triggerMutationSetDigest',
),
});
}
function updateHash(hash: Hash, value: object): void {
hash.update(JSON.stringify(value), 'utf8').update('\n', 'utf8');
}
function finalizeHash(hash: Hash, count: number): string {
updateHash(hash, { count });
return hash.digest('hex');
}
export function createClusterLegacyEnvMigrationTaskMutationSetDigester(): Readonly<{
update(
value: ClusterLegacyEnvMigrationTaskMutation,
): Readonly<ClusterLegacyEnvMigrationTaskMutation>;
finish(): Readonly<ClusterLegacyEnvMigrationTaskMutationSetDigestResult>;
}> {
const revisionHash = createHash('sha256').update(TASK_REVISION_SET_DOMAIN);
const mutationHash = createHash('sha256').update(TASK_MUTATION_SET_DOMAIN);
let count = 0;
let previousId: string | undefined;
let finished = false;
return Object.freeze({
update(value: ClusterLegacyEnvMigrationTaskMutation) {
if (finished) return invalid('Task mutation digester is finished');
const item = normalizeClusterLegacyEnvMigrationTaskMutation(value);
if (
item.ordinal !== count ||
(previousId !== undefined && item.taskId <= previousId)
) {
return invalid(
'Task mutations must be contiguous and ordered by taskId',
);
}
updateHash(revisionHash, {
ordinal: item.ordinal,
taskId: item.taskId,
revision: item.previousRevision,
contentDigest: item.previousContentDigest,
});
updateHash(mutationHash, item);
previousId = item.taskId;
count += 1;
return item;
},
finish() {
if (finished) return invalid('Task mutation digester is finished');
finished = true;
return Object.freeze({
count,
revisionSetDigest: finalizeHash(revisionHash, count),
mutationSetDigest: finalizeHash(mutationHash, count),
});
},
});
}
export function createClusterLegacyEnvMigrationTriggerMutationSetDigester(): Readonly<{
update(
value: ClusterLegacyEnvMigrationTriggerMutation,
): Readonly<ClusterLegacyEnvMigrationTriggerMutation>;
finish(): Readonly<ClusterLegacyEnvMigrationTriggerMutationSetDigestResult>;
}> {
const revisionHash = createHash('sha256').update(TRIGGER_REVISION_SET_DOMAIN);
const mutationHash = createHash('sha256').update(TRIGGER_MUTATION_SET_DOMAIN);
let count = 0;
let previousId: string | undefined;
let finished = false;
return Object.freeze({
update(value: ClusterLegacyEnvMigrationTriggerMutation) {
if (finished) return invalid('Trigger mutation digester is finished');
const item = normalizeClusterLegacyEnvMigrationTriggerMutation(value);
if (
item.ordinal !== count ||
(previousId !== undefined && item.triggerId <= previousId)
) {
return invalid(
'Trigger mutations must be contiguous and ordered by triggerId',
);
}
updateHash(revisionHash, {
ordinal: item.ordinal,
triggerId: item.triggerId,
taskId: item.taskId,
revision: item.previousRevision,
contentDigest: item.previousContentDigest,
taskRevision: item.previousTaskRevision,
taskContentDigest: item.previousTaskContentDigest,
});
updateHash(mutationHash, item);
previousId = item.triggerId;
count += 1;
return item;
},
finish() {
if (finished) return invalid('Trigger mutation digester is finished');
finished = true;
return Object.freeze({
count,
revisionSetDigest: finalizeHash(revisionHash, count),
mutationSetDigest: finalizeHash(mutationHash, count),
});
},
});
}
function unsignedReceipt(
value: Omit<ClusterLegacyEnvMigrationApplicationReceipt, 'receiptDigest'>,
): object {
return {
schema: value.schema,
applicationId: value.applicationId,
mutationId: value.mutationId,
projectId: value.projectId,
planId: value.planId,
planDigest: value.planDigest,
environmentBundleRef: value.environmentBundleRef,
taskRevisionSetDigest: value.taskRevisionSetDigest,
triggerRevisionSetDigest: value.triggerRevisionSetDigest,
taskMutationSetDigest: value.taskMutationSetDigest,
triggerMutationSetDigest: value.triggerMutationSetDigest,
taskCount: value.taskCount,
triggerCount: value.triggerCount,
committedAtMs: value.committedAtMs,
};
}
export function clusterLegacyEnvMigrationApplicationReceiptDigest(
value: Omit<ClusterLegacyEnvMigrationApplicationReceipt, 'receiptDigest'>,
): string {
return createHash('sha256')
.update(RECEIPT_DIGEST_DOMAIN)
.update(JSON.stringify(unsignedReceipt(value)), 'utf8')
.digest('hex');
}
export function createClusterLegacyEnvMigrationApplicationReceipt(
value: Omit<
ClusterLegacyEnvMigrationApplicationReceipt,
'schema' | 'receiptDigest'
>,
): Readonly<ClusterLegacyEnvMigrationApplicationReceipt> {
const candidate = record(value, 'application receipt input');
exactKeys(
candidate,
[
'applicationId',
'committedAtMs',
'environmentBundleRef',
'mutationId',
'planDigest',
'planId',
'projectId',
'taskCount',
'taskMutationSetDigest',
'taskRevisionSetDigest',
'triggerCount',
'triggerMutationSetDigest',
'triggerRevisionSetDigest',
],
'application receipt input',
);
const intent = normalizeClusterLegacyEnvMigrationApplicationIntent({
applicationId: value.applicationId,
mutationId: value.mutationId,
projectId: value.projectId,
planId: value.planId,
planDigest: value.planDigest,
taskMutationSetDigest: value.taskMutationSetDigest,
triggerMutationSetDigest: value.triggerMutationSetDigest,
});
let environmentBundleReference;
try {
environmentBundleReference = parseSecretRef(value.environmentBundleRef);
} catch {
return invalid('environmentBundleRef is invalid');
}
if (
!Number.isSafeInteger(value.taskCount) ||
value.taskCount < 1 ||
value.taskCount > MAX_CLUSTER_LEGACY_ENV_TASKS ||
!Number.isSafeInteger(value.triggerCount) ||
value.triggerCount < 0 ||
value.triggerCount > MAX_CLUSTER_LEGACY_ENV_TRIGGERS ||
environmentBundleReference.projectId !== intent.projectId ||
environmentBundleReference.version === undefined
) {
return invalid('receipt target is invalid');
}
const unsigned = Object.freeze({
schema: CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_RECEIPT_SCHEMA,
...intent,
environmentBundleRef: value.environmentBundleRef,
taskRevisionSetDigest: digest(
value.taskRevisionSetDigest,
'taskRevisionSetDigest',
),
triggerRevisionSetDigest: digest(
value.triggerRevisionSetDigest,
'triggerRevisionSetDigest',
),
taskCount: value.taskCount,
triggerCount: value.triggerCount,
committedAtMs: timestamp(value.committedAtMs),
});
const receipt = Object.freeze({
...unsigned,
receiptDigest: clusterLegacyEnvMigrationApplicationReceiptDigest(unsigned),
});
if (
Buffer.byteLength(JSON.stringify(receipt), 'utf8') >
MAX_CLUSTER_LEGACY_ENV_MIGRATION_RECEIPT_JSON_BYTES
) {
return invalid('encoded receipt exceeds the size limit');
}
return receipt;
}
export function normalizeClusterLegacyEnvMigrationApplicationReceipt(
value: ClusterLegacyEnvMigrationApplicationReceipt,
): Readonly<ClusterLegacyEnvMigrationApplicationReceipt> {
const candidate = record(value, 'application receipt');
exactKeys(
candidate,
[
'applicationId',
'committedAtMs',
'environmentBundleRef',
'mutationId',
'planDigest',
'planId',
'projectId',
'receiptDigest',
'schema',
'taskCount',
'taskMutationSetDigest',
'taskRevisionSetDigest',
'triggerCount',
'triggerMutationSetDigest',
'triggerRevisionSetDigest',
],
'application receipt',
);
if (
value.schema !== CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_RECEIPT_SCHEMA
) {
return invalid('receipt schema is invalid');
}
const expected = createClusterLegacyEnvMigrationApplicationReceipt({
applicationId: value.applicationId,
mutationId: value.mutationId,
projectId: value.projectId,
planId: value.planId,
planDigest: value.planDigest,
environmentBundleRef: value.environmentBundleRef,
taskRevisionSetDigest: value.taskRevisionSetDigest,
triggerRevisionSetDigest: value.triggerRevisionSetDigest,
taskMutationSetDigest: value.taskMutationSetDigest,
triggerMutationSetDigest: value.triggerMutationSetDigest,
taskCount: value.taskCount,
triggerCount: value.triggerCount,
committedAtMs: value.committedAtMs,
});
if (value.receiptDigest !== expected.receiptDigest) {
return invalid('receiptDigest does not match receipt');
}
return expected;
}
export function clusterLegacyEnvMigrationApplicationReceiptMatchesIntent(
receiptValue: ClusterLegacyEnvMigrationApplicationReceipt,
intentValue: ClusterLegacyEnvMigrationApplicationIntent,
): boolean {
const receipt =
normalizeClusterLegacyEnvMigrationApplicationReceipt(receiptValue);
const intent =
normalizeClusterLegacyEnvMigrationApplicationIntent(intentValue);
return (
receipt.applicationId === intent.applicationId &&
receipt.mutationId === intent.mutationId &&
receipt.projectId === intent.projectId &&
receipt.planId === intent.planId &&
receipt.planDigest === intent.planDigest &&
receipt.taskMutationSetDigest === intent.taskMutationSetDigest &&
receipt.triggerMutationSetDigest === intent.triggerMutationSetDigest
);
}
@@ -0,0 +1,222 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_RECEIPT_SCHEMA,
InvalidClusterLegacyEnvMigrationApplicationError,
clusterLegacyEnvMigrationApplicationReceiptMatchesIntent,
createClusterLegacyEnvMigrationApplicationReceipt,
createClusterLegacyEnvMigrationTaskMutationSetDigester,
createClusterLegacyEnvMigrationTriggerMutationSetDigester,
normalizeClusterLegacyEnvMigrationApplicationReceipt,
} = require('@qinglong/runtime-core/cluster-legacy-env-migration-application');
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
const IDS = Object.freeze({
application: '11111111-1111-4111-8111-111111111111',
applicationMutation: '22222222-2222-4222-8222-222222222222',
taskOneMutation: '33333333-3333-4333-8333-333333333333',
taskTwoMutation: '44444444-4444-4444-8444-444444444444',
triggerMutation: '55555555-5555-4555-8555-555555555555',
});
function taskMutations() {
return [
{
ordinal: 0,
taskId: 'task-a',
previousRevision: 3,
previousContentDigest: '1'.repeat(64),
mutationId: IDS.taskOneMutation,
},
{
ordinal: 1,
taskId: 'task-b',
previousRevision: 8,
previousContentDigest: '2'.repeat(64),
mutationId: IDS.taskTwoMutation,
},
];
}
function triggerMutations() {
return [
{
ordinal: 0,
triggerId: 'trigger-a',
taskId: 'task-a',
previousRevision: 4,
previousContentDigest: '3'.repeat(64),
previousTaskRevision: 3,
previousTaskContentDigest: '1'.repeat(64),
mutationId: IDS.triggerMutation,
},
];
}
function digestTaskMutations(values = taskMutations()) {
const digester = createClusterLegacyEnvMigrationTaskMutationSetDigester();
for (const value of values) digester.update(value);
return digester.finish();
}
function digestTriggerMutations(values = triggerMutations()) {
const digester = createClusterLegacyEnvMigrationTriggerMutationSetDigester();
for (const value of values) digester.update(value);
return digester.finish();
}
function receiptInput(overrides = {}) {
const task = digestTaskMutations();
const trigger = digestTriggerMutations();
return {
applicationId: 'legacy-env-application-a',
mutationId: IDS.applicationMutation,
projectId: 'project-a',
planId: 'legacy-env-plan-a',
planDigest: '6'.repeat(64),
environmentBundleRef: createSecretRef({
projectId: 'project-a',
name: 'legacy-env-bundle',
version: 7,
}),
taskRevisionSetDigest: task.revisionSetDigest,
triggerRevisionSetDigest: trigger.revisionSetDigest,
taskMutationSetDigest: task.mutationSetDigest,
triggerMutationSetDigest: trigger.mutationSetDigest,
taskCount: task.count,
triggerCount: trigger.count,
committedAtMs: 12_345,
...overrides,
};
}
test('streams canonical Task and Trigger revision and mutation sets', () => {
const firstTask = digestTaskMutations();
const secondTask = digestTaskMutations();
const firstTrigger = digestTriggerMutations();
const secondTrigger = digestTriggerMutations();
assert.deepEqual(firstTask, secondTask);
assert.deepEqual(firstTrigger, secondTrigger);
assert.equal(firstTask.count, 2);
assert.equal(firstTrigger.count, 1);
assert.notEqual(firstTask.revisionSetDigest, firstTask.mutationSetDigest);
assert.notEqual(
firstTrigger.revisionSetDigest,
firstTrigger.mutationSetDigest,
);
});
test('rejects gaps, duplicate ordering, malformed UUIDs and unknown fields', () => {
const gap = taskMutations();
gap[1] = { ...gap[1], ordinal: 2 };
assert.throws(() => digestTaskMutations(gap), {
name: 'InvalidClusterLegacyEnvMigrationApplicationError',
});
const unordered = taskMutations();
unordered[1] = { ...unordered[1], taskId: 'task-a' };
assert.throws(
() => digestTaskMutations(unordered),
InvalidClusterLegacyEnvMigrationApplicationError,
);
const malformedMutation = taskMutations();
malformedMutation[0] = { ...malformedMutation[0], mutationId: 'not-a-uuid' };
assert.throws(
() => digestTaskMutations(malformedMutation),
InvalidClusterLegacyEnvMigrationApplicationError,
);
const extra = taskMutations();
extra[0] = { ...extra[0], command: 'must-not-enter-ledger' };
assert.throws(
() => digestTaskMutations(extra),
InvalidClusterLegacyEnvMigrationApplicationError,
);
});
test('creates a frozen content-free receipt and detects tampering', () => {
const receipt = createClusterLegacyEnvMigrationApplicationReceipt(
receiptInput(),
);
assert.equal(
receipt.schema,
CLUSTER_LEGACY_ENV_MIGRATION_APPLICATION_RECEIPT_SCHEMA,
);
assert.equal(Object.isFrozen(receipt), true);
assert.match(receipt.receiptDigest, /^[0-9a-f]{64}$/);
assert.deepEqual(
normalizeClusterLegacyEnvMigrationApplicationReceipt(receipt),
receipt,
);
assert.equal(
clusterLegacyEnvMigrationApplicationReceiptMatchesIntent(receipt, {
applicationId: receipt.applicationId,
mutationId: receipt.mutationId,
projectId: receipt.projectId,
planId: receipt.planId,
planDigest: receipt.planDigest,
taskMutationSetDigest: receipt.taskMutationSetDigest,
triggerMutationSetDigest: receipt.triggerMutationSetDigest,
}),
true,
);
assert.throws(
() =>
normalizeClusterLegacyEnvMigrationApplicationReceipt({
...receipt,
taskCount: receipt.taskCount + 1,
}),
InvalidClusterLegacyEnvMigrationApplicationError,
);
});
test('requires one Task and a canonical version-pinned bundle in the same Project', () => {
assert.throws(
() =>
createClusterLegacyEnvMigrationApplicationReceipt(
receiptInput({ taskCount: 0 }),
),
InvalidClusterLegacyEnvMigrationApplicationError,
);
assert.throws(
() =>
createClusterLegacyEnvMigrationApplicationReceipt(
receiptInput({
environmentBundleRef: createSecretRef({
projectId: 'project-a',
name: 'legacy-env-bundle',
}),
}),
),
InvalidClusterLegacyEnvMigrationApplicationError,
);
assert.throws(
() =>
createClusterLegacyEnvMigrationApplicationReceipt(
receiptInput({
environmentBundleRef: createSecretRef({
projectId: 'project-b',
name: 'legacy-env-bundle',
version: 7,
}),
}),
),
InvalidClusterLegacyEnvMigrationApplicationError,
);
});
test('supports an empty Trigger mutation set without weakening Task authority', () => {
const trigger = digestTriggerMutations([]);
const receipt = createClusterLegacyEnvMigrationApplicationReceipt(
receiptInput({
triggerRevisionSetDigest: trigger.revisionSetDigest,
triggerMutationSetDigest: trigger.mutationSetDigest,
triggerCount: 0,
}),
);
assert.equal(receipt.taskCount, 2);
assert.equal(receipt.triggerCount, 0);
});