fix: 修复 PaC reused delivery 终态证据

从目标 TaskRun condition/result 与结构化合同日志恢复 artifact catalog、digest 和 GitOps 证据,并为真实 PipelineRun 增加有界 debug-step。严格保留缺 artifact、缺 GitOps、runtime mismatch 与冲突证据的 fail-closed closeout。
This commit is contained in:
Codex
2026-07-11 01:29:22 +02:00
parent ea8e3ed204
commit 1eafb1f89e
5 changed files with 665 additions and 139 deletions
@@ -344,7 +344,7 @@ history_rows() {
node <<'NODE'
const fs = require('node:fs');
const cp = require('node:child_process');
const { extractPacSourceObservation } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5));
const detailId = process.env.UNIDESK_PAC_HISTORY_ID || '';
const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8'));
@@ -497,18 +497,41 @@ function recursiveEnvReuse(value, state) {
for (const item of Object.values(value)) recursiveEnvReuse(item, state);
}
function envReuseForPipelineRun(namespace, name) {
const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'logs', sourceObservation: null };
function envReuseForPipelineRun(namespace, name, taskRuns) {
const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'pipeline-task-logs', sourceObservation: null, artifact: null, collector: null };
const matching = (taskRuns.items || []).filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === name);
const contractTaskRuns = matching.filter((item) => taskTerminalRecord(item) !== null);
const terminalRecords = contractTaskRuns.map(taskTerminalRecord).filter(Boolean);
const logErrors = [];
let logs = '';
try {
logs = cp.execFileSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${name}`, '--all-containers', '--tail=320'], {
for (const item of contractTaskRuns) {
const podName = item.status?.podName;
if (!podName) {
logErrors.push({ taskRun: item.metadata?.name || null, reason: 'pod-name-missing' });
continue;
}
const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', podName, '--all-containers=true'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 12000,
maxBuffer: 1024 * 1024,
maxBuffer: 4 * 1024 * 1024,
});
} catch {
return { ...state, source: 'logs-unavailable' };
if (result.error || result.status !== 0) {
logErrors.push({ taskRun: item.metadata?.name || null, reason: result.error ? String(result.error.message || result.error) : String(result.stderr || '').slice(0, 240) });
continue;
}
logs += `${result.stdout || ''}\n`;
}
if (contractTaskRuns.length === 0) {
state.source = 'pipeline-run-log-fallback';
const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${name}`, '--all-containers=true', '--tail=320'], {
encoding: 'utf8',
timeout: 12000,
maxBuffer: 2 * 1024 * 1024,
});
if (result.error || result.status !== 0) {
return { ...state, source: 'logs-unavailable', collector: { mode: state.source, contractTaskCount: 0, terminalRecordCount: 0, logErrorCount: 1, valuesPrinted: false } };
}
logs = result.stdout || '';
}
const lines = logs.split(/\r?\n/u);
const headerIndex = lines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim()));
@@ -519,17 +542,18 @@ function envReuseForPipelineRun(namespace, name) {
state.buildCache = row[4];
}
}
const records = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue;
try {
const parsed = JSON.parse(trimmed);
records.push(parsed);
recursiveEnvReuse(parsed, state);
} catch {}
}
state.sourceObservation = extractPacSourceObservation(records);
const records = [...parsePacLogRecords(logs), ...terminalRecords];
for (const parsed of records) recursiveEnvReuse(parsed, state);
state.artifact = extractPacArtifactEvidence(records, logs);
state.sourceObservation = state.artifact.sourceObservation;
state.collector = {
mode: state.source,
contractTaskCount: contractTaskRuns.length,
terminalRecordCount: terminalRecords.length,
logErrorCount: logErrors.length,
logErrors,
valuesPrinted: false,
};
return state;
}
@@ -583,7 +607,7 @@ function rowFor(consumer, item, taskRuns) {
const labels = item.metadata?.labels || {};
const annotations = item.metadata?.annotations || {};
const sourceCommit = extractCommit(item, params);
const evidence = envReuseForPipelineRun(consumer.namespace, item.metadata.name);
const evidence = envReuseForPipelineRun(consumer.namespace, item.metadata.name, taskRuns);
const sourceObservation = evidence.sourceObservation === null ? null : {
...evidence.sourceObservation,
sourceMatched: sourceCommit !== null && evidence.sourceObservation.sourceCommit === sourceCommit,
@@ -618,6 +642,8 @@ function rowFor(consumer, item, taskRuns) {
source: evidence.source,
},
sourceObservation,
artifact: evidence.artifact,
collector: evidence.collector,
taskRuns: tasks,
source: 'gitea-pac-tekton-live',
valuesPrinted: false,
@@ -703,98 +729,61 @@ artifact_summary() {
printf '{}'
return
fi
log_file=$(mktemp)
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" logs -l "tekton.dev/pipelineRun=$UNIDESK_PAC_TARGET_PIPELINERUN" --all-containers --tail=240 >"$log_file" 2>/dev/null || true
node - "$log_file" <<'NODE'
task_file=$(mktemp)
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get taskrun -l "tekton.dev/pipelineRun=$UNIDESK_PAC_TARGET_PIPELINERUN" -o json >"$task_file" 2>/dev/null || printf '{"items":[]}' >"$task_file"
node - "$task_file" <<'NODE'
const fs = require('node:fs');
const { extractPacSourceObservation } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const lines = fs.readFileSync(process.argv[2], 'utf8').split(/\r?\n/);
const records = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue;
try { records.push(JSON.parse(trimmed)); } catch {}
}
const sourceObservation = extractPacSourceObservation(records);
const publish = [...records].reverse().find((item) => item.phase === 'gitops-publish' || item.gitopsCommit);
const image = publish || [...records].reverse().find((item) => item.imageStatus || item.status === 'reused' || item.status === 'built');
const gitopsCommit = image?.gitopsCommit
|| [...lines].reverse().map((line) => line.match(/^\[[^\]]+\s+([0-9a-f]{7,64})\]/u)?.[1] || null).find(Boolean)
|| null;
const loggedDigests = [...new Set(lines.flatMap((line) => [...line.matchAll(/"(?:digest|environmentDigest)"\s*:\s*"(sha256:[0-9a-f]{64})"/gu)].map((match) => match[1])))];
const envHeaderIndex = lines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim()));
let humanEnv = null;
if (envHeaderIndex >= 0) {
const row = (lines.slice(envHeaderIndex + 1).find((line) => line.trim()) || '').trim().split(/\s+/u);
if (row.length >= 5) {
humanEnv = { envReuse: row[0], nodeDeps: row[1], buildPackage: row[2], buildNetwork: row[3], cache: row[4] };
const cp = require('node:child_process');
const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}');
const namespace = process.env.UNIDESK_PAC_TARGET_NAMESPACE;
const pipelineRun = process.env.UNIDESK_PAC_TARGET_PIPELINERUN;
const taskRuns = (input.items || []).filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === pipelineRun);
const contractTaskRuns = taskRuns.filter((item) => taskTerminalRecord(item) !== null);
const terminalRecords = contractTaskRuns.map(taskTerminalRecord).filter(Boolean);
const logErrors = [];
let logText = '';
for (const item of contractTaskRuns) {
const podName = item.status?.podName;
if (!podName) {
logErrors.push({ taskRun: item.metadata?.name || null, reason: 'pod-name-missing' });
continue;
}
const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', podName, '--all-containers=true'], {
encoding: 'utf8',
timeout: 12000,
maxBuffer: 4 * 1024 * 1024,
});
if (result.error || result.status !== 0) {
logErrors.push({ taskRun: item.metadata?.name || null, reason: result.error ? String(result.error.message || result.error) : String(result.stderr || '').slice(0, 240) });
continue;
}
logText += `${result.stdout || ''}\n`;
}
function digestOf(item) {
if (item.digest) return item.digest;
const ref = item.digestRef || '';
const index = String(ref).indexOf('@');
return index >= 0 ? String(ref).slice(index + 1) : null;
}
function envReuseOf(item) {
const env = item.envReuse || {};
return env.dependencyReuse || env.mode || item.envReuseStatus || null;
}
let out = { valuesPrinted: false };
if (image) {
out = {
imageStatus: image.imageStatus || image.status || (image.digestRef ? 'built' : null),
envIdentity: image.envIdentity || null,
envReuse: envReuseOf(image),
nodeDepsReuse: null,
buildCache: null,
digest: digestOf(image) || (loggedDigests.length === 1 ? loggedDigests[0] : null),
digests: loggedDigests,
gitopsCommit,
sourceCommit: image.sourceCommit || null,
runtimeSourceCommit: image.runtimeSourceCommit || null,
baselineSourceCommit: image.baselineSourceCommit || null,
action: image.action || null,
reason: image.reason || null,
sourceObservation,
valuesPrinted: false,
};
} else if (sourceObservation) {
out = {
imageStatus: sourceObservation.mode === 'no-runtime-change' && sourceObservation.valid === true ? 'retained' : null,
envIdentity: null,
envReuse: null,
nodeDepsReuse: null,
buildCache: null,
digest: null,
digests: [],
gitopsCommit: null,
sourceCommit: sourceObservation.sourceCommit || null,
runtimeSourceCommit: null,
action: sourceObservation.mode,
reason: sourceObservation.reason,
sourceObservation,
valuesPrinted: false,
};
} else if (humanEnv || gitopsCommit || loggedDigests.length > 0) {
out = {
imageStatus: humanEnv ? 'published' : null,
envIdentity: null,
envReuse: humanEnv?.envReuse || null,
nodeDepsReuse: humanEnv?.nodeDeps || null,
buildCache: humanEnv?.cache || null,
buildPackage: humanEnv?.buildPackage || null,
buildNetwork: humanEnv?.buildNetwork || null,
digest: loggedDigests.length === 1 ? loggedDigests[0] : null,
digests: loggedDigests,
gitopsCommit,
sourceCommit: null,
valuesPrinted: false,
};
let mode = 'pipeline-task-logs';
if (contractTaskRuns.length === 0) {
mode = 'pipeline-run-log-fallback';
const fallback = cp.spawnSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${pipelineRun}`, '--all-containers=true', '--tail=320'], {
encoding: 'utf8',
timeout: 12000,
maxBuffer: 2 * 1024 * 1024,
});
if (!fallback.error && fallback.status === 0) logText = fallback.stdout || '';
else logErrors.push({ taskRun: null, reason: fallback.error ? String(fallback.error.message || fallback.error) : String(fallback.stderr || '').slice(0, 240) });
}
const records = [...parsePacLogRecords(logText), ...terminalRecords];
const out = extractPacArtifactEvidence(records, logText);
out.collector = {
mode,
contractTaskCount: contractTaskRuns.length,
terminalRecordCount: terminalRecords.length,
logErrorCount: logErrors.length,
logErrors,
valuesPrinted: false,
};
process.stdout.write(JSON.stringify(out));
NODE
rm -f "$log_file"
rm -f "$task_file"
}
runtime_summary() {
@@ -1206,11 +1195,124 @@ history_action() {
}
debug_step_action() {
node <<'NODE'
fixtures=$(node <<'NODE'
const { runPacStatusFixtureChecks } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const result = runPacStatusFixtureChecks();
process.stdout.write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
process.stdout.write(JSON.stringify(result));
NODE
)
if [ -z "$UNIDESK_PAC_HISTORY_ID" ]; then
printf '%s\n' "$fixtures"
FIXTURES_JSON="$fixtures" node -e 'const result=JSON.parse(process.env.FIXTURES_JSON||"{}"); if(!result.ok) process.exitCode=1'
return
fi
history=$(history_rows)
UNIDESK_PAC_DEBUG_FIXTURES_JSON="$fixtures" UNIDESK_PAC_DEBUG_HISTORY_JSON="$history" node <<'NODE'
function record(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
function terminalRow(sourceObservation, key, step) {
const source = record(record(sourceObservation.terminalSources)[key]);
return {
step,
present: source.present === true,
status: source.status || 'missing',
source: source.source || null,
taskRun: source.taskRun || null,
reason: source.reason || null,
results: Array.isArray(source.results) ? source.results : [],
markerPresent: source.markerPresent === true,
markerSource: source.markerSource || null,
valuesPrinted: false,
};
}
const fixtures = JSON.parse(process.env.UNIDESK_PAC_DEBUG_FIXTURES_JSON || '{}');
const history = JSON.parse(process.env.UNIDESK_PAC_DEBUG_HISTORY_JSON || '{}');
const rows = Array.isArray(history.rows) ? history.rows : [];
const historyErrors = Array.isArray(history.errors) ? history.errors : [];
const row = record(rows[0]);
const sourceObservation = record(row.sourceObservation);
const artifact = record(row.artifact);
const catalog = record(artifact.catalog);
const collector = record(row.collector);
const terminal = [
terminalRow(sourceObservation, 'planArtifacts', 'plan-artifacts'),
terminalRow(sourceObservation, 'collectArtifacts', 'collect-artifacts'),
terminalRow(sourceObservation, 'gitopsPromote', 'gitops-promote'),
];
let firstBreak = null;
function breakAt(code, stage, reason) {
if (firstBreak === null) firstBreak = { code, stage, reason, valuesPrinted: false };
}
if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', historyErrors[0]?.error || historyErrors[0]?.stderr || 'target-side history read failed');
if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`);
if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`);
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing');
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun');
if (sourceObservation.valid !== true) breakAt('source-observation-invalid', 'plan-artifacts', sourceObservation.reason || 'delivery plan is incomplete or contradictory');
for (const item of terminal) {
if (item.present !== true || item.status !== 'succeeded') breakAt('terminal-evidence-incomplete', item.step, `${item.step} TaskRun terminal status is ${item.status}`);
}
if (Number(collector.logErrorCount || 0) > 0) breakAt('contract-log-read-failed', 'collector', 'one or more contract TaskRun logs could not be read');
if (sourceObservation.mode === 'delivery') {
if (catalog.present !== true || catalog.status !== 'published') breakAt('artifact-catalog-missing', 'collect-artifacts', 'published artifact catalog evidence is missing');
if (catalog.registryVerified !== true) breakAt('artifact-catalog-unverified', 'collect-artifacts', 'artifact catalog registry verification is not true');
if (!Array.isArray(artifact.digests) || artifact.digests.length === 0) breakAt('artifact-digest-missing', 'collect-artifacts', 'artifact catalog did not expose any sha256 digest');
if (typeof artifact.gitopsCommit !== 'string' || artifact.gitopsCommit.length === 0) breakAt('gitops-commit-missing', 'gitops-promote', 'GitOps commit is not visible in the successful promotion TaskRun');
}
const realRun = {
ok: firstBreak === null,
found: Object.keys(row).length > 0,
pipelineRun: Object.keys(row).length === 0 ? null : {
id: row.id || row.pipelineRun || null,
status: row.status || null,
reason: row.reason || null,
sourceCommit: row.commit || null,
sourceMatched: sourceObservation.sourceMatched === true,
valuesPrinted: false,
},
mode: sourceObservation.mode || null,
valid: sourceObservation.valid === true,
terminal,
artifact: {
imageStatus: artifact.imageStatus || null,
envReuse: artifact.envReuse || null,
digests: Array.isArray(artifact.digests) ? artifact.digests : [],
gitopsCommit: artifact.gitopsCommit || null,
sourceCommit: artifact.sourceCommit || null,
catalog: {
present: catalog.present === true,
status: catalog.status || null,
sourceCommit: catalog.sourceCommit || null,
registryVerified: catalog.registryVerified === true,
publishedCount: catalog.publishedCount ?? null,
reusedCount: catalog.reusedCount ?? null,
requiredServiceCount: catalog.requiredServiceCount ?? null,
digestCount: catalog.digestCount ?? null,
source: catalog.source || null,
valuesPrinted: false,
},
valuesPrinted: false,
},
collector: {
mode: collector.mode || null,
contractTaskCount: collector.contractTaskCount ?? null,
terminalRecordCount: collector.terminalRecordCount ?? null,
logErrorCount: collector.logErrorCount ?? null,
logErrors: Array.isArray(collector.logErrors) ? collector.logErrors : [],
valuesPrinted: false,
},
firstBreak,
valuesPrinted: false,
};
const out = {
ok: fixtures.ok === true && realRun.ok,
checks: Array.isArray(fixtures.checks) ? fixtures.checks : [],
realRun,
valuesPrinted: false,
};
process.stdout.write(`${JSON.stringify(out)}\n`);
if (!out.ok) process.exitCode = 1;
NODE
}