ci(ql3): expose content-free migration failure facts

This commit is contained in:
whyour
2026-08-25 09:51:00 +08:00
parent 82348e9f91
commit 9afcf9413c
2 changed files with 102 additions and 11 deletions
@@ -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) { async function terminalJobSnapshot(fixture, name, timeoutMs = 180_000) {
return ( return (
await waitFor(`${name} terminal`, timeoutMs, () => { await waitFor(`${name} terminal`, timeoutMs, () => {
@@ -858,17 +889,24 @@ async function main(argv = process.argv.slice(2)) {
'registry.example.com/qinglong/qinglong3-cluster-control', 'registry.example.com/qinglong/qinglong3-cluster-control',
controlImage, controlImage,
); );
fixture.kubectl(['create', '-f', '-'], { const migrationJob = yaml.load(migrationManifest);
input: `${migrationManifest}\n`, assert.equal(migrationJob?.kind, 'Job');
}); const migrationContainer = findNamed(
fixture.kubectl([ migrationJob.spec.template.spec.containers,
'-n', 'migration',
NAMESPACE, );
'wait', migrationContainer.terminationMessagePolicy = 'FallbackToLogsOnError';
'--for=condition=Complete', fixture.create(migrationJob);
'job/ql3-cluster-migration', const migrationSnapshot = await terminalJobSnapshot(
'--timeout=10m', 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 primary = currentPrimaryPod(fixture);
const migrationState = JSON.parse( const migrationState = JSON.parse(
psql( psql(
@@ -1386,4 +1424,5 @@ module.exports = {
identity, identity,
identityRegisterCommand, identityRegisterCommand,
inputAuthorityEvidenceSource, inputAuthorityEvidenceSource,
migrationFailureEvidence,
}; };
@@ -14,6 +14,7 @@ const {
identity, identity,
identityRegisterCommand, identityRegisterCommand,
inputAuthorityEvidenceSource, inputAuthorityEvidenceSource,
migrationFailureEvidence,
} = require('../../scripts/ql3-security-administration-kubernetes-live-contract.cjs'); } = require('../../scripts/ql3-security-administration-kubernetes-live-contract.cjs');
const values = Object.freeze({ 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/); 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', () => { test('live runner remains opt-in, reviewed, cleanup-bound and log-free', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.resolve( path.resolve(