From 442fe89528e70345bb3a04de54df212693540d28 Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 12:30:04 +0900 Subject: [PATCH 01/17] ci: restore machine-translated link targets before the build Every push to main regenerates docs/zh with an LLM and only commits the result if the site builds. Since the Pipeline Policy Constraints solution landed, that build has failed on the same dead link in the generated Chinese file: "[..](URL)" -> /zh/solutions/URL.html The English source has no such link: all 623 of its link targets are anchors, external URLs or relative paths. The target `URL` is a verbatim copy of the example inside doom's own translation prompt ("URLs in markdown links: [text](URL) - keep URL exactly as is"). The document is 990KB, so translate splits it into 22 chunks, and the model leaks that example into one of them -- reproducibly, in five consecutive runs. Because the build gates the commit, this also held back every other translation: two documents have no Chinese version at all and several more are stale, while each push burns a full translation pass that is then discarded. Rather than rely on the model honouring the instruction, verify it. The English document is the source of truth: walk both sides' links in document order and restore any internal target that drifted. Scope is deliberately narrow -- only internal route links, the exact set rspress resolves and fails the build over. In-page anchors legitimately differ (a translated heading gets a translated slug) and external URLs are never resolved, so both stay out of it. When the two sides disagree on how many internal links exist, positional alignment is unsound, so the file is left untouched and reported for a human. Also keep the generated docs as an artifact when a main build fails. Nothing is committed on failure, so today the translation that broke the build is discarded with it and cannot be inspected. Checked against the whole repository: 400 of 401 translated documents pass; the one exception is a stale Chinese file waiting on this same blocked pipeline. --- .github/workflows/main.yml | 22 ++ package.json | 1 + scripts/check-translation-links.mjs | 302 ++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 scripts/check-translation-links.mjs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48536d9b4..ff62e4ac9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,6 +63,14 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: yarn translate -s en -t zh -g "*" + # Machine translation occasionally rewrites a link target -- it has emitted + # the literal `URL` copied from its own prompt example -- which then fails + # the build's dead-link check. Restore internal targets from English before + # building, using the English document as the source of truth. + - name: Repair machine-translated links + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: node scripts/check-translation-links.mjs --fix + - name: Build run: yarn build @@ -81,3 +89,17 @@ jobs: with: branch: ${{ github.ref }} github_token: ${{ secrets.KNOWLEDGE_SECRET }} + + # Nothing is committed unless the build passes, so a failed run otherwise + # discards the generated translations and leaves no way to see what the + # model produced. Keep them for post-mortems. docs/en is included because + # add_id.sh rewrites it earlier in the same run. + - name: Upload generated docs on failure + if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: generated-docs-${{ github.run_id }} + path: | + docs/en + docs/zh + retention-days: 7 diff --git a/package.json b/package.json index 49bbf24a4..ab19ab711 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "new": "doom new", "serve": "doom serve", "translate": "doom translate", + "check:translation-links": "node scripts/check-translation-links.mjs", "export": "doom export" } } diff --git a/scripts/check-translation-links.mjs b/scripts/check-translation-links.mjs new file mode 100644 index 000000000..0aea8a998 --- /dev/null +++ b/scripts/check-translation-links.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +/** + * Verify (and optionally repair) link targets in machine-translated docs. + * + * `doom translate` sends each document to an LLM with the instruction to keep + * every link target byte-identical. On very large documents the content is cut + * into 60KB chunks and the model occasionally rewrites a target -- e.g. it once + * emitted the literal `URL` copied straight out of the prompt's own example, + * which then fails the rspress dead-link check at build time. + * + * The English document is the ground truth: for every translated file we walk + * the inline links of both sides in document order and compare targets. With + * --fix, and only when both sides expose the same number of links (so the + * positional alignment is sound), a drifted target is restored from English. + * + * Usage: + * node scripts/check-translation-links.mjs [--fix] [--all] [--source en] [--target zh] + * + * (default scope) files under docs/ that git reports as modified or + * untracked -- i.e. the ones translate just (re)wrote + * --all every file under docs/ + * --docs check a docs tree outside this repo (implies a full scan) + * + * Only internal route links are compared -- the exact set rspress resolves + * against the route table and fails the build over. In-page anchors and + * external URLs are out of scope: a translated heading legitimately gets a + * translated slug, and neither kind is ever resolved by the dead-link check. + */ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const argv = process.argv.slice(2) +const hasFlag = (name) => argv.includes(name) +const flagValue = (name, fallback) => { + const i = argv.indexOf(name) + return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback +} + +const FIX = hasFlag('--fix') +const ALL = hasFlag('--all') +const SOURCE_LANG = flagValue('--source', 'en') +const TARGET_LANG = flagValue('--target', 'zh') +const DOCS_OVERRIDE = flagValue('--docs', '') + +const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '..') +// --docs points the checker at a docs tree outside the repo (used by the tests); +// it also switches off git-based scoping, since that tree is not tracked here. +const docsDir = DOCS_OVERRIDE ? path.resolve(DOCS_OVERRIDE) : path.join(repoRoot, 'docs') +const scanAll = ALL || Boolean(DOCS_OVERRIDE) +const sourceDir = path.join(docsDir, SOURCE_LANG) +const targetDir = path.join(docsDir, TARGET_LANG) + +const DOC_EXTENSIONS = new Set(['.md', '.mdx']) + +/** Recursively collect every markdown file under `dir`. */ +const walk = (dir) => { + const out = [] + if (!fs.existsSync(dir)) return out + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...walk(full)) + else if (DOC_EXTENSIONS.has(path.extname(entry.name))) out.push(full) + } + return out +} + +/** Files translate touched in this working tree (modified, staged or untracked). */ +const changedTargetFiles = () => { + const stdout = execFileSync( + 'git', + ['status', '--porcelain', '--', path.relative(repoRoot, targetDir)], + { cwd: repoRoot, encoding: 'utf8' }, + ) + const files = [] + for (const line of stdout.split('\n')) { + if (!line.trim()) continue + // Porcelain v1: two status chars, a space, then the path; renames use "old -> new". + let file = line.slice(3).trim() + const arrow = file.indexOf(' -> ') + if (arrow !== -1) file = file.slice(arrow + 4) + if (file.startsWith('"') && file.endsWith('"')) file = JSON.parse(file) + const abs = path.resolve(repoRoot, file) + if (DOC_EXTENSIONS.has(path.extname(abs)) && fs.existsSync(abs)) files.push(abs) + } + return files +} + +/** + * Blank out everything a markdown link must not be harvested from, keeping the + * offsets of the remaining text intact so match indices stay usable for --fix: + * frontmatter, fenced code blocks and inline code spans. + */ +const maskNonProse = (content) => { + const lines = content.split('\n') + const masked = lines.slice() + let inFrontmatter = false + let fence = null // { char: '`' | '~', length: number } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (i === 0 && /^---\s*$/.test(line)) { + inFrontmatter = true + masked[i] = ' '.repeat(line.length) + continue + } + if (inFrontmatter) { + masked[i] = ' '.repeat(line.length) + if (/^---\s*$/.test(line)) inFrontmatter = false + continue + } + + // Any indentation counts as a fence, not just CommonMark's 0-3 spaces: + // these docs nest fences inside JSX () where remark still reads + // them as fences, and en/zh must be masked identically or the link + // sequences stop lining up. + const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line) + if (fence) { + masked[i] = ' '.repeat(line.length) + // A closing fence is the same character, at least as long, and alone on its line. + if ( + fenceMatch && + fenceMatch[1][0] === fence.char && + fenceMatch[1].length >= fence.length && + /^\s*[`~]+\s*$/.test(line) + ) { + fence = null + } + continue + } + if (fenceMatch) { + fence = { char: fenceMatch[1][0], length: fenceMatch[1].length } + masked[i] = ' '.repeat(line.length) + continue + } + + masked[i] = maskInlineCode(line) + } + + return masked.join('\n') +} + +/** Replace `code span` runs with spaces, preserving length. */ +const maskInlineCode = (line) => { + let out = '' + let i = 0 + while (i < line.length) { + if (line[i] !== '`') { + out += line[i++] + continue + } + let run = 0 + while (line[i + run] === '`') run++ + const delimiter = '`'.repeat(run) + const close = line.indexOf(delimiter, i + run) + if (close === -1) { + // Unterminated span: not a code span at all, keep the backticks as-is. + out += delimiter + i += run + continue + } + const end = close + run + out += ' '.repeat(end - i) + i = end + } + return out +} + +/** Mirrors isExternalUrl / normalizeLink's early returns in @rspress/shared. */ +const isInternalRouteLink = (target) => + target !== '' && + !target.startsWith('#') && + !target.startsWith('http://') && + !target.startsWith('https://') && + !target.startsWith('mailto:') && + !target.startsWith('tel:') && + !/^\s*data:/i.test(target) + +// [text](target "optional title") -- the leading `!` marks an image, which we skip: +// normalizeImgSrc legitimately rewrites image paths between languages. +const LINK_RE = /(!?)\[((?:[^[\]\\]|\\.|\[[^[\]]*\])*)\]\(\s*([^()\s]*)((?:\s+"[^"]*")?)\s*\)/g + +/** Internal route links of a document, in order, with offsets into the raw text. */ +const extractLinks = (content) => { + const masked = maskNonProse(content) + const links = [] + for (const match of masked.matchAll(LINK_RE)) { + if (match[1] === '!') continue + const target = match[3] + // Only internal route links are compared -- exactly the set rspress + // resolves against the route table and fails the build over. In-page + // anchors legitimately differ (a translated heading gets a translated + // slug) and external URLs are never resolved, so neither is our business; + // both mirror normalizeLink's early returns in @rspress/core. + if (!isInternalRouteLink(target)) continue + // Offset of the target inside the raw document -- `masked` preserves every + // offset, so the index computed here also addresses the original content. + // `![` or `[` + text + `](`, then any padding before the target itself. + const afterOpen = match[1].length + 1 + match[2].length + 2 + const padding = /^\s*/.exec(match[0].slice(afterOpen))[0].length + const targetStart = match.index + afterOpen + padding + links.push({ + target, + start: targetStart, + end: targetStart + target.length, + line: content.slice(0, match.index).split('\n').length, + }) + } + return links +} + +const relative = (file) => path.relative(repoRoot, file) + +const targetFiles = (scanAll ? walk(targetDir) : changedTargetFiles()).filter((file) => + file.startsWith(targetDir + path.sep), +) + +if (targetFiles.length === 0) { + console.log(`no ${TARGET_LANG} documents to check (${scanAll ? 'full scan' : 'changed files only'})`) + console.log('== result: 0 pass / 0 fail ==') + process.exit(0) +} + +let pass = 0 +let fail = 0 +let repaired = 0 + +for (const file of targetFiles.sort()) { + const sourceFile = path.join(sourceDir, path.relative(targetDir, file)) + if (!fs.existsSync(sourceFile)) { + // translate removes orphan target files itself; nothing to compare against. + console.log(`SKIP ${relative(file)} (no ${SOURCE_LANG} counterpart)`) + continue + } + + const sourceLinks = extractLinks(fs.readFileSync(sourceFile, 'utf8')) + let content = fs.readFileSync(file, 'utf8') + let targetLinks = extractLinks(content) + + if (sourceLinks.length !== targetLinks.length) { + fail++ + console.log( + `FAIL ${relative(file)} internal link count ${targetLinks.length} != ${sourceLinks.length} in ${relative(sourceFile)}` + + ' -- cannot align positionally, repair by hand', + ) + continue + } + + const drifted = [] + for (let i = 0; i < sourceLinks.length; i++) { + if (sourceLinks[i].target !== targetLinks[i].target) drifted.push(i) + } + + if (drifted.length === 0) { + pass++ + console.log(`PASS ${relative(file)} (${sourceLinks.length} links)`) + continue + } + + if (!FIX) { + fail++ + console.log(`FAIL ${relative(file)} ${drifted.length} drifted internal link target(s):`) + for (const i of drifted) { + console.log( + ` ${relative(file)}:${targetLinks[i].line} got "${targetLinks[i].target}"` + + ` expected "${sourceLinks[i].target}" (${relative(sourceFile)}:${sourceLinks[i].line})`, + ) + } + continue + } + + // Rewrite back-to-front so earlier offsets stay valid. + for (const i of [...drifted].reverse()) { + const { start, end } = targetLinks[i] + content = content.slice(0, start) + sourceLinks[i].target + content.slice(end) + } + fs.writeFileSync(file, content) + repaired += drifted.length + + const after = extractLinks(content) + const stillDrifted = sourceLinks.some((link, i) => link.target !== after[i]?.target) + if (stillDrifted) { + fail++ + console.log(`FAIL ${relative(file)} repair did not converge, inspect by hand`) + continue + } + + pass++ + console.log(`PASS ${relative(file)} repaired ${drifted.length} link target(s):`) + for (const i of drifted) { + console.log(` ${relative(file)}:${targetLinks[i].line} "${targetLinks[i].target}" -> "${sourceLinks[i].target}"`) + } +} + +if (FIX && repaired > 0) { + console.log(`restored ${repaired} link target(s) from ${SOURCE_LANG}`) +} +console.log(`== result: ${pass} pass / ${fail} fail ==`) +process.exit(fail > 0 ? 1 : 0) From 6220122d26d79939634806affd19aabf85428cde Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 12:41:17 +0900 Subject: [PATCH 02/17] ci: align translated links by content, not position alone Positional alignment only holds when both sides expose the same number of internal links. That assumption is thin here: the document that broke the build has exactly one internal route link, in its last line, so a model that keeps that link and invents a second one -- rather than rewriting the one that exists -- would leave the counts unequal and the repair refused. The build error cannot tell those two cases apart: an invented dead link is reported either way, and a surviving valid link is not reported at all. Anchor on the links both sides agree on (longest common subsequence) and decide per gap instead. Equal counts still restore positionally. Links the English original does not have at all are the model's invention: strip the link syntax and keep the text, since a target that cannot be sourced from English must not be guessed at. Dropped links, and mixed gaps that are ambiguous in the same way, are still left for a human, as is any file where the number of inventions exceeds a small cap -- past that point the comparison itself is more likely to be wrong than the translation. --- scripts/check-translation-links.mjs | 163 +++++++++++++++++++++++----- 1 file changed, 134 insertions(+), 29 deletions(-) diff --git a/scripts/check-translation-links.mjs b/scripts/check-translation-links.mjs index 0aea8a998..0d0aa91d9 100644 --- a/scripts/check-translation-links.mjs +++ b/scripts/check-translation-links.mjs @@ -196,22 +196,129 @@ const extractLinks = (content) => { // slug) and external URLs are never resolved, so neither is our business; // both mirror normalizeLink's early returns in @rspress/core. if (!isInternalRouteLink(target)) continue - // Offset of the target inside the raw document -- `masked` preserves every - // offset, so the index computed here also addresses the original content. + // Offsets into the raw document -- `masked` preserves every offset, so the + // indices computed here also address the original content. // `![` or `[` + text + `](`, then any padding before the target itself. const afterOpen = match[1].length + 1 + match[2].length + 2 const padding = /^\s*/.exec(match[0].slice(afterOpen))[0].length const targetStart = match.index + afterOpen + padding links.push({ target, + text: match[2], start: targetStart, end: targetStart + target.length, + // The whole `[text](target)` span, needed to demote a hallucinated link. + linkStart: match.index, + linkEnd: match.index + match[0].length, line: content.slice(0, match.index).split('\n').length, }) } return links } +/** + * Longest common subsequence of two target lists, as index pairs. Equal targets + * are the anchors we trust; everything between two anchors is a gap the caller + * has to make a decision about. + */ +const lcsPairs = (a, b) => { + const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0)) + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + table[i][j] = + a[i].target === b[j].target + ? table[i + 1][j + 1] + 1 + : Math.max(table[i + 1][j], table[i][j + 1]) + } + } + const pairs = [] + let i = 0 + let j = 0 + while (i < a.length && j < b.length) { + if (a[i].target === b[j].target) { + pairs.push([i, j]) + i++ + j++ + } else if (table[i + 1][j] >= table[i][j + 1]) i++ + else j++ + } + return pairs +} + +/** + * Decide what to do with every translated link, English being the truth. + * + * Anchoring on the links both sides agree on leaves gaps, and each gap shape + * says something different about what the model did: + * same count -> it rewrote targets in place; restore them positionally. + * nothing in en -> it invented links that have no original; strip the link + * syntax and keep the text (a target we cannot source + * from English is a target we must not guess at). + * fewer in zh -> it dropped links; there is no sound place to put them + * back, so the file is left for a human. + * A mixed gap (some English links, but more links on the translated side) is + * ambiguous in the same way, and is refused too. + */ +const planEdits = (sourceLinks, targetLinks) => { + const edits = [] + const unresolved = [] + const anchors = [...lcsPairs(sourceLinks, targetLinks), [sourceLinks.length, targetLinks.length]] + let si = 0 + let ti = 0 + + for (const [sEnd, tEnd] of anchors) { + const srcGap = sourceLinks.slice(si, sEnd) + const tgtGap = targetLinks.slice(ti, tEnd) + + if (srcGap.length === tgtGap.length) { + for (const [k, link] of tgtGap.entries()) { + edits.push({ + kind: 'restore', + start: link.start, + end: link.end, + replacement: srcGap[k].target, + line: link.line, + from: link.target, + to: srcGap[k].target, + }) + } + } else if (srcGap.length === 0) { + for (const link of tgtGap) { + edits.push({ + kind: 'demote', + start: link.linkStart, + end: link.linkEnd, + replacement: link.text, + line: link.line, + from: link.target, + to: null, + }) + } + } else { + unresolved.push( + `${tgtGap.length} translated link(s) against ${srcGap.length} English one(s)` + + ` around line ${(tgtGap[0] ?? srcGap[0]).line}` + + ` [${tgtGap.map((l) => l.target).join(', ') || '-'}] vs [${srcGap.map((l) => l.target).join(', ')}]`, + ) + } + + si = sEnd + 1 + ti = tEnd + 1 + } + + // Demoting is the one edit that removes markup rather than correcting it. + // A handful is a model slip; a flood means the comparison itself is off. + const demotions = edits.filter((edit) => edit.kind === 'demote') + const demotionCap = Math.max(3, Math.floor(sourceLinks.length * 0.2)) + if (demotions.length > demotionCap) { + unresolved.push( + `${demotions.length} invented link(s) exceeds the cap of ${demotionCap} -- refusing to strip that many`, + ) + } + + return { edits, unresolved } +} + const relative = (file) => path.relative(repoRoot, file) const targetFiles = (scanAll ? walk(targetDir) : changedTargetFiles()).filter((file) => @@ -240,21 +347,26 @@ for (const file of targetFiles.sort()) { let content = fs.readFileSync(file, 'utf8') let targetLinks = extractLinks(content) - if (sourceLinks.length !== targetLinks.length) { + const describe = (edit) => + edit.kind === 'restore' + ? `${relative(file)}:${edit.line} "${edit.from}" -> "${edit.to}"` + : `${relative(file)}:${edit.line} "${edit.from}" has no English original -- link syntax stripped, text kept` + + const { edits, unresolved } = planEdits(sourceLinks, targetLinks) + + // An unresolved gap makes the whole alignment suspect, so nothing is written: + // a partially repaired file is harder to reason about than an untouched one. + if (unresolved.length > 0) { fail++ console.log( - `FAIL ${relative(file)} internal link count ${targetLinks.length} != ${sourceLinks.length} in ${relative(sourceFile)}` + - ' -- cannot align positionally, repair by hand', + `FAIL ${relative(file)} ${targetLinks.length} internal link(s) against` + + ` ${sourceLinks.length} in ${relative(sourceFile)} -- repair by hand:`, ) + for (const problem of unresolved) console.log(` ${problem}`) continue } - const drifted = [] - for (let i = 0; i < sourceLinks.length; i++) { - if (sourceLinks[i].target !== targetLinks[i].target) drifted.push(i) - } - - if (drifted.length === 0) { + if (edits.length === 0) { pass++ console.log(`PASS ${relative(file)} (${sourceLinks.length} links)`) continue @@ -262,37 +374,30 @@ for (const file of targetFiles.sort()) { if (!FIX) { fail++ - console.log(`FAIL ${relative(file)} ${drifted.length} drifted internal link target(s):`) - for (const i of drifted) { - console.log( - ` ${relative(file)}:${targetLinks[i].line} got "${targetLinks[i].target}"` + - ` expected "${sourceLinks[i].target}" (${relative(sourceFile)}:${sourceLinks[i].line})`, - ) - } + console.log(`FAIL ${relative(file)} ${edits.length} drifted internal link(s):`) + for (const edit of edits) console.log(` ${describe(edit)}`) continue } - // Rewrite back-to-front so earlier offsets stay valid. - for (const i of [...drifted].reverse()) { - const { start, end } = targetLinks[i] - content = content.slice(0, start) + sourceLinks[i].target + content.slice(end) + // Apply back-to-front so earlier offsets stay valid. + for (const edit of [...edits].sort((a, b) => b.start - a.start)) { + content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end) } fs.writeFileSync(file, content) - repaired += drifted.length + repaired += edits.length const after = extractLinks(content) - const stillDrifted = sourceLinks.some((link, i) => link.target !== after[i]?.target) - if (stillDrifted) { + const converged = + after.length === sourceLinks.length && sourceLinks.every((link, i) => link.target === after[i].target) + if (!converged) { fail++ console.log(`FAIL ${relative(file)} repair did not converge, inspect by hand`) continue } pass++ - console.log(`PASS ${relative(file)} repaired ${drifted.length} link target(s):`) - for (const i of drifted) { - console.log(` ${relative(file)}:${targetLinks[i].line} "${targetLinks[i].target}" -> "${sourceLinks[i].target}"`) - } + console.log(`PASS ${relative(file)} repaired ${edits.length} internal link(s):`) + for (const edit of edits) console.log(` ${describe(edit)}`) } if (FIX && repaired > 0) { From e3b48c2fafd6725833535beef2faa63e74169cf6 Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 12:45:17 +0900 Subject: [PATCH 03/17] ci: TEMPORARY -- run the translate chain on this branch to validate the repair Reverted before merge. The workflow only reacts to pushes on main, so the translate -> repair -> build chain cannot be exercised from a pull request at all. Adding this branch to the push trigger and to each step's guard runs it here instead; the push-back step targets github.ref, so the generated translation lands on this branch and main is untouched. --- .github/workflows/main.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ff62e4ac9..d152bc5ac 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,9 @@ on: push: branches: - main + # TEMPORARY (revert before merge): lets this branch exercise the + # translate -> repair -> build chain, which only runs on push events. + - ci/repair-machine-translated-link-targets workflow_dispatch: jobs: @@ -56,11 +59,11 @@ jobs: run: yarn --immutable - name: generate id - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge run: ./add_id.sh docs/en/solutions && ./add_id.sh docs/en/articles - name: translate - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge run: yarn translate -s en -t zh -g "*" # Machine translation occasionally rewrites a link target -- it has emitted @@ -68,14 +71,14 @@ jobs: # the build's dead-link check. Restore internal targets from English before # building, using the English document as the source of truth. - name: Repair machine-translated links - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge run: node scripts/check-translation-links.mjs --fix - name: Build run: yarn build - name: Commit - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -84,7 +87,7 @@ jobs: git commit -m "ci: update file from GitHub Action [skip ci]" || echo "Nothing to commit" - name: Push changes - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge uses: ad-m/github-push-action@master with: branch: ${{ github.ref }} @@ -95,7 +98,7 @@ jobs: # model produced. Keep them for post-mortems. docs/en is included because # add_id.sh rewrites it earlier in the same run. - name: Upload generated docs on failure - if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' + if: failure() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge uses: actions/upload-artifact@v4 with: name: generated-docs-${{ github.run_id }} From 89e5bc86c29a347dc80919c30fde1baa76648fdc Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 13:34:04 +0900 Subject: [PATCH 04/17] Revert "ci: TEMPORARY -- run the translate chain on this branch to validate the repair" This reverts commit e3b48c2fafd6725833535beef2faa63e74169cf6. --- .github/workflows/main.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d152bc5ac..ff62e4ac9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,9 +12,6 @@ on: push: branches: - main - # TEMPORARY (revert before merge): lets this branch exercise the - # translate -> repair -> build chain, which only runs on push events. - - ci/repair-machine-translated-link-targets workflow_dispatch: jobs: @@ -59,11 +56,11 @@ jobs: run: yarn --immutable - name: generate id - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: ./add_id.sh docs/en/solutions && ./add_id.sh docs/en/articles - name: translate - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: yarn translate -s en -t zh -g "*" # Machine translation occasionally rewrites a link target -- it has emitted @@ -71,14 +68,14 @@ jobs: # the build's dead-link check. Restore internal targets from English before # building, using the English document as the source of truth. - name: Repair machine-translated links - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: node scripts/check-translation-links.mjs --fix - name: Build run: yarn build - name: Commit - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -87,7 +84,7 @@ jobs: git commit -m "ci: update file from GitHub Action [skip ci]" || echo "Nothing to commit" - name: Push changes - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: ad-m/github-push-action@master with: branch: ${{ github.ref }} @@ -98,7 +95,7 @@ jobs: # model produced. Keep them for post-mortems. docs/en is included because # add_id.sh rewrites it earlier in the same run. - name: Upload generated docs on failure - if: failure() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ci/repair-machine-translated-link-targets') # TEMPORARY: drop the second ref before merge + if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v4 with: name: generated-docs-${{ github.run_id }} From bce90eb9d938112080113c5e89d1cc23b1c2b90c Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 13:39:45 +0900 Subject: [PATCH 05/17] ci: also strip image markup the translation invents A trial run of the real translation on this branch showed the dead link was not the whole story. The model did not rewrite an existing link: it dropped 44 lines of its own system prompt into the prose, translated into Chinese -- heading, terminology table, chunking notice and all -- and the `URL` target came from the prompt's own example. The prompt's `![alt](src)` example rode in with it, and once the link was repaired the build failed again on Module not found: Can't resolve 'src' in docs/zh/solutions Images cannot be aligned against English the way links are: normalizeImgSrc rewrites their paths between languages on purpose, so the two sides legitimately disagree and there is no stable value to anchor on. Anchoring on equal srcs was tried first and is wrong -- with one real image present, an invented one can no longer be told apart, and the file gets refused. Judge images on their own terms instead: a src that resolves to a file is fine however much it differs from the English one, and a src that resolves to nothing is invented markup. That is also the exact question rspack asks, so the check matches the failure it exists to prevent. Demote the latter to their alt text, under the same cap as links. Verified on the generated translation from run 33139819063: the repair strips the invented image and `yarn build` then exits 0. Injection tests: 26 assertions. Whole-repository baseline unchanged at 400 pass / 1 fail. --- scripts/check-translation-links.mjs | 143 ++++++++++++++++++++-------- 1 file changed, 105 insertions(+), 38 deletions(-) diff --git a/scripts/check-translation-links.mjs b/scripts/check-translation-links.mjs index 0d0aa91d9..1f03c6dfe 100644 --- a/scripts/check-translation-links.mjs +++ b/scripts/check-translation-links.mjs @@ -179,16 +179,20 @@ const isInternalRouteLink = (target) => !target.startsWith('tel:') && !/^\s*data:/i.test(target) -// [text](target "optional title") -- the leading `!` marks an image, which we skip: -// normalizeImgSrc legitimately rewrites image paths between languages. +// [text](target "optional title"), with a leading `!` marking an image. const LINK_RE = /(!?)\[((?:[^[\]\\]|\\.|\[[^[\]]*\])*)\]\(\s*([^()\s]*)((?:\s+"[^"]*")?)\s*\)/g -/** Internal route links of a document, in order, with offsets into the raw text. */ -const extractLinks = (content) => { +/** + * Internal links and images of a document, in order, with offsets into the raw + * text. They are kept apart because only one of them has a stable target: + * normalizeImgSrc rewrites image paths between languages on purpose, so an + * image src that differs is expected, while a link target that differs is not. + */ +const extractRefs = (content) => { const masked = maskNonProse(content) const links = [] + const images = [] for (const match of masked.matchAll(LINK_RE)) { - if (match[1] === '!') continue const target = match[3] // Only internal route links are compared -- exactly the set rspress // resolves against the route table and fails the build over. In-page @@ -202,18 +206,20 @@ const extractLinks = (content) => { const afterOpen = match[1].length + 1 + match[2].length + 2 const padding = /^\s*/.exec(match[0].slice(afterOpen))[0].length const targetStart = match.index + afterOpen + padding - links.push({ + const ref = { target, text: match[2], start: targetStart, end: targetStart + target.length, - // The whole `[text](target)` span, needed to demote a hallucinated link. + // The whole `[text](target)` span, needed to demote a hallucinated ref. linkStart: match.index, linkEnd: match.index + match[0].length, line: content.slice(0, match.index).split('\n').length, - }) + } + if (match[1] === '!') images.push(ref) + else links.push(ref) } - return links + return { links, images } } /** @@ -246,20 +252,71 @@ const lcsPairs = (a, b) => { } /** - * Decide what to do with every translated link, English being the truth. + * Does an image src point at a file that actually exists? This is the same + * question rspack asks, and the reason a bogus src fails the build with + * "Module not found". Absolute srcs are served out of docs/public. + */ +const imageResolves = (src, fileDir) => { + const clean = src.split('#')[0].split('?')[0] + if (!clean) return false + const candidate = clean.startsWith('/') + ? path.join(docsDir, 'public', clean.slice(1)) + : path.resolve(fileDir, clean) + return fs.existsSync(candidate) +} + +/** + * Images cannot be aligned against English the way links are: normalizeImgSrc + * rewrites their paths on purpose, so the two sides legitimately disagree and + * there is no stable value to anchor on. Judge them on their own terms instead + * -- a src that resolves to a file is fine however much it differs from the + * English one, and a src that resolves to nothing is the model inventing markup + * (it copied its own prompt's `![alt](src)` example into the prose once), which + * is exactly what breaks the build. Demote those to their alt text. + */ +const planImageEdits = (targetImages, fileDir) => { + const edits = [] + const unresolved = [] + + for (const image of targetImages) { + if (imageResolves(image.target, fileDir)) continue + edits.push({ + kind: 'demote', + label: 'image', + start: image.linkStart, + end: image.linkEnd, + replacement: image.text, + line: image.line, + from: image.target, + to: null, + }) + } + + // Same reasoning as the link cap: a flood means the check is wrong, not the + // translation, and stripping markup wholesale would be worse than failing. + const cap = Math.max(3, Math.floor(targetImages.length * 0.2)) + if (edits.length > cap) { + unresolved.push(`${edits.length} unresolvable image(s) exceeds the cap of ${cap} -- refusing to strip that many`) + } + return { edits, unresolved } +} + +/** + * Decide what to do with every translated reference, English being the truth. * - * Anchoring on the links both sides agree on leaves gaps, and each gap shape - * says something different about what the model did: - * same count -> it rewrote targets in place; restore them positionally. - * nothing in en -> it invented links that have no original; strip the link - * syntax and keep the text (a target we cannot source + * Anchoring on the references both sides agree on leaves gaps, and each gap + * shape says something different about what the model did: + * same count -> it rewrote targets in place; restore them positionally, + * but only where restoring is meaningful (see `restore`). + * nothing in en -> it invented references that have no original; strip the + * markup and keep the text (a target we cannot source * from English is a target we must not guess at). - * fewer in zh -> it dropped links; there is no sound place to put them - * back, so the file is left for a human. - * A mixed gap (some English links, but more links on the translated side) is - * ambiguous in the same way, and is refused too. + * fewer in zh -> it dropped references; there is no sound place to put + * them back, so the file is left for a human. + * A mixed gap (some English refs, but more on the translated side) is ambiguous + * in the same way, and is refused too. */ -const planEdits = (sourceLinks, targetLinks) => { +const planEdits = (sourceLinks, targetLinks, { label }) => { const edits = [] const unresolved = [] const anchors = [...lcsPairs(sourceLinks, targetLinks), [sourceLinks.length, targetLinks.length]] @@ -274,6 +331,7 @@ const planEdits = (sourceLinks, targetLinks) => { for (const [k, link] of tgtGap.entries()) { edits.push({ kind: 'restore', + label, start: link.start, end: link.end, replacement: srcGap[k].target, @@ -286,6 +344,7 @@ const planEdits = (sourceLinks, targetLinks) => { for (const link of tgtGap) { edits.push({ kind: 'demote', + label, start: link.linkStart, end: link.linkEnd, replacement: link.text, @@ -296,7 +355,7 @@ const planEdits = (sourceLinks, targetLinks) => { } } else { unresolved.push( - `${tgtGap.length} translated link(s) against ${srcGap.length} English one(s)` + + `${tgtGap.length} translated ${label}(s) against ${srcGap.length} English one(s)` + ` around line ${(tgtGap[0] ?? srcGap[0]).line}` + ` [${tgtGap.map((l) => l.target).join(', ') || '-'}] vs [${srcGap.map((l) => l.target).join(', ')}]`, ) @@ -312,7 +371,7 @@ const planEdits = (sourceLinks, targetLinks) => { const demotionCap = Math.max(3, Math.floor(sourceLinks.length * 0.2)) if (demotions.length > demotionCap) { unresolved.push( - `${demotions.length} invented link(s) exceeds the cap of ${demotionCap} -- refusing to strip that many`, + `${demotions.length} invented ${label}(s) exceeds the cap of ${demotionCap} -- refusing to strip that many`, ) } @@ -343,24 +402,30 @@ for (const file of targetFiles.sort()) { continue } - const sourceLinks = extractLinks(fs.readFileSync(sourceFile, 'utf8')) + const source = extractRefs(fs.readFileSync(sourceFile, 'utf8')) let content = fs.readFileSync(file, 'utf8') - let targetLinks = extractLinks(content) - - const describe = (edit) => - edit.kind === 'restore' - ? `${relative(file)}:${edit.line} "${edit.from}" -> "${edit.to}"` - : `${relative(file)}:${edit.line} "${edit.from}" has no English original -- link syntax stripped, text kept` + let target = extractRefs(content) + + const describe = (edit) => { + const where = `${relative(file)}:${edit.line}` + if (edit.kind === 'restore') return `${where} "${edit.from}" -> "${edit.to}"` + return edit.label === 'image' + ? `${where} "${edit.from}" resolves to no file -- image syntax stripped, alt text kept` + : `${where} "${edit.from}" has no English original -- link syntax stripped, text kept` + } - const { edits, unresolved } = planEdits(sourceLinks, targetLinks) + const linkPlan = planEdits(source.links, target.links, { label: 'link' }) + const imagePlan = planImageEdits(target.images, path.dirname(file)) + const edits = [...linkPlan.edits, ...imagePlan.edits] + const unresolved = [...linkPlan.unresolved, ...imagePlan.unresolved] // An unresolved gap makes the whole alignment suspect, so nothing is written: // a partially repaired file is harder to reason about than an untouched one. if (unresolved.length > 0) { fail++ console.log( - `FAIL ${relative(file)} ${targetLinks.length} internal link(s) against` + - ` ${sourceLinks.length} in ${relative(sourceFile)} -- repair by hand:`, + `FAIL ${relative(file)} ${target.links.length} link(s) / ${target.images.length} image(s) against` + + ` ${source.links.length} / ${source.images.length} in ${relative(sourceFile)} -- repair by hand:`, ) for (const problem of unresolved) console.log(` ${problem}`) continue @@ -368,13 +433,13 @@ for (const file of targetFiles.sort()) { if (edits.length === 0) { pass++ - console.log(`PASS ${relative(file)} (${sourceLinks.length} links)`) + console.log(`PASS ${relative(file)} (${source.links.length} links, ${source.images.length} images)`) continue } if (!FIX) { fail++ - console.log(`FAIL ${relative(file)} ${edits.length} drifted internal link(s):`) + console.log(`FAIL ${relative(file)} ${edits.length} drifted reference(s):`) for (const edit of edits) console.log(` ${describe(edit)}`) continue } @@ -386,9 +451,11 @@ for (const file of targetFiles.sort()) { fs.writeFileSync(file, content) repaired += edits.length - const after = extractLinks(content) + const after = extractRefs(content) const converged = - after.length === sourceLinks.length && sourceLinks.every((link, i) => link.target === after[i].target) + after.links.length === source.links.length && + source.links.every((link, i) => link.target === after.links[i].target) && + after.images.every((image) => imageResolves(image.target, path.dirname(file))) if (!converged) { fail++ console.log(`FAIL ${relative(file)} repair did not converge, inspect by hand`) @@ -396,12 +463,12 @@ for (const file of targetFiles.sort()) { } pass++ - console.log(`PASS ${relative(file)} repaired ${edits.length} internal link(s):`) + console.log(`PASS ${relative(file)} repaired ${edits.length} reference(s):`) for (const edit of edits) console.log(` ${describe(edit)}`) } if (FIX && repaired > 0) { - console.log(`restored ${repaired} link target(s) from ${SOURCE_LANG}`) + console.log(`repaired ${repaired} reference(s) against ${SOURCE_LANG}`) } console.log(`== result: ${pass} pass / ${fail} fail ==`) process.exit(fail > 0 ? 1 : 0) From 41e4ea2f64c2ef7948c9ccd327eff9c85e574a2b Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 14:41:42 +0900 Subject: [PATCH 06/17] ci: stop translations from silently losing content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repairing the dead link that broke the build turned out to be the small half of this problem. A trial translation run showed what actually happens when gpt-4o-mini loses the thread partway through a chunked document: it recited 44 lines of its own system prompt into the prose, in Chinese, and dropped an entire section along with 1177 lines of YAML. The dead link was incidental -- the `URL` target came from the prompt's own example. Had the model not happened to emit that one bad link, a document missing §4.2 and a third of its code would have been published with every check green. Checking the repository for the same signature found it was not a one-off: Software_Supply_Chain_Security… -46 code blocks, -77 headings, -35 table rows How_to_Use_PostgreSQL_Hot_Standby… -18 code blocks, -8 headings, -10 table rows Install_Multi-Primary_Service_Mesh… +7 table rows with no English original How_to_Migrate_MySQL_57_to_80… +1 code block with no English original Three of those are live Chinese pages missing about a third of their content. Nothing was capable of noticing, because the only thing the build validates about a translation is whether its links resolve. Two changes, addressing cause and detection separately. Cause: doom.config.ts now supplies its own translation prompt. The default one illustrates its rules with literal placeholders -- a link written as [text], an image written with a fake src -- which is exactly the text that ends up in the document when the model starts reciting, and exactly what failed the build. It also never asks for the translation to be complete: it constrains formatting and says nothing about content, so swallowing a section violates nothing it was told. The replacement describes the rules instead of illustrating them with copyable values, puts completeness first, and forbids emitting the instructions themselves. Detection: the checker now compares structure before references. Heading anchors, the heading outline, fenced code block count and table row count are not the translator's to change; when they differ, content was lost or invented and the file is reported rather than rewritten -- what is missing cannot be reconstructed, and repairing the links around it would only make the loss quieter. Anchors held across all 401 existing pairs, so the check is exact rather than heuristic. The five already-damaged documents are listed in .translation-known-damage, which downgrades them to KNOWN so this can ship without blocking main on debt it did not create. They still need retranslating; the list is the record. Renamed to check-translation-integrity.mjs, since links are now the smaller half of what it does. Baseline: 396 pass / 0 fail / 5 known-damaged. Injection tests: 26 assertions. Prompt template: 23 assertions. --- .github/workflows/main.yml | 2 +- .translation-known-damage | 25 ++++ doom.config.ts | 123 +++++++++++++--- package.json | 2 +- ...ks.mjs => check-translation-integrity.mjs} | 138 ++++++++++++++++-- 5 files changed, 256 insertions(+), 34 deletions(-) create mode 100644 .translation-known-damage rename scripts/{check-translation-links.mjs => check-translation-integrity.mjs} (74%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ff62e4ac9..f20307a9c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -69,7 +69,7 @@ jobs: # building, using the English document as the source of truth. - name: Repair machine-translated links if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: node scripts/check-translation-links.mjs --fix + run: node scripts/check-translation-integrity.mjs --fix - name: Build run: yarn build diff --git a/.translation-known-damage b/.translation-known-damage new file mode 100644 index 000000000..826c38924 --- /dev/null +++ b/.translation-known-damage @@ -0,0 +1,25 @@ +# Documents an earlier translation run damaged, as found by +# scripts/check-translation-integrity.mjs when the structural check was first +# switched on. Listing a file here downgrades its report from FAIL to KNOWN, so +# the check can run in CI without blocking on debt it did not create. +# +# Nothing here is acceptable. Each of these is missing part of its English +# original, or has content the original never had, and needs to be retranslated +# and re-checked before its line is removed. A file leaves this list by being +# fixed -- never by being forgotten. + +# -46 code blocks, -77 headings, and all 35 table rows (about a third of the article) +docs/zh/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md + +# -18 code blocks, -8 headings, -10 table rows +docs/zh/solutions/How_to_Use_PostgreSQL_Hot_Standby_Cluster.md + +# +7 table rows the English original does not have +docs/zh/solutions/Install_Multi-Primary_Service_Mesh_on_Different_Networks.md + +# +1 code block the English original does not have +docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md + +# -1 heading. Still short after being retranslated in run 33139819063, so it is +# listed pending a run under the hardened prompt in doom.config.ts. +docs/zh/solutions/ecosystem/opensearch/How_to_Migrate_from_Elasticsearch_to_OpenSearch.md diff --git a/doom.config.ts b/doom.config.ts index 7d0a58aca..6fe8b25fd 100644 --- a/doom.config.ts +++ b/doom.config.ts @@ -1,28 +1,113 @@ import { defineConfig } from "@alauda/doom/config"; +// Pulls in doom's module augmentation of rspress's UserConfig, which is where +// `translate` is declared. +import type {} from "@alauda/doom/types"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { blogPostResolver } from "./plugins/plugin-post-resolver/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); +// The default prompt shipped by doom has two properties this repository cannot +// live with, both of which produced real damage in run 33057885704 and before: +// +// 1. It illustrates its rules with literal placeholders -- a link written as +// [text](URL), an image written as ![alt](src). When the model loses the +// thread mid-chunk it recites its own instructions into the prose, and those +// placeholders arrive as real markup: `URL` became a dead link and `src` +// became "Module not found", both of which failed the build. +// 2. It never says the translation has to be complete. It asks for the format +// to be preserved and says nothing about the content, so dropping an entire +// section, 46 code blocks or every table in the document breaks no rule it +// was given. Three articles in this repository lost about a third of their +// content that way, unnoticed, because nothing checked. +// +// So: describe the rules instead of illustrating them with copyable fake values, +// put completeness first, and forbid emitting these instructions outright. +// scripts/check-translation-links.mjs is the backstop that catches what slips. +const TRANSLATE_SYSTEM_PROMPT = ` +You are a professional technical documentation engineer. Translate the document below from <%= sourceLang %> into <%= targetLang %>, so that it reads as if the same engineer had written it in <%= targetLang %>, at the same level of precision. + +## What you return + +Return the translated document, and nothing else: no preamble, no closing remark, no code fence wrapped around the whole answer, no notes about what you did. + +Never reproduce any part of these instructions in your output. They are not part of the document. If you find yourself writing a heading such as "Baseline Requirements", a glossary of term mappings, or a note about chunked translation, you have started copying this prompt into the document -- stop and return to translating the source. + +## Completeness comes first + +Your output must contain everything the source contains, in the same order and at the same structural level: every heading, paragraph, list item, table row, fenced code block, admonition, blockquote and footnote. + +Never summarise, merge, abbreviate, or skip a passage, however repetitive or boilerplate it looks. Never stop before the end of the input. A clumsy sentence can be fixed later; a section you silently dropped cannot, because nobody will know it is missing. + +## What must survive unchanged + +- Link destinations. Translate the visible text of a link; reproduce the destination exactly as written, character for character, including any anchor fragment or query string. This applies to inline links, reference definitions, bare URLs, and href or src attributes in HTML and JSX. +- Anchor placeholders. Tokens of the form __ANCHOR_ followed by a number are heading identifiers that the document cross-references. Reproduce every one of them, exactly as written, in the same position. Dropping one silently breaks navigation. +- The contents of fenced code blocks and inline code spans: field names, CLI flags, resource kinds, expressions and regular expressions are code, not prose. +- JSX and MDX component names and their attribute keys; only the content between component tags is translated. +- Escape characters already present in the source, such as backslashes and angle brackets. Do not add escapes that the source does not have -- brackets and parentheses in ordinary prose stay as they are. +- Technical terms and proper nouns that are conventionally left untranslated: product names, Kubernetes and cloud-native project names, language and format names, and API object names. + +## Frontmatter and comments + +- In frontmatter, translate the title and description fields only; leave every other field exactly as it is. +- Preserve these comments and their contents, in both MDX and HTML comment syntax: release-notes-for-bugs. +- Remove these comments entirely, in both MDX and HTML comment syntax: reference-start and reference-end. + +## Language + +Sentences should read naturally to a native <%= targetLang %> speaker and follow the conventions of technical documentation in that language. Keep the register of the source: if it is dense and exact, stay dense and exact rather than smoothing it out. +<% if (titleTranslationPrompt) { %> +<%- titleTranslationPrompt %> +<% } %> +<% if (terms) { %> +<%- terms %> +<% } %> +<% if (isChunk) { %> +## This is one chunk of a longer document + +The text below is a consecutive slice of a larger document, cut at a heading boundary. Translate the whole slice as a continuous part of that document, keeping the style consistent with it. + +Being handed a fragment changes nothing about the rules above. Translate from its first line to its last. Do not introduce it, do not summarise it, do not comment on the fact that it is a fragment, and do not write anything about the chunking itself. +<% } %> +<% if (userPrompt || additionalPrompts) { %> +## Additional requirements + +These apply in addition to everything above; where they conflict with it, everything above wins. + +""" +<% if (userPrompt) { %> +<%- userPrompt %> +<% } %> +<% if (additionalPrompts) { %> +<%- additionalPrompts %> +<% } %> +""" +<% } %> +`.trim() + export default defineConfig({ title: "Alauda Knowledge", - base: "/knowledge/", - description: - "Welcome to Alauda's Knowledgebase information center. Find resources for resolving problems and troubleshooting.", - logo: "/logo.svg", - logoText: "Alauda Knowledge", - globalStyles: join(__dirname, "styles/index.css"), - plugins: [ - blogPostResolver({ - postsDir: join(__dirname, "docs"), - }), - ], - themeConfig: { - darkMode: false, - lastUpdated: true, - footer: { - message: "© 2025 Alauda Inc. All Rights Reserved.", - }, - }, -}); + base: "/knowledge/", + description: + "Welcome to Alauda's Knowledgebase information center. Find resources for resolving problems and troubleshooting.", + logo: "/logo.svg", + logoText: "Alauda Knowledge", + globalStyles: join(__dirname, "styles/index.css"), + plugins: [ + blogPostResolver({ + postsDir: join(__dirname, "docs"), + }), + ], + translate: { + systemPrompt: TRANSLATE_SYSTEM_PROMPT, + }, + themeConfig: { + darkMode: false, + lastUpdated: true, + footer: { + message: "© 2025 Alauda Inc. All Rights Reserved.", + }, + }, +}); diff --git a/package.json b/package.json index ab19ab711..6fd17a365 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "new": "doom new", "serve": "doom serve", "translate": "doom translate", - "check:translation-links": "node scripts/check-translation-links.mjs", + "check:translation": "node scripts/check-translation-integrity.mjs", "export": "doom export" } } diff --git a/scripts/check-translation-links.mjs b/scripts/check-translation-integrity.mjs similarity index 74% rename from scripts/check-translation-links.mjs rename to scripts/check-translation-integrity.mjs index 1f03c6dfe..04dbe83c6 100644 --- a/scripts/check-translation-links.mjs +++ b/scripts/check-translation-integrity.mjs @@ -1,20 +1,28 @@ #!/usr/bin/env node /** - * Verify (and optionally repair) link targets in machine-translated docs. + * Check machine-translated docs against their English originals, and repair the + * damage that is safely repairable. * - * `doom translate` sends each document to an LLM with the instruction to keep - * every link target byte-identical. On very large documents the content is cut - * into 60KB chunks and the model occasionally rewrites a target -- e.g. it once - * emitted the literal `URL` copied straight out of the prompt's own example, - * which then fails the rspress dead-link check at build time. + * `doom translate` hands each document to an LLM, cutting anything over 60KB + * into chunks. The model does not always come back with a translation: it has + * rewritten link targets, invented image markup, recited its own prompt into + * the prose, and -- worst, because nothing noticed -- silently dropped entire + * sections. Three documents in this repository were found missing roughly a + * third of their content, having passed every check that existed at the time. * - * The English document is the ground truth: for every translated file we walk - * the inline links of both sides in document order and compare targets. With - * --fix, and only when both sides expose the same number of links (so the - * positional alignment is sound), a drifted target is restored from English. + * Two kinds of check, because the two failures need opposite treatment: + * + * Structure (headings, anchors, code blocks, table rows) must survive + * translation untouched. When it does not, content was lost or invented and + * the file is reported, never rewritten -- what is missing cannot be + * reconstructed here, and repairing the rest would only hide it. + * + * References (link targets, image srcs) are compared against English, which + * is the ground truth, and with --fix a drifted target is restored or + * invented markup demoted to plain text. * * Usage: - * node scripts/check-translation-links.mjs [--fix] [--all] [--source en] [--target zh] + * node scripts/check-translation-integrity.mjs [--fix] [--all] [--source en] [--target zh] * * (default scope) files under docs/ that git reports as modified or * untracked -- i.e. the ones translate just (re)wrote @@ -251,6 +259,77 @@ const lcsPairs = (a, b) => { return pairs } +/** Number of fenced code blocks. Masking erases their content but not their count. */ +const countFences = (content) => { + let fence = null + let n = 0 + for (const line of content.split('\n')) { + const m = /^\s*(`{3,}|~{3,})/.exec(line) + if (fence) { + if (m && m[1][0] === fence.char && m[1].length >= fence.length && /^\s*[`~]+\s*$/.test(line)) fence = null + continue + } + if (m) { + fence = { char: m[1][0], length: m[1].length } + n++ + } + } + return n +} + +/** + * Counts a translation must not change. Wording is the translator's business; + * how many sections, code blocks and table rows a document has is not. + * + * This is the part that would have caught the real damage. A run of gpt-4o-mini + * swallowed an entire section plus 1177 lines of YAML out of one article and + * nothing noticed, because the only thing the build checks is whether links + * resolve -- and they did. Two more articles already in the repository turned + * out to be missing a third of their content the same way. + */ +const documentStructure = (content) => { + const masked = maskNonProse(content) + const lines = masked.split('\n') + return { + anchors: (masked.match(/\{#[a-z0-9-]+\}/g) || []), + // Levels rather than text: the text is translated, the shape is not. + headingLevels: lines.flatMap((l) => { + const m = /^(#{1,6}) /.exec(l) + return m ? [m[1].length] : [] + }), + tableRows: lines.filter((l) => l.trimStart().startsWith('|')).length, + codeBlocks: countFences(content), + } +} + +/** Structural differences, phrased so the reader can see what went missing. */ +const structuralProblems = (source, target) => { + const problems = [] + const s = documentStructure(source) + const t = documentStructure(target) + + if (s.anchors.join('\u0000') !== t.anchors.join('\u0000')) { + const missing = s.anchors.filter((a) => !t.anchors.includes(a)) + const extra = t.anchors.filter((a) => !s.anchors.includes(a)) + problems.push( + `heading anchors differ (${t.anchors.length} vs ${s.anchors.length})` + + (missing.length ? ` -- missing ${[...new Set(missing)].slice(0, 8).join(' ')}` : '') + + (extra.length ? ` -- unexpected ${[...new Set(extra)].slice(0, 8).join(' ')}` : '') + + (!missing.length && !extra.length ? ' -- same set, different order' : ''), + ) + } + if (s.codeBlocks !== t.codeBlocks) { + problems.push(`fenced code blocks ${t.codeBlocks} vs ${s.codeBlocks} -- ${Math.abs(s.codeBlocks - t.codeBlocks)} ${t.codeBlocks < s.codeBlocks ? 'lost' : 'invented'}`) + } + if (s.headingLevels.join(',') !== t.headingLevels.join(',')) { + problems.push(`heading outline differs (${t.headingLevels.length} headings vs ${s.headingLevels.length})`) + } + if (s.tableRows !== t.tableRows) { + problems.push(`table rows ${t.tableRows} vs ${s.tableRows} -- ${Math.abs(s.tableRows - t.tableRows)} ${t.tableRows < s.tableRows ? 'lost' : 'invented'}`) + } + return problems +} + /** * Does an image src point at a file that actually exists? This is the same * question rspack asks, and the reason a bogus src fails the build with @@ -380,6 +459,22 @@ const planEdits = (sourceLinks, targetLinks, { label }) => { const relative = (file) => path.relative(repoRoot, file) +// Documents already damaged by an earlier translation run. They are reported as +// KNOWN rather than FAIL so this check can be switched on without main going red +// over debt it did not create -- but they stay listed, and visible, until they +// are retranslated. A file drops off this list by being fixed, never by being +// forgotten. +const knownDamagedFile = path.join(repoRoot, '.translation-known-damage') +const knownDamaged = new Set( + fs.existsSync(knownDamagedFile) + ? fs + .readFileSync(knownDamagedFile, 'utf8') + .split('\n') + .map((line) => line.replace(/#.*$/, '').trim()) + .filter(Boolean) + : [], +) + const targetFiles = (scanAll ? walk(targetDir) : changedTargetFiles()).filter((file) => file.startsWith(targetDir + path.sep), ) @@ -392,6 +487,7 @@ if (targetFiles.length === 0) { let pass = 0 let fail = 0 +let knownCount = 0 let repaired = 0 for (const file of targetFiles.sort()) { @@ -402,8 +498,24 @@ for (const file of targetFiles.sort()) { continue } - const source = extractRefs(fs.readFileSync(sourceFile, 'utf8')) + const sourceContent = fs.readFileSync(sourceFile, 'utf8') let content = fs.readFileSync(file, 'utf8') + + // Shape before wording. If whole sections are missing, no amount of link + // repair makes the document publishable, and repairing it anyway would only + // make the damage quieter. + const structural = structuralProblems(sourceContent, content) + if (structural.length > 0) { + const known = knownDamaged.has(relative(file)) + if (known) knownCount++ + else fail++ + console.log(`${known ? 'KNOWN' : 'FAIL '} ${relative(file)} does not match ${relative(sourceFile)}:`) + for (const problem of structural) console.log(` ${problem}`) + if (!known) console.log(' translation lost or invented content -- retranslate; this is not repairable here') + continue + } + + const source = extractRefs(sourceContent) let target = extractRefs(content) const describe = (edit) => { @@ -470,5 +582,5 @@ for (const file of targetFiles.sort()) { if (FIX && repaired > 0) { console.log(`repaired ${repaired} reference(s) against ${SOURCE_LANG}`) } -console.log(`== result: ${pass} pass / ${fail} fail ==`) +console.log(`== result: ${pass} pass / ${fail} fail${knownCount ? ` / ${knownCount} known-damaged` : ''} ==`) process.exit(fail > 0 ? 1 : 0) From 8726748a82ba4a4181fe5cd8bb334a67e0b17183 Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 14:55:15 +0900 Subject: [PATCH 07/17] docs: disable auto translation for pipeline policy solution - Add the KB document id metadata - Prevent machine translation from rewriting the English page - Keep the solution page aligned with the source target --- .../Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md b/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md index f076d3a3f..0545a0a9b 100644 --- a/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md +++ b/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md @@ -6,6 +6,9 @@ kind: - Solution ProductsVersion: - 4.3.x +id: KB260800021 +i18n: + disableAutoTranslation: true --- # Pipeline Policy Constraints with Tekton and Kyverno From fd637e6b2bb4b746f8efc783921a6611304283d2 Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 06:27:47 +0000 Subject: [PATCH 08/17] ci: fail a translation that quietly drops prose The structural checks only see what a translation changed the shape of. A swallowed paragraph takes no heading, no code fence and no table row with it, so a page can lose a third of its text and still pass. That is how three articles in this repository ended up short without anyone noticing. Compare volume and digits instead: prose line count and the multiset of numbers outside URLs. Both must drop together before this fails -- Chinese legitimately merges English lines (healthy pages go as low as 0.33) and rewording loses the odd number, but no healthy page does both. Measured over all 401 en/zh pairs: 0 of 396 healthy pages flagged, and every content-losing entry in .translation-known-damage caught. URL digits are excluded because [http://x](http://x) and carry them a different number of times; links are checked separately anyway. --- scripts/check-translation-integrity.mjs | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/check-translation-integrity.mjs b/scripts/check-translation-integrity.mjs index 04dbe83c6..252e3875a 100644 --- a/scripts/check-translation-integrity.mjs +++ b/scripts/check-translation-integrity.mjs @@ -299,9 +299,34 @@ const documentStructure = (content) => { }), tableRows: lines.filter((l) => l.trimStart().startsWith('|')).length, codeBlocks: countFences(content), + // Volume and identifiers: what a translation that silently drops prose + // cannot fake. Wording is the translator's business; how much text there is, + // and which numbers it carries, is not. + proseLines: lines.filter((l) => l.trim()).length, + // URLs are excluded: their digits belong to the link, which is checked + // separately, and the two languages legitimately spell the same link + // differently -- [http://x](http://x) carries the number twice where the + // autolink carries it once. + numbers: masked.replace(/ { + if (source.length === 0) return 1 + const pool = new Map() + for (const n of target) pool.set(n, (pool.get(n) || 0) + 1) + let kept = 0 + for (const n of source) { + const left = pool.get(n) || 0 + if (left > 0) { + pool.set(n, left - 1) + kept++ + } + } + return kept / source.length +} + /** Structural differences, phrased so the reader can see what went missing. */ const structuralProblems = (source, target) => { const problems = [] @@ -327,6 +352,23 @@ const structuralProblems = (source, target) => { if (s.tableRows !== t.tableRows) { problems.push(`table rows ${t.tableRows} vs ${s.tableRows} -- ${Math.abs(s.tableRows - t.tableRows)} ${t.tableRows < s.tableRows ? 'lost' : 'invented'}`) } + + // Dropped prose keeps every count above intact -- a swallowed paragraph takes + // no heading, no code fence and no table row with it. What it does take is + // volume and the digits that were in it. Both signals must fall together + // before this fires: Chinese legitimately merges English lines (healthy pages + // go as low as 0.33), and rewording legitimately loses the odd number, but no + // healthy page in this repository does both at once. Measured over all 401 + // en/zh pairs: 0 of 396 healthy pages flagged, and every content-losing entry + // in .translation-known-damage caught. + const lineRatio = s.proseLines ? t.proseLines / s.proseLines : 1 + const kept = numbersKept(s.numbers, t.numbers) + if (lineRatio < 0.9 && kept < 0.9) { + problems.push( + `prose volume is ${Math.round(lineRatio * 100)}% of the original and ${Math.round((1 - kept) * 100)}% of its numbers are gone` + + ' -- text was dropped rather than translated', + ) + } return problems } From 6af998e6db9e28e385d96aaf24154f672f75e8f9 Mon Sep 17 00:00:00 2001 From: qingliu Date: Fri, 28 Aug 2026 07:03:40 +0000 Subject: [PATCH 09/17] docs: add the Chinese translation of the pipeline policy solution Translated in 14 heading-aligned slices with the 160 code blocks lifted out first, so no slice boundary ever falls inside a fenced block and the code is restored byte for byte rather than retyped by a model. The English page carries i18n.disableAutoTranslation, so this file is the Chinese version from now on. Verified before assembly: the English slices reassemble to a byte-identical copy of the source, every cut lands on a balanced boundary, and each slice matches its original on line count, paragraph blocks, headings, anchors, table rows, list items, bold spans, numbers and inline code spans. After assembly all 160 code blocks are byte-identical, no link or image target was lost or invented, and the build emits the page with no dead links. --- ...icy_Constraints_with_Tekton_and_Kyverno.md | 11527 ++++++++++++++++ 1 file changed, 11527 insertions(+) create mode 100644 docs/zh/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md diff --git a/docs/zh/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md b/docs/zh/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md new file mode 100644 index 000000000..4b647de51 --- /dev/null +++ b/docs/zh/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md @@ -0,0 +1,11527 @@ +--- +products: + - Alauda Container Platform + - Alauda DevOps +kind: + - Solution +ProductsVersion: + - 4.3.x +id: KB260800021 +sourceSHA: 32cdbf0c2930400cac1878695c0c30b1cf532fc3e7803538b5e2858fa3310476 +--- + +# 基于 Tekton 与 Kyverno 的流水线策略约束 + +:::info 适用版本 + +**适用于:Alauda DevOps Pipelines v4.14.x 及更高版本** —— 判断标准是该版本,而不是 ACP 版本(本文档依赖 Alauda DevOps Pipelines 附带的 Tekton API 与特性;ACP 版本只决定能否安装 Kyverno 插件)。在更早版本上这些特性并不完整:本文档中的策略资产与示例无法原样套用(硬性前置条件见 [§3.2](#s3-2)),但其中的机制与设计取舍仍值得一读。本文档中所有机制讲解、策略资产与量化数据均基于以下版本组合产出: + +| 组件 | 版本 | 角色 | +|---|---|---| +| Alauda DevOps Pipelines(Tekton Pipelines 的 ACP 发行版) | v4.14.x | **适用性判断标准** —— 低于该版本时,策略资产不适用 | +| Alauda Artifact Hub Shim(ACP 内置 hub:供 Tekton hub resolver 消费的 Artifact Hub 兼容 API;本文档所引用的 catalog Task / Pipeline 定义的发布来源) | v1.0.0 | [§3.2](#s3-2) 契约矩阵中的模板 / Task 定义随其一同发布 | +| Kyverno(ACP 合规管理插件) | v1.15.9-v4.3.2 | 策略引擎;由 ACP 的合规管理插件交付 | +| Alauda Container Platform | 4.3 | 承载上述两者的平台(本文档的验证环境) | + +**每当变更版本都要重新测试。** 这些机制通常向后兼容,但 result 与参数契约会随 Task 与模板版本变化(见 [§3.2](#s3-2) 中的矩阵),跨版本套用会以**静默不匹配**的方式失败——失败形态不是报错:策略仍是 `Ready`,报告仍然干净,而你关心的路径只是不再被看住了。本文档中的具体数字同样依赖运行环境(规模、网络、负载);写入变更单之前请先在目标环境重新实测。对于此表之外的任何组合,切换到 Enforce 之前先按 [§3.4](#s3-4) 跑正/负探针回归;上线后,每当 Kyverno / Tekton / 模板 / Task / ACP 任一升级,用 [§3.6](#s3-6) 定位受影响的判据,并按 [§3.8](#s3-8) 跑最小回归集。 + +::: + +## 1. 概述 {#s1} + +在平台工程实践中,CI/CD 流水线是每个变更进入生产的必经之路——这使它成为落实组织工程规范的关键抓手。常见的治理诉求包括: + +- **模板失控蔓延**:业务团队绕过平台认可的流水线模板,自行拼装缺少质量步骤的流水线; +- **门禁被关掉**:模板中的代码扫描与质量门禁被一个参数就禁用(例如把扫描开关设为 false)——流水线“看起来在跑模板”,而关键步骤从未执行; +- **未授权的来源与目标**:制品从未经批准的仓库拉取,应用被部署到未授权的 namespace; +- **不达标的结果照样发布**:覆盖率或漏洞数没达标,流水线却照样走完发布阶段。 + +本文档介绍如何在 Alauda Container Platform (ACP) 上使用 **Kyverno** 对基于 **Tekton** 的流水线实施策略约束。它不是逐条规则的操作指南,而是聚焦**机制**:Kyverno 在流水线生命周期各处能看到什么、何时看到、能做什么动作(拦截、审计、注入、取消)——以及如何基于这些机制点,结合自定义 Task 与 Task results,构建适合你所在组织的策略体系。 + +### 1.0 读完之后你将能做什么 {#s1-0} + +按 [§3](#s3) 准备好环境后,你应当能够: + +- **判定某项治理诉求该归哪一层负责**——哪些 Kyverno 能在 admission 阶段拦截、哪些只有可信模板的构建方式才能保证、哪些必须交给 RBAC 或事后审计([§1.4](#s1-4) 边界、[§2.3](#s2-3) 七条契约); +- **锁定模板与 Task 身份**,让业务团队无法改动“用哪个模板、哪个版本”([§4.1](#s4-1)); +- **校验门禁参数的生效值**,让“把扫描开关设为 false”或“把阈值降到 0”这类改动在门禁 TaskRun 创建的那一刻就被拒绝([§4.2](#s4-2)); +- **约束来源与发布目标**——只允许从批准的仓库/镜像仓库拉取物料,只允许发布到授权的 namespace([§4.5](#s4-5)); +- **封住绕过流水线的入口**——裸 TaskRun、未经批准的内联定义与 resolver 类型([§4.5.4](#s4-5-4)); +- **消费自定义 Task results** 用于审计、报表与自动取消,把你的自研检查纳入同一套治理体系([§2.4](#s2-4)、[§4.4](#s4-4)、[§4.6](#s4-6)); +- **安全地做差异化与豁免**——平台基线加项目收紧的两层模型、经由 PolicyException 的受控豁免,并确保作用域本身不会被绕过([§5](#s5)); +- **运营整套体系**——分阶段上线顺序、变更与升级触发点、规模与失败预算,以及升级后要跑的最小回归集([§3.5](#s3-5)–[§3.8](#s3-8))。 + +**不在本文范围内**:镜像签名与供应链证明(见配套文档 *Software Supply Chain Security of ACP with Tekton and Kyverno*)、Kyverno 自身的安装与运维(见 ACP 合规管理文档),以及如何编写流水线模板——本文档只陈述模板必须满足的契约。 + +**最短评估路径**:如果你只想确认这套机制能否拦住你关心的场景,读 [§1.4](#s1-4) + [§2.3](#s2-3)。要动手实践,按 [§1.1](#s1-1) 中按角色划分的路径走。 + +### 1.1 目标读者与阅读路径 {#s1-1} + +| 角色 | 关注点 | 建议路径 | +|---|---|---| +| 平台管理员(编写策略、管理作用域) | 完整机制图景、作用域安全、策略资产 | [§2](#s2) 机制总览(先弄清能看到什么、能做什么)→ [§3](#s3) 通用配置(安装、验证、构建夹具)→ [§5](#s5) 作用域控制 → [§4](#s4) Cookbook → [§6](#s6) FAQ | +| 项目管理员(维护项目级约束) | Namespaced `Policy`、项目级收紧、权限边界 | [§1.3](#s1-3) 项目差异化与作用域安全(两层模型)→ [§5.1](#s5-1)–[§5.2](#s5-2) 作用域与 RBAC → [§4](#s4) Cookbook(按需取用;记得把示例中的跨 namespace 作用域改写成你自己 namespace 里的 `Policy`) | +| 模板 / Task 作者(提供被治理的流水线) | 硬门禁契约、扩展契约 | [§2.3](#s2-3) 硬门禁契约 → [§2.4](#s2-4) 扩展模型 → [§3.2](#s3-2) 版本与依赖特性 → [§3.3](#s3-3) 夹具 → [§4.3](#s4-3) 真实门禁失败 → [§4.1](#s4-1)–[§4.2](#s4-2) 中的相关部分 | +| 流水线用户(运行流水线、被策略拦截) | 失败形态速查、豁免路径 | [§1.5](#s1-5) 结果形态速查 → [§6.2](#s6-2) 用户侧 FAQ(只有当你的 run 被自动取消时才需要读 [§6.2.3](#s6-2-3)) | +| Walkthrough 操作者(把整篇文档当实验跑一遍) | 策略与运行清单可直接复制粘贴;探针需自己基于 [§3.4.1](#s3-4-1) 骨架组装(九个小节只给出期望表);并且在共享集群上不留残留 | [§3.1](#s3-1) 验证 → **[§3.2](#s3-2) 先确认 object results 已启用**(`enable-api-fields`;可接受取值见 [§3.2](#s3-2)——若未开启,第一个夹具创建就会被拒绝,报错看起来像 Kyverno 的问题)→ **[§4.0.3](#s4-0-3) 占位符 + [§4.0.4](#s4-0-4) 清理纪律(创建任何东西之前先读:自建 namespace 加上对集群级资源名冲突的预检查,才能保证事后删得掉)** → [§3.3](#s3-3) 构建夹具并**随手记好你的 walkthrough id** → [§4.0.1](#s4-0-1) 安装顺序 + **[§4.0.5](#s4-0-5) 各示例之间的跨小节干扰**(“探针跑不起来”的头号原因)→ 你的目标小节(**每做完一节立刻执行该节的“清理”**——不要攒到最后一起做)→ [§3.3](#s3-3) 的“最终清理”删除两个共享 namespace;如果你做了 [§5.3](#s5-3),最后再回到 [§3.1.1](#s3-1-1) 还原平台配置 | + +**[§3.1](#s3-1) 检查清单中有几项是前向引用**([§3.1.1](#s3-1-1) 中的 `--exceptionNamespace`、[§4.6](#s4-6) 引言中的 mutate-existing RBAC、[§6.1.8](#s6-1-8) 中的副本规划):那份清单是一份**能力盘点**,不是“全部通过才能继续”的闸门——第 1、2 项是共享前置条件;其余各项按你实际用到哪一章的能力再回头处理。 + +### 1.2 Kyverno 简介 {#s1-2} + +Kyverno 是 Kubernetes 原生的策略引擎(CNCF 项目),在 ACP 上通过合规管理(Kyverno 插件)交付。与流水线治理相关的核心概念: + +- **架构**:admission 控制器(admission webhook——执行 validate / mutate / 镜像校验)、background 控制器(扫描既有资源、执行 mutate-existing / generate)、reports 控制器(产出合规报告)、cleanup 控制器(周期性清理)。 +- **策略资源**:`ClusterPolicy` 是集群级资源,由平台管理员维护,既能匹配整个集群范围内的 namespaced 资源,也能匹配集群级资源;`Policy` 是 namespaced 资源,只作用于其自身 `metadata.namespace` 内的资源——是让项目管理员自维护本项目约束的合适载体。规则(rule)不是独立的 Kubernetes 资源;它内嵌在策略的 `spec.rules` 中,每条规则 = `match/exclude`(选择哪些资源与操作)+ 可选的 `preconditions`(进一步过滤)+ 一个动作。 +- **动作类型**: + - `validate`:校验资源。`Enforce` 模式下在 admission 阶段拒绝;`Audit` 模式下放行请求,但把结果记录到 **PolicyReport**; + - `mutate`:在 admission 阶段修改资源(注入默认值);其 **mutate-existing** 变体可以在触发事件发生时,修改集群中**已经存在**的其他资源; + - `generate`:在被触发时创建新资源; + - `verifyImages`:镜像签名校验(本文不涉及——见配套文档 *Software Supply Chain Security of ACP with Tekton and Kyverno*)。 +- **PolicyException**:受控豁免机制——把“谁可以绕过哪条规则”变成一个由 RBAC 管辖的独立资源([§5.3](#s5-3))。 +- **工作原理**:策略加载后被注册为 admission webhook;每个匹配的 API 请求(CREATE/UPDATE/……)都会经过策略评估。Audit 结果与后台扫描结果都会落入 PolicyReport。 + +Kyverno 的完整能力见 ACP 合规管理文档与上游 Kyverno 文档([§8.2](#s8-2) 参考资料);本文档只展开与流水线治理相关的用法。 + +**贯穿全文的术语**(这些词分属不同层面;混为一谈会让你误判策略在哪里起作用): + +| 术语 | 含义 | 不是什么 | +|---|---|---| +| **policy(策略)** | 一个 Kyverno `ClusterPolicy` / `Policy` 资源 | 不是流水线里的某个门禁步骤 | +| **rule(规则)** | 策略 `spec.rules` 中的一个条目(`match` + 可选 `preconditions` + 一个动作) | 不是独立的 Kubernetes 资源 | +| **criterion(判据)** | 规则内部判定合规/不合规的布尔表达式(通常写成 `context` 中的 JMESPath 变量) | 不是某个 YAML 结构的名字 | +| **`deny.conditions`** | 承载判据的 YAML 结构;`any:` 之下命中一条即拒绝,`all:` 之下所有条件都必须成立 | — | +| **guard(precondition,守卫)** | 决定规则是否对当前请求生效的条件:身份、终态、列表唯一性等。不匹配意味着**跳过(放行)**,而不是拒绝 | 不是判据;把判据写成守卫等于放行一切 | +| **gate / gate Task(门禁 Task)** | 流水线中给出质量裁决的 Tekton Task(不达标时 `exit 1`),例如 `sonarqube-scanner`、`trivy-scanner` | 不是 Kyverno 的动作 | +| **DAG**(有向无环图) | 流水线各 task 之间的依赖图。Tekton 由 `runAfter` 加上 task 之间的 result 引用推导出它:有依赖的 task 按序执行,相互独立的并行执行,且不允许出现环。“门禁的 DAG 后继”指直接或传递依赖门禁的那些 task;门禁失败时它们会被**跳过**——根本不会被创建 | 不包含 finally task——finally 不属于 DAG;它只在整个 DAG 结束后才被调度(这一区别是 [§2.3](#s2-3) 结果形态表的关键) | +| **profile** | 针对某个真实模板 / Task 的**特定版本**编写的一组判据 | 不是通用模板 | + +一句话串起来:**门禁 Task 的职责是拦住不合格的构建;Kyverno 的职责是确保门禁 Task 该在的时候在、且参数没有被篡改**([§1.4](#s1-4))——并注意“该在的时候在”不等于“保证会运行”:被 `when` / matrix 整体跳过的门禁从不产生 TaskRun,admission 根本看不到它,只有事后审计才能抓住([§4.1.5](#s4-1-5))。 + +### 1.3 项目差异化与作用域安全 {#s1-3} + +不同项目几乎必然需要不同的约束:项目 A 把覆盖率线定在 80,项目 B 定在 60;同时平台还有一组任何人都不得逾越的红线。**差异化是硬需求——但其实现方式绝不能给策略开出绕行漏洞。**本文档中的每条策略都遵循两层模型(细节与验证见 [§5](#s5)): + +- **平台基线**:一个覆盖**所有业务 namespace** 的 `ClusterPolicy`,用**否定式 `exclude`** 剔除平台自身的系统 namespace。基线**不得**依赖“该 namespace 带有某个标签”——否则新建的无标签 namespace,或标签被改掉的 namespace,会天然逃出基线之外。 +- **项目级收紧**:项目管理员的主路径是在自己的项目 namespace 内维护 namespaced `Policy` 资源——他们不需要、也不应被授予 `ClusterPolicy` 权限。若由平台团队为多个项目集中管理策略,可用 `ClusterPolicy` + `namespaceSelector`(例如基于 `cpaas.io/project` 标签)圈选目标项目。 + +**本节描述的是目标治理模型,不是本文档演示资产的现状**:[§4](#s4) 中的每条策略都把作用域硬编码到演示 namespace `policy-poc`,以便统一安装与清理([§4](#s4) 引言、[§4.0.2](#s4-0-2))。**“覆盖所有业务 namespace”是你在生产部署时要自己修改的**——原样照抄演示 YAML 不会覆盖任何真实项目,新建的 namespace 当然也不会被自动纳入(这正是 [§3.6](#s3-6) 列出的第一个触发点)。 + +与之配套的语义(同样只有在按上述目标模型部署后才成立):未归类的 namespace 必然落入基线;多条策略匹配同一资源时其关系是 **AND**(全部通过才行;不存在项目 `Policy` 覆盖或削弱平台基线的优先级语义);改动作用域标签本身的权限也必须受控([§5.0](#s5-0))。注意 `Policy` 的作用域是单个 Kubernetes namespace;若一个 ACP 项目横跨多个 namespace,就在每个 namespace 里各部署一份对应的 `Policy`,或由平台通过受控的集中机制统一分发。 + +### 1.4 角色与边界:Kyverno 管什么、不管什么 {#s1-4} + +一句话说清分工:**硬门禁由流水线内的门禁 Task 实现(不达标 → `exit 1` → 流水线原生失败);Kyverno 的角色是收窄门禁被移除、被篡改、被从侧面绕过的路径——并提供审计与响应动作。** + +**这里刻意没有说“不可能绕过”**——那个属性只能由**策略 + RBAC + 模板设计三者合力**产生;单靠 Kyverno 给不出来。下文“做不到”清单中的**最后三项**对应**不由 Kyverno 承担**的两类职责——“门禁与发布之间的接线”属于**模板设计**,而“绕开 Tekton 的路径”与“保护策略体系自身”属于 **RBAC**。全文的条件式表述见 [§4.0.1](#s4-0-1) “最小可用集的保证是有条件的”;逐项暴露面见 [§2.5](#s2-5)。 + +Kyverno 能做什么: + +- **在 admission 阶段做硬校验**:在 PipelineRun / TaskRun / Pod 创建时拦截——模板身份不合规、门禁参数被关掉、镜像来源未授权;对象根本创建不出来,流水线以清晰的失败形态终止([§2.1](#s2-1)、[§4](#s4)); +- **审计可见性**:在资源 status 更新时读取运行结果(覆盖率、漏洞数、扫描裁决),把不达标记录进 PolicyReport([§4.4](#s4-4)); +- **注入默认值**:在 admission 阶段 mutate(默认超时、标签等,[§4.2](#s4-2)); +- **响应动作**:对运行中的流水线执行受控取消(以 mutate-existing 补丁写 `spec.status`,[§4.6](#s4-6))。 + +Kyverno 明确做不到的(边界): + +- **它无法把运行中的流水线变成 Failed**:PipelineRun/TaskRun 的终态由 Tekton 控制器决定。想要“结果不达标 → 失败”,正确答案是让门禁 Task 自己 `exit 1`;Kyverno 能做的是**取消**(终态 Cancelled,[§4.6](#s4-6))。 +- **绝不要用 Enforce 拦截对 `*/status` 子资源的写入**:你拦下的将是 Tekton 控制器的状态回写。结果是资源卡在 Running、控制器无限重试(wedge 卡死)——而不是失败([§2.2](#s2-2)、[§6.1.4](#s6-1-4))。 +- **远程引用的定义(hub / git resolver)永远不经过集群 admission**:Kyverno 只能锁定**身份**(哪个 catalog 条目、哪个 commit);对内容的信任来自外部治理(catalog 发布流程、仓库权限)。三档强度见 [§2.1](#s2-1)。 +- **它看不见被跳过的门禁**:当 `when` 表达式为假、或 matrix 展开为空时,该门禁**从不产生 TaskRun**,admission 没有可拒绝的对象——“门禁必须运行”只能靠模板设计(不要给门禁一个业务团队能关掉的 `when`)加上 [§4.1.5](#s4-1-5) 对 `status.skippedTasks` 的**事后 Audit** 来保证。它不是 admission 时的硬拦截。 +- **它看不见门禁与发布之间的接线是否正确**:门禁消费的是不是**预期那个 task** 的 result([§2.3](#s2-3) 契约 4)、发布类 task 是否排在门禁之后(契约 5)、finally 里是否藏着受门禁保护的副作用(契约 6)——这三项是**模板设计的职责**。契约 4 / 5 / 6 在 admission 侧连现成的事后 Audit 都没有(在 [§2.3](#s2-3) 的表中它们唯一的保证者是 `T`;[§4.1.4](#s4-1-4) 只审计门禁的**身份**,既不读 `runAfter` 也不读 `finally`——它所挂靠的已解析定义快照是你想自建这种 Audit 时的抓手,取舍见 [§4.1](#s4-1) 引言末尾)。**“门禁在、参数没被关掉”不等于“门禁真正管住了发布”**——这就是上文“三者合力”那句话里**模板设计**的份额。 +- **它无法拦截完全绕开 Tekton 的路径**:拥有工作负载 API 权限的身份可以直接创建 Pod / Job / Deployment,或把部署凭证用在别处,全程不产生一个 PipelineRun。**只有 RBAC 能封住这一层**([§4.5.4](#s4-5-4))——本文档的入口封堵策略封的是裸 `TaskRun` / `CustomRun`,不是所有能跑容器的 API。 +- **它无法保护自己**:本文档的每个结论都建立在“策略体系与 Kyverno 自身配置受控”之上。能改 `ClusterPolicy` / `PolicyException` 的人就能改门禁([§5.3](#s5-3) / [§5.0](#s5-0));能改 Kyverno 的 `resourceFilters` 或其 webhook 的人能让一整章策略**静默失效**([§3.1](#s3-1) 清单第 7 项 / [§5.0](#s5-0));能改 Tekton 平台配置的人能换掉模板解析源([§4.1.1](#s4-1-1))或破坏镜像策略依赖的作用域标签([§3.6](#s3-6))。**这些身份在本文档的威胁模型之外**——靠 RBAC 职责分离、变更审计与策略体系的自我保护([§5.0](#s5-0))来封堵,而不是再写一条策略。 + +### 1.5 结果形态速查(面向流水线用户) {#s1-5} + +当策略作用到你的流水线时,你会看到以下六种形态之一(机制见 [§2](#s2),排障见 [§6](#s6))——**注意最后一种是“你什么都看不到”**: + +| 你看到什么 | 它意味着什么 | 去哪里找原因 | +|---|---|---| +| 创建 PipelineRun 被直接拒绝(kubectl / UI 显示 admission 报错) | Admission 拦截:模板 / 参数 / 入口不合规 | 报错信息本身就是策略消息(策略名、规则名、原因) | +| PipelineRun 以 reason `CreateRunFailed` 失败;流水线中途某个 Task 从未被创建 | 运行中段的 admission 拦截:某个门禁 Task 的生效参数不合规 | `kubectl describe pipelinerun`;condition 消息中携带完整的策略消息 | +| PipelineRun 以 reason `Failed` 失败;门禁 Task 是红的 | 真实的质量门禁失败(覆盖率 / 漏洞不达标)。**例外**:`spec.status` 若持有**取消值**(`Cancelled` / `CancelledRunFinally` / `StoppedRunFinally`),意味着**确有某人——或某条策略——请求过取消**,只是 task 自身的失败抢在了它前面;仅凭 `spec.status` 无法判断是谁写入的 | 门禁 Task 的日志;若 `spec.status` 持有取消值,按 [§6.2.3](#s6-2-3) 查找 `cancel-reason` / `statusMessage`——这些标记只指向策略取消(确认写入者需要审计日志,见该节);没有这些标记则来源未知(手工取消看起来一模一样)。**不要把“非空”等同于“被取消”**:该字段还有一个与取消无关的合法取值 `PipelineRunPending`(见 [§6.2.3](#s6-2-3)) | +| TaskRun 以 reason `PodCreationFailed` 失败;Pod 从未出现 | Pod 层的 admission 拦截:该步骤的容器镜像不在批准列表内([§4.5.3](#s4-5-3)) | `kubectl describe taskrun`;消息中携带完整的策略消息 | +| PipelineRun 变成 `Cancelled`(而你并没有取消它) | **头号嫌疑是策略取消——但不要急着下结论**:取消字段是 Tekton 的公共字段,另一位用户、某个运维工具或其他自动化写入的方式完全相同;[§6.2.3](#s6-2-3) 的标记只指向策略取消(确认写入者需要审计日志——见该节)。策略侧**有四种可能来源**,按 [§6.2.3](#s6-2-3) 的排障顺序列出:门禁 TaskRun 被取消([§4.2.3](#s4-2-3))、父 run 被取消([§4.2.2](#s4-2-2))、定义漂移([§4.6.2](#s4-6-2))、结果不达标([§4.6.1](#s4-6-1)) | 证据只存在于两处:第一种在那个门禁 TaskRun 上;后三种共用父 run 的 `cancel-reason` 注解,靠其文本区分。按 [§6.2.3](#s6-2-3) 给出的顺序逐一排查(机制差异汇总在 [§4.6](#s4-6) 引言的表中) | +| **流水线完全正常、全绿——却仍然记录了一条违规** | Audit 模式的策略只记录不拦截([§4.4](#s4-4))。**“跑过了”不等于“合规”**:[§4](#s4) 中有多条纯 Audit 策略,[§4.2.4](#s4-2-4) 还有一条策略带 Audit 规则——它们对你完全不可见(哪些是 Audit 见 [§4.0.2](#s4-0-2) 的策略速查) | 只在 PolicyReport 里:`kubectl get policyreport -n `,查找 `result: fail` 的条目([§6.1.5](#s6-1-5)) | + +## 2. 理解机制 {#s2} + +本章是全文的核心。贯穿后文的有两个模型: + +- **模型 1:生命周期观察/动作矩阵([§2.1](#s2-1)–[§2.2](#s2-2))**——Kyverno 沿流水线生命周期能看到什么、何时看到、能做什么; +- **模型 2:信任与硬门禁契约([§2.3](#s2-3))**——构成“不可绕过的质量门禁”的七条契约,以及每一条由谁保证。 + +Cookbook([§4](#s4))的每一节都是这两个模型在具体场景下的实例化。 + +### 2.1 生命周期观察/动作矩阵 {#s2-1} + +引用式流水线(`pipelineRef` 指向模板)的典型生命周期,以及 Kyverno 的介入点: + +```text +Pipeline/Task definition stored (CREATE/UPDATE) ← observation point 1 (in-cluster definitions only) + │ +PipelineRun CREATE ── admission ─────────────── ← observation point 2 (the primary hard blocking point) + │ resolver resolution (cluster/hub/git) +PipelineRun status UPDATE (resolution written) ── ← observation point 3 (the only place a referenced definition can be introspected) + │ TaskRuns created one by one +TaskRun CREATE ── admission ─────────────────── ← observation point 4 (hard blocking point after parameter expansion) + │ execution Pod created +Pod CREATE ── admission ─────────────────────── ← observation point 5 (hard blocking point for the images that actually run) + │ execute, write back results +TaskRun status UPDATE (results written) ──────── ← observation point 6 (the only source of results) + │ +PipelineRun status UPDATE (terminal state, pipelineResults) +``` + +| # | 观察点 | 能看到什么 | 能做什么 / 注意事项 | +|---|---|---|---| +| 1 | Pipeline / Task 定义资源 CREATE/UPDATE(**仅限集群内定义**) | 完整定义 spec 可审视:tasks、finally、参数默认值、标签 | 理论上这里能做两件事:对存储内容做 Enforce validate(必须包含门禁 task 等)+ 锁定变更权限。**本文档只用了后者**,且交给标准 RBAC 而不是策略([§4.1.2](#s4-1-2))——**本文档没有任何一条策略匹配 `Pipeline` / `Task` 定义资源**;原因见 [§4.1](#s4-1) 引言(包括“什么样的场景值得自建”)。**覆盖分三档**:① 内联 / 集群内直接引用——admission 阶段可审视、可锁定;② hub / git **不可变引用**(钉住版本 / commit SHA)——集群内只能锁定**身份**;对内容的信任来自外部 catalog / 仓库治理;③ hub / git **可变引用**(分支 / tag)——远端一动内容就自动生效;Kyverno 只能锁定“引用了哪个分支 / tag”。使用这一档需要仓库侧权限控制(受保护分支 / tag);否则就应当拒绝 | +| 2 | `PipelineRun` CREATE admission | `pipelineRef`(resolver 类型 + 全部 resolver 参数)、**带值的 `spec.params`**、workspaces、标签、**`request.userInfo`**(创建者身份) | Enforce:模板身份白名单、PipelineRun 级参数契约、入口身份约束;mutate:注入默认值(超时 / 标签,[§4.2.6](#s4-2-6))。⚠️ 对引用式流水线,此刻 `spec.pipelineSpec` 是**空的**——定义内容不可见,task 级参数同样不可见 | +| 3 | `PipelineRun/status` UPDATE(子资源) | resolver 解析出的 **`status.pipelineSpec`**(集群内唯一能审视被引用定义的地方)、`status.childReferences`、**`status.skippedTasks`**(每个被跳过 task 的 `name` + `reason` + `whenExpressions`;`reason` 取值来自 Tekton 的 `SkippingReason` 枚举)、`status.pipelineResults`(仅在完成后才出现——对 admission 而言为时已晚) | 过了 admission = 事后视角。**绝不要 Enforce 拒绝**(wedge 卡死,[§2.2](#s2-2))。正确用法:**作为纵深防御的 Audit**(解析出的定义缺少门禁 task → 记入 PolicyReport,[§4.1.4](#s4-1-4);门禁被 `when` / 空 matrix 跳过 → 读 `status.skippedTasks` 并记录,[§4.1.5](#s4-1-5));**响应动作**:触发自取消([§4.6.2](#s4-6-2)) | +| 4 | `TaskRun` CREATE admission | `spec.taskRef`(resolver + kind/catalog/name/version/namespace)、标签(可见但**不可信**:`tekton.dev/pipeline` / `tekton.dev/pipelineTask` / `tekton.dev/pipelineRun` 可通过 `taskRunSpecs` 覆盖——可作排障线索,绝不可用来定位可信 profile 或父 run)、`request.userInfo`、控制器 ownerReference,以及 **`spec.params` = 展开后的生效参数值**(`$(params.x)` 已解析为具体值——**task 级门禁参数无需上提到 PipelineRun 层即可校验**);step 镜像仅对内联 taskSpec 可见。⚠️ `tekton.dev/task` 在最终的 TaskRun 上可见,但在真正的 CREATE admission 时刻可能尚未存在,因此同样不能在这一阶段充当身份 precondition;父身份必须由控制器 ownerReference + 对在线父资源 UID/`spec.pipelineRef` 的 `apiCall` 推导 | Enforce:**门禁 task 生效参数的校验**(deny → 父 run 以 `CreateRunFailed` 干净失败,策略消息原样透传进 run condition,[§4.2](#s4-2))、裸 TaskRun 封堵([§4.5.4](#s4-5-4))、taskRef 白名单。⚠️ 流水线未绑定的参数**不会出现**在 `spec.params` 中(生效的是 task 定义的默认值)——只有当 `spec.taskRef` 已锁定到默认值可信的确切 Task 版本时,策略才可以把“缺席”解释为该可信默认值;身份不可信或默认值未知时必须失败关闭(fail closed) | +| 5 | **Pod CREATE / 普通 UPDATE / `Pod/ephemeralcontainers` UPDATE admission**(Tekton 执行 Pod、运行中的镜像更新、事后注入的调试容器) | CREATE 与普通 UPDATE 暴露实际的 step / sidecar / init 容器镜像、securityContext、标签(`tekton.dev/taskRun` 等)、volumes;子资源 UPDATE 暴露 `spec.ephemeralContainers` | **对实际运行镜像的可靠硬拦截点**(执行镜像不合规 → TaskRun `PodCreationFailed`;普通 UPDATE 上不合规的主/init 镜像与不合规的 ephemeral 镜像补丁以同样方式被拒绝,[§4.5.3](#s4-5-3))。**这一层能做的**:镜像仓库白名单、digest 要求、禁止特权、镜像签名校验(verifyImages);**本文档只提供仓库前缀白名单**([§4.5.3](#s4-5-3))——digest / 特权 / 签名各需单独的策略;verifyImages 见配套文档 | +| 6 | `TaskRun/status` UPDATE(子资源) | **Task results**(object result 逐层下钻 / 聚合字符串解析)与终态——**results 的唯一来源** | 一次运行会触发多次 UPDATE,因此必须有终态守卫([§4.4](#s4-4));只能用于 **Audit** 或作为 **mutate-existing 触发器**(取消,[§4.6](#s4-6))——**绝不要 Enforce**(wedge 卡死);这些策略还必须声明 `failurePolicy: Ignore`——否则在 Kyverno 故障期间,API server 会替它们拒绝状态回写([§3.7](#s3-7) 分级) | +| 7 | Pod status / 事件 | 运行时的失败现场 | 仅用于排障观察([§6](#s6));不承载策略动作 | +| 8 | 外部数据源 | `context.apiCall`(admission 期间查询集群内其他资源:Pipeline 定义、父 PipelineRun……)、`context.imageRegistry`(读取镜像配置;用法见 [§4.5.2](#s4-5-2)) | apiCall 的 JMESPath 语法很严格([§6.1.7](#s6-1-7));imageRegistry 只能读取镜像仓库中已存在的镜像,且会把外部网络调用放到 admission 路径上(延迟与超时风险见 [§4.5.2](#s4-5-2))。**apiCall 失败后走哪个方向由它所在的规则决定,而不是机制本身**:在同步 `validate` 规则上([§4.2.1](#s4-2-1)),无法完成的查询——目标不可达、不存在或被禁止——会让规则报错、请求被拒绝(fail-closed);在 mutate-existing 规则上([§4.2.2](#s4-2-2) / [§4.6.1](#s4-6-1)),它运行在 background-controller 里、完全处于 admission 裁决之外,查询失败只会让补丁静默消失而原请求被放行(fail-open)——见 [§3.7](#s3-7) “异步交付链”一行 | +### 2.2 执行与动作模式 {#s2-2} + +| 模式 | 适用场景 | 关键边界 | +|---|---|---| +| `validate` + **Enforce** | 模板 / 参数 / 定义 / Pod 约束(观测点 1/2/4 上的 CREATE,加上观测点 5 上的 Pod CREATE / 普通 UPDATE / `Pod/ephemeralcontainers` UPDATE)——不合规请求被直接拒绝 | 用于主资源的 CREATE/UPDATE,或显式纳入治理的**非 status 子资源**(如 `Pod/ephemeralcontainers`);绝不用于 `*/status` UPDATE。运维边界:webhook 的 `failurePolicy` 决定 Kyverno 不可用时是全放行(Ignore)还是全拒绝(Fail)——在 [§3.1](#s3-1) 验证,并在 [§6.1](#s6-1) 备好处置手册 | +| `validate` + **Audit** | 结果约束(观测点 3/6 上的 status UPDATE)——予以放行,但记录进 PolicyReport | **读取 status 只能用 Audit。** ⚠️ 子资源匹配与 `background: true` 互斥——结果类 Audit 只有 admission 这一个时机,没有后台扫描兜底 | +| `mutate`(admission 注入) | 注入默认超时 / 标签 / SA 等(观测点 2) | `+(field)` 锚点 = 缺失才添加:绝不覆盖用户显式设置的值([§4.2.6](#s4-2-6)) | +| **mutate-existing** | 响应动作:在触发事件发生时,修补集群中**已存在**的其他资源——本文用它取消流水线([§4.6](#s4-6)) | 要求 background controller 持有目标资源的 update RBAC(**Kyverno 在策略创建时校验该 RBAC;缺失则策略安装失败**,[§3.1](#s3-1))。由 admission 事件触发且使用 `subjects` / `request.userInfo` 时,必须设置 `background: false`;只有当你确实需要策略更新时扫描已存在的触发资源、且规则不使用任何上述请求变量时,才启用 `background: true` | +| `generate` | 为新项目 namespace 自动下发 namespace 级 Policy 等 | 生命周期管理复杂;本文不展开(进阶) | +| `verifyImages` | 镜像签名 / attestation | 见配套文档;[§2.3](#s2-3) 中「身份」契约的信任前提之一 | + +**反机制(务必牢记)**:把 `validate + Enforce` 挂到 `tekton.dev/v1/TaskRun/status` 或 `PipelineRun/status` 的 UPDATE 上,会阻断 **Tekton 控制器的完成状态回写**——TaskRun 卡在 Running,控制器无限重试 `UpdateFailed`,流水线既不失败也不结束,直到人工介入(复现与恢复步骤见 [§6.1.4](#s6-1-4))。这是通往「我想让流水线失败」路上最容易踩中的陷阱:**拒绝 status 写入 ≠ 让它失败**。 + +### 2.3 信任与硬门禁契约 {#s2-3} + +**定位**:硬门禁(覆盖率红线、漏洞阈值——「低于红线不得通过」)由**流水线内部的门禁 Task** 实现——门禁读取前序任务的结果,未达标即以 1 退出;流水线原生失败(`Failed`),排在门禁之后(`runAfter`)的发布任务被 DAG 跳过,**根本不会被创建**。Kyverno 的职责是**校验这套契约中可静态校验的部分**;其余由可信模板的构造方式(by construction)与外部治理保证。 + +「不可绕过的硬门禁」= 以下七条契约同时成立。担保方分三类:**K** = 可由 Kyverno 静态校验,**T** = 由可信模板构造承诺(由模板的构建方式天然成立,而非运行时检查),**E** = 外部治理。先看骨架: + +| # | 契约 | 一句话 | 担保方 | 详见 | +|---|---|---|---|---| +| 1 | 身份 | 门禁使用带不可变引用(固定版本 / digest)的可信 Task | K + E | [§4.1](#s4-1) | +| 2 | 参数生效值 | 开关 / 阈值在展开后的生效值上校验 | K | [§4.2.1](#s4-2-1) | +| 3 | 必执行 | 门禁不会经 `when` / matrix / 默认值被跳过 | T + K 事后 Audit | [§4.1.5](#s4-1-5) | +| 4 | 数据绑定 | 门禁消费的是预期任务的结果 | T | ——(模板职责) | +| 5 | DAG 支配 | 发布类副作用任务必须排在门禁之后 | T | ——(模板职责;自建 Audit 的挂钩点与权衡:见 [§4.1](#s4-1) 引言末尾) | +| 6 | finally 安全 | finally 内不得有受门禁保护的副作用 | T | ——(模板职责;finally 执行语义:[§4.2.2](#s4-2-2)) | +| 7 | 入口闭合 | 不得经裸 TaskRun / 内联定义 / 未批准的 resolver 绕过流水线 | K + RBAC | [§4.5](#s4-5) | + +逐条展开: + +1. **身份**(K + E):门禁使用带不可变引用(固定版本 / digest)的可信 Task。K 锁定引用身份([§4.1](#s4-1));step 镜像的完整性(digest / 签名)、镜像仓库推送权限、外部扫描服务的凭证安全属于外部信任面(E;镜像签名即 verifyImages / 配套文档)。 +2. **参数生效值**(K):门禁开关、阈值、目标分支等在**展开后的生效值**上校验。校验点 = **门禁 TaskRun CREATE**——此刻 `$(params.x)` 已解析为具体值;身份由控制器 `ownerReference` + 在线父 run + `spec.taskRef` 推导(子对象标签可被调用方伪造,不可用),且模板作者无需任何改动。响应方式:Enforce 拒绝(门禁 TaskRun 无法创建 → 父 run 以 `CreateRunFailed` 干净地失败)或取消父 run([§4.6](#s4-6));当模板已在 PipelineRun 层暴露这些参数时,在 PipelineRun CREATE **提前拦截**是一项可选优化。完整推导与策略见 [§4.2.1](#s4-2-1)。 +3. **必执行**(T + K 事后 Audit):门禁不会被 `when` 表达式 / matrix / 条件分支 / 参数默认值跳过。经典陷阱:扫描 URL 参数默认为空 + `when: sonarURL != ''` ⇒ 默认情况下扫描被整体跳过,门禁变成「自愿加入」。⚠️ **被跳过的门禁不会产生 TaskRun**——契约 2 的 admission 校验对「缺席」视而不见(admission 无法拦截从未发生的事)。因此必执行的根基在 T(模板不提供跳过路径);K 侧则用对 **`status.skippedTasks`** 的事后 Audit(控制器把每次跳过连同其 `reason` 记入 PipelineRun status)判定门禁是否被规避——仍是 Audit,无法阻止当前这次 run。reason 如何分类、策略怎么写:[§4.1.5](#s4-1-5)。 +4. **数据绑定**(T):门禁确实消费指定生产者任务的结果(`$(tasks.scan.results.x)` 接线正确)。admission 看不到表达式级绑定;由模板保证。 +5. **DAG 支配**(T):**每一个**发布 / 推送 / 晋级类副作用任务都必须传递性地依赖门禁(`runAfter`,直接或间接)。门禁只能拦住它的 DAG 后继——**排在门禁之前或与之并行的任务可能已经执行完,失败不会回滚已经发生的副作用**。让副作用受门禁支配是模板设计职责;本文不提供现成的 DAG 支配 Audit(判定传递依赖意味着计算闭包——权衡见 [§4.1](#s4-1) 引言末尾),[§4.1.4](#s4-1-4) 的已解析定义快照(其中含 `runAfter`)是你自建这类判据的挂钩点。 +6. **finally 安全**(T):finally 任务在流水线失败、或以 **`CancelledRunFinally`** 取消时执行(deny 与 cancel 下 finally 是否运行的对比见 [§4.2.2](#s4-2-2) 的表格;三种响应形态的完整权衡见 [§4.2.3](#s4-2-3));普通的 `spec.status: Cancelled` 不保证尚未启动的 finally 任务会被调度——因此 finally 内不得包含任何受门禁保护的副作用(发布、推送)。finally 内容同样没有现成的 Audit([§4.1.4](#s4-1-4) 的快照含 `finally` 列表;可以自建——权衡同上)。 +7. **入口闭合**(K + RBAC):业务身份不得通过创建裸 TaskRun 绕过流水线,不得使用未批准的内联定义,不得使用未批准的 resolver 类型;`CustomRun` 默认拒绝或显式声明不支持([§4.5.4](#s4-5-4))。 + +**Kyverno 可校验的三件事**(本文所有 Enforce 策略的分类法):模板身份白名单(按 [§2.1](#s2-1) 的三个层级;集群内定义的**变更权限**由标准 RBAC 另行封死,见 [§4.1.2](#s4-1-2)——那一项不算 Kyverno 可校验);参数契约(TaskRun 层的生效值为主路径,PipelineRun 层的提前拦截为辅路径);入口闭合。**Audit / PolicyReport 是事后的第二道防线——用于发现漂移与兜底告警;不计入硬门禁的保证。** Audit 不拦截任何东西。 + +**失败 / 终止形态对比**(流水线使用者的速查表见 [§1.5](#s1-5)): + +| 形态 | 触发条件 | run 终态原因 | 下游发布任务 | finally | 失败如何呈现 | +|---|---|---|---|---|---| +| admission 拒绝门禁 TaskRun 的创建(契约 2 的响应) | 门禁生效参数不合规 | `CreateRunFailed`(终态;这里的「不重试」指**不会无限重试**,并不承诺只尝试一次;它还**有前提条件**——见下方 info 块的最后一条) | 从未被创建(`skippedTasks` 为空) | **不运行** | Kyverno 策略消息被逐字透传进 PipelineRun condition | +| 门禁任务以 1 退出(主线硬门禁) | 结果未达标 | `Failed` | 被 DAG 跳过;列入 `skippedTasks`(reason 为 `PipelineRun was stopping`) | **运行** | 门禁任务的日志 + "Tasks Completed: N (Failed: 1)" | +| mutate-existing 取消([§4.6.1](#s4-6-1)) | 结果未达标(由 status 事件触发);结果缺失 / 格式异常同样触发,fail-closed(**判据方向 fail-closed ≠ 送达保证**:取消在后台异步送达,链路断裂时会静默不发生——见 [§3.7](#s3-7) 的「异步送达链路」行) | 通常为 `Cancelled`;当产出结果的任务自身先失败时为 `Failed`(失败裁定优先于取消;`spec.status` 仍显示 `CancelledRunFinally`) | 进行中的任务以 `TaskRunCancelled` 停止 | **运行** | 父 run 的 `cancel-reason` 注解(由同一个 patch 写入;文本注明触发的 TaskRun 与越界的结果值)+ 事件;配合配套的 Audit 规则还会有 PolicyReport 记录 | +| mutate-existing 自取消([§4.6.2](#s4-6-2)) | 已解析定义漂移(回写进 `status` 的 `pipelineSpec` 与批准的身份不符) | `Cancelled` | 同上 | **运行** | 父 run 的 `cancel-reason` 注解(说明漂移情况)+ 事件 | +| **用 mutate-existing 取消(RunFinally)替代对门禁参数不合规的 deny([§4.2.2](#s4-2-2))** | 在门禁 TaskRun 上检测到生效参数不合规 | 通常为 `Cancelled`;当取消与任务失败竞态时为 `Failed`(裁定规则与上面第二行相同:失败裁定优先于取消,`spec.status` 仍显示 `CancelledRunFinally`;[§4.6.1](#s4-6-1) 的初始化窗口同样适用于此路径) | 门禁之前的任务已执行;从门禁起被取消 | **运行** | 通用取消文本 + `cancel-reason` 注解 | +| **admission mutate 取消门禁 TaskRun 自身(deny 的同步替代方案,[§4.2.3](#s4-2-3))** | 门禁生效参数不合规 | `Cancelled` | 被 DAG 跳过;列入 `skippedTasks`(reason 为 `PipelineRun was stopping`) | **运行** | TaskRun condition 逐字携带策略写入的 `statusMessage`(在 tkn / UI 中可见);PolicyReport 中无违规记录 | + +:::info 为什么「admission 拒绝门禁 TaskRun」会跳过 finally(社区已知问题) + +该行为已上报上游:https://github.com/tektoncd/pipeline/issues/10514 (*finally tasks are not executed when a child run creation is permanently rejected*;截至本文撰写仍为 open)。当前 ACP 版本所用的社区 Pipelines 版本带有此问题;在上游修复落地之前,适用下面的选型指引。机制如下: + +- **机制**:finally 只在整个 DAG 结束后才被调度,而「结束」要求每个 DAG 任务落入 succeeded / failed / **skipped** 三者之一。在 admission 被拒绝的门禁 TaskRun **从未被创建**,该节点永远到不了这三种状态中的任何一种——DAG 永远不算结束,finally 永远不会被调度,控制器随即把 run 置为 `CreateRunFailed` 终态。 +- **对比**:门禁任务以 1 退出时,TaskRun 被创建、运行并失败——该节点有终态,DAG 可以结束,finally 照常运行。分界线是**门禁节点是否到达终态**,而不是 run 是否失败。 +- **如何识别这种形态**:run 为 `CreateRunFailed`,没有子 TaskRun,finally 从未被创建,且 `skippedTasks` 为空。 +- **为什么是终态而不是无限重试**:创建子 run 失败时,控制器先对错误分类(上游 `pkg/reconciler/pipelinerun/pipelinerun.go` 中的 `handleRunCreationError`),**只有被判定为「永久」的错误才写为 `CreateRunFailed`**——其余一律按可重试处理。admission 拒绝落入永久桶,是因为对「webhook 拒绝但未提供状态码」的响应,API server 统一返回 400。**因此还存在一种形态**:如果你的拒绝响应携带已知的失败 reason(如 `Forbidden`),错误可能被归为可重试——症状变成 run **卡在 Running、控制器一遍遍重试创建同一个子 run**,而不是直接失败。看到这种卡住的形态时,别去查 DAG;去看拒绝响应的状态码和 reason。 +- **「永久」不等于「恰好尝试一次」**:错误一旦落入永久桶,run 随即终止,但控制器**不保证只发出了一次创建请求**——单个 run 的 `TaskRunsCreationFailed` 事件的 `count` 可能大于 1(`Failed` / `InternalError` 合并计数)。所以本节承诺的是 run **快速到达终态**,而不是只发送一次请求:排障时**不要把 `count > 1` 当成异常**——需要警惕的形态是上一条所说的、run **卡在 Running、控制器一遍遍重试创建同一个子 run**。要按 run 精确计数,用 `kubectl get events -n --field-selector involvedObject.uid=`;按名称查询会把同名旧 run 留下的陈旧事件也算进来。 + +::: + +:::warning 选型提示:依赖 finally 做通知 / 清理的团队请注意 + +- 在 admission 拒绝形态(`CreateRunFailed`)下,finally **不运行**;只有门禁任务已落地后失败、或 run 被显式以 `CancelledRunFinally` 取消时,finally 才按上面的对比表运行。 +- 如果你的通知 / 清理在门禁参数被拦截时也必须触发,不要只挂在 finally 上——用**取消(RunFinally)**替代 deny,两条路线任选其一: + - **[§4.2.2](#s4-2-2)(取消父 run)**:在扫描 TaskRun CREATE 时触发 mutate-existing,把父 PipelineRun 的 `spec.status=CancelledRunFinally` patch 上去并打上原因注解;run 以 `Cancelled` 终止,但 finally 照常运行(由未达标结果触发的取消见 [§4.6](#s4-6))。 + - **[§4.2.3](#s4-2-3)(另一种同步形态:取消门禁 TaskRun 自身)**:不动父 run,改为在 admission 期间把门禁 TaskRun 自身 mutate 为 `spec.status=TaskRunCancelled`——在同一次 admission 内完成,没有竞态窗口,也不需要额外的 background controller RBAC。 +- 三种形态的权衡见 [§4.2.3](#s4-2-3) 的对比表。 + +::: + +### 2.4 扩展模型:从自定义 Task 与结果生长出策略 {#s2-4} + +在平台内置的扫描 / 门禁能力之外,每个组织都有自己的检查项(自研 linter、许可证扫描、安全基线、制品规范……)。扩展路径分三步: + +1. **Task 产出声明式结果**:自定义 Task 把结论写成**可判定的结果**——一个数字(`error-count`)、一个枚举裁定(`verdict: pass|fail`)或一个结构化对象——而不是「报告文件的路径」。Tekton 结果有三种声明类型——`string` / `array` / `object`——策略侧三种都能消费:`status.results[].value` 按声明类型序列化(string → 字符串,array → 字符串数组,object → 字符串映射),因此 JMESPath 拿到的是对应的原生结构: + - **`type: object`(多字段结构用它)**:Task 声明 `type: object` + `properties`,策略用 JMESPath `.value.xxx` 直接下钻——字段有名字、有 schema,策略完全不解析任何文本格式。注意 `properties` 下的值只能是 `string`(不支持嵌套对象 / 数组);需要层级时把字段名拍平; + - **`type: array`(同质列表用它)**:值是字符串数组;策略用 `[?...]`、`contains(...)`、`length(...)` 过滤——例如「未修复的严重 CVE 列表必须为空」。它解决的是「值多」,不是「字段多」——语义不同的字段仍应放进 object; + - **`type: string`(默认)**:单值最直接——每个结果放一个数字或一个枚举裁定;策略用 `to_number` 转换或直接比较,零解析风险。 + - **聚合字符串(叠加在 `type: string` 之上的约定;兼容性手段,不推荐)**:把多个字段以 `key=value` 拼接塞进一个字符串结果,策略侧用 `split` + 正则 + `to_number` 拆开([§4.4.2](#s4-4-2))。**确实可行**——但只在消费**暂时无法改动的既有 Task 契约**时才这么做:文本格式不是稳定契约;字段顺序、分隔符、新增字段、「数量不可知」哨兵值都会导致静默失配——而失配通常表现为**被误判为通过**。当契约在你手里、又有多个字段要聚合时,用 `type: object`。 +2. **要做硬门禁**:Task 自行裁定并以 1 退出(或紧随其后接一个读取结果的门禁任务)——进入 [§2.3](#s2-3) 的契约体系,接受身份锁定与参数校验; +3. **要做可见性 / 兜底**:Audit 策略把结果读进 PolicyReport([§4.4](#s4-4));未达标结果还可以额外触发自动取消([§4.6](#s4-6))。 + +**两层参数校验**(与契约 2 相同):主路径 = 在 TaskRun CREATE 时校验展开后的生效值(对任何模板开箱即用,模板作者零设计义务);可选优化 = 当模板已把开关 / 阈值作为 PipelineRun 级参数暴露时,在 PipelineRun CREATE 提前拦截。 + +**信任前提**:自定义 Task 与其他一切同样落在契约 1 之下——不可变引用 + 可信镜像。否则「推一个永远打印 pass 的脚本版本」就是成本最低的绕过手段。 + +[§4](#s4)(Cookbook)用一个虚构的、自包含的扫描任务(`policy-demo-scanner`)把这条扩展路径贯穿始终;平台目录中的真实 Task(如 sonarqube / trivy)以 profile 小节的形式出现,并附带它们真实的结果契约。 + +### 2.5 残余风险台账(装完最小可用集之后,还剩哪些路径) {#s2-5} + +[§1.4](#s1-4) 讲了 Kyverno 管什么、不管什么,[§2.3](#s2-3) 讲了七条契约各由谁担保,每一节还各自带着「本节不覆盖什么」的说明。本节把它们合并成一张表:**假设你已按 [§4.0.1](#s4-0-1) 安装最小可用集并固定了作用域,这就是你手中实际持有的保证与暴露面的集合。** 这张表同时也是本文的范围声明——标 ❌ 的行是本文**明确不覆盖**的内容,不是遗漏。 + +图例:✅ = admission Enforce 硬拦截(各白名单类型共同的前提是名单填写完整,见 [§4.0.7](#s4-0-7)——下文不再逐行重复);🟡 = 仅事后 Audit / 异步响应,或拦截依赖模板设计等 Kyverno 之外的条件;❌ = 本文不覆盖。 + +| # | 绕过或失效路径 | 覆盖度 | 靠什么封堵 | +|---|---|---|---| +| 1 | 不走 Tekton:直接创建 Pod / Job / Deployment,或在别处使用部署凭证 | ❌ | 对工作负载 API 与凭证做 RBAC 收窄([§1.4](#s1-4) / [§4.5.4](#s4-5-4))——本文无法封住这一层 | +| 2 | 裸 `TaskRun` / `CustomRun` 绕过流水线 | ✅ | [§4.5.4](#s4-5-4);本行的「名单」指**合法的自动化创建者身份**——漏掉一个就会把一条合法路径直接堵死 | +| 3 | 引用未批准的模板,或使用内联定义 | ✅ | [§4.1.1](#s4-1-1) 的三通道白名单——内联被它**天然拒绝**(不在三个通道中的任何一个)。要做集群级一刀切禁止还有 [§4.1.2](#s4-1-2) 的 `disable-inline-spec`,但那是 **Tekton 自己的 webhook,不是 Kyverno**;[§4.1.3](#s4-1-3) 讲的是反向操作(审慎地开例外),不是本行的拦截手段 | +| 4 | 引用坐标未变,但**远端定义的内容**被换掉 | 🟡 | 仅有身份锁定;内容信任来自目录 / 仓库治理([§2.1](#s2-1) 的三个层级),叠加 [§4.1.4](#s4-1-4) 的事后漂移 Audit | +| 5 | 门禁经 `when` / 空 matrix 被跳过(完全不产生 TaskRun) | 🟡 | admission 没有可拒绝的对象;依靠模板不提供跳过路径 + [§4.1.5](#s4-1-5) 中读取 `skippedTasks` 的事后 Audit | +| 6 | 门禁开关被关闭、阈值被调低、覆盖注入(`taskRunSpecs` / `taskRunTemplate`) | ✅ | 官方模板走 [§4.2.5](#s4-2-5) / [§4.2.4](#s4-2-4) 的真实 profile,**改好作用域和占位符即可使用**;自建模板走 [§4.2.1](#s4-2-1),但那**是一个模板,不是现成实现**——其身份与参数契约必须按你的门禁重写([§4.0.1](#s4-0-1) 阶段 3) | +| 7 | 发布类任务不受门禁支配,或受门禁保护的副作用被放进 finally | 🟡 | 契约 5 / 6 是模板设计职责;K 侧不提供现成判据([§4.1.4](#s4-1-4) 只审计门禁的身份;其快照是自建这类 Audit 的挂钩点)。**本文没有走定义侧 admission 这条路**;其形态与成本见 [§4.1](#s4-1) 引言末尾 | +| 8 | 门禁消费的结果不是预期任务的(接错线,或被改线) | ❌ | 契约 4:admission 看不到表达式级绑定;只有模板能保证 | +| 9 | 执行镜像被换成**已批准仓库内**的另一个镜像,或可变 tag 的内容被替换 | 🟡 | [§4.5.3](#s4-5-3) 只判前缀;要更强就固定 digest 或加 `verifyImages`(配套文档) | +| 10 | 其他 Pod 级面:privileged / `securityContext` / `automountServiceAccountToken` / 挂载 | ❌ | 同一观测点本可以做到([§2.1](#s2-1) 第 5 行),但**本文只提供镜像仓库前缀白名单**;治理这些需要额外的策略 | +| 11 | workspace 绑定:除 [§4.5.5](#s4-5-5) kubeconfig 之外的 Secret / PVC 被挂进流水线 | ❌ | 本文只治理「发布步骤的 kubeconfig 从哪来」这一个绑定;凭证面整体由 RBAC 与 Secret 治理承担 | +| 12 | 伪造结果(扫描步骤自己写一个 `pass`) | 🟡 | 落在契约 1 之下:不可变引用 + 可信镜像;[§4.6.1](#s4-6-1) 额外有一道身份防伪检查 | +| 13 | 该发生的取消没有发生(mutate-existing 的异步送达链路断裂) | 🟡 | Fail-open;按 [§3.7](#s3-7) 的「异步送达链路」行做监控;要同步硬保证就换成 [§4.2.1](#s4-2-1) / [§4.2.3](#s4-2-3) | +| 14 | 修改 Kyverno 自身配置、策略对象或 PolicyException | ❌ | 在本文威胁模型之外;由 RBAC 职责分离与变更审计封堵([§5.0](#s5-0) / [§5.3](#s5-3)) | +| 15 | 新 namespace / 新集群未纳入治理 | 🟡 | 两者都会被**静默放行**:按 [§3.6](#s3-6) 第一行更新作用域;不存在跨集群分发机制([§7.3](#s7-3)) | +| 16 | 以 `v1beta1` 提交 `PipelineRun` / `TaskRun`(且环境仍在提供该版本服务) | ✅ | **本行不是暴露面;列在这里是因为它经常被误当成暴露面**:Kyverno 生成的 webhook 是 `matchPolicy: Equivalent` 且只注册 `v1`,API server 会先把 `v1beta1` 请求转换为 `v1` 再送审——`kinds` 里只写 `tekton.dev/v1` 就已覆盖。**真正开洞的是「为保险起见往 `kinds` 里加 `v1beta1`」**——从那之后转换不再发生,**跨版本改名的字段路径**读出来为空,依赖它们的判据静默跳过(两个版本共有的路径仍可解析,所以是**部分**失效——更难察觉)。详见 [§3.2](#s3-2) 的「API 组版本前提」;`CustomRun` 是例外——它只有 v1beta1 | +| 17 | `StepAction`(step 级远程引用)、Tekton Chains / provenance、资源配额与并发滥用 | ❌ | 在本文范围之外;未做分析、未给判据——需要时各自用其对应机制治理 | +| 18 | 判据依赖的「生效值」有请求之外的来源(sonar 的 properties 文件可能来自被扫描仓库或 workspace) | 🟡 | admission 只能看到请求。对分支值 [§4.2.4](#s4-2-4) 已对文件来源免疫(参数非空时 Task 用它覆盖文件值;参数缺失时判据按保护范围处理);剩下的路径是文件中注入非空的 `sonar.pullrequest.key` 把分析静默切换到 PR 模式——由仓库治理([§2.1](#s2-1))与受评审对象的内容控制([§2.3](#s2-3) 契约 1)承担 | +| 19 | [§4.2.4](#s4-2-4) 契约收窄的已知误拒面:① 契约外形态一律拒绝——`sonarProperties` 内出现受治理的键(即使参数本会覆盖它们)、注释行、行首空白、单个元素内嵌换行、重复的 PR 声明或含空白的值;② `sonarBranchName` 缺失 + 仓库 properties 文件把分析指向 feature 分支 + 门禁被显式关闭的组合 | 🟡 | 方向 fail-closed:① 按拒绝消息与 [§4.2.4](#s4-2-4) 第一个 warning 中的对照表改写为推荐形态,即可放行;② 为该次 run 显式传入 feature 分支值。确实在契约之外的存量形态走 [§5.3](#s5-3) 的显式豁免 | + +**这张表怎么用**:① 上线前逐行走一遍标 ❌ / 🟡 的行,确认「在我的组织里这一条归谁负责」——没有负责人的行就是真实暴露面;② 汇报「这套策略集保证了什么」时,引用标 ✅ 的行,绝不把 🟡 说成 ✅;③ 每次升级或作用域变更后回来重读([§3.6](#s3-6))。 + +## 3. 通用配置与运维纪律 {#s3} + +本章一次性完成后续所有章节依赖的环境验证与共享资源([§3.1](#s3-1)–[§3.4](#s3-4)),并确立这些策略上线后需要持续遵守的运维纪律([§3.5](#s3-5)–[§3.8](#s3-8):分阶段灰度、变更触发条件、规模与失败预算、升级回归集)。**起步只需要前半部分;后半部分是策略进入生产后你会反复回来查阅的内容。** + +:::warning 命令在哪个集群上执行 + +**本文的 `kubectl` 命令默认在承载 Kyverno 与 Tekton 的业务集群上执行**(下文称目标集群)——包括本章的验证清单与基础资源,以及 [§4](#s4)–[§6](#s6) 的所有策略与探针。 + +**唯一的例外是 [§3.1.1](#s3-1-1)**:修改平台托管组件的配置要经由 global 管理集群上的 `ModuleInfo`;该节的命令显式携带 `--kubeconfig `——请按原样书写,不要复用当前 context。 + +动手之前,先确认当前 context 指向目标集群;不要在 global 集群上创建演示资源: + +```bash +kubectl config current-context +# Expect the context of the cluster that runs Kyverno and Tekton. If it points +# anywhere else, switch first: kubectl config use-context +kubectl get deploy -n kyverno kyverno-admission-controller +# Expect the controller to exist here. NotFound means you are on the wrong +# cluster (or Kyverno is not installed yet -- see the checklist below). +``` + +::: + +### 3.1 安装与能力验证清单 {#s3-1} + +两个组件都通过 ACP 的模块化机制安装,且都支持离线(air-gapped)环境: + +- **Kyverno**:管理员视图 → **Marketplace → Cluster Plugins** → 搜索 `kyverno` → 安装 **"Alauda Container Platform Compliance for Kyverno"**。安装后 Kyverno 由平台以 Helm / AppRelease 方式托管,四个控制器部署在 `kyverno` namespace。 +- **Tekton Pipelines**:管理员视图 → **Marketplace → OperatorHub** → 安装 **"Alauda DevOps Pipelines"**;此后由 `TektonConfig` 管理 Pipelines / Triggers / Chains 与 resolver 开关。 + +产品文档:合规管理(Kyverno)的安装与配置、DevOps(Tekton)的安装——见 [§8.2](#s8-2) 中的 ACP 官方文档链接。 + +:::warning 不要直接在 Deployment 上修改托管配置 + +ACP 的 Kyverno 由平台模块(Helm / AppRelease)托管并**周期性 reconcile**——任何通过直接 `kubectl patch` 控制器 Deployment 做出的参数修改(例如手动添加 `--exceptionNamespace`)**都会被下一次 reconcile 还原**。所有控制器级配置必须通过平台模块的配置入口持久化(操作方法见 [§3.1.1](#s3-1-1))。 + +::: + +**先确认三个前提,否则下面的命令会给出误导性结果**: + +```bash +# 1) Tekton's namespace: this document (including the checklist below) writes the +# literal tekton-pipelines for readability, but on ACP the operator decides it +# and it may be something else. TektonConfig is authoritative. Every later code +# block that uses it starts with a fallback line : "${TEKTON_NS:=tekton-pipelines}", +# so the blocks run even when read out of order; but **tekton-pipelines inside +# policy YAML is a literal** (controller ServiceAccount subjects, +# system:serviceaccount:tekton-pipelines:... and the like) -- a shell variable +# cannot be substituted in. When targetNamespace is not that name, every +# occurrence must be edited by hand; a missed one means the rule silently skips. +TEKTON_NS=$(kubectl get tektonconfig config -o jsonpath='{.spec.targetNamespace}') +# Exported so the commands you run from this shell (including subshells and scripts) +# see it. It does NOT survive a new terminal, which is why later blocks re-assert the +# default on their first line instead of trusting the variable to be there. +export TEKTON_NS=${TEKTON_NS:-tekton-pipelines} +echo "Tekton namespace: $TEKTON_NS" + +# 2) Checklist items 3 and 4 use --as to query someone else's permissions, which +# requires impersonate permission; without it the command itself reports +# forbidden (which is NOT a "permission missing" verdict). If you lack +# impersonate permission, inspect the ClusterRoleBindings directly instead: +# kubectl get clusterrolebinding -o json | jq '…kyverno…' +echo "can impersonate serviceaccounts: $(kubectl auth can-i impersonate serviceaccounts)" + +# 3) Client tools: besides kubectl, the commands in this document use jq (parsing +# childReferences / PolicyReport / result JSON) and python3 (generating the +# regex in §4.5.3). Install whichever is missing -- you can read without them, +# but the corresponding steps cannot be followed along. +for tool in kubectl jq python3; do + command -v "$tool" >/dev/null 2>&1 && echo "$tool: ok" || echo "$tool: MISSING" +done +# §4.5.2 reads image labels, and for that EITHER skopeo OR crane is enough -- so this +# one is an either-or, not a per-tool requirement. Missing both only blocks §4.5.2. +if command -v skopeo >/dev/null 2>&1 || command -v crane >/dev/null 2>&1; then + echo "skopeo/crane: ok (at least one)" +else + echo "skopeo/crane: BOTH MISSING -- only §4.5.2 needs them" +fi +# The kyverno CLI is a LOCAL binary, separate from the in-cluster Kyverno install -- +# having Kyverno running does not give you this command. Only §6.1.6's offline +# evaluation uses it, so missing it blocks nothing on the walkthrough path. +# Probed by running it rather than by resolving its path, so a broken install is +# reported as missing instead of as "ok". +kyverno version >/dev/null 2>&1 \ + && echo "kyverno (CLI): ok" \ + || echo "kyverno (CLI): MISSING or not runnable -- optional, only §6.1.6 uses it" +``` + +安装完成后,逐项验证本方案依赖的各项能力。这份清单是**能力盘点,不是「全绿才许继续」的关卡**:第 1、2 项是共享前提;第 3、4、5 项只在使用对应章节能力时才需要成立;第 6 项的**层级选择**没有对错——那部分属于规划输入——但其**声明与生成的分组必须一致**(不一致按修复表处理);第 7 项是**唯一一处「不符合预期」意味着整章策略失效的检查**。**每一项不符合预期时去哪修,见代码块之后的修复表。** + +```bash +# 1. All four controllers must be Ready +# Expect kyverno-admission-controller / background-controller / cleanup-controller / +# reports-controller with all replicas Ready. A single replica is not acceptable +# long term in production; size the replica count per your HA plan (§6.1.8). +# Every item below prints an "== N) ... ==" banner first, so the combined output of +# this block reads back against the checklist numbers without guessing. +echo "== 1) Kyverno controllers ==" +kubectl get deploy -n kyverno + +# 2. Tekton controllers and resolver feature flags +# TEKTON_NS is set by the prerequisite block above; this line only fills it in if you +# copied this block alone. It is not cosmetic: with the variable unset, `-n ""` reads +# the CURRENT namespace and still exits 0, so the three checks would report an empty +# Tekton namespace instead of failing loudly. +: "${TEKTON_NS:=tekton-pipelines}" +echo "== 2) Tekton controllers and resolver flags (ns: $TEKTON_NS) ==" +kubectl get deploy -n "$TEKTON_NS" +echo "resolver feature flags:" +kubectl get cm -n "$TEKTON_NS" resolvers-feature-flags -o jsonpath='{.data}{"\n"}' +# Expect enable-cluster-resolver / enable-hub-resolver / enable-git-resolver to be +# "true" as required by the resolvers you actually use +echo "hub default-type: $(kubectl get cm -n "$TEKTON_NS" hubresolver-config -o jsonpath='{.data.default-type}')" +HUB_API=$(kubectl get cm -n "$TEKTON_NS" hubresolver-config -o jsonpath='{.data.artifact-hub-api}') +echo "artifact-hub-api: $HUB_API" +# Expect the in-cluster Artifact Hub (the Shim service) here. A public https://artifacthub.io/ +# means every hub reference in this document resolves against the public hub and 404s -- +# and the flags above stay green while it happens, which is why the next probe exists. + +# 2b. Hub endpoint smoke test: the flags only say the resolver is ON, never that its endpoint +# can actually serve the coordinates this document pins. Resolve-side failures surface far +# later as CouldntGetPipeline / CouldntGetTask, so probe the five coordinates up front. +# Pass criterion: every exact version detail endpoint returns HTTP 200 AND a non-empty +# data.manifestRaw. A package-list 200 is insufficient: the pinned version or its +# manifest can still be absent. Any failed coordinate makes the whole block exit non-zero. +echo "== 2b) hub endpoint smoke (expect five usable exact-version manifests) ==" +kubectl -n '' run hub-smoke-$$ --rm -i --restart=Never \ + --image='/busybox:latest' --env="HUB_API=$HUB_API" --command -- sh -c ' +failed=0 +for coordinate in \ + tekton-task/catalog/sonarqube-scanner/0.7 \ + tekton-task/catalog/trivy-scanner/0.6 \ + tekton-task/catalog/skopeo-copy/0.1 \ + tekton-pipeline/catalog/java-image-build-scan-deploy/0.3 \ + tekton-pipeline/catalog/python-image-build-scan-deploy/0.3; do + body=/tmp/hub-detail.json + headers=$(wget -S -O "$body" "${HUB_API%/}/api/v1/packages/$coordinate" 2>&1) || true + code=$(printf "%s\n" "$headers" | awk "/^ HTTP\// { code=\$2 } END { print code }") + if [ "$code" != 200 ]; then + echo "$coordinate -> ${code:-UNREACHABLE}" + failed=1 + elif ! grep -Eq "\"manifestRaw\"[[:space:]]*:[[:space:]]*\"[^\"].*\"" "$body"; then + echo "$coordinate -> 200 but data.manifestRaw is empty or absent" + failed=1 + else + echo "$coordinate -> 200 + non-empty data.manifestRaw" + fi +done +exit "$failed"' +# The detail path is +# /api/v1/packages////: package type +# is tekton-task / tekton-pipeline, and is the value pinned by taskRef / +# pipelineRef (this document pins `catalog`) -- NOT the default-*-catalog keys, which only +# apply when the reference omits the catalog param. The Shim accepts normalized exact +# SemVer forms (for example 0.1 and 0.1.0), but the probe should use the exact coordinates +# present in your Run references. Adjust catalog, name and version together. + +# 3. RBAC prerequisite for mutate-existing (required by the three mutate-existing +# cancellation policies: §4.2.2 / §4.6.1 / §4.6.2. §4.2.3 is an ADMISSION mutate +# on the incoming object and needs no extra RBAC) +echo "== 3) mutate-existing RBAC (only needed for §4.2.2 / §4.6) ==" +echo "background-controller can update pipelineruns: $(kubectl auth can-i update pipelineruns.tekton.dev \ + --as=system:serviceaccount:kyverno:kyverno-background-controller -A)" +# "no" means you must grant it as described in the §4.6 preamble; without the grant Kyverno +# rejects those policies at creation time + +# 4. Effective reports-controller permissions on the Tekton /status subresource +# (all three verbs: get / list / watch). "no" is usually fine -- see the notes below +echo "== 4) reports-controller perms on /status (no is usually fine) ==" +for resource in pipelineruns.tekton.dev taskruns.tekton.dev; do + for verb in get list watch; do + echo " $resource status/$verb: $(kubectl auth can-i "$verb" "$resource" \ + --subresource=status \ + --as=system:serviceaccount:kyverno:kyverno-reports-controller -A)" + done +done + +# 5. PolicyException feature flags (required by §5.3) +echo "== 5) PolicyException flags ==" +kubectl get deploy -n kyverno kyverno-admission-controller \ + -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i exception +# Expect BOTH --enablePolicyException=true and --exceptionNamespace=. +# Only the first one present is the ACP default -- configure the second per §3.1.1 + +# 6. Webhook failure policy (fail-open or fail-closed while Kyverno is unavailable) +# and the per-request timeout every rule -- including its external calls (§3.7) -- must fit inside. +# Read BOTH layers: the per-policy intent declared in spec.webhookConfiguration, +# then the generated webhook groups (-fail / -ignore) it must land in +echo "== 6) webhook failurePolicy / timeout (declared intent vs generated grouping) ==" +kubectl get clusterpolicy -o \ + custom-columns='NAME:.metadata.name,FAILURE_POLICY:.spec.webhookConfiguration.failurePolicy,TIMEOUT:.spec.webhookConfiguration.timeoutSeconds' +# Namespaced Policy objects (§5 project autonomy) carry the same field and are +# NOT in the clusterpolicy listing -- read them too when §5 is in use +kubectl get policy -A -o \ + custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,FAILURE_POLICY:.spec.webhookConfiguration.failurePolicy,TIMEOUT:.spec.webhookConfiguration.timeoutSeconds' +kubectl get validatingwebhookconfiguration -o \ + custom-columns='NAME:.metadata.name,WEBHOOK:.webhooks[*].name,POLICY:.webhooks[*].failurePolicy,TIMEOUT:.webhooks[*].timeoutSeconds' \ + | grep kyverno + +# 7. Which resources Kyverno ignores outright, BEFORE any policy is consulted +echo "== 7) Kyverno resourceFilters (silent, pre-policy exemptions) ==" +kubectl get cm -n kyverno kyverno -o jsonpath='{.data.resourceFilters}' | tr ' ' '\n' | grep -n ',' +# Expect no entry covering a namespace where pipelines run, and none covering +# PipelineRun / TaskRun / Pod. A match here produces no denial and no report at all +``` + +**每一项的预期值,以及结果不符时去哪里处理**(先读这张表,再看表下方三条容易误判的解读): + +| 检查项 | 预期 | 不符合预期时 | +|---|---|---| +| 1 控制器就绪 | 四个控制器全部 Ready | 先查插件安装状态(Marketplace → Cluster Plugins)与 Pod 事件定位故障;副本数按 [§6.1.8](#s6-1-8) 的高可用方案确定,且该变更同样走 [§3.1.1](#s3-1-1) 的 `ModuleInfo.spec.valuesOverride` 入口——对应的 chart values 键为 `admissionController.replicas` / `backgroundController.replicas` / `cleanupController.replicas` / `reportsController.replicas`(四个键都可以直接在已部署 `AppRelease` 的 values 中核对;写入前按 [§3.1.1](#s3-1-1) 同样的方式确认你环境中该 chart 的实际键名)——**不要直接改 Deployment**(平台 reconcile 会还原) | +| 2 resolver 开关与 hub 端点 | 你实际使用的 resolver 均为 `true`;Hub 的 `default-type` 为 `artifact`;`artifact-hub-api` 指向集群内的 Artifact Hub(Shim)服务;**2b 冒烟测试的五个坐标全部返回 200** | 这两个 ConfigMap 由 Tekton operator 托管,直接编辑会被还原——改 `TektonConfig.spec.pipeline`:按需把 `enable-cluster-resolver` / `enable-hub-resolver` / `enable-git-resolver` 设为 `true`;Hub 端点与输出类型都在**同一个位置** `TektonConfig.spec.pipeline.hub-resolver-config`(一个字符串映射,键与 ConfigMap 一致:`artifact-hub-api` / `default-type` / `default-artifact-hub-task-catalog` / `default-artifact-hub-pipeline-catalog`),由 operator reconcile 进 `tekton-pipelines/hubresolver-config`。**不要走 `spec.hub`**——那一节配置的是 Tekton Hub 组件本身,不是 hub resolver。如果不想动平台配置,就让每个 Hub 引用都显式携带 `type=artifact`([§4.5.1](#s4-5-1))。**2b 冒烟测试出现 404**:先检查 `artifact-hub-api` 是否为集群内 Shim 地址(指向公网 hub 时,本文的每个 hub 引用都会以 `CouldntGetPipeline` / `CouldntGetTask` 失败,而上面三个开关仍然全绿),再检查坐标中的 catalog 与包名是否与你环境实际发布的内容一致;若端点指向公网 Artifact Hub,按环境配置问题处理——请平台管理员把它指回集群内 Shim 后再继续。**冒烟测试出现 UNREACHABLE**:探针 Pod 到该地址没有网络 / DNS 通路;先修好连通性,再谈策略 | +| 3 mutate-existing RBAC | 若使用 mutate-existing 取消能力([§4.2.2](#s4-2-2) 与 [§4.6](#s4-6),共三条策略),应返回 `yes` | 为 `no` 时,授予 [§4.6](#s4-6) 序言给出的聚合 ClusterRole(其 labels 中的 `rbac.kyverno.io/aggregate-to-background-controller: "true"` 标签会把它聚合进 background controller 的权限)。**若想改用 namespace 级 Role,必须同时把 `mutate.targets[].namespace` 从 `{{ request.namespace }}` 改为 namespace 字面量**——否则 Kyverno 创建时的鉴权检查解析不了该变量,只认集群级权限,策略仍会安装失败(见 [§4.6](#s4-6) 序言)。**如果不安装 [§4.2.2](#s4-2-2) / [§4.6](#s4-6) 的 mutate-existing 取消策略,则不需要此权限——[§4.2.3](#s4-2-3) 的 admission mutate 修改的是进入的请求对象,不需要它** | +| 4 reports-controller 读取 status | 六项全为 `yes`(可选,非必需) | 出现 `no` **通常无需处理**(理由见下方第三条解读)。只有当其他特性确实需要 reports-controller 直接读取 status 时,才按第 3 项同样的聚合方式再加一个最小权限 ClusterRole,聚合标签换成 `rbac.kyverno.io/aggregate-to-reports-controller: "true"` | +| 5 PolicyException 开关 | `--enablePolicyException=true` 与 `--exceptionNamespace=` 两者都存在 | 只看到前者是 ACP 的默认状态——按 [§3.1.1](#s3-1-1),把 `features.policyExceptions` 的 `enabled` / `namespace` 写进 kyverno `ModuleInfo` 的 `spec.valuesOverride["ait/chart-kyverno"]`(**`ModuleInfo` 只存在于 global 管理集群**,见 [§3.1.1](#s3-1-1) 的 warning);**不要 patch Deployment 参数**。[§3.1.1](#s3-1-1) 提供可直接复制的原子 patch 与回滚命令。**如果不打算使用 PolicyException 豁免([§5.3](#s5-3)),可以不配置此项** | +| 6 webhook 失败策略与超时 | **先读策略体中声明的意图,再核对生成结果**(字段语义、生成侧的 ⚠️ 时序陷阱、平台级覆盖开关的影响见 [§3.1.2](#s3-1-2)——那是该机制的完整版本):声明意图用 `kubectl get clusterpolicy -o custom-columns='NAME:.metadata.name,FAILURE_POLICY:.spec.webhookConfiguration.failurePolicy,TIMEOUT:.spec.webhookConfiguration.timeoutSeconds'` 查看(在使用 [§5](#s5) 的 namespace 级 `Policy` 对象时,还要用相同的列读 `kubectl get policy -A`——它们绝不会出现在 clusterpolicy 列表中,跳过它们就漏检了它们的声明),再看生成的 webhook,它们**按值分组生效**(`validate.kyverno.svc-fail` / `validate.kyverno.svc-ignore`,各自携带自己的 `failurePolicy` / `timeoutSeconds`)。本文所有策略资产都显式声明该项(分层理由见 [§3.7](#s3-7)) | 声明与分组不一致、或某条策略需要不同层级时:**修改该策略体的 `spec.webhookConfiguration` 并用 GitOps 管理**——这是唯一能表达按策略分层的入口;三个陷阱(`ModuleInfo` 只能平台级覆盖、`timeoutSeconds` 是单请求总预算、绝不手改 `ValidatingWebhookConfiguration`)见 [§3.1.2](#s3-1-2) | +| 7 Kyverno 直接忽略的资源 | 过滤列表中**没有**覆盖流水线所在 namespace 的条目,也没有覆盖 `PipelineRun` / `TaskRun` / `Pod` 的条目 | `kyverno` ConfigMap 中的 `resourceFilters` 在**任何策略之前**生效:命中的请求既不被拒绝,也不记入 PolicyReport,也不留日志——一条**完全静默**的豁免通道。出厂值一般排除四个 namespace(**以上面命令实际读到的值为准**)——`kyverno` / `kube-system` / `kube-public` / `kube-node-lease`:同一个违规 Pod 在 `policy-poc` 被拒绝,在 `kube-system` 却一路放行。因此 ① 不要在被排除的 namespace 里跑流水线;② 要清楚以 `namespaces: ["*"]` 写的策略天生带着这个洞;③ 这份配置的写权限必须与 `ClusterPolicy` 同级管控([§5.0](#s5-0)) | + +上面的解读中有三条容易出错: + +- **第 2 项的 `default-type`**:本文允许 Hub 引用省略 `type` 参数,前提是该平台设置输出 `artifact`。若不是,要么先治理好该平台设置,要么要求每个 Hub 引用都显式写 `type=artifact`([§4.5.1](#s4-5-1))。 +- **第 4 项必须带 `--subresource=status`**:把 `taskruns.tekton.dev/status` 作为位置参数传给 `kubectl auth can-i` 会被解析为 `TYPE/NAME`——你查的不是 status 子资源权限,而是一个名为 `status` 的对象。 +- **第 4 项返回 `no` 不代表要立刻放宽权限**:`background: false` 的 status Audit 经 admission report 链路聚合,不要求 reports-controller 直接读取 TaskRun / PipelineRun 的 status;即使六项权限全为 `no`,[§4.4.1](#s4-4-1) / [§4.4.2](#s4-4-2) 依然会产出终态 PolicyReport。因此**不要仅因策略创建时出现权限告警就扩大 ClusterRole**——先跑一次真实的受控请求,确认 PolicyReport 是否从早期的 skip 收敛为终态 pass/fail;只有当其他特性确实需要 reports-controller 直接读取 status 时,才单独按最小权限授予。 +#### 3.1.1 启用 PolicyException(可选;§5.3 必需) {#s3-1-1} + +ACP 的 “Compliance for Kyverno” 插件**默认交付时只带 `--enablePolicyException=true`,不带 `--exceptionNamespace`**。这个默认状态最具迷惑性:PolicyException 对象**可以创建成功**,仅有一条警告 `The exceptionNamespace flag is not set` —— 但它**完全不生效**:豁免已经就位,目标资源仍然被拒绝。这两个 flag 必须一起配置,Kyverno 只认 `--exceptionNamespace` 指向的那个 namespace 中的 PolicyException(这正是豁免权限被收口的地方,[§5.3](#s5-3))。该 flag **接受单个 namespace 名称,或 `*`**(表示任意 namespace 中的 PolicyException 都生效)——**不支持多个 namespace**(在 Kyverno 1.15 线上已确认;多 namespace 列表的需求已在上游提出 —— [kyverno#6980](https://github.com/kyverno/kyverno/issues/6980) —— 并于 2026-01 以 not-planned 关闭,因为 informer 只有“单 namespace / 整集群”两种形态,实现复杂)。在多项目 / 多租户环境中,这一单值约束会落到以下两种方式之一: + +- **集中审批(本文档采用)**:受信 namespace **归属于审批方(平台)**;项目成员从不进入它 —— 豁免走申请-审批流程,由审批者身份代申请方签发(这正是 [§5.3](#s5-3) 演示的模型)。项目之间的天然隔离不受影响:这个 namespace 不是各项目共享的空间,而是审批流程的落点。**不要**让多个项目共用一个受信 namespace 并自助签发豁免 —— RBAC 只能管“谁可以创建 PolicyException”,管不了“豁免内容是否越界”(`spec.match` 可以写任意 namespace),因此项目 A 可以创建一条豁免项目 B 流水线的例外。 +- **项目自治(`*`)**:各项目在自己的 namespace 中创建 PolicyException,签发权限跟随项目 RBAC。此模式下**必须**追加一条元策略,把 PolicyException 限制为**只能豁免其所在 namespace 内的资源** —— 否则上文“内容越界”问题在每个 namespace 中都成立;并且每个项目中 `policyexceptions` 的写权限都必须显式收紧 —— 默认角色不应携带该权限。 + +:::warning ModuleInfo 仅存在于 global 管理集群;业务集群没有该资源 + +`ModulePlugin` / `ModuleConfig` / `ModuleInfo` 都是平台管理面对象,**只存在于 global 管理集群**。在运行 Kyverno 的业务集群上执行 `kubectl get moduleinfo` 什么也查不到 —— 那个集群甚至没有这个 CRD。因此**本节的定位与 patch 命令必须用 global 集群的 kubeconfig 执行**;而第 4 点三处确认中的 ② Deployment args 与 ③ rollout 及 Pod 实际参数,必须在 **Kyverno 所在的集群**上执行。 + +另外注意,在 global 上,一个插件**每个安装目标集群各有一个 `ModuleInfo`**,所以在断言“恰好一条匹配”之前必须先按目标集群收窄 —— 平台用 `cpaas.io/cluster-name` 标记交付目标;安装在 global 集群自身上的实例可能不带该标签,此时通过指向其 `Cluster` 对象的 ownerReference 来识别。 + +下面的命令按 Kyverno 与 Tekton 同集群的场景编写,因此不存在跨集群切换;如果你的环境将两者分开部署,请按上文所述把命令拆到两侧执行。 + +::: + +正确的启用路径有四个要点: + +1. **绝不要直接 patch controller Deployment 的 args** —— 平台 reconcile 会把它改回去(见上方警告)。 +2. **覆盖入口是插件 `ModuleInfo` 的 `spec.valuesOverride`**,不是 `spec.config`。kyverno 的 `ModuleInfo` 默认 spec 中只有 `version`;`spec.config` 是模块实例的用户配置,不是 chart values 的覆盖面 —— 改错字段则什么都不会生效。`valuesOverride` 按 **chart 名称**分层(与 `ModuleConfig.spec.valuesTemplates` 同构),chart 名称是 `ait/chart-kyverno`。 +3. **定位 ModuleInfo 必须断言唯一性**:在 global 集群上,按模块标签精确查询,再按目标集群标签收窄,然后硬性断言恰好 1 条匹配;不要靠版本或 `global-` 前缀去猜,也不要默默取 `items[0]`。 +4. **改完后在三处确认 —— 一处都不能少**:① `AppRelease` 已合入这些 values;② Deployment 模板 args 携带该 flag;③ rollout 已完成且**每个 Ready 的 admission Pod** 实际运行着新参数。只看 Deployment 模板、或只命中一个新 Pod,不足以证明 HA 滚动更新期间每个在役实例都已切换。 + +:::warning 单节点 / CPU 紧张的集群:配置可能是对的,flag 却仍未生效 + +admission-controller 的 rollout **先起 surge pod,再退旧 pod**(`maxUnavailable` 实际为 0);在 CPU 不足的节点上 surge pod 会 Pending,rollout 卡死,旧 pod 继续提供服务,症状是 PolicyException 仍然报 `exceptionNamespace flag is not set` —— 这不是配置错误。**判据只有一条:在役 pod 实际运行着什么参数**(第 4 点的 ③);flag 出现在 Deployment 模板上不代表它出现在在役 pod 上。卡住时,释放节点资源、让 rollout 自行完成 —— 不要指望删掉某个旧 pod 就够了(新 pod 的实际资源请求未必等于模板值)。 + +::: + +⚠️ **先看它当前指向哪里**:`--exceptionNamespace` **只接受一个值**。如果集群已经启用了它、并指向另一个承载着真实豁免的 namespace,把它改成演示值会让**那些豁免全部立即失效**(并且在你改回去之前一直失效)。这种情况下不要改 —— 复用既有的受信 namespace 来跑 [§5.3](#s5-3)([§5.3](#s5-3) 开头读取的正是该值;文中的 `policy-exceptions` 只是本文档 [§3.1.1](#s3-1-1) 配置出来的值,不是必须匹配的常量)。这项更改是一个**在目标集群上全局唯一**的开关;任一时刻只应有一个人在动它。 + +**本节需要你提供的所有取值都汇总在下面的输入块里** —— 后续所有块(a)–g) 各块、落盘块、回读块)都只引用这里设置的变量,不再携带任何 `<...>` 占位符,所以此块必须最先执行: + +```bash +# The ONLY user-supplied inputs of this section, gathered in one place so a pasted +# block never hides a in its middle; later blocks validate these +# variables instead of re-declaring them. +GLOBAL_KUBECONFIG='' # kubeconfig of the GLOBAL management cluster +TARGET_CLUSTER='' # the cluster Kyverno runs on; a) narrows its query by it +TRUSTED_EXCEPTION_NS='' # namespace that will hold PolicyExceptions (§5.3) +# ModuleInfo lives only on the global management cluster, so every command in this +# section goes through this one wrapper. A shell FUNCTION, not a KGLOBAL="kubectl ..." +# string: zsh keeps an unquoted expansion as one word, so the string form pasted into +# an interactive zsh looks for a command literally named "kubectl --kubeconfig ...". +# The :? inside makes every call refuse by name in a shell that never ran this block. +KGLOBAL() { + kubectl --kubeconfig "${GLOBAL_KUBECONFIG:?run the inputs block at the top of §3.1.1 in this shell first}" "$@" +} +KGLOBAL config view --minify -o jsonpath='{.clusters[0].cluster.server}{"\n"}' +``` + +最后那条命令打印出的 API server 地址必须是**你打算修改的 global 集群**;如果不是,先修正 kubeconfig 再继续。 + +**执行顺序总览** —— a)–g) 全部位于下方的可折叠块中;顺序不可改变,且**不要把整个可折叠块一次性粘贴执行**(e) 是回滚 —— 一次跑完等于启用后立刻回退): + +1. **开始前先检查旧账本**:如果 `ls moduleinfo-target.txt moduleinfo-original.json moduleinfo-expected.json 2>/dev/null` 有任何输出,说明上一轮启用从未回滚 —— 先用“在新终端中恢复回滚状态”块重新加载该状态,执行 e)–g) 收尾那一轮,然后再开始新的一轮。此步骤必须在 a) 之前进行:一旦 c) 执行过,那个全局唯一的开关就已经被改掉了。 +2. **启用**:a) 定位并断言唯一性 → b) 保存原始值 → **落盘**(把回滚状态持久化到上面三个文件;这一步必须在 c) 之前 —— c) 不可逆,而在状态落盘之前“原始值”只存在于当前 shell:此刻关掉终端它就永远丢了,事后重跑 b) 只会把已修改的值记录成原始值)→ c) 原子写入 → d) 三处确认。 +3. **使用**:去执行 [§5.3](#s5-3);等它全部完成并清理干净后再回来做回滚。 +4. **回滚**:e) 原子恢复 → f) 按 d) 的方式确认已生效 → g) 删除回滚文件。若中途切换过终端,先用“在新终端中恢复回滚状态”块从文件重建状态 —— **绝不要重跑 b)**。此恢复属于平台侧配置;它不属于任何小节的“清理”小节,只能在这里手工执行。 + +:::details 启用与回滚命令(原子 JSON Patch,可直接复制粘贴) + +```bash +# a) Locate the ModuleInfo on the GLOBAL management cluster and assert the match is unique. +# ModuleInfo exists only there -- the cluster running Kyverno has no such resource. +# KGLOBAL and TARGET_CLUSTER come from the inputs block at the top of §3.1.1; stop +# here if this shell never ran it, rather than query the wrong cluster. +: "${GLOBAL_KUBECONFIG:?run the inputs block at the top of §3.1.1 in this shell first}" +# Presetting GLOBAL_KUBECONFIG by hand is not enough -- the KGLOBAL wrapper +# function must exist too, or every call below dies as "command not found". +command -v KGLOBAL >/dev/null || : "${KGLOBAL:?run the inputs block at the top of §3.1.1 in this shell first}" +: "${TARGET_CLUSTER:?run the inputs block at the top of §3.1.1 in this shell first}" +# One plugin gets one ModuleInfo per target cluster, so narrow the query to the cluster +# Kyverno runs on before asserting uniqueness. An instance installed onto the global +# cluster itself may carry no cpaas.io/cluster-name label -- identify that one by the +# ownerReference pointing at its Cluster object instead of by this selector. +# ModuleInfo is CLUSTER-SCOPED -- it has no namespace, so nothing here passes -n. +MODULES=$(KGLOBAL get moduleinfo -o json \ + -l cpaas.io/module-name=kyverno,cpaas.io/cluster-name="$TARGET_CLUSTER") +# `test ... -eq 1` on its own line does NOT stop an interactive shell: it only sets $?, +# and the next line would take items[0] anyway -- the very thing point 3 above forbids. +# Branch instead, so a non-unique match leaves MODULE unset and c) cannot run. +if [ "$(jq '.items | length' <<<"$MODULES")" -ne 1 ]; then + echo "expected exactly ONE ModuleInfo, got $(jq '.items | length' <<<"$MODULES") --" + echo "narrow the selector by target cluster first; do NOT continue to b)/c)." + unset MODULE +else + MODULE=$(jq -r '.items[0].metadata.name' <<<"$MODULES") + echo "target ModuleInfo: $MODULE" +fi + +# b) Save the complete original spec and compute the target spec to write. +# Keeping the original verbatim is what lets the rollback restore an absent field, +# an explicit null, or an arbitrary non-empty object exactly as it was. +: "${TRUSTED_EXCEPTION_NS:?run the inputs block at the top of §3.1.1 in this shell first}" +# a) prints "do NOT continue to b)/c)" when the match is not unique -- but printing is not +# stopping, and the whole block is pasted in one go, so b) has to refuse for itself. A bare +# `: "${MODULE:?...}"` would not do it either: in an INTERACTIVE shell that fails only that +# one command and the next line still runs. Branch, exactly as a) does. +if [ -z "${MODULE:-}" ]; then + echo "a) did not settle on exactly one ModuleInfo -- fix a) first; b) and c) are skipped." +else + ORIGINAL_MODULEINFO_SPEC=$(KGLOBAL get moduleinfo "$MODULE" -o json | jq -c '.spec') + TEST_MODULEINFO_SPEC=$(jq -c --arg ns "$TRUSTED_EXCEPTION_NS" ' + .valuesOverride = (.valuesOverride // {}) | + .valuesOverride["ait/chart-kyverno"].features.policyExceptions = { + enabled: true, + namespace: $ns + } + ' <<<"$ORIGINAL_MODULEINFO_SPEC") +fi +``` + +**b) 完成后、动 c) 之前先落盘** —— e) 所依赖的状态(`GLOBAL_KUBECONFIG`、`MODULE`、两份 spec)此刻只存在于当前 shell;先把它写入三个回滚文件,看到 `saved:` 后再继续: + +```bash +# Everything here comes from earlier blocks IN THIS SHELL: GLOBAL_KUBECONFIG (which +# the KGLOBAL wrapper reads) from the inputs block at the top of §3.1.1, the rest +# from a)-b). Checked first and by name -- a bare "command not found: KGLOBAL" +# further down would not say which piece of state is missing. +if [ -z "$GLOBAL_KUBECONFIG" ] || ! command -v KGLOBAL >/dev/null \ + || [ -z "$MODULE" ] \ + || [ -z "$ORIGINAL_MODULEINFO_SPEC" ] || [ -z "$TEST_MODULEINFO_SPEC" ]; then + echo "missing state in this shell -- run the inputs block (GLOBAL_KUBECONFIG +" + echo "the KGLOBAL wrapper) and a)+b)" + echo "(MODULE / the two specs) here first, then this block." + # Refuse to overwrite: if these files are already here, an earlier enable was never + # rolled back, and b) has just captured the ALREADY-MODIFIED spec as "the original". + # Overwriting would destroy the only record of the true original value. +elif [ -e moduleinfo-target.txt ] || [ -e moduleinfo-original.json ] \ + || [ -e moduleinfo-expected.json ]; then + # Any of the three still here means an earlier enable was never rolled back -- and + # b) has just captured the ALREADY-MODIFIED spec as "the original". Overwriting + # would destroy the only record of the true original value. + echo "rollback files from an earlier run are still here, so what this shell is" + echo "holding as 'the original' is really the PREVIOUS round's modified spec." + echo "Do NOT run c). The true original is in moduleinfo-original.json: load it with" + echo "the read-back block below, run e)+f)+g) to finish THAT round, then start over." + # Not just a printed refusal: e) reads these variables, and running it with + # what this shell currently holds would write the previous round's change back as + # if it were the original. Clearing them makes e) fail until the read-back block + # has reloaded the real values from the files. + unset MODULE ORIGINAL_MODULEINFO_SPEC TEST_MODULEINFO_SPEC + # The API server URL goes in too: a name alone does not identify a CLUSTER, and + # e)'s test would happily pass against a same-named ModuleInfo on another global + # cluster whose current spec matches -- writing this cluster's original onto it. + # The uid is the tie-breaker: one kubeconfig can spell the same API server several + # ways (DNS alias, load balancer, :443 written out, a tunnel), so a URL mismatch on + # the way back is not proof of a different cluster -- the uid settles it. + # Each value is read and checked separately: inside `printf "$(...)"` a failed + # command substitution is invisible, and an empty field would still print "saved". +elif ! saved_api=$(KGLOBAL config view --minify \ + -o jsonpath='{.clusters[0].cluster.server}') || [ -z "$saved_api" ]; then + echo "could not read the API server URL out of this kubeconfig -- fix that first." +elif ! saved_uid=$(KGLOBAL get moduleinfo "$MODULE" \ + -o jsonpath='{.metadata.uid}' 2>&1) || [ -z "$saved_uid" ]; then + echo "could not read the ModuleInfo uid ($saved_uid)." + echo "Do NOT run c) yet: with no uid there is nothing to bind the rollback files to," + echo "and c) is the step that makes this shell's variables irreplaceable." +elif ! printf '%s %s %s\n' "$MODULE" "$saved_api" "$saved_uid" \ + > moduleinfo-target.txt \ + || ! printf '%s' "$ORIGINAL_MODULEINFO_SPEC" > moduleinfo-original.json \ + || ! printf '%s' "$TEST_MODULEINFO_SPEC" > moduleinfo-expected.json; then + # "Run this block again" is not enough on its own: a partial write can leave one or + # two of the three files behind, and the guard at the top would then read them as an + # earlier round's rollback and refuse -- with the true values still only in this + # shell. They came from THIS block, seconds ago, so deleting them is safe here and + # nowhere else; say so explicitly rather than leaving the reader in that deadlock. + echo "writing the rollback files failed -- do NOT run c), and do NOT close this shell:" + echo "its variables are the only copy. Free space / fix permissions, then delete" + echo "whatever this attempt left behind and run this block again:" + echo " rm -f moduleinfo-target.txt moduleinfo-original.json moduleinfo-expected.json" + echo "(safe ONLY right here: at the top of this block none of the three existed.)" +else + echo "saved: rollback for $MODULE (uid $saved_uid)" +fi +``` + +```bash +# Same-shell state from the inputs block, a)-b) and the save block; fail by name here +# instead of feeding jq an empty --argjson or patching a nameless object. +# Collected and branched, not `: "${VAR:?msg}"` -- see block b) for why that shape does +# not guard a block that writes. +# +# `$MODULE` is also checked against the name the save block recorded. An unset variable is +# caught by the emptiness test; a STALE one -- left in a reused shell by an earlier attempt +# -- is not, and it is the dangerous case, because the patch would then rewrite a DIFFERENT +# ModuleInfo that the rollback files do not describe. +missing= +for v in GLOBAL_KUBECONFIG TRUSTED_EXCEPTION_NS MODULE ORIGINAL_MODULEINFO_SPEC TEST_MODULEINFO_SPEC; do + eval "[ -n \"\${$v:-}\" ]" || missing="$missing $v" +done +command -v KGLOBAL >/dev/null || missing="$missing KGLOBAL(the wrapper function)" +# The rollback files are inputs here too: this is a block that CHANGES the cluster, and +# it must not run unless the on-disk record to roll back from exists. The target file +# carries three fields (name, API server URL, uid) -- the recovery block needs all +# three -- so the stale-shell comparison reads only the first field, not the whole line. +for f in moduleinfo-target.txt moduleinfo-original.json moduleinfo-expected.json; do + [ -s "$f" ] || missing="$missing $f(missing or empty -- the save block has not written it)" +done +if [ -z "$missing" ]; then + read -r saved_name _ < moduleinfo-target.txt + if [ "$MODULE" != "$saved_name" ]; then + missing="$missing MODULE(='$MODULE' but the save block recorded '$saved_name' -- stale shell?)" + fi +fi +if [ -n "$missing" ]; then + echo "NOT RUN -- missing or inconsistent state from earlier blocks IN THIS SHELL:$missing" + echo "Run the inputs block at the top of §3.1.1, then a), b) and the save block, then paste this block again." +else + # c) Atomic write (still on the global cluster): the test op guarantees no concurrent + # modification happened -- on conflict the whole patch fails instead of silently overwriting + KGLOBAL patch moduleinfo "$MODULE" --type json -p \ + "$(jq -cn \ + --argjson expected "$ORIGINAL_MODULEINFO_SPEC" \ + --argjson replacement "$TEST_MODULEINFO_SPEC" ' + [ + {op:"test",path:"/spec",value:$expected}, + {op:"replace",path:"/spec",value:$replacement} + ] + ')" + + # d) Confirm in three places -- after waiting out the reconcile. The platform + # propagates asynchronously (ModuleInfo -> AppRelease -> Deployment -> rollout), and + # until the Deployment TEMPLATE has actually changed, (3)'s `rollout status` returns + # success for the PREVIOUS, already-finished rollout and the closing jq prints false: + # pasted in one go straight after c), every check below races the operator and + # proves nothing (live run on the validation environment: apprelease empty, args unchanged, + # "successfully rolled out", `false` -- and 30s later all four converged). So first + # wait, bounded, for the observable precondition: the template carrying the flag. + # Steps (2) and (3) inspect the workloads, so run them against the cluster Kyverno runs + # on -- that is the global cluster only when Kyverno is installed there. + EXPECTED_ARG="--exceptionNamespace=$TRUSTED_EXCEPTION_NS" + elapsed=0 + # `--` before the pattern is required, not tidiness: the pattern itself starts with + # `--`, and without the separator grep parses it as an option and dies with + # "unrecognized option" on every iteration. The loop would then never succeed -- + # it burns the full timeout and reports the reconcile as stuck on an enable that + # actually worked, sending you off to debug an operator that is fine. + until kubectl get deploy -n kyverno kyverno-admission-controller \ + -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -qF -- "$EXPECTED_ARG"; do + if [ "$elapsed" -ge 120 ]; then + echo "no $EXPECTED_ARG on the Deployment template after ${elapsed}s -- the reconcile" + echo "is stuck, not merely slow. Check the kyverno AppRelease/operator, then re-run d)." + break + fi + sleep 5; elapsed=$((elapsed + 5)) + done + + # (1) AppRelease has merged the values; expect {"enabled":true,"namespace":""} + kubectl get apprelease -n cpaas-system kyverno \ + -o jsonpath='{.spec.values.features.policyExceptions}' + + # (2) The Deployment template args now carry the flag (re-run item 5 of the checklist) + kubectl get deploy -n kyverno kyverno-admission-controller \ + -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i exception + + # (3) Rollout finished AND every Ready admission Pod actually runs the new arg + kubectl rollout status deployment/kyverno-admission-controller -n kyverno --timeout=5m + # rollout status can return in the brief window before the new admission Pod flaps + # NotReady to reload config with the changed arg; for that instant there are zero + # Ready Pods and the jq below (which requires `($ready|length)>0`) would print false + # on an enable that in fact succeeded. Wait for a Ready Pod first so the check reads + # steady state, not the flap. Best-effort: on timeout the jq still runs and prints + # the real verdict. + kubectl wait --for=condition=Ready pod -n kyverno \ + -l app.kubernetes.io/component=admission-controller --timeout=120s + kubectl get pod -n kyverno -l app.kubernetes.io/component=admission-controller -o json | \ + jq -e --arg expected "$EXPECTED_ARG" ' + [.items[] | select(any(.status.conditions[]?; .type == "Ready" and .status == "True"))] as $ready + | ($ready | length) > 0 + and all($ready[]; + any(.spec.containers[]?; + .name == "kyverno" and any(.args[]?; . == $expected))) + ' +fi +``` + +d) 的三处确认通过后,去执行 [§5.3](#s5-3);等 **[§5.3](#s5-3) 的全部步骤**完成并清理干净后再回来执行 e)–g)。若已切换过终端,先用下方“在新终端中恢复回滚状态”可折叠块重建状态。 + +```bash +# Same-shell state again -- from the shell that ran a)-d), or rebuilt by the recovery +# block below. Refuse by name rather than patch a nameless object as the admin user. +# Collected and branched, not `: "${VAR:?msg}"` -- see block b) for why that shape does +# not guard a block that writes. +# +# `$MODULE` is also checked against the name the save block recorded. An unset variable is +# caught by the emptiness test; a STALE one -- left in a reused shell by an earlier attempt +# -- is not, and it is the dangerous case, because the patch would then rewrite a DIFFERENT +# ModuleInfo that the rollback files do not describe. +missing= +for v in GLOBAL_KUBECONFIG MODULE ORIGINAL_MODULEINFO_SPEC TEST_MODULEINFO_SPEC; do + eval "[ -n \"\${$v:-}\" ]" || missing="$missing $v" +done +command -v KGLOBAL >/dev/null || missing="$missing KGLOBAL(the wrapper function)" +# The rollback files are inputs here too: this is a block that CHANGES the cluster, and +# it must not run unless the on-disk record to roll back from exists. The target file +# carries three fields (name, API server URL, uid) -- the recovery block needs all +# three -- so the stale-shell comparison reads only the first field, not the whole line. +for f in moduleinfo-target.txt moduleinfo-original.json moduleinfo-expected.json; do + [ -s "$f" ] || missing="$missing $f(missing or empty -- the save block has not written it)" +done +if [ -z "$missing" ]; then + read -r saved_name _ < moduleinfo-target.txt + if [ "$MODULE" != "$saved_name" ]; then + missing="$missing MODULE(='$MODULE' but the save block recorded '$saved_name' -- stale shell?)" + fi +fi +if [ -n "$missing" ]; then + echo "NOT RUN -- the rollback would target the wrong object or fail halfway:$missing" + echo "Rebuild state with the 'Recovering rollback state in a new terminal' block below, then paste this block again." +else + # e) Rollback (global cluster again): test that the current spec still equals what we wrote, + # then replace it with the complete original spec. A failing test means someone else + # changed the ModuleInfo meanwhile -- do a manual three-way merge and revert only the + # policyExceptions change. + KGLOBAL patch moduleinfo "$MODULE" --type json -p \ + "$(jq -cn \ + --argjson expected "$TEST_MODULEINFO_SPEC" \ + --argjson original "$ORIGINAL_MODULEINFO_SPEC" ' + [ + {op:"test",path:"/spec",value:$expected}, + {op:"replace",path:"/spec",value:$original} + ] + ')" + + # f) Confirm the rollback the same way d) confirmed the enable -- a patched ModuleInfo is + # not a withdrawn flag. Until the platform has reconciled and the Pods have rolled, + # `--exceptionNamespace` is still live on the admission controllers actually serving + # requests, which means every PolicyException in that namespace is still in force. + # The asymmetry is the trap: enabling has three confirmations, and a rollback that + # just ends looks equally finished while leaving the exemption entrance open. + # Expect: an empty/absent policyExceptions value, no exception flag in the args, and + # the jq below printing true (every Ready admission Pod is free of the flag). + # (1)-(3) inspect workloads, so like d) they run against the cluster Kyverno runs on, + # not the global one -- plain kubectl, not the KGLOBAL wrapper. + # Same operator race as d), mirrored: until the Deployment template has dropped the + # flag, `rollout status` blesses the PREVIOUS rollout and the jq below prints false + # while the exemption entrance is still open. Wait, bounded, for the drop first. + elapsed=0 + until ! kubectl get deploy -n kyverno kyverno-admission-controller \ + -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'exceptionNamespace'; do + if [ "$elapsed" -ge 120 ]; then + echo "the Deployment template still carries --exceptionNamespace after ${elapsed}s --" + echo "the reconcile is stuck and the exemption entrance is STILL OPEN. Check the" + echo "kyverno AppRelease/operator, then re-run f); do not proceed to g)." + break + fi + sleep 5; elapsed=$((elapsed + 5)) + done + # Re-check once, explicitly: the loop above exits BOTH when the flag dropped and when + # the timeout branch broke out of it, and g) below must not have to guess which. A + # failed read answers "no match" too, so capture the read and require it to succeed + # before interpreting emptiness as absence. + if ARGS_NOW=$(kubectl get deploy -n kyverno kyverno-admission-controller \ + -o jsonpath='{.spec.template.spec.containers[0].args}' 2>&1) \ + && ! printf '%s' "$ARGS_NOW" | grep -q 'exceptionNamespace'; then + flag_dropped=yes + else + flag_dropped=no + fi + kubectl get apprelease -n cpaas-system kyverno \ + -o jsonpath='{.spec.values.features.policyExceptions}{"\n"}' + kubectl get deploy -n kyverno kyverno-admission-controller \ + -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i exception + kubectl rollout status deployment/kyverno-admission-controller -n kyverno --timeout=5m + # Same readiness flap as d): rollout status can return just before the admission Pod + # flaps NotReady to reload config, and the jq below requires at least one Ready Pod, so + # a single shot would print false on a rollback that in fact completed. Wait for a Ready + # Pod first; best-effort, the jq still runs and prints the real verdict on timeout. + kubectl wait --for=condition=Ready pod -n kyverno \ + -l app.kubernetes.io/component=admission-controller --timeout=120s + kubectl get pod -n kyverno -l app.kubernetes.io/component=admission-controller -o json | \ + jq -e ' + [.items[] | select(any(.status.conditions[]?; .type == "Ready" and .status == "True"))] as $ready + | ($ready | length) > 0 + and all($ready[]; + all(.spec.containers[]?; + .name != "kyverno" or all(.args[]?; (. | test("exceptionNamespace")) | not))) + ' + + # g) Only now retire the rollback files. Leaving them behind is not harmless: the check + # you are told to run before the NEXT enable ("ls moduleinfo-*") reads any of them as + # "the previous round was never rolled back", and the save block then refuses to + # record the new round and clears its variables. Delete them only after f) came back + # clean -- while any of it is unconfirmed, these three files are still the record. + if [ "$flag_dropped" = yes ]; then + rm -f moduleinfo-target.txt moduleinfo-original.json moduleinfo-expected.json + else + echo "KEEPING the rollback files: the Deployment template still carries (or could not" + echo "be confirmed free of) --exceptionNamespace, so the withdrawal is unconfirmed and" + echo "these three files are still the only record. Re-run f); delete only when it is clean." + fi + unset flag_dropped +fi +``` + + +::: + +:::details 在新终端中恢复回滚状态(按需,在执行 e) 之前) + +**从文件读取目标;不要靠重新查询来选定**: + +```bash +# A new terminal has none of the variables, so re-declare the wrapper here (this is the +# one place it is re-declared on purpose -- everywhere else it comes from the block at +# the top of this section). +GLOBAL_KUBECONFIG='' +KGLOBAL() { + kubectl --kubeconfig "${GLOBAL_KUBECONFIG:?fill GLOBAL_KUBECONFIG in this block first}" "$@" +} +# The saved target is the authority. Re-running a) would pick an object by querying +# again -- point it at the wrong cluster and e)'s test could pass against a DIFFERENT +# ModuleInfo whose current spec happens to equal the saved one, writing this cluster's +# original spec onto somebody else's object. +# Guarded on purpose: a missing or empty file must stop you here, not leave MODULE +# empty and let the patch below run against a name the API server fills in for you. +if [ -s moduleinfo-target.txt ] && [ -s moduleinfo-original.json ] \ + && [ -s moduleinfo-expected.json ] \ + && read -r MODULE SAVED_API SAVED_UID < moduleinfo-target.txt \ + && [ -n "$SAVED_UID" ]; then + # The read is kept OUT of the condition above and its exit status kept: an + # unreachable API server, a missing token and a deleted object all answer "empty" + # to a `2>/dev/null` query, and only one of those means "wrong cluster". + if ! live_uid=$(KGLOBAL get moduleinfo "$MODULE" \ + -o jsonpath='{.metadata.uid}' 2>&1); then + echo "could not read $MODULE ($live_uid)." + echo "NotFound means wrong cluster or a deleted object; anything else (Forbidden," + echo "connection refused, timeout) says nothing at all about what is there." + echo "Fix the kubeconfig / RBAC / connectivity and run this block again." + # Cleared AFTER the message, so the message can still name the target. + unset MODULE ORIGINAL_MODULEINFO_SPEC TEST_MODULEINFO_SPEC + elif [ "$live_uid" != "$SAVED_UID" ]; then + echo "same name, DIFFERENT object (live $live_uid vs saved $SAVED_UID): the" + echo "ModuleInfo was recreated, or this is another cluster. The saved spec belongs" + echo "to an object that no longer exists -- do a manual three-way merge instead." + unset MODULE ORIGINAL_MODULEINFO_SPEC TEST_MODULEINFO_SPEC + else + # The uid is what binds this file to an OBJECT; the URL below is only a hint about + # which cluster you were on. Same uid = same object, whatever the URL says. + ORIGINAL_MODULEINFO_SPEC=$(cat moduleinfo-original.json) + TEST_MODULEINFO_SPEC=$(cat moduleinfo-expected.json) + echo "rollback target: $MODULE (uid $SAVED_UID)" + [ "$SAVED_API" = "$(KGLOBAL config view --minify \ + -o jsonpath='{.clusters[0].cluster.server}')" ] \ + || echo "note: the API server is spelled differently than when saved ($SAVED_API) -- same object though" + fi +else + # A printed refusal is only a refusal if something downstream reads it. Nothing + # stops you from pasting e) anyway, and stale values left in this shell from an + # earlier session would let it patch the WRONG ModuleInfo -- successfully. So + # clear them: e) then stops at its state guard, which is the intended outcome. + unset MODULE ORIGINAL_MODULEINFO_SPEC TEST_MODULEINFO_SPEC + echo "the three saved files are not all here (or the target line has no uid) --" + echo "do NOT run e) from memory. Recover them from the shell that ran a)-d), or do" + echo "a manual three-way merge: read the live spec, remove only the policyExceptions" + echo "change, write it back." +fi +``` + +重跑 a) 做交叉核对没有问题,但**查询结果必须与 `moduleinfo-target.txt` 逐字一致 —— 如有出入,停下来排查**。**绝不要重跑 b)** —— 此时集群上的 spec 已经是修改后的了,b) 会把“原始值”记录成改过的值,回滚就永远丢失了;除了目标与这两份 spec,e) 不依赖 b) 的任何东西。 + +::: +#### 3.1.2 Webhook 失败策略与超时:字段语义、读取时机与分层调整方式 {#s3-1-2} + +本小节展开检查清单第 6 项,并且是本文档中 `failurePolicy` 机制的**唯一事实来源** —— [§3.7](#s3-7) 的分层权衡、[§4.0.7](#s4-0-7) 第 1 步的部署检查、[§6.1.8](#s6-1-8) 的控制平面观察都回指这里;机制层面的修订只落在本小节。 + +- **字段语义**:策略级入口是每条策略自己的 `spec.webhookConfiguration.failurePolicy` / `.timeoutSeconds`(同一策略内所有规则共享;允许值 `Ignore` / `Fail`,默认 `Fail`;超时默认 `10`,范围 1–30 —— 依 1.15 CRD)。旧的顶层 `spec.failurePolicy` / `spec.webhookTimeoutSeconds` 已弃用,新旧同时声明会在安装时被拒绝。`timeoutSeconds` 是**单个请求的总预算**,不是每条规则各自的额度 —— [§3.7](#s3-7) 的外部调用必须装进这个数字之内。 +- ⚠️ **读取生成侧对时机敏感**:`kyverno-resource-validating-webhook-cfg`(真正管辖 `PipelineRun` / `TaskRun` / `Pod` 的那一个)是 **Kyverno 根据已安装策略动态生成的** —— 在未安装本文档任何策略时它的 `webhooks` 为空;那时你能读到的 `Fail/10` 行全部属于 Kyverno **自身 CR**(policy / exception / cleanup / ttl)的 webhook。**装上任一 [§4](#s4) 策略之后再回来读生成侧。** +- **平台级覆盖开关无法表达分层**:对这项设置而言,[§3.1.1](#s3-1-1) 的 `ModuleInfo` 入口**只用于平台级覆盖** —— 例如打开 `features.forceFailurePolicyIgnore.enabled` 后,所有策略都按 `Ignore` 生效,所有声明的 `Fail` 都被压掉。**不要拿它代替策略体内的声明**;反过来,检查时**只读声明同样不够**:只有生成侧的分组才反映覆盖之后的实际生效值 —— 一条声明 `Fail` 的策略,其 webhook 落进 `-ignore` 分组,就是被平台强制覆盖了;先解决覆盖,再谈分层。各集群此开关的状态必须作为集群级条目纳入基线漂移检查的对比([§3.6](#s3-6) 的新集群行;范围与 [§7.3](#s7-3) 相同)。 +- **绝不要手工编辑 `ValidatingWebhookConfiguration`**:它是 Kyverno 自己维护的对象(带有 `webhook.kyverno.io/managed-by=kyverno`),手工编辑会在按策略分组重算时被覆盖。切换分层的唯一正确路径是策略体内的 `spec.webhookConfiguration`,用 GitOps 管理 —— 这也是唯一能表达 [§3.7](#s3-7) 按策略分层(“硬门禁 `Fail`,记账类 Audit 可 `Ignore`”)的入口。 + +### 3.2 适用版本与依赖特性 {#s3-2} + +适用范围已在本文档顶部的“适用版本”框中说明:判据是 **Alauda DevOps Pipelines v4.14.x 及以后**,而不是 ACP 版本。在更早的版本上,下列依赖特性不完整,策略可能静默失效而不是报错 —— 机制章节在那些版本上照样读得通,但不要把本文档的策略资产与示例原样套用。 + +具体依赖的特性(也是你在旧版本上的降级检查清单): + +- **Tekton**:`tekton.dev/v1` API、object 结果(`enable-api-fields: beta`)、`status.pipelineSpec` 回写、`status.childReferences`、`spec.status: CancelledRunFinally`、cluster / hub / git resolver; +- **Kyverno**:子资源匹配(`kind/subresource` 形式)、mutate-existing(`targets`)、`context.apiCall`、`foreach` + `element`、PolicyException v2(`--enablePolicyException` + `--exceptionNamespace`)。 + +**API group-version 前提**:本文档各策略的 `match` 块对 `PipelineRun` / `TaskRun` 及其 `/status` 子资源一律写 `tekton.dev/v1`,依据是在适用版本内 Tekton 已把三者的 storage 与 served 版本都定为 `v1`。**唯一的例外是 `CustomRun`**([§4.5.4](#s4-5-4) 与 [§5.3](#s5-3) 的入口封口策略):Tekton 只在 `v1beta1` 中定义并注册该类型 —— 它在 `v1` 中根本不存在 —— 所以那两处写 `tekton.dev/v1beta1/CustomRun` 不是疏漏,也绝不能顺手“统一成 v1” —— 一改,规则就会**静默失配**。 + +**它们的 `v1beta1` 通常也仍在被 serve**:在上游 Tekton Pipelines 各版本随附的 CRD 中,`pipelineruns.tekton.dev` 与 `taskruns.tekton.dev` 的 **`v1beta1` 与 `v1` 都是 `served: true`**(只有 `v1` 是 `storage: true`)——“两个版本同时可提交”是默认形态,不是什么异常配置。**但这并不构成绕过** —— 下方的警告解释了原因(一句话概括:请求在到达 Kyverno 之前已被 API server 转换为 `v1`,**所以不要**因此往 `kinds` 里加 `v1beta1`)。上游 CRD 的证据不等于你环境里的那一份;安装后仍建议确认一次 served 版本: + +```bash +# Which tekton.dev versions this cluster actually serves. A v1beta1 row for +# PipelineRun / TaskRun is NORMAL and does not bypass these policies -- see the +# warning below for why (the API server converts such requests to v1 first). +kubectl get crd pipelineruns.tekton.dev taskruns.tekton.dev customruns.tekton.dev \ + -o jsonpath='{range .items[*]}{.metadata.name}{": "}{range .spec.versions[*]}{.name}{"(served="}{.served}{",storage="}{.storage}{") "}{end}{"\n"}{end}' +``` + +:::warning 提交 `v1beta1` 不会绕过这些策略 —— 把 `v1beta1` 写进 `kinds` 才会 + +**结论:什么都不要加** —— `kinds` 里只写 `tekton.dev/v1`。Kyverno 生成的资源 webhook 是 `matchPolicy: Equivalent` 且只注册 `v1`,因此 API server 会**先把 `v1beta1` 请求转换为 `v1` 再送去 admission**,此时字段名已经归一化(`spec.serviceAccountName` → `spec.taskRunTemplate.serviceAccountName`、`taskPodTemplate` → `podTemplate` 等等)。**反之,`kinds` 里一旦出现 `v1beta1`,这次转换就不再发生**,送进 admission 的是原始的 `v1beta1` 对象 —— **在两个版本间搬过家的字段路径**从此读到的都是空,依赖它们的判据静默跳过,这才是真正的放行漏洞。 + +**注意这里的失效是“部分的”,不是“整体的”** —— 不要指望整条规则在你看得见的地方塌掉:两个版本在相同路径下共有的字段(`spec.taskRef` 及其 resolver 参数、`spec.params` 等)在 `v1beta1` 对象上仍能正常读取,建立在它们之上的判据照常拒绝。真正读空的是那些搬过家的字段 —— `spec.serviceAccountName` → `spec.taskRunTemplate.serviceAccountName`、`taskPodTemplate` → `podTemplate` 之类。因此症状是**同一条规则内的部分判据失灵**,比整条规则跳过更难察觉。 + +对于只声明 `tekton.dev/v1/PipelineRun` 的策略,两种写法的实际行为如下(适用版本以本文档顶部的表格为准): + +| 以 `v1beta1` 提交时,策略的 `kinds` 为 | Kyverno 看到的对象 | 判据读到的值 | +|---|---|---| +| 仅 `v1`(本文档写法) | `apiVersion: tekton.dev/v1`(`requestKind` 仍为 `v1beta1`) | 一切正常读取 | +| `v1` **加** `v1beta1` | `apiVersion: tekton.dev/v1beta1` | 共有路径照常读取;**跨版本改名的路径**返回 `ABSENT`,依赖它们的判据跳过 | + +安装后自检一次(该对象**只有装了策略之后才有内容**;输出为空只说明尚未安装任何策略): + +```bash +# matchPolicy must be Equivalent, and apiVersions must NOT list v1beta1. +kubectl get validatingwebhookconfiguration kyverno-resource-validating-webhook-cfg \ + -o jsonpath='{range .webhooks[*]}{.name}{" matchPolicy="}{.matchPolicy}{" apiVersions="}{range .rules[*]}{.apiVersions}{end}{"\n"}{end}' +``` + +**`CustomRun` 不受本段影响**:它只有 `v1beta1` 这一个版本,没有可被转换的对应版本;在 [§4.5.4](#s4-5-4) / [§5.3](#s5-3) 中写 `tekton.dev/v1beta1/CustomRun` 是必须的。 + +::: + + + +其中,**只有 `enable-api-fields` 会在一开始就拦住你**:[§3.3](#s3-3) 的夹具 Task 声明了一个 `type: object` 的 result,当这个开关不是 `beta`(或 `alpha`)时,Tekton 自身的 admission 会直接拒绝 `kubectl apply -f public-fixtures.yaml` —— **拦截点在共享夹具,不在任何策略上**,很容易被误诊为 Kyverno 的问题。所以先读它(`TEKTON_NS` 见 [§3.1](#s3-1)): + +```bash +# Either read is fine; they must agree. Expect: beta (alpha also enables object +# results). Anything else -- including empty output -- means object results are off. +: "${TEKTON_NS:=tekton-pipelines}" # §3.1 sets it; this only covers a fresh shell +kubectl -n "$TEKTON_NS" get configmap feature-flags \ + -o jsonpath='{.data.enable-api-fields}{"\n"}' +kubectl get tektonconfig config \ + -o jsonpath='{.spec.pipeline.enable-api-fields}{"\n"}' +``` + +当它不是 `beta` 时,**改 `TektonConfig` —— 不要直接编辑 ConfigMap**:operator 的下一次 reconcile 会把手工编辑的 ConfigMap 改回去(与 [§3.1.1](#s3-1-1) 相同的纪律)。在验证环境上两处读取都返回 `beta`。 + +**模板 → Task → result 契约版本矩阵。** Cookbook 中的每个真实 profile 都按版本钉死:不同版本可能携带不同的 result 契约,跨版本套用会以**静默失配**的方式失败。 + +**下表是本文档唯一的契约基线**:参数名、类型、默认值与 result 形态以此处为权威。**升级这些版本的行动项在 [§3.6](#s3-6)(哪些判据受影响)与 [§3.8](#s3-8)(升级后要跑什么)。**后续各节会就地重复与自身判据相关的一两行(方便你边读边写策略),但**升级模板 / Task 版本时只需回到本表逐行复核** —— 不必去各节翻找零散注记。矩阵中的模板与 Task 定义随 **Alauda Artifact Hub Shim v1.0.0** 交付(ACP 内置 hub:一个供 Tekton hub resolver 消费的 Artifact Hub 兼容 API);**后续 Shim 版本可能改变这些定义** —— 升级 Shim 的处理方式与升级模板 / Task 版本相同,按 [§3.6](#s3-6) / [§3.8](#s3-8) 执行。 + +| 模板 / 场景 | 包含的关键 Task(版本) | 消费的 result / 参数契约 | +|---|---|---| +| 官方 `java-image-build-scan-deploy` 0.3、`python-image-build-scan-deploy` 0.3 | `sonarqube-scanner` 0.7 | `code-scan-results`(object:result/reportURL/taskID/projectID)、`code-scan-metrics` | +| 同上 | `trivy-scanner` **0.6**(两个模板都钉死此版本) | `trivy-summary-metadata`(object,11 个键,**推荐的消费形态**)+ `trivy-summary`(array,其首元素是同一聚合的字符串镜像);门禁参数是结构化的 `trivyExitCode`(string,**默认 `"1"`**)与 `trivySeverity`(array);`trivyExtraArgs`(array)只承载其余原生参数 | +| 同上 | `deploy-or-upgrade` 别名 → `kubectl` 0.1 | 发布开关与目标来自 PipelineRun 的 `workloadName` / `workloadNamespace` / `kubeconfig` workspace;解析出的 TaskRun 只携带 `args` / `script` | +| **独立 profile**(不包含在上述模板中) | `skopeo-copy` 0.1 | 参数 `srcImage` / `srcTransport` / `imageMappings`(在 [§4.5.1](#s4-5-1) 中校验) | + +:::warning 四个容易搞错的点 + +1. **漏洞门禁由结构化参数控制 —— 不要去比对 `trivyExtraArgs` 字面量**:门禁开关是 `trivyExitCode`(string,默认 `"1"`)与 `trivySeverity`(array),模板将它们原样透传给 `trivy-scanner` 的 `exitCode` / `severity`。`trivyExtraArgs` 是一个**数组**(每个元素一个完整参数),只承载其余原生参数 —— 判据应要求它为空,而不是等于某个已批准列表(见 [§4.2.5](#s4-2-5))。 +2. **参数是结构化传给 Task 的,不再拼接进 shell 命令字符串**:`scanType` / `scanTargets` / `severity` / `exitCode` / `extraArgs` 各走各的槽位。因此扫描侧的主要风险不是命令注入,而是“门禁是否被关掉”;仍然真正需要注入防护的是同一批模板中 string 类型的 `buildExtraArgs` / `pushExtraArgs`(本文档不治理 build/push 侧,见 [§4.2.5](#s4-2-5))。 +3. **java 0.3 与 python 0.3 的 DAG 形态不同**:java 0.3 中 `deploy-or-upgrade` 只有 `runAfter: [trivy-scanner]`;python 0.3 中则是 `runAfter: [sonarqube-scanner, trivy-scanner]` ——“Sonar 结论支配发布”只在 python 的 DAG 中成立(详见 [§4.3](#s4-3))。把一边的结论搬到另一边正好搞反。两者的**参数面**也不同(python 用 `preBuildScript` / `pythonImage` 一组替换了 maven 组;workspace 数量为 **12**,java 为 **16**;`trivy-config` 两边都有);但 **trivy 门禁相关参数两边逐字段一致**(sonar 侧参数名也相同;只有 `sonarProperties` 默认值不同,不影响判据),因此 [§4.2.5](#s4-2-5) 的门禁判据用一条规则即可覆盖两个模板 —— 只有构建输入与 workspace 允许清单需要按模板拆分。 +4. **这两条流水线都不包含 `skopeo-copy`**:[§4.5.1](#s4-5-1) 是制品搬运场景的独立 profile。 + +上表中的 Task 版本以**你环境中模板实际钉住的版本**为准;策略中的字段名必须匹配目标版本的真实契约。 + +::: + +旧版本上的降级:仅当 object 结果不可用时才回退到聚合字符串结果([§4.4.2](#s4-4-2) 的解析模式正是那个兜底形态)——**这是降级路径,不是目标形态**。自 0.6 起,`trivy-scanner` 也发布 object 结果,所以 **trivy 结果请直接用 [§4.4.1](#s4-4-1) 的下钻模式消费**;[§4.4.2](#s4-4-2) 留给“只给你字符串、短期内改不了”的第三方 / 自研 Task。理由见 [§2.4](#s2-4)。 + +### 3.3 共享夹具 {#s3-3} + +:::info 演练会留下什么(复制粘贴之前先看清东西会落在哪里) + +- **本地工作目录**:[§3.1.1](#s3-1-1) 的回滚文件 —— `moduleinfo-target.txt` / `moduleinfo-original.json` / `moduleinfo-expected.json`(**只有回滚步骤 g) 才删除;如果它们还在,说明那一轮从未收尾**);[§4.0.4](#s4-0-4) 的 `cluster-scoped-ownership.tsv`;[§5.3](#s5-3) 六个步骤沿途写下的快照与结论文件(`gate-snapshot.txt`、`step*-verdict.txt`、`exemption-id.txt` / `exemption-uid.txt` 之类 —— 以各步骤实际写出的为准);用于分离 stderr 的旁路文件 `*.err`(**成功时为空,但同样会留在目录里**);再加上你在各节复制出来的 YAML / JSON。集群清理从不触碰这些本地文件 —— 是否留作证据由你自行决定。 +- **集群上**:本节创建的两个共享 namespace,`policy-poc` / `tekton-templates`;[§5.2](#s5-2) 探针块创建的 namespace(`proj-a` / `proj-b` / `rogue-ns` —— 以该节的创建循环为准);以及 [§5.3](#s5-3) 的 `policy-exempt-runs` / `policy-exceptions`(**只有本次演练亲手创建它们时才会打上 walkthrough-id 标签** —— 既有的从不打标签、清理也从不触碰)。namespace 级的演示对象 —— `PipelineRun` / `TaskRun`、夹具 `Task` / `Pipeline` 对象、允许清单型 `ConfigMap`、[§4.2.2](#s4-2-2) 与 [§5.3](#s5-3) 的 `Role` / `RoleBinding`、`PolicyException` —— 全部位于这些 namespace 内。除此之外,个别小节还会创建**集群级对象**:`ClusterPolicy` 与 [§4.6](#s4-6) 的聚合 `ClusterRole` —— **删除 namespace 不会连带删除它们**。 +- **清理如何落地([§4.0.4](#s4-0-4) 的两条规则)**:集群级对象在各节收尾的“清理”中,按创建时账本里的 UID 逐一删除;namespace 在检查 walkthrough-id 标签后删除,把其中的一切级联清掉([§5.2](#s5-2) / [§5.3](#s5-3) 的 namespace 由各自的清理段落处理;`policy-poc` / `tekton-templates` 由本节末尾的“最终清理”处理)。因此**每节结束就清理 —— 不要攒到最后一起做**。还有一件事**任何清理段落都不会替你做**:为 [§5.3](#s5-3) 按 [§3.1.1](#s3-1-1) 修改的平台配置(`ModuleInfo` 中的 PolicyException 开关)—— 完成 [§5.3](#s5-3) 后,自己回到 [§3.1.1](#s3-1-1) 执行其回滚步骤。 + +::: + +后续所有章节共享的资源。先创建两个 namespace:`policy-poc` 承载业务侧的 run 与探针,`tekton-templates` 承载受信的模板与 Task 定义。 + +```bash +# Record which namespaces THIS walkthrough created, so the final cleanup never +# deletes one that was already there (§4.0.4 keeps the same discipline per object). +# The marker is a LABEL on the namespace carrying an id UNIQUE TO THIS RUN. A fixed +# value like "created-here" would not do: on a shared cluster an earlier unfinished +# walkthrough may have left its own marked namespaces behind, and a fixed marker +# cannot tell the two apart -- the cleanup would delete somebody else's work. +# WRITE THE ID DOWN. Without it the cleanup refuses to delete anything, which is the +# safe direction, but you then have to compare the label by hand. +# date+PID alone is not unique across machines (same second, same PID happens); +# $RANDOM makes an accidental collision between two parallel walkthroughs unlikely. +# Any unique string works -- what matters is that it is not a constant. +WALKTHROUGH_ID=$(date +%Y%m%d-%H%M%S)-$$-$RANDOM +export WALKTHROUGH_ID +echo "walkthrough id: $WALKTHROUGH_ID" + +for ns in policy-poc tekton-templates; do + # --ignore-not-found gives three distinguishable outcomes without matching any error + # text: exit 0 + a name = it exists, exit 0 + empty = it does not, non-zero = the + # query itself failed (no RBAC, API server down) and you must not create anything. + if ! out=$(kubectl get namespace "$ns" -o name --ignore-not-found 2>&1); then + echo "$ns: CHECK FAILED ($out)" + elif [ -n "$out" ]; then + # §4.0.4's premise: every demo object lives in a namespace THIS walkthrough + # created, because cleanup is a namespace cascade. A pre-existing namespace has + # no removal path here, so going on inside it would strand everything you make. + echo "$ns: pre-existing -- STOP: this walkthrough must own its namespaces (§4.0.4)." + echo " Pick your own names and substitute them throughout, or finish the earlier" + echo " walkthrough that left this one behind." + elif ! kubectl create namespace "$ns" >/dev/null 2>&1; then + # Somebody created it between the check and the create: it is theirs, not yours. + echo "$ns: create failed -- do NOT label it, and treat it as pre-existing (STOP)" + elif ! kubectl label namespace "$ns" "policy.alauda.io/walkthrough=$WALKTHROUGH_ID" >/dev/null; then + # Created but unlabelled: the cleanup loop keys on that label and would skip it + # forever. The namespace is seconds old, empty, and certainly yours -- delete it + # by hand and re-run this loop rather than going on without the marker. + echo "$ns: created but LABEL FAILED -- the cleanup loop will not touch it." + echo " Run: kubectl delete namespace $ns # then re-run this loop" + else + echo "$ns: created" + fi +done +``` + +夹具的核心是一个 **SonarQube Scanner 0.7 契约夹具**(`policy-demo-scanner`)。它不是真实的扫描器,但它**完整镜像了本文档所依赖的 0.7 对外契约面**,因此 Cookbook 针对该契约写下的每条策略表达式在真实 Task 上同样成立: + +- `enableScanQualityGate` / `enableAnalyzeQualityGate` 均为 `string`,默认 `"true"`; +- `analyzeQualityGateRules` 为 `array`,默认 `[]`;`sonarBranchName` 为 `string`,默认为空; +- `code-scan-results` 是只声明 `result` / `reportURL` / `taskID` / `projectID` 的 object result;四个属性的真实 schema 都是空 map `{}`,没有额外的 `type: string`; +- `code-scan-metrics` 是一个 object result,其 schema 只声明真实 0.7 一定会有的那个属性 `bugs: {}`(真实 Task 可以通过其 `metrics` 参数动态收集更多字段,但**策略不得假设未声明的字段必然存在**); +- `code-scan-results.result` 使用真实取值范围 `Succeeded` / `Failed` / `Skipped` / `Canceled`。 + +夹具还用 `demoCoverage` / `demoBugs` / `demoDelaySeconds` / `demoResult` 驱动可复现的通过 / 失败 / 取消与四值范围审计测试,模板层再加一个 `demoSkipScan`(默认 `"false"`;设为 `"true"` 时通过 `when` 整体跳过 `scan`,让 [§4.1.5](#s4-1-5) 能复现“门禁被选择退出”)。这些 `demo*` 参数**明确不属于产品化的 Task 契约** —— 替换为真实 Task 时不要保留。没有单独的门禁 task:夹具自身失败即挡住其后的 `release`。 + +:::warning 替换占位符 + +把夹具中的 `` 替换为你环境中能拉取 busybox 的 registry 前缀。生产环境应把 step 镜像钉到 digest —— 否则任何拥有 registry 推送权限的人都能整个换掉扫描逻辑(契约 1,[§2.3](#s2-3))。 + +**不知道该填什么时,先看平台自己从哪里拉取** —— 在离线环境中这是最容易的起点: + +```bash +# Where the platform itself pulls from. Output shape: [:port]//... +: "${TEKTON_NS:=tekton-pipelines}" # §3.1 sets it; this only covers a fresh shell +kubectl -n "$TEKTON_NS" get deploy tekton-pipelines-controller \ + -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}' + +# Wider sample: every distinct prefix in use in that namespace. +kubectl -n "$TEKTON_NS" get pods \ + -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' \ + | sed 's#/[^/]*$##' | sort -u +``` + +⚠️ **这些是候选项,不是答案**:平台 namespace 能拉不代表 `policy-poc` 也能拉(拉取凭证按 namespace 授予),而且这两条命令打印的都是**平台镜像**路径,可能根本没有 `busybox`。**唯一算数的验证是夹具真的跑起来** —— 按 [§3.3](#s3-3) 建好夹具后运行 `demo-run-pass`;若 Pod 起不来,在 `kubectl -n policy-poc describe pod` 中找 `ImagePullBackOff` / `ErrImagePull` 事件。那不是 Tekton 或 Kyverno 的问题 —— 是前缀不对或凭证缺失。 + +::: + +:::details 完整共享夹具 YAML(Task、模板、反面模板 —— 可直接复制粘贴) + +一个 YAML 文件包含五个对象;后续章节按需引用: + +- `Task/policy-demo-scanner`(`tekton-templates`)—— 契约夹具本体; +- `Pipeline/gated-build` —— 标准受治理模板:scan → release,finally 只做通知; +- `Pipeline/gated-build-with-prep` —— 供 [§4.2.2](#s4-2-2) 证明“scan 之前已完成的工作 + RunFinally 取消 + finally 仍然执行”; +- `Task/policy-demo-scanner`(`policy-poc`)—— **同名不同源**的 Task,[§4.6.2](#s4-6-2) 的定义漂移标的; +- `Pipeline/gated-build-rogue` —— 反面模板:`scan` 别名保留受信名称,却从 `policy-poc` 解析。 + +```yaml +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: policy-demo-scanner + namespace: tekton-templates +spec: + # This fixture mirrors the sonarqube-scanner 0.7 contract surface consumed by + # this document. Parameters prefixed with demo are test drivers, not product + # task parameters. + params: + - name: enableScanQualityGate + type: string + default: "true" + - name: enableAnalyzeQualityGate + type: string + default: "true" + - name: analyzeQualityGateRules + type: array + default: [] + - name: sonarBranchName + type: string + default: "" + - name: demoCoverage + type: string + default: "85" + - name: demoBugs + type: string + default: "0" + - name: demoDelaySeconds + type: string + default: "0" + - name: demoResult + type: string + default: Auto + results: + - name: code-scan-results + description: quality-gate verdict object (result/reportURL/taskID/projectID) + type: object + properties: + # Empty property schemas exactly match the catalog 0.7 Task. + result: {} + reportURL: {} + taskID: {} + projectID: {} + - name: code-scan-metrics + description: metrics collected after the scan; real 0.7 always declares bugs + type: object + properties: + bugs: {} + steps: + - name: scan + # pin to a digest in production so a registry pusher cannot swap the scan logic + image: /busybox:latest + # params passed via env (NOT text-substituted into the script body) to avoid + # Tekton parameter injection; the script reads shell variables only + env: + - name: ENABLE_SCAN_QG + value: $(params.enableScanQualityGate) + - name: ENABLE_ANALYZE_QG + value: $(params.enableAnalyzeQualityGate) + - name: DEMO_COVERAGE + value: $(params.demoCoverage) + - name: BUGS + value: $(params.demoBugs) + - name: DEMO_DELAY_SECONDS + value: $(params.demoDelaySeconds) + - name: DEMO_RESULT + value: $(params.demoResult) + script: | + #!/bin/sh + set -eu + case "$ENABLE_SCAN_QG" in true|false) ;; *) exit 1 ;; esac + case "$ENABLE_ANALYZE_QG" in true|false) ;; *) exit 1 ;; esac + case "$DEMO_COVERAGE" in ''|*[!0-9]*) exit 1 ;; esac + case "$BUGS" in ''|*[!0-9]*) exit 1 ;; esac + case "$DEMO_DELAY_SECONDS" in ''|*[!0-9]*) exit 1 ;; esac + # The numeric-looking "1" is an intentional invalid-contract probe. It + # does not extend the scanner 0.7 result enum. + case "$DEMO_RESULT" in Auto|Succeeded|Failed|Skipped|Canceled|1) ;; *) exit 1 ;; esac + [ "$DEMO_DELAY_SECONDS" -le 300 ] || exit 1 + sleep "$DEMO_DELAY_SECONDS" + + RESULT="$DEMO_RESULT" + if [ "$RESULT" = Auto ]; then + RESULT=Succeeded + if [ "$DEMO_COVERAGE" -lt 80 ]; then RESULT=Failed; fi + fi + + # The fixture self-gates whenever either 0.7 quality-gate phase is enabled. + # Setting both switches false is reserved for the explicit negative fixture + # that proves §4.2 rejects a fully disabled gate. + FAIL=0 + if [ "$RESULT" != "Succeeded" ] && { [ "$ENABLE_SCAN_QG" = "true" ] || [ "$ENABLE_ANALYZE_QG" = "true" ]; }; then + FAIL=1 + fi + + printf '{"result":"%s","reportURL":"https://sonar.example/dashboard?id=demo","taskID":"demo-task-001","projectID":"demo-proj"}' "$RESULT" > "$(results.code-scan-results.path)" + printf '{"bugs":"%s"}' "$BUGS" > "$(results.code-scan-metrics.path)" + echo "scan: demoCoverage=$DEMO_COVERAGE result=$RESULT fail=$FAIL" + if [ "$FAIL" = 1 ]; then + echo "task-side quality gate FAILED"; exit 1 + fi + echo "quality gate not enforced or passed" +--- +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: gated-build + namespace: tekton-templates +spec: + params: + - name: coverage + type: string + default: "85" + - name: enableScanQualityGate + type: string + default: "true" + - name: enableAnalyzeQualityGate + type: string + default: "true" + - name: analyzeQualityGateRules + type: array + default: [] + - name: demoDelaySeconds + type: string + default: "0" + - name: demoResult + type: string + default: Auto + # §4.1.5 needs a run where the gate is skipped BY CONFIGURATION. The default keeps + # `scan` running, so every other section behaves exactly as before; passing "true" + # is the opt-out that section's Audit is supposed to catch. + - name: demoSkipScan + type: string + default: "false" + tasks: + - name: scan + # the scanner self-gates; failing it blocks `release` (no separate gate task) + when: + - input: $(params.demoSkipScan) + operator: notin + values: + - "true" + taskRef: + resolver: cluster + params: + - name: kind + value: task + - name: name + value: policy-demo-scanner + - name: namespace + value: tekton-templates + params: + - name: demoCoverage + value: $(params.coverage) + - name: enableScanQualityGate + value: $(params.enableScanQualityGate) + - name: enableAnalyzeQualityGate + value: $(params.enableAnalyzeQualityGate) + - name: analyzeQualityGateRules + value: + - $(params.analyzeQualityGateRules[*]) + - name: demoDelaySeconds + value: $(params.demoDelaySeconds) + - name: demoResult + value: $(params.demoResult) + - name: release + runAfter: + - scan + taskSpec: + steps: + - name: release + image: /busybox:latest + script: | + #!/bin/sh + echo "releasing..." + finally: + - name: notify + taskSpec: + steps: + - name: notify + image: /busybox:latest + script: | + #!/bin/sh + echo "notify: run finished" +--- +# 4.2.2 uses this profile to prove that work completed before `scan` can be +# followed by a RunFinally cancellation and still execute the final notifier. +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: gated-build-with-prep + namespace: tekton-templates +spec: + params: + - name: coverage + type: string + default: "85" + - name: enableScanQualityGate + type: string + default: "true" + - name: enableAnalyzeQualityGate + type: string + default: "true" + - name: demoDelaySeconds + type: string + default: "0" + tasks: + - name: prep + taskSpec: + steps: + - name: prep + image: /busybox:latest + script: | + #!/bin/sh + echo "prep completed" + - name: scan + runAfter: + - prep + taskRef: + resolver: cluster + params: + - name: kind + value: task + - name: name + value: policy-demo-scanner + - name: namespace + value: tekton-templates + params: + - name: demoCoverage + value: $(params.coverage) + - name: enableScanQualityGate + value: $(params.enableScanQualityGate) + - name: enableAnalyzeQualityGate + value: $(params.enableAnalyzeQualityGate) + - name: demoDelaySeconds + value: $(params.demoDelaySeconds) + - name: release + runAfter: + - scan + taskSpec: + steps: + - name: release + image: /busybox:latest + script: | + #!/bin/sh + echo "release completed" + finally: + - name: notify + taskSpec: + steps: + - name: notify + image: /busybox:latest + script: | + #!/bin/sh + echo "finally notification completed" +--- +# 4.6.2 uses a same-name Task from another namespace as the resolved-definition +# drift target. The name still looks trusted, but the complete source does not. +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: policy-demo-scanner + namespace: policy-poc +spec: + steps: + - name: wait + image: /busybox:latest + script: | + #!/bin/sh + sleep 30 +--- +# Negative fixture for 4.6.2: the scan alias keeps the trusted Task name but +# resolves it from policy-poc instead of tekton-templates. +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: gated-build-rogue + namespace: tekton-templates +spec: + tasks: + - name: prep + taskSpec: + steps: + - name: prep + image: /busybox:latest + script: | + #!/bin/sh + sleep 30 + - name: scan + runAfter: + - prep + taskRef: + resolver: cluster + params: + - name: kind + value: task + - name: name + value: policy-demo-scanner + - name: namespace + value: policy-poc + - name: release + runAfter: + - scan + taskSpec: + steps: + - name: release + image: /busybox:latest + script: | + #!/bin/sh + echo "release must not complete after self-cancel" + finally: + - name: notify + taskSpec: + steps: + - name: notify + image: /busybox:latest + script: | + #!/bin/sh + echo "finally notification completed" +``` + +::: + +把上面的 YAML 保存为 `public-fixtures.yaml`(`` 已替换)并在目标集群上创建 —— **后续每一节的探针都假设这五个对象存在**: + +```bash +# If either namespace pre-existed, check for same-named objects FIRST: `apply` would +# overwrite somebody else's Task or Pipeline with this document's fixture, and the +# cleanup at the end of §3.3 would then delete what you overwrote (§4.0.4). +# Fail-closed on purpose: a query that ERRORS (no RBAC, API server hiccup, CRD not +# installed) must stop you too -- silencing stderr and reading "empty" as "absent" is +# how a guard turns into decoration. +FIXTURES_SAFE=yes +# Heredoc + read, not `set -- $spec`: zsh keeps an unquoted expansion as ONE word, so +# a splitting-based loop pasted into an interactive zsh queries an empty resource type. +# `read` splits on IFS in bash and zsh alike, and the redirect (no pipe) keeps the +# FIXTURES_SAFE assignment in the current shell. +while read -r ns kind name; do + # Same three-way outcome as the namespace check: exists / absent / query failed -- + # decided by the exit code and whether anything was printed, not by error text. + if ! out=$(kubectl get "$kind" -n "$ns" "$name" -o name --ignore-not-found 2>&1); then + echo "CHECK FAILED for $ns/$kind/$name: $out"; FIXTURES_SAFE=no + elif [ -n "$out" ]; then + echo "COLLISION: $ns/$out already exists -- stop, and use namespaces of your own"; FIXTURES_SAFE=no + fi +done <<'FIXTURE_LIST' +tekton-templates task policy-demo-scanner +policy-poc task policy-demo-scanner +tekton-templates pipeline gated-build +tekton-templates pipeline gated-build-with-prep +tekton-templates pipeline gated-build-rogue +FIXTURE_LIST +echo "FIXTURES_SAFE=$FIXTURES_SAFE" +# Expect FIXTURES_SAFE=yes and nothing else. COLLISION means the name is taken (change +# the two namespace names in the block above and in every later probe). CHECK FAILED +# means you do not know yet -- fix that query before applying anything. +``` + +**这个探针按首次安装编写:它分不清“别人的同名对象”与“你上次建的同一批夹具”** —— 两者都报 `COLLISION`。因此: + +- **首次安装**:探针应当什么都不打印;若有输出,按上文指引更换 namespace。 +- **重跑同一演练**:那五个对象就是你上次建的。核实它们确实是你的(`kubectl get -o yaml` —— 内容是否为本夹具、namespace 的演练标签是否是你上次的 id),然后**手工设置 `FIXTURES_SAFE=yes`** 再运行下一块 —— 对同一份 YAML 执行 `apply` 是幂等的。或者先删掉上次那批再重来。 +- **要求“绝不覆盖”时**:把下一块中的 `kubectl apply -f` 换成 `kubectl create -f`;存在同名对象时它会以 `AlreadyExists` 失败而不是覆盖。探针与创建之间仍有一段窗口(可能恰好有人在其间创建同名对象)—— `create` 的价值恰恰在于那种情况下它会失败,而不是静默覆盖。 + +**探针与下面的 apply 有意拆成两块**:若在同一块里,整体粘贴会让 `apply` 无论如何都执行,探针沦为事后通知。下一块会再检查一次 `FIXTURES_SAFE` —— 两道防线并存是因为**拆块只能拦“顺手一路粘贴下来”,拦不住“跳过上一块直接粘贴这一块”**: + +```bash +# Refuse to run if the check above did not pass (or was never run at all). +if [ "${FIXTURES_SAFE:-no}" != yes ]; then + echo "run the collision check above first, and fix what it reported" +else + + # `apply` on purpose, so that re-running the whole walkthrough is idempotent. It is + # NOT collision-proof: the check above and this line are separate requests, and a + # same-named object created in between would be overwritten rather than reported. On + # a shared cluster prefer `kubectl create -f public-fixtures.yaml` -- it fails with + # AlreadyExists instead, which is the answer you want there (see the bullet above). + kubectl apply -f public-fixtures.yaml + # Expect five objects created. Verify all five before going on: a missing template + # makes the cluster resolver fail later, and the run will report a resolution error + # instead of the gate behaviour this document describes. + kubectl get task -n tekton-templates policy-demo-scanner + kubectl get task -n policy-poc policy-demo-scanner + kubectl get pipeline -n tekton-templates gated-build gated-build-with-prep gated-build-rogue + +fi +``` + +如果有任何一行报 `NotFound`,回到那份 YAML 找到对应对象 —— 最常见的原因是未替换的 `` 导致整个 apply 中途失败,或者两个 namespace 还没创建(本节开头的循环)。 + +⚠️ **两个共享 namespace 必须由本次演练创建**([§4.0.4](#s4-0-4) 的前提纪律 —— 清理依赖 namespace 删除的级联,而级联的前提是里面没有任何别人的东西)。当上面的创建循环打印 `pre-existing` 时,这个集群上已有人占用该 namespace 名称 —— **不要在里面做演示**:全程用你自己的名字替换 `policy-poc` / `tekton-templates`(最终清理也在你的名字下执行);或者先确认那是你自己上一轮演练留下的(标签里的演练 id 是你记下的那个),把那一轮收尾后再重新开始。 + +该模板体现了 [§2.3](#s2-3) 契约中模板侧的职责:门禁由 scanner 自身承载(契约 3“必须执行”+ 契约 4“消费真实生效值”在同一个 task 内融为一体),`release` 排在 scanner 之后(契约 5,DAG 支配),finally 只做通知(契约 6)。 + +标准的业务侧用法通过 cluster resolver 引用该模板: + +```yaml +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: demo-run-pass + namespace: policy-poc +spec: + pipelineRef: + resolver: cluster + params: + - name: kind + value: pipeline + - name: name + value: gated-build + - name: namespace + value: tekton-templates + params: + - name: coverage + value: "85" +``` + +保存为 `demo-run-pass.yaml` 并创建(在目标集群上;下面的观察命令需要它真实存在): + +```bash +kubectl create -n policy-poc -f demo-run-pass.yaml +kubectl wait -n policy-poc pipelinerun/demo-run-pass \ + --for=condition=Succeeded --timeout=5m +``` + +下表最后一列的 `code-scan-results.result` 是**由 scan task 产出的 Tekton task result** —— 既不是 Pipeline 级字段,也不是 Kyverno 概念。先把它弄清楚;接下来各章的“result 型”策略全都取决于它: + +- **谁产出它**:`scan` task(夹具中的 `policy-demo-scanner`)在其 step 脚本里向 `$(results.code-scan-results.path)` 写入一段 JSON; +- **落在哪里**:Tekton 把它记录在**该 task 对应的 TaskRun** 的 `status.results` 上。PipelineRun 自身不持有这份数据 —— 要看子 TaskRun([§2.1](#s2-1) 观察点 6); +- **`.result` 是什么**:这个 result 的类型是 `object`([§2.4](#s2-4)),其 `result` 字段是**扫描结论**,真实取值范围为 `Succeeded` / `Failed` / `Skipped` / `Canceled`; +- **本文档为何反复回到它**:[§4.4](#s4-4) 的结果审计与 [§4.6.1](#s4-6-1) 的自动取消都读取该字段。表中列出它,方便你确认夹具环境产出的结论符合预期。 + +亲眼看一看(用上面的 `demo-run-pass`): + +```bash +# The verdict lives on the scan TaskRun, not on the PipelineRun. +# childReferences is the API-level mapping from pipeline task name to TaskRun name -- +# unlike the tekton.dev/pipelineTask label, it cannot be overridden by the submitter. +TR=$(kubectl get pipelinerun -n policy-poc demo-run-pass -o json \ + | jq -r '.status.childReferences[] | select(.pipelineTaskName == "scan") | .name') +kubectl get taskrun -n policy-poc "$TR" -o jsonpath='{.status.results}{"\n"}' +``` + +三个 run 覆盖门禁的三种形态,同时兼作环境就绪检查: + +| run | 输入 | scan | release | finally notify | 扫描结论(scan 的 task result `code-scan-results.result`) | +|---|---|---|---|---|---| +| pass | `coverage=85` | ✅ 成功 | ✅ 执行 | ✅ 执行 | `Succeeded` | +| gate-fail | `coverage=30`(两个门禁开关均为 `true`) | ❌ 自身失败 | ⏭ 跳过(原因 `PipelineRun was stopping`) | ✅ 执行 | `Failed` | +| gates-off | `coverage=30` + 两个门禁开关均为 `false` | ✅ 夹具成功 | ✅ 执行(**刻意暴露的绕过**) | ✅ 执行 | `Failed` | + +后两个 run 与 `demo-run-pass` **只有 params 不同**(除模板身份外,仅 `metadata.name` 与 params 有差异)。保存为 `demo-runs-negative.yaml`: + +```yaml +# gate-fail: coverage below the bar; both gate switches keep the template default "true" +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: demo-run-gate-fail + namespace: policy-poc +spec: + pipelineRef: + resolver: cluster + params: + - name: kind + value: pipeline + - name: name + value: gated-build + - name: namespace + value: tekton-templates + params: + - name: coverage + value: "30" +--- +# gates-off: below the bar as well, but both gate switches explicitly off (the deliberately exposed bypass) +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: demo-run-gates-off + namespace: policy-poc +spec: + pipelineRef: + resolver: cluster + params: + - name: kind + value: pipeline + - name: name + value: gated-build + - name: namespace + value: tekton-templates + params: + - name: coverage + value: "30" + - name: enableScanQualityGate + value: "false" + - name: enableAnalyzeQualityGate + value: "false" +``` + +两个一起创建并等待各自的终态 —— **注意二者的结局相反**,因此等待条件也相反: + +```bash +kubectl create -n policy-poc -f demo-runs-negative.yaml + +# gate-fail must end NOT Succeeded (the scanner fails itself and stops the run) +kubectl wait -n policy-poc pipelinerun/demo-run-gate-fail \ + --for=condition=Succeeded=false --timeout=5m +# gates-off must end Succeeded -- that "green" run is the exposed bypass, not a pass +kubectl wait -n policy-poc pipelinerun/demo-run-gates-off \ + --for=condition=Succeeded --timeout=5m + +# Then read the scan verdict of each: expect Failed for BOTH (the table's last column) +for run in demo-run-gate-fail demo-run-gates-off; do + TR=$(kubectl get pipelinerun -n policy-poc "$run" -o json \ + | jq -r '.status.childReferences[] | select(.pipelineTaskName == "scan") | .name') + printf '%s -> %s\n' "$run" \ + "$(kubectl get taskrun -n policy-poc "$TR" -o jsonpath='{.status.results}')" +done +``` + +`wait` 超时而不是及时返回,通常意味着 run 卡在解析上(模板从未建好 —— 回到上面的五对象验证);当任一 run 的终态与表不符时,先确认夹具的 `demo*` 参数没有被改动。 + +前两行是硬门禁的基线形态(扫描器自身失败 → `release` 被跳过 → finally 照常执行 —— 正是 [§2.3](#s2-3) 对比表的第二行)。 + +第三行是**仅限夹具的反面测试**,它的 `Failed` 不是笔误 —— 这一行刻意把两件事拆开:**结论**仍计算为 `Failed`(`demoResult` 默认 `Auto`;coverage 30 < 80 判为 `Failed`),但夹具只有在**至少一个门禁开关为 `true`** 时才把失败结论转换为 `exit 1`。两个开关都关掉后,scan 成功退出、`release` 照跑 —— 而 scan TaskRun 的 `code-scan-results.result` 明晃晃写着 `Failed`。**结论说不合规,流水线却一路绿灯** —— 正是“门禁开关被关掉”的危害形态,也正是 [§4.2.1](#s4-2-1) 必须在 TaskRun CREATE 时拦下不合规开关值的原因:等结果出来时,发布已经跑完了。本行只描述夹具的确定性行为;并不主张真实 SonarQube 服务在双门禁关闭时必然产生同样的组合。 + +#### 最终清理(走完整个文档之后) + +各节收尾的“清理”只删除该节自己的策略与 run 对象;**这两个共享 namespace 在整个文档完成后单独删除** —— 否则夹具会永远留在集群上: + +```bash +# First LOOK: which namespaces carry a walkthrough marker at all, and whose? +kubectl get namespace -l policy.alauda.io/walkthrough \ + -o custom-columns='NAME:.metadata.name,WALKTHROUGH:.metadata.labels.policy\.alauda\.io/walkthrough' +# Expect your own id (printed when you created them) on the namespaces you created. +# A DIFFERENT id belongs to another run of this document -- leave it alone and go ask +# its owner. +# +# Then delete BY NAME, with your own id as the precondition. Deliberately not +# `kubectl delete namespace -l