diff --git a/scripts/ql3-security-administration-kubernetes-live-contract.cjs b/scripts/ql3-security-administration-kubernetes-live-contract.cjs index 4ae8b7ca..29ae722c 100644 --- a/scripts/ql3-security-administration-kubernetes-live-contract.cjs +++ b/scripts/ql3-security-administration-kubernetes-live-contract.cjs @@ -327,6 +327,37 @@ function administrationFailureEvidence(snapshot) { } } +function migrationFailureEvidence(snapshot) { + const status = snapshot.pod.status.containerStatuses?.find( + (container) => container.name === 'migration', + ); + const terminated = status?.state?.terminated; + const evidence = { + jobComplete: snapshot.complete, + jobFailed: snapshot.failed, + podPhase: snapshot.pod.status.phase ?? null, + exitCode: terminated?.exitCode ?? null, + reason: terminated?.reason ?? status?.state?.waiting?.reason ?? null, + }; + const message = (terminated?.message || '').trim(); + if (!message) return evidence; + try { + const parsed = JSON.parse(message); + const allowedKeys = ['schemaVersion', 'component', 'event', 'name', 'code']; + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.keys(parsed).some((key) => !allowedKeys.includes(key)) + ) { + return { ...evidence, failureMessage: 'rejected' }; + } + return { ...evidence, failureMessage: parsed }; + } catch { + return { ...evidence, failureMessage: 'unparseable' }; + } +} + async function terminalJobSnapshot(fixture, name, timeoutMs = 180_000) { return ( await waitFor(`${name} terminal`, timeoutMs, () => { @@ -858,17 +889,24 @@ async function main(argv = process.argv.slice(2)) { 'registry.example.com/qinglong/qinglong3-cluster-control', controlImage, ); - fixture.kubectl(['create', '-f', '-'], { - input: `${migrationManifest}\n`, - }); - fixture.kubectl([ - '-n', - NAMESPACE, - 'wait', - '--for=condition=Complete', - 'job/ql3-cluster-migration', - '--timeout=10m', - ]); + const migrationJob = yaml.load(migrationManifest); + assert.equal(migrationJob?.kind, 'Job'); + const migrationContainer = findNamed( + migrationJob.spec.template.spec.containers, + 'migration', + ); + migrationContainer.terminationMessagePolicy = 'FallbackToLogsOnError'; + fixture.create(migrationJob); + const migrationSnapshot = await terminalJobSnapshot( + fixture, + 'ql3-cluster-migration', + 10 * 60_000, + ); + const migrationEvidence = JSON.stringify( + migrationFailureEvidence(migrationSnapshot), + ); + assert.equal(migrationSnapshot.complete, true, migrationEvidence); + assert.equal(migrationSnapshot.failed, false, migrationEvidence); const primary = currentPrimaryPod(fixture); const migrationState = JSON.parse( psql( @@ -1386,4 +1424,5 @@ module.exports = { identity, identityRegisterCommand, inputAuthorityEvidenceSource, + migrationFailureEvidence, }; diff --git a/test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs b/test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs index 00c19aa7..5e3bdabc 100644 --- a/test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs +++ b/test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs @@ -14,6 +14,7 @@ const { identity, identityRegisterCommand, inputAuthorityEvidenceSource, + migrationFailureEvidence, } = require('../../scripts/ql3-security-administration-kubernetes-live-contract.cjs'); const values = Object.freeze({ @@ -112,6 +113,57 @@ test('constrains only the exact local-path fixture root without network authorit assert.doesNotMatch(source, /net|fetch|http/); }); +test('keeps migration failure evidence content-free', () => { + const base = { + complete: false, + failed: true, + pod: { + status: { + phase: 'Failed', + containerStatuses: [ + { + name: 'migration', + state: { + terminated: { + exitCode: 1, + reason: 'Error', + message: JSON.stringify({ + schemaVersion: 1, + component: 'qinglong3-cluster-migration', + event: 'migration_failed', + name: 'Error', + code: 'EAI_AGAIN', + }), + }, + }, + }, + ], + }, + }, + }; + assert.deepEqual(migrationFailureEvidence(base), { + jobComplete: false, + jobFailed: true, + podPhase: 'Failed', + exitCode: 1, + reason: 'Error', + failureMessage: { + schemaVersion: 1, + component: 'qinglong3-cluster-migration', + event: 'migration_failed', + name: 'Error', + code: 'EAI_AGAIN', + }, + }); + const rejected = structuredClone(base); + rejected.pod.status.containerStatuses[0].state.terminated.message = + JSON.stringify({ code: 'EAI_AGAIN', secret: 'must-not-escape' }); + assert.equal( + migrationFailureEvidence(rejected).failureMessage, + 'rejected', + ); +}); + test('live runner remains opt-in, reviewed, cleanup-bound and log-free', () => { const source = fs.readFileSync( path.resolve(