fix(ci): align recovery gate with durable admission

This commit is contained in:
whyour
2026-08-22 16:16:16 +08:00
parent eaf9bf486a
commit 42c1bfbb56
5 changed files with 431 additions and 326 deletions
@@ -340,6 +340,172 @@ function signedPackageRoutes(material, lock, privateKey) {
]); ]);
} }
function createInstallAuthority(actionInput, phase, proposedAtMs, eventBase) {
const {
consumeApprovalRequest,
createApprovalRequest,
decideApprovalRequest,
} = ql3Require('@qinglong/runtime-core/approved-action');
const {
createPluginPackageInstallProposal,
resolvePluginPackageInstallProposal,
} = ql3Require('@qinglong/runtime-core/plugin-package-proposal');
const requester = { type: 'user', id: 'plugin-recovery-e2e-owner' };
const reviewer = { type: 'user', id: 'plugin-recovery-e2e-reviewer' };
const consumer = { type: 'system', id: 'plugin-recovery-e2e-dispatcher' };
const fence = { projectVersion: 1, bindingVersion: 1 };
const requestedAtMs = proposedAtMs + 1;
const decidedAtMs = proposedAtMs + 2;
const consumedAtMs = proposedAtMs + 3;
const lockCreatedAtMs = proposedAtMs + 4;
const expiresAtMs = proposedAtMs + 60 * 60 * 1000;
const actionRef = `plugin-package:e2e-monitor:${phase}`;
const proposal = createPluginPackageInstallProposal({
actionRef,
actionInput,
proposedBy: requester,
proposalFence: fence,
createdAtMs: proposedAtMs,
});
const action = {
actionRef: proposal.actionRef,
actionType: proposal.actionType,
permission: proposal.permission,
actionDigest: proposal.actionDigest,
previewDigest: proposal.previewDigest,
};
const request = createApprovalRequest({
id: `approval-plugin-recovery-e2e-${phase}`,
projectId: proposal.projectId,
action,
risk: 'high',
decisionMode: 'separation_of_duty',
requestedBy: requester,
requestedAtMs,
expiresAtMs,
requestFence: fence,
});
const decision = {
expectedVersion: 1,
decisionId: `decision-plugin-recovery-e2e-${phase}`,
decision: 'approved',
reasonCode: 'reviewed',
principal: {
subject: reviewer,
authenticationId: `auth-plugin-recovery-e2e-reviewer-${phase}`,
authenticatedAtMs: proposedAtMs,
expiresAtMs,
assurance: 'multi_factor',
},
decidedAtMs,
authorizationFence: fence,
};
const decided = decideApprovalRequest(request, decision);
const consumption = {
expectedVersion: 2,
consumptionId: `consume-plugin-recovery-e2e-${phase}`,
dispatchId: `dispatch-plugin-recovery-e2e-${phase}`,
action,
requestedBy: requester,
consumedBy: consumer,
consumedAtMs,
authorizationFence: fence,
};
const consumed = consumeApprovalRequest(decided, consumption);
const audit = (
offset,
requestId,
operationId,
subject,
authenticationId,
outcome,
reasons,
occurredAtMs,
) => ({
eventId: `10000000-0000-4000-8000-${String(
eventBase + offset,
).padStart(12, '0')}`,
requestId,
operationId,
projectId: proposal.projectId,
subject,
authenticationId,
outcome,
reasons,
fence,
occurredAtMs,
});
return Object.freeze({
requester,
reviewer,
consumer,
fence,
proposalCommand: {
proposal,
audit: audit(
0,
proposal.actionRef,
'plugin_package.propose',
requester,
`auth-plugin-recovery-e2e-owner-${phase}`,
'allowed',
['package_proposal'],
proposedAtMs,
),
},
requestCommand: {
request,
audit: audit(
1,
`request-plugin-recovery-e2e-${phase}`,
'approval.request',
requester,
`auth-plugin-recovery-e2e-owner-${phase}`,
'approval_required',
['package_review'],
requestedAtMs,
),
},
decisionCommand: {
requestId: request.id,
...decision,
audit: audit(
2,
`decision-plugin-recovery-e2e-${phase}`,
'approval.decide',
reviewer,
decision.principal.authenticationId,
'allowed',
['role_grant'],
decidedAtMs,
),
},
consumptionCommand: {
requestId: request.id,
...consumption,
audit: audit(
3,
`consume-plugin-recovery-e2e-${phase}`,
'approval.consume',
consumer,
`auth-plugin-recovery-e2e-dispatcher-${phase}`,
'allowed',
['role_grant'],
consumedAtMs,
),
},
dispatch: consumed.dispatch,
lock: resolvePluginPackageInstallProposal(
proposal,
consumed.dispatch,
lockCreatedAtMs,
),
admissionAuditEventId: `10000000-0000-4000-8000-${String(
eventBase + 4,
).padStart(12, '0')}`,
});
}
function createFixture({ registry, architecture, createdAtMs = Date.now() }) { function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
if ( if (
typeof registry !== 'string' || typeof registry !== 'string' ||
@@ -356,11 +522,6 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
const { planPluginPackageInstall } = ql3Require( const { planPluginPackageInstall } = ql3Require(
'@qinglong/runtime-core/plugin-package', '@qinglong/runtime-core/plugin-package',
); );
const {
createPluginPackageLock,
pluginPackageInstallActionDigest,
pluginPackageInstallPlanDigest,
} = ql3Require('@qinglong/runtime-core/plugin-package-install');
const { createPluginPackageResourceGenerationFromReferences } = ql3Require( const { createPluginPackageResourceGenerationFromReferences } = ql3Require(
'@qinglong/runtime-core/plugin-package-resource-generation', '@qinglong/runtime-core/plugin-package-resource-generation',
); );
@@ -386,22 +547,14 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
deploymentProfile: 'cluster-control', deploymentProfile: 'cluster-control',
targetGeneration: 1, targetGeneration: 1,
}; };
const initialLock = createPluginPackageLock({ const initialAuthority = createInstallAuthority(
...initialAction, initialAction,
approval: { 'initial',
requestId: 'approval-plugin-recovery-e2e-initial',
requestVersion: 1,
dispatchId: 'dispatch-plugin-recovery-e2e-initial',
actionDigest: pluginPackageInstallActionDigest(initialAction),
previewDigest: pluginPackageInstallPlanDigest(initialPlan),
approvedBy: { type: 'user', id: 'e2e-owner' },
approvedAtMs: createdAtMs - 1,
expiresAtMs: createdAtMs + 60 * 60 * 1000,
fence: { projectVersion: 1, bindingVersion: 1 },
},
createdAtMs, createdAtMs,
}); 1,
const upgradeCreatedAtMs = createdAtMs + 10; );
const initialLock = initialAuthority.lock;
const upgradeCreatedAtMs = initialLock.createdAtMs + 10;
const upgradeManifest = pluginManifest(architecture, '2.0.0', true); const upgradeManifest = pluginManifest(architecture, '2.0.0', true);
const upgradeMaterial = packageMaterial( const upgradeMaterial = packageMaterial(
registry, registry,
@@ -426,21 +579,13 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
targetGeneration: 2, targetGeneration: 2,
previousLockDigest: initialLock.lockDigest, previousLockDigest: initialLock.lockDigest,
}; };
const upgradeLock = createPluginPackageLock({ const upgradeAuthority = createInstallAuthority(
...upgradeAction, upgradeAction,
approval: { 'upgrade',
requestId: 'approval-plugin-recovery-e2e-upgrade', upgradeCreatedAtMs,
requestVersion: 1, 11,
dispatchId: 'dispatch-plugin-recovery-e2e-upgrade', );
actionDigest: pluginPackageInstallActionDigest(upgradeAction), const upgradeLock = upgradeAuthority.lock;
previewDigest: pluginPackageInstallPlanDigest(upgradePlan),
approvedBy: { type: 'user', id: 'e2e-owner' },
approvedAtMs: upgradeCreatedAtMs - 1,
expiresAtMs: upgradeCreatedAtMs + 60 * 60 * 1000,
fence: { projectVersion: 1, bindingVersion: 1 },
},
createdAtMs: upgradeCreatedAtMs,
});
const { publicKey, privateKey } = generateKeyPairSync('ed25519'); const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' }); const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' });
const trust = { const trust = {
@@ -467,6 +612,7 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
privateKey, privateKey,
); );
const initial = Object.freeze({ const initial = Object.freeze({
authority: initialAuthority,
installationId: 'install-plugin-recovery-e2e-initial', installationId: 'install-plugin-recovery-e2e-initial',
manifest: initialManifest, manifest: initialManifest,
lock: initialLock, lock: initialLock,
@@ -483,6 +629,7 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
routes: initialRoutes, routes: initialRoutes,
}); });
const upgrade = Object.freeze({ const upgrade = Object.freeze({
authority: upgradeAuthority,
installationId: 'install-plugin-recovery-e2e-upgrade', installationId: 'install-plugin-recovery-e2e-upgrade',
manifest: upgradeManifest, manifest: upgradeManifest,
lock: upgradeLock, lock: upgradeLock,
@@ -518,8 +665,12 @@ function readFixture(filePath) {
value.schema !== FIXTURE_SCHEMA || value.schema !== FIXTURE_SCHEMA ||
!Array.isArray(value.routes) || !Array.isArray(value.routes) ||
!value.initial?.lock || !value.initial?.lock ||
!value.initial?.authority?.proposalCommand?.proposal ||
!value.initial?.authority?.dispatch ||
!value.initial?.generation || !value.initial?.generation ||
!value.upgrade?.lock || !value.upgrade?.lock ||
!value.upgrade?.authority?.proposalCommand?.proposal ||
!value.upgrade?.authority?.dispatch ||
!value.upgrade?.generation || !value.upgrade?.generation ||
!value.trust !value.trust
) { ) {
@@ -624,32 +775,31 @@ async function runSeed() {
assertPostgresPackageExecutorSchemaReady, assertPostgresPackageExecutorSchemaReady,
createPostgresDatabaseOpener, createPostgresDatabaseOpener,
loadPostgresConnectionEnvironment, loadPostgresConnectionEnvironment,
PostgresPluginPackageSecretBindingTransitionRepository, PostgresApprovedActionExecutionRepository,
} = ql3Require('@qinglong/cluster-postgres/package-executor'); } = ql3Require('@qinglong/cluster-postgres/package-executor');
const { const {
assertPostgresPackageManagerSchemaReady, assertPostgresPackageManagerSchemaReady,
PostgresApprovalRequestRepository,
PostgresPluginPackagePublisherTrustAuthorityRepository, PostgresPluginPackagePublisherTrustAuthorityRepository,
} = ql3Require('@qinglong/cluster-postgres/package-manager'); } = ql3Require('@qinglong/cluster-postgres/package-manager');
const { PostgresProjectPolicyRepository } = ql3Require(
'@qinglong/cluster-postgres/project-policy',
);
const { PostgresPluginPackageInstallRepository } = ql3Require( const { PostgresPluginPackageInstallRepository } = ql3Require(
'@qinglong/cluster-postgres/plugin-package-install', '@qinglong/cluster-postgres/plugin-package-install',
); );
const { const { PostgresPluginPackageInstallProposalRepository } = ql3Require(
createPluginPackageInstall, '@qinglong/cluster-postgres/plugin-package-proposal',
normalizePluginPackageLock,
pluginPackageInstallCreate,
} = ql3Require('@qinglong/runtime-core/plugin-package-install');
const { createPluginPackageSecretBindingTarget } = ql3Require(
'@qinglong/runtime-core/plugin-package-secret-binding',
); );
const { createPluginPackageSecretBindingTransitionPlan } = ql3Require( const { normalizePluginPackageLock } = ql3Require(
'@qinglong/runtime-core/plugin-package-secret-binding-transition-plan', '@qinglong/runtime-core/plugin-package-install',
); );
const { createPluginPackagePublisherTrustSnapshot } = ql3Require( const { createPluginPackagePublisherTrustSnapshot } = ql3Require(
'@qinglong/runtime-core/plugin-package-publisher-trust', '@qinglong/runtime-core/plugin-package-publisher-trust',
); );
const fixture = readFixture(process.env.QL3_E2E_FIXTURE_FILE); const fixture = readFixture(process.env.QL3_E2E_FIXTURE_FILE);
const mode = process.env.QL3_E2E_MODE; const mode = process.env.QL3_E2E_MODE;
if (!['seed-initial', 'seed-upgrade', 'commit-transition'].includes(mode)) { if (!['seed-initial', 'seed-upgrade'].includes(mode)) {
throw new Error('Plugin Package E2E seed mode is invalid'); throw new Error('Plugin Package E2E seed mode is invalid');
} }
const connection = loadPostgresConnectionEnvironment(process.env, { const connection = loadPostgresConnectionEnvironment(process.env, {
@@ -671,45 +821,54 @@ async function runSeed() {
})(); })();
try { try {
await assertPostgresPackageExecutorSchemaReady(database.pool); await assertPostgresPackageExecutorSchemaReady(database.pool);
if (mode === 'commit-transition') {
const plannedAtMs = Date.now();
const transitionPlan = createPluginPackageSecretBindingTransitionPlan({
previousTarget: createPluginPackageSecretBindingTarget(
fixture.initial.generation,
fixture.initial.manifest,
),
previousBinding: null,
previousAttemptGeneration: 1,
nextGeneration: fixture.upgrade.generation,
nextManifest: fixture.upgrade.manifest,
assignments: [],
plannedAtMs,
});
const result =
await new PostgresPluginPackageSecretBindingTransitionRepository(
database.pool,
).apply({
transitionPlan,
evidenceDigest: transitionPlan.transitionDigest,
committedAtMs: plannedAtMs + 1,
});
process.stdout.write(
`${JSON.stringify({
schema: 'qinglong/plugin-package-recovery-e2e-transition-result@v1',
event: 'transition_completed',
status: result.status,
generationDigest: transitionPlan.nextTarget.generationDigest,
transitionDigest: transitionPlan.transitionDigest,
bindingDigest: result.receipt.bindingDigest,
receiptDigest: result.receipt.receiptDigest,
})}\n`,
);
return;
}
const selected = const selected =
mode === 'seed-initial' ? fixture.initial : fixture.upgrade; mode === 'seed-initial' ? fixture.initial : fixture.upgrade;
const runtimeConnection = loadPostgresConnectionEnvironment(process.env, {
host: 'QL3_E2E_POSTGRES_HOST',
port: 'QL3_E2E_POSTGRES_PORT',
database: 'QL3_E2E_POSTGRES_DATABASE',
user: 'QL3_E2E_POSTGRES_RUNTIME_USER',
password: 'QL3_E2E_POSTGRES_RUNTIME_PASSWORD',
});
const runtimeDatabase = await createPostgresDatabaseOpener({
role: 'runtime',
connection: { ...runtimeConnection, tls: { mode: 'disable' } },
pool: {
applicationName: 'qinglong3-plugin-package-e2e-policy-seed',
maxConnections: 1,
connectionTimeoutMs: 15_000,
},
onPoolError() {},
})();
try {
const policies = new PostgresProjectPolicyRepository(
runtimeDatabase.pool,
);
for (const [subject, role] of [
[selected.authority.requester, 'owner'],
[selected.authority.reviewer, 'admin'],
]) {
const policy = await policies.append({
expectedCurrentVersion: 0,
binding: {
projectId: selected.lock.projectId,
subject,
version: 1,
state: 'active',
role,
mutationId: `plugin-recovery-e2e-grant-${role}`,
changedBy: { type: 'system', id: 'plugin-recovery-e2e-bootstrap' },
createdAtMs: 1,
},
});
if (!['inserted', 'existing'].includes(policy.status)) {
throw new Error('Plugin Package E2E policy seed was not durable');
}
}
} finally {
await runtimeDatabase.close();
}
let publisherTrustStatus = null; let publisherTrustStatus = null;
if (mode === 'seed-initial') {
const packageManagerConnection = loadPostgresConnectionEnvironment( const packageManagerConnection = loadPostgresConnectionEnvironment(
process.env, process.env,
{ {
@@ -727,7 +886,7 @@ async function runSeed() {
tls: { mode: 'disable' }, tls: { mode: 'disable' },
}, },
pool: { pool: {
applicationName: 'qinglong3-plugin-package-e2e-trust-seed', applicationName: 'qinglong3-plugin-package-e2e-management-seed',
maxConnections: 1, maxConnections: 1,
connectionTimeoutMs: 15_000, connectionTimeoutMs: 15_000,
}, },
@@ -737,6 +896,7 @@ async function runSeed() {
await assertPostgresPackageManagerSchemaReady( await assertPostgresPackageManagerSchemaReady(
packageManagerDatabase.pool, packageManagerDatabase.pool,
); );
if (mode === 'seed-initial') {
const observed = const observed =
await new PostgresPluginPackagePublisherTrustAuthorityRepository( await new PostgresPluginPackagePublisherTrustAuthorityRepository(
packageManagerDatabase.pool, packageManagerDatabase.pool,
@@ -754,10 +914,18 @@ async function runSeed() {
); );
} }
publisherTrustStatus = observed.status; publisherTrustStatus = observed.status;
}
await new PostgresPluginPackageInstallProposalRepository(
packageManagerDatabase.pool,
).createProposal(selected.authority.proposalCommand);
const approvals = new PostgresApprovalRequestRepository(
packageManagerDatabase.pool,
);
await approvals.create(selected.authority.requestCommand);
await approvals.decide(selected.authority.decisionCommand);
} finally { } finally {
await packageManagerDatabase.close(); await packageManagerDatabase.close();
} }
}
const lock = normalizePluginPackageLock(selected.lock); const lock = normalizePluginPackageLock(selected.lock);
const repository = new PostgresPluginPackageInstallRepository( const repository = new PostgresPluginPackageInstallRepository(
database.pool, database.pool,
@@ -772,16 +940,68 @@ async function runSeed() {
) { ) {
throw new Error('Plugin Package E2E previous install head is invalid'); throw new Error('Plugin Package E2E previous install head is invalid');
} }
const record = createPluginPackageInstall(lock, { const consumed = await new PostgresApprovalRequestRepository(
database.pool,
).consume(selected.authority.consumptionCommand);
if (
JSON.stringify(consumed.dispatch) !==
JSON.stringify(selected.authority.dispatch)
) {
throw new Error('Plugin Package E2E durable dispatch drifted');
}
const executions = new PostgresApprovedActionExecutionRepository(
database.pool,
);
const executionAtMs = Date.now();
const owner = 'plugin_recovery_e2e_dispatcher';
const leaseToken = `lease-plugin-recovery-e2e-${
mode === 'seed-initial' ? 'initial' : 'upgrade'
}`;
const claimed = await executions.claimExecution({
dispatchId: consumed.dispatch.id,
owner,
leaseToken,
nowMs: executionAtMs,
leaseDurationMs: 60_000,
});
if (claimed.status !== 'claimed') {
throw new Error('Plugin Package E2E execution was not claimable');
}
const started = await executions.startExecution({
dispatchId: consumed.dispatch.id,
approvalRequestId: consumed.dispatch.approvalRequestId,
actionDigest: consumed.dispatch.action.actionDigest,
owner,
leaseToken,
expectedVersion: claimed.snapshot.execution.version,
startedAtMs: executionAtMs + 1,
});
const admittedAtMs = executionAtMs + 2;
const result = await repository.admit({
lock,
proposalDigest:
selected.authority.proposalCommand.proposal.proposalDigest,
execution: started.execution,
installationId: selected.installationId, installationId: selected.installationId,
mutationId: `mutation-plugin-recovery-e2e-${ mutationId: `mutation-plugin-recovery-e2e-${
mode === 'seed-initial' ? 'initial' : 'upgrade' mode === 'seed-initial' ? 'initial' : 'upgrade'
}-create`, }-admit`,
occurredAtMs: lock.createdAtMs + 1, admittedAtMs,
audit: {
eventId: selected.authority.admissionAuditEventId,
requestId: consumed.dispatch.id,
operationId: 'plugin_package.admit',
projectId: lock.projectId,
subject: selected.authority.consumer,
authenticationId: `auth-plugin-recovery-e2e-dispatcher-${
mode === 'seed-initial' ? 'initial' : 'upgrade'
}`,
outcome: 'allowed',
reasons: ['approved_action'],
fence: selected.authority.fence,
occurredAtMs: admittedAtMs,
},
}); });
const result = await repository.create(
pluginPackageInstallCreate(lock, record, previous),
);
process.stdout.write( process.stdout.write(
`${JSON.stringify({ `${JSON.stringify({
schema: 'qinglong/plugin-package-recovery-e2e-seed-result@v1', schema: 'qinglong/plugin-package-recovery-e2e-seed-result@v1',
@@ -806,15 +1026,13 @@ async function main() {
return; return;
} }
if ( if (
['seed-initial', 'seed-upgrade', 'commit-transition'].includes( ['seed-initial', 'seed-upgrade'].includes(process.env.QL3_E2E_MODE)
process.env.QL3_E2E_MODE,
)
) { ) {
await runSeed(); await runSeed();
return; return;
} }
throw new Error( throw new Error(
'QL3_E2E_MODE must be registry, seed-initial, seed-upgrade or commit-transition', 'QL3_E2E_MODE must be registry, seed-initial or seed-upgrade',
); );
} }
@@ -5,7 +5,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const FIXTURE = 'qinglong/plugin-package-recovery-e2e-live-contract@v2'; const FIXTURE = 'qinglong/plugin-package-recovery-e2e-live-contract@v3';
const LIMITATIONS = Object.freeze([ const LIMITATIONS = Object.freeze([
'isolated PostgreSQL uses explicit TLS disable; production manifests remain verify-full', 'isolated PostgreSQL uses explicit TLS disable; production manifests remain verify-full',
'the authenticated HTTPS OCI Distribution fixture implements the immutable GET/referrers surface used by the resolver, not a production registry storage implementation', 'the authenticated HTTPS OCI Distribution fixture implements the immutable GET/referrers surface used by the resolver, not a production registry storage implementation',
@@ -13,7 +13,6 @@ const LIMITATIONS = Object.freeze([
]); ]);
const GATE_KEYS = Object.freeze([ const GATE_KEYS = Object.freeze([
'healthyInitialActivation', 'healthyInitialActivation',
'missingTransitionFailedClosed',
'invalidUpgradeRejectedBeforeActivation', 'invalidUpgradeRejectedBeforeActivation',
'activePointerUidUnchanged', 'activePointerUidUnchanged',
'activePointerResourceVersionUnchanged', 'activePointerResourceVersionUnchanged',
@@ -119,8 +118,8 @@ function validRuntime(report) {
]) && ]) &&
runtime.replicas === 2 && runtime.replicas === 2 &&
runtime.creationTimestamp === ordering?.runtimeCreatedAt && runtime.creationTimestamp === ordering?.runtimeCreatedAt &&
runtime.recoveryJobUid === ordering?.rejectionRecoveryJobUid && runtime.recoveryJobUid === ordering?.upgradeRecoveryJobUid &&
runtime.recoveryCompletedAt === ordering?.rejectionRecoveryCompletedAt && runtime.recoveryCompletedAt === ordering?.upgradeRecoveryCompletedAt &&
Array.isArray(runtime.nodes) && Array.isArray(runtime.nodes) &&
runtime.nodes.length === 2 && runtime.nodes.length === 2 &&
new Set(runtime.nodes).size === 2 && new Set(runtime.nodes).size === 2 &&
@@ -179,8 +178,7 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
'postgresRepositoryDigest', 'postgresRepositoryDigest',
'migrationImageId', 'migrationImageId',
'initialRecoveryImageId', 'initialRecoveryImageId',
'stageRecoveryImageId', 'upgradeRecoveryImageId',
'rejectionRecoveryImageId',
'postgresImageId', 'postgresImageId',
]) || ]) ||
!SHA256_ID.test(images?.adminBuildId ?? '') || !SHA256_ID.test(images?.adminBuildId ?? '') ||
@@ -192,9 +190,7 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
!imageDigest(images?.migrationImageId) || !imageDigest(images?.migrationImageId) ||
imageDigest(images?.initialRecoveryImageId) !== imageDigest(images?.initialRecoveryImageId) !==
imageDigest(images?.migrationImageId) || imageDigest(images?.migrationImageId) ||
imageDigest(images?.stageRecoveryImageId) !== imageDigest(images?.upgradeRecoveryImageId) !==
imageDigest(images?.migrationImageId) ||
imageDigest(images?.rejectionRecoveryImageId) !==
imageDigest(images?.migrationImageId) || imageDigest(images?.migrationImageId) ||
!imageDigest(images?.postgresImageId) !imageDigest(images?.postgresImageId)
) { ) {
@@ -207,17 +203,13 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
const timeKeys = [ const timeKeys = [
'migrationCompletedAt', 'migrationCompletedAt',
'initialRecoveryCompletedAt', 'initialRecoveryCompletedAt',
'upgradeStageFailedAt', 'upgradeRecoveryCompletedAt',
'transitionCompletedAt',
'rejectionRecoveryCompletedAt',
'runtimeCreatedAt', 'runtimeCreatedAt',
]; ];
const uidKeys = [ const uidKeys = [
'migrationJobUid', 'migrationJobUid',
'initialRecoveryJobUid', 'initialRecoveryJobUid',
'upgradeStageJobUid', 'upgradeRecoveryJobUid',
'transitionJobUid',
'rejectionRecoveryJobUid',
'runtimeBoundRecoveryJobUid', 'runtimeBoundRecoveryJobUid',
]; ];
const times = timeKeys.map((key) => Date.parse(ordering?.[key])); const times = timeKeys.map((key) => Date.parse(ordering?.[key]));
@@ -225,7 +217,7 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
!exactKeys(ordering, [...uidKeys, ...timeKeys]) || !exactKeys(ordering, [...uidKeys, ...timeKeys]) ||
uidKeys.some((key) => !UUID.test(ordering?.[key] ?? '')) || uidKeys.some((key) => !UUID.test(ordering?.[key] ?? '')) ||
ordering?.runtimeBoundRecoveryJobUid !== ordering?.runtimeBoundRecoveryJobUid !==
ordering?.rejectionRecoveryJobUid || ordering?.upgradeRecoveryJobUid ||
timeKeys.some((key) => !validIso(ordering?.[key])) || timeKeys.some((key) => !validIso(ordering?.[key])) ||
times.some((value, index) => index > 0 && value < times[index - 1]) times.some((value, index) => index > 0 && value < times[index - 1])
) { ) {
@@ -240,18 +232,12 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
const failed = report?.failedUpgrade; const failed = report?.failedUpgrade;
if ( if (
!exactKeys(failed, [ !exactKeys(failed, [
'stageFailure', 'recoveryJobUid',
'transitionReceiptDigest',
'rejectionReason', 'rejectionReason',
'candidateRevisionCount', 'candidateRevisionCount',
'activePointerUnchanged', 'activePointerUnchanged',
]) || ]) ||
!exactKeys(failed?.stageFailure, ['jobUid', 'reason', 'durableState']) || failed?.recoveryJobUid !== ordering?.upgradeRecoveryJobUid ||
failed?.stageFailure?.jobUid !== ordering?.upgradeStageJobUid ||
failed?.stageFailure?.reason !==
'ClusterPluginPackageRecoveryRequiredError' ||
failed?.stageFailure?.durableState !== 'staged' ||
!SHA256.test(failed?.transitionReceiptDigest ?? '') ||
failed?.rejectionReason !== 'activation_fact_conflict' || failed?.rejectionReason !== 'activation_fact_conflict' ||
failed?.candidateRevisionCount !== 0 || failed?.candidateRevisionCount !== 0 ||
failed?.activePointerUnchanged !== true failed?.activePointerUnchanged !== true
@@ -277,7 +263,6 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
'initialMutationCount', 'initialMutationCount',
'upgradeMutationCount', 'upgradeMutationCount',
'headInstallationId', 'headInstallationId',
'transitionReceiptCount',
'initialRevisionCount', 'initialRevisionCount',
'upgradeRevisionCount', 'upgradeRevisionCount',
'recoverableCount', 'recoverableCount',
@@ -296,7 +281,6 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
database?.initialMutationCount !== 4 || database?.initialMutationCount !== 4 ||
database?.upgradeMutationCount !== 3 || database?.upgradeMutationCount !== 3 ||
database?.headInstallationId !== 'install-plugin-recovery-e2e-upgrade' || database?.headInstallationId !== 'install-plugin-recovery-e2e-upgrade' ||
database?.transitionReceiptCount !== 1 ||
database?.initialRevisionCount !== 1 || database?.initialRevisionCount !== 1 ||
database?.upgradeRevisionCount !== 0 || database?.upgradeRevisionCount !== 0 ||
database?.recoverableCount !== 0 database?.recoverableCount !== 0
@@ -323,11 +307,11 @@ function validatePluginPackageRecoveryE2ELiveReport(report) {
]) || ]) ||
oci?.https !== true || oci?.https !== true ||
oci?.authentication !== 'exact-registry-basic' || oci?.authentication !== 'exact-registry-basic' ||
oci?.authenticatedRequestCount !== 18 || oci?.authenticatedRequestCount !== 12 ||
oci?.requestCount !== 18 || oci?.requestCount !== 12 ||
oci?.uniquePaths !== 12 || oci?.uniquePaths !== 12 ||
oci?.initialRequestCount !== 6 || oci?.initialRequestCount !== 6 ||
oci?.upgradeRequestCount !== 12 || oci?.upgradeRequestCount !== 6 ||
oci?.redirects !== 0 oci?.redirects !== 0
) { ) {
findings.push( findings.push(
@@ -38,13 +38,11 @@ const POSTGRES_RUNTIME_IMAGE =
const DEFAULT_ADMIN_IMAGE = 'qinglong3-cluster-admin:ql3-plugin-recovery-e2e'; const DEFAULT_ADMIN_IMAGE = 'qinglong3-cluster-admin:ql3-plugin-recovery-e2e';
const DEFAULT_CONTROL_IMAGE = const DEFAULT_CONTROL_IMAGE =
'qinglong3-cluster-control:ql3-plugin-recovery-e2e'; 'qinglong3-cluster-control:ql3-plugin-recovery-e2e';
const REPORT_SCHEMA = 'qinglong/plugin-package-recovery-e2e-live-contract@v2'; const REPORT_SCHEMA = 'qinglong/plugin-package-recovery-e2e-live-contract@v3';
const INITIAL_SEED_JOB = 'ql3-plugin-package-e2e-seed-initial'; const INITIAL_SEED_JOB = 'ql3-plugin-package-e2e-seed-initial';
const INITIAL_RECOVERY_JOB = 'ql3-plugin-package-recovery-initial'; const INITIAL_RECOVERY_JOB = 'ql3-plugin-package-recovery-initial';
const UPGRADE_SEED_JOB = 'ql3-plugin-package-e2e-seed-upgrade'; const UPGRADE_SEED_JOB = 'ql3-plugin-package-e2e-seed-upgrade';
const UPGRADE_STAGE_JOB = 'ql3-plugin-package-recovery-stage-upgrade'; const UPGRADE_RECOVERY_JOB = 'ql3-plugin-package-recovery-reject-upgrade';
const TRANSITION_JOB = 'ql3-plugin-package-e2e-transition';
const UPGRADE_REJECTION_JOB = 'ql3-plugin-package-recovery-reject-upgrade';
const SAFE_CLUSTER = const SAFE_CLUSTER =
/^ql3-plugin-recovery-e2e(?:-[a-z0-9](?:[-a-z0-9]{0,24}[a-z0-9])?)?$/; /^ql3-plugin-recovery-e2e(?:-[a-z0-9](?:[-a-z0-9]{0,24}[a-z0-9])?)?$/;
@@ -879,6 +877,19 @@ function seedJob(name, mode) {
{ name: 'QL3_E2E_POSTGRES_HOST', value: POSTGRES_NAME }, { name: 'QL3_E2E_POSTGRES_HOST', value: POSTGRES_NAME },
{ name: 'QL3_E2E_POSTGRES_PORT', value: '5432' }, { name: 'QL3_E2E_POSTGRES_PORT', value: '5432' },
{ name: 'QL3_E2E_POSTGRES_DATABASE', value: 'qinglong' }, { name: 'QL3_E2E_POSTGRES_DATABASE', value: 'qinglong' },
{
name: 'QL3_E2E_POSTGRES_RUNTIME_USER',
value: 'ql3_runtime',
},
{
name: 'QL3_E2E_POSTGRES_RUNTIME_PASSWORD',
valueFrom: {
secretKeyRef: {
name: 'ql3-e2e-postgres-auth',
key: 'runtime-password',
},
},
},
{ {
name: 'QL3_E2E_POSTGRES_PACKAGE_MANAGER_USER', name: 'QL3_E2E_POSTGRES_PACKAGE_MANAGER_USER',
value: 'ql3_package_manager', value: 'ql3_package_manager',
@@ -1179,67 +1190,6 @@ function canI(verb, resource) {
return result.stdout === 'yes'; return result.stdout === 'yes';
} }
function upgradeStageEvidence(fixture, transitionReceiptCount) {
const generationDigest = fixture.upgrade.generation.generationDigest;
assert.match(generationDigest, /^[0-9a-f]{64}$/);
const sql = `
SELECT json_build_object(
'state', (
SELECT state FROM ql3.plugin_package_installs
WHERE installation_id = '${fixture.upgrade.installationId}'
),
'previousActiveLockDigest', (
SELECT previous_active_lock_digest FROM ql3.plugin_package_installs
WHERE installation_id = '${fixture.upgrade.installationId}'
),
'activeLockDigest', (
SELECT active_lock_digest FROM ql3.plugin_package_installs
WHERE installation_id = '${fixture.upgrade.installationId}'
),
'mutationCount', (
SELECT count(*) FROM ql3.plugin_package_install_mutations
WHERE installation_id = '${fixture.upgrade.installationId}'
),
'transitionReceiptCount', (
SELECT count(*)
FROM ql3.plugin_package_secret_binding_transition_receipts
WHERE generation_digest = '${generationDigest}'
),
'candidateRevisionCount', (
SELECT count(*) FROM ql3.plugin_package_materialized_revisions
WHERE generation_digest = '${generationDigest}'
)
)::text;
`.trim();
const output = kubectl(
[
'-n',
NAMESPACE,
'exec',
POSTGRES_NAME,
'--',
'psql',
'--username',
'postgres',
'--dbname',
'qinglong',
'--tuples-only',
'--no-align',
'--command',
sql,
],
{ capture: true, quiet: true },
).stdout;
const value = JSON.parse(output);
assert.equal(value.state, 'staged');
assert.equal(value.previousActiveLockDigest, fixture.initial.lock.lockDigest);
assert.equal(value.activeLockDigest, fixture.initial.lock.lockDigest);
assert.equal(value.mutationCount, 2);
assert.equal(value.transitionReceiptCount, transitionReceiptCount);
assert.equal(value.candidateRevisionCount, 0);
return value;
}
function databaseEvidence(fixture) { function databaseEvidence(fixture) {
const initialGenerationDigest = fixture.initial.generation.generationDigest; const initialGenerationDigest = fixture.initial.generation.generationDigest;
const upgradeGenerationDigest = fixture.upgrade.generation.generationDigest; const upgradeGenerationDigest = fixture.upgrade.generation.generationDigest;
@@ -1298,11 +1248,6 @@ SELECT json_build_object(
FROM ql3.plugin_package_install_heads FROM ql3.plugin_package_install_heads
WHERE project_id = 'default' AND package_name = 'e2e-monitor' WHERE project_id = 'default' AND package_name = 'e2e-monitor'
), ),
'transitionReceiptCount', (
SELECT count(*)
FROM ql3.plugin_package_secret_binding_transition_receipts
WHERE generation_digest = '${upgradeGenerationDigest}'
),
'initialRevisionCount', ( 'initialRevisionCount', (
SELECT count(*) FROM ql3.plugin_package_materialized_revisions SELECT count(*) FROM ql3.plugin_package_materialized_revisions
WHERE generation_digest = '${initialGenerationDigest}' WHERE generation_digest = '${initialGenerationDigest}'
@@ -1352,7 +1297,6 @@ SELECT json_build_object(
assert.equal(value.initialMutationCount, 4); assert.equal(value.initialMutationCount, 4);
assert.equal(value.upgradeMutationCount, 3); assert.equal(value.upgradeMutationCount, 3);
assert.equal(value.headInstallationId, fixture.upgrade.installationId); assert.equal(value.headInstallationId, fixture.upgrade.installationId);
assert.equal(value.transitionReceiptCount, 1);
assert.equal(value.initialRevisionCount, 1); assert.equal(value.initialRevisionCount, 1);
assert.equal(value.upgradeRevisionCount, 0); assert.equal(value.upgradeRevisionCount, 0);
assert.equal(value.recoverableCount, 0); assert.equal(value.recoverableCount, 0);
@@ -1411,10 +1355,7 @@ function registryEvidence(fixture) {
const packageRequests = events.filter((event) => event.path !== '/v2/'); const packageRequests = events.filter((event) => event.path !== '/v2/');
const expectedPaths = [ const expectedPaths = [
...fixture.initial.routes.map((routeValue) => routeValue.path), ...fixture.initial.routes.map((routeValue) => routeValue.path),
...fixture.upgrade.routes.flatMap((routeValue) => [ ...fixture.upgrade.routes.map((routeValue) => routeValue.path),
routeValue.path,
routeValue.path,
]),
].sort(); ].sort();
assert.equal(packageRequests.length, expectedPaths.length); assert.equal(packageRequests.length, expectedPaths.length);
assert.deepEqual( assert.deepEqual(
@@ -1430,7 +1371,7 @@ function registryEvidence(fixture) {
requestCount: packageRequests.length, requestCount: packageRequests.length,
uniquePaths: new Set(packageRequests.map((event) => event.path)).size, uniquePaths: new Set(packageRequests.map((event) => event.path)).size,
initialRequestCount: fixture.initial.routes.length, initialRequestCount: fixture.initial.routes.length,
upgradeRequestCount: fixture.upgrade.routes.length * 2, upgradeRequestCount: fixture.upgrade.routes.length,
redirects: 0, redirects: 0,
}); });
} }
@@ -1874,7 +1815,7 @@ async function main(argv = process.argv.slice(2)) {
(value) => value.event === 'seed_completed', (value) => value.event === 'seed_completed',
); );
assert.equal(initialSeed.phase, 'initial'); assert.equal(initialSeed.phase, 'initial');
assert.equal(initialSeed.status, 'created'); assert.equal(initialSeed.status, 'admitted');
assert.equal(initialSeed.state, 'queued'); assert.equal(initialSeed.state, 'queued');
assert.equal(initialSeed.lockDigest, fixture.initial.lock.lockDigest); assert.equal(initialSeed.lockDigest, fixture.initial.lock.lockDigest);
@@ -1901,52 +1842,16 @@ async function main(argv = process.argv.slice(2)) {
(value) => value.event === 'seed_completed', (value) => value.event === 'seed_completed',
); );
assert.equal(upgradeSeed.phase, 'upgrade'); assert.equal(upgradeSeed.phase, 'upgrade');
assert.equal(upgradeSeed.status, 'created'); assert.equal(upgradeSeed.status, 'admitted');
assert.equal(upgradeSeed.state, 'queued'); assert.equal(upgradeSeed.state, 'queued');
assert.equal(upgradeSeed.lockDigest, fixture.upgrade.lock.lockDigest); assert.equal(upgradeSeed.lockDigest, fixture.upgrade.lock.lockDigest);
for (const resource of recoveryResources(fixture, UPGRADE_STAGE_JOB)) { for (const resource of recoveryResources(fixture, UPGRADE_RECOVERY_JOB)) {
apply(resource, `apply upgrade staging recovery ${resource.kind}`);
}
const stagedUpgrade = waitForJob(UPGRADE_STAGE_JOB, 'failed');
const stagedFailureCondition = stagedUpgrade.status.conditions.find(
(condition) => condition.type === 'Failed' && condition.status === 'True',
);
assert.ok(stagedFailureCondition?.lastTransitionTime);
const stageFailure = lastJsonLine(
jobLog(UPGRADE_STAGE_JOB),
(value) => value.event === 'recovery_failed',
);
assert.equal(
stageFailure.name,
'ClusterPluginPackageRecoveryRequiredError',
);
const stagedDatabase = upgradeStageEvidence(fixture, 0);
assert.deepEqual(activePointerEvidence(fixture), pointerBeforeUpgrade);
apply(
seedJob(TRANSITION_JOB, 'commit-transition'),
'commit durable no-secret binding transition receipt',
);
const committedTransition = waitForJob(TRANSITION_JOB);
const transition = lastJsonLine(
jobLog(TRANSITION_JOB),
(value) => value.event === 'transition_completed',
);
assert.equal(transition.status, 'created');
assert.equal(
transition.generationDigest,
fixture.upgrade.generation.generationDigest,
);
assert.equal(transition.bindingDigest, null);
upgradeStageEvidence(fixture, 1);
for (const resource of recoveryResources(fixture, UPGRADE_REJECTION_JOB)) {
apply(resource, `apply upgrade rejection recovery ${resource.kind}`); apply(resource, `apply upgrade rejection recovery ${resource.kind}`);
} }
const rejectedUpgrade = waitForJob(UPGRADE_REJECTION_JOB); const rejectedUpgrade = waitForJob(UPGRADE_RECOVERY_JOB);
const rejectionCompleted = lastJsonLine( const rejectionCompleted = lastJsonLine(
jobLog(UPGRADE_REJECTION_JOB), jobLog(UPGRADE_RECOVERY_JOB),
(value) => value.event === 'recovery_completed', (value) => value.event === 'recovery_completed',
); );
assert.equal(rejectionCompleted.recovery.safeToAdmit, true); assert.equal(rejectionCompleted.recovery.safeToAdmit, true);
@@ -1965,8 +1870,7 @@ async function main(argv = process.argv.slice(2)) {
secrets, secrets,
); );
const initialRecoveryImageId = jobImageId(INITIAL_RECOVERY_JOB); const initialRecoveryImageId = jobImageId(INITIAL_RECOVERY_JOB);
const stageRecoveryImageId = jobImageId(UPGRADE_STAGE_JOB); const upgradeRecoveryImageId = jobImageId(UPGRADE_RECOVERY_JOB);
const rejectionRecoveryImageId = jobImageId(UPGRADE_REJECTION_JOB);
const migrationImageId = jobImageId('ql3-cluster-migration'); const migrationImageId = jobImageId('ql3-cluster-migration');
const postgresPod = kubectlJson([ const postgresPod = kubectlJson([
'-n', '-n',
@@ -1991,8 +1895,7 @@ async function main(argv = process.argv.slice(2)) {
postgresRepositoryDigest: POSTGRES_REPOSITORY_DIGEST, postgresRepositoryDigest: POSTGRES_REPOSITORY_DIGEST,
migrationImageId, migrationImageId,
initialRecoveryImageId, initialRecoveryImageId,
stageRecoveryImageId, upgradeRecoveryImageId,
rejectionRecoveryImageId,
postgresImageId: postgresPod.status.containerStatuses[0].imageID, postgresImageId: postgresPod.status.containerStatuses[0].imageID,
}), }),
ordering: Object.freeze({ ordering: Object.freeze({
@@ -2000,22 +1903,13 @@ async function main(argv = process.argv.slice(2)) {
migrationCompletedAt: migrated.status.completionTime, migrationCompletedAt: migrated.status.completionTime,
initialRecoveryJobUid: initialRecovered.metadata.uid, initialRecoveryJobUid: initialRecovered.metadata.uid,
initialRecoveryCompletedAt: initialRecovered.status.completionTime, initialRecoveryCompletedAt: initialRecovered.status.completionTime,
upgradeStageJobUid: stagedUpgrade.metadata.uid, upgradeRecoveryJobUid: rejectedUpgrade.metadata.uid,
upgradeStageFailedAt: stagedFailureCondition.lastTransitionTime, upgradeRecoveryCompletedAt: rejectedUpgrade.status.completionTime,
transitionJobUid: committedTransition.metadata.uid,
transitionCompletedAt: committedTransition.status.completionTime,
rejectionRecoveryJobUid: rejectedUpgrade.metadata.uid,
rejectionRecoveryCompletedAt: rejectedUpgrade.status.completionTime,
runtimeCreatedAt: runtime.creationTimestamp, runtimeCreatedAt: runtime.creationTimestamp,
runtimeBoundRecoveryJobUid: runtime.recoveryJobUid, runtimeBoundRecoveryJobUid: runtime.recoveryJobUid,
}), }),
failedUpgrade: Object.freeze({ failedUpgrade: Object.freeze({
stageFailure: Object.freeze({ recoveryJobUid: rejectedUpgrade.metadata.uid,
jobUid: stagedUpgrade.metadata.uid,
reason: stageFailure.name,
durableState: stagedDatabase.state,
}),
transitionReceiptDigest: transition.receiptDigest,
rejectionReason: database.upgradeFailureReason, rejectionReason: database.upgradeFailureReason,
candidateRevisionCount: database.upgradeRevisionCount, candidateRevisionCount: database.upgradeRevisionCount,
activePointerUnchanged: true, activePointerUnchanged: true,
@@ -2029,7 +1923,6 @@ async function main(argv = process.argv.slice(2)) {
runtime, runtime,
gates: Object.freeze({ gates: Object.freeze({
healthyInitialActivation: true, healthyInitialActivation: true,
missingTransitionFailedClosed: true,
invalidUpgradeRejectedBeforeActivation: true, invalidUpgradeRejectedBeforeActivation: true,
activePointerUidUnchanged: true, activePointerUidUnchanged: true,
activePointerResourceVersionUnchanged: true, activePointerResourceVersionUnchanged: true,
@@ -42,8 +42,7 @@ function validReport() {
'postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296', 'postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296',
migrationImageId: `docker://${admin}`, migrationImageId: `docker://${admin}`,
initialRecoveryImageId: `docker://${admin}`, initialRecoveryImageId: `docker://${admin}`,
stageRecoveryImageId: `docker://${admin}`, upgradeRecoveryImageId: `docker://${admin}`,
rejectionRecoveryImageId: `docker://${admin}`,
postgresImageId: `containerd://sha256:${'e'.repeat(64)}`, postgresImageId: `containerd://sha256:${'e'.repeat(64)}`,
}, },
ordering: { ordering: {
@@ -51,22 +50,13 @@ function validReport() {
migrationCompletedAt: '2026-08-14T08:00:01.000Z', migrationCompletedAt: '2026-08-14T08:00:01.000Z',
initialRecoveryJobUid: '00000000-0000-4000-8000-000000000002', initialRecoveryJobUid: '00000000-0000-4000-8000-000000000002',
initialRecoveryCompletedAt: '2026-08-14T08:00:02.000Z', initialRecoveryCompletedAt: '2026-08-14T08:00:02.000Z',
upgradeStageJobUid: '00000000-0000-4000-8000-000000000003', upgradeRecoveryJobUid: '00000000-0000-4000-8000-000000000003',
upgradeStageFailedAt: '2026-08-14T08:00:03.000Z', upgradeRecoveryCompletedAt: '2026-08-14T08:00:03.000Z',
transitionJobUid: '00000000-0000-4000-8000-000000000004', runtimeCreatedAt: '2026-08-14T08:00:04.000Z',
transitionCompletedAt: '2026-08-14T08:00:04.000Z', runtimeBoundRecoveryJobUid: '00000000-0000-4000-8000-000000000003',
rejectionRecoveryJobUid: '00000000-0000-4000-8000-000000000005',
rejectionRecoveryCompletedAt: '2026-08-14T08:00:05.000Z',
runtimeCreatedAt: '2026-08-14T08:00:06.000Z',
runtimeBoundRecoveryJobUid: '00000000-0000-4000-8000-000000000005',
}, },
failedUpgrade: { failedUpgrade: {
stageFailure: { recoveryJobUid: '00000000-0000-4000-8000-000000000003',
jobUid: '00000000-0000-4000-8000-000000000003',
reason: 'ClusterPluginPackageRecoveryRequiredError',
durableState: 'staged',
},
transitionReceiptDigest: 'f'.repeat(64),
rejectionReason: 'activation_fact_conflict', rejectionReason: 'activation_fact_conflict',
candidateRevisionCount: 0, candidateRevisionCount: 0,
activePointerUnchanged: true, activePointerUnchanged: true,
@@ -83,7 +73,6 @@ function validReport() {
initialMutationCount: 4, initialMutationCount: 4,
upgradeMutationCount: 3, upgradeMutationCount: 3,
headInstallationId: 'install-plugin-recovery-e2e-upgrade', headInstallationId: 'install-plugin-recovery-e2e-upgrade',
transitionReceiptCount: 1,
initialRevisionCount: 1, initialRevisionCount: 1,
upgradeRevisionCount: 0, upgradeRevisionCount: 0,
recoverableCount: 0, recoverableCount: 0,
@@ -91,11 +80,11 @@ function validReport() {
oci: { oci: {
https: true, https: true,
authentication: 'exact-registry-basic', authentication: 'exact-registry-basic',
authenticatedRequestCount: 18, authenticatedRequestCount: 12,
requestCount: 18, requestCount: 12,
uniquePaths: 12, uniquePaths: 12,
initialRequestCount: 6, initialRequestCount: 6,
upgradeRequestCount: 12, upgradeRequestCount: 6,
redirects: 0, redirects: 0,
}, },
kubernetes: { kubernetes: {
@@ -118,9 +107,9 @@ function validReport() {
}, },
runtime: { runtime: {
replicas: 2, replicas: 2,
creationTimestamp: '2026-08-14T08:00:06.000Z', creationTimestamp: '2026-08-14T08:00:04.000Z',
recoveryJobUid: '00000000-0000-4000-8000-000000000005', recoveryJobUid: '00000000-0000-4000-8000-000000000003',
recoveryCompletedAt: '2026-08-14T08:00:05.000Z', recoveryCompletedAt: '2026-08-14T08:00:03.000Z',
nodes: ['worker-a', 'worker-b'], nodes: ['worker-a', 'worker-b'],
imageIds: [`docker://${control}`], imageIds: [`docker://${control}`],
}, },
@@ -142,7 +131,8 @@ test('offline audit accepts one exact low-sensitive recovery report', () => {
test('offline audit rejects broken upgrade, ordering and image relationships', () => { test('offline audit rejects broken upgrade, ordering and image relationships', () => {
const report = validReport(); const report = validReport();
report.database.upgradeRevisionCount = 1; report.database.upgradeRevisionCount = 1;
report.ordering.transitionCompletedAt = '2026-08-14T07:59:59.000Z'; report.ordering.upgradeRecoveryCompletedAt =
'2026-08-14T07:59:59.000Z';
report.runtime.imageIds = [`docker://sha256:${'9'.repeat(64)}`]; report.runtime.imageIds = [`docker://sha256:${'9'.repeat(64)}`];
report.gates.activePointerJsonUnchanged = false; report.gates.activePointerJsonUnchanged = false;
const codes = validatePluginPackageRecoveryE2ELiveReport(report).findings.map( const codes = validatePluginPackageRecoveryE2ELiveReport(report).findings.map(
@@ -3,6 +3,9 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { test } = require('node:test'); const { test } = require('node:test');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const {
createFixture,
} = require('../../scripts/ql3-plugin-package-recovery-e2e-fixture.cjs');
const root = path.resolve(__dirname, '../..'); const root = path.resolve(__dirname, '../..');
const livePath = path.join( const livePath = path.join(
@@ -53,6 +56,27 @@ test('fixture uses a real HTTPS and content-addressed OCI Distribution surface',
assert.match(live, /requestCount: packageRequests\.length/); assert.match(live, /requestCount: packageRequests\.length/);
}); });
test('fixture locks are bound to durable version-three approval dispatches', () => {
const value = createFixture({
registry: 'registry.fixture.test',
architecture: 'amd64',
createdAtMs: 1_000,
});
for (const selected of [value.initial, value.upgrade]) {
const proposal = selected.authority.proposalCommand.proposal;
const dispatch = selected.authority.dispatch;
assert.equal(selected.lock.approval.requestVersion, 3);
assert.equal(selected.lock.approval.dispatchId, dispatch.id);
assert.equal(selected.lock.actionDigest, proposal.actionDigest);
assert.equal(selected.lock.planDigest, proposal.previewDigest);
assert.equal(dispatch.action.actionRef, proposal.actionRef);
assert.equal(
selected.authority.consumptionCommand.dispatchId,
dispatch.id,
);
}
});
test('gate runs migration, healthy activation and a durable rejected upgrade', () => { test('gate runs migration, healthy activation and a durable rejected upgrade', () => {
assert.match(live, /operations\/base\/migrate-job\.yaml/); assert.match(live, /operations\/base\/migrate-job\.yaml/);
assert.match( assert.match(
@@ -69,6 +93,14 @@ test('gate runs migration, healthy activation and a durable rejected upgrade', (
); );
assert.match(live, /plugin-package-recovery\/base\/recover-job\.yaml/); assert.match(live, /plugin-package-recovery\/base\/recover-job\.yaml/);
assert.match(fixture, /PostgresPluginPackageInstallRepository/); assert.match(fixture, /PostgresPluginPackageInstallRepository/);
assert.match(fixture, /PostgresPluginPackageInstallProposalRepository/);
assert.match(fixture, /PostgresApprovalRequestRepository/);
assert.match(fixture, /PostgresApprovedActionExecutionRepository/);
assert.match(fixture, /PostgresProjectPolicyRepository/);
assert.match(fixture, /\.createProposal\(selected\.authority\.proposalCommand\)/);
assert.match(fixture, /\.consume\(selected\.authority\.consumptionCommand\)/);
assert.match(fixture, /repository\.admit\(/);
assert.doesNotMatch(fixture, /pluginPackageInstallCreate/);
assert.match( assert.match(
fixture, fixture,
/PostgresPluginPackagePublisherTrustAuthorityRepository/, /PostgresPluginPackagePublisherTrustAuthorityRepository/,
@@ -79,12 +111,9 @@ test('gate runs migration, healthy activation and a durable rejected upgrade', (
assert.match(fixture, /publisherTrustStatus/); assert.match(fixture, /publisherTrustStatus/);
assert.match(live, /QL3_E2E_POSTGRES_PACKAGE_MANAGER_USER/); assert.match(live, /QL3_E2E_POSTGRES_PACKAGE_MANAGER_USER/);
assert.match(live, /key: 'package-manager-password'/); assert.match(live, /key: 'package-manager-password'/);
assert.match( assert.match(live, /QL3_E2E_POSTGRES_RUNTIME_USER/);
fixture, assert.match(live, /key: 'runtime-password'/);
/PostgresPluginPackageSecretBindingTransitionRepository/, assert.doesNotMatch(fixture, /commit-transition/);
);
assert.match(fixture, /createPluginPackageInstall/);
assert.match(fixture, /pluginPackageInstallCreate/);
assert.match(live, /initialSeed\.state, 'queued'/); assert.match(live, /initialSeed\.state, 'queued'/);
assert.match(live, /upgradeSeed\.state, 'queued'/); assert.match(live, /upgradeSeed\.state, 'queued'/);
assert.match(live, /value\.migrationCount, 65/); assert.match(live, /value\.migrationCount, 65/);
@@ -99,27 +128,18 @@ test('gate runs migration, healthy activation and a durable rejected upgrade', (
}); });
test('deployment controller rejects the upgrade before creating runtime', () => { test('deployment controller rejects the upgrade before creating runtime', () => {
const failedStageWait = live.indexOf( const rejectionWait = live.indexOf('waitForJob(UPGRADE_RECOVERY_JOB)');
"waitForJob(UPGRADE_STAGE_JOB, 'failed')",
);
const transitionWait = live.indexOf('waitForJob(TRANSITION_JOB)');
const rejectionWait = live.indexOf('waitForJob(UPGRADE_REJECTION_JOB)');
const pointerProof = live.indexOf( const pointerProof = live.indexOf(
'assert.deepEqual(pointerAfterRejection, pointerBeforeUpgrade)', 'assert.deepEqual(pointerAfterRejection, pointerBeforeUpgrade)',
); );
const runtimeApply = live.indexOf( const runtimeApply = live.indexOf(
'const runtime = applyRuntimeAfterRecovery(', 'const runtime = applyRuntimeAfterRecovery(',
); );
assert.ok(failedStageWait > 0); assert.ok(rejectionWait > 0);
assert.ok(transitionWait > failedStageWait);
assert.ok(rejectionWait > transitionWait);
assert.ok(pointerProof > rejectionWait); assert.ok(pointerProof > rejectionWait);
assert.ok(runtimeApply > pointerProof); assert.ok(runtimeApply > pointerProof);
assert.match(live, /activePointerUnchanged: true/); assert.match(live, /activePointerUnchanged: true/);
assert.match( assert.doesNotMatch(live, /missingTransitionFailedClosed/);
live,
/stageFailure\.name,[\s\S]*'ClusterPluginPackageRecoveryRequiredError'/,
);
assert.match(live, /qinglong\.io\/plugin-recovery-job-uid/); assert.match(live, /qinglong\.io\/plugin-recovery-job-uid/);
assert.match(live, /qinglong\.io\/plugin-recovery-completed-at/); assert.match(live, /qinglong\.io\/plugin-recovery-completed-at/);
assert.match(live, /rollout[\s\S]*status/); assert.match(live, /rollout[\s\S]*status/);