diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8ae2f6d45..52e55a804 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -61,6 +61,15 @@ jobs: - name: Test the translation integrity check run: yarn test:translation + # The unit tests above prove the check works; they do not look at a single + # document. On main that job belongs to "Translate and verify", but that + # step only runs on a push -- so until now a pull request could add or + # edit a page and nothing compared it against its English source. The + # branch scope does exactly that, for the pairs this branch touches. + - name: Check the translations this branch changes + if: github.event_name == 'pull_request' + run: node scripts/check-translation-integrity.mjs --since origin/${{ github.base_ref }} + - name: generate id if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: ./add_id.sh docs/en/solutions && ./add_id.sh docs/en/articles 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 4565d3aef..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 @@ -7,6 +7,8 @@ kind: ProductsVersion: - 4.3.x id: KB260800021 +i18n: + disableAutoTranslation: true --- # Pipeline Policy Constraints with Tekton and Kyverno diff --git a/docs/en/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md b/docs/en/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md index d6a2c201d..65c6efd82 100644 --- a/docs/en/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md +++ b/docs/en/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md @@ -4,6 +4,8 @@ products: - Alauda Container Platform kind: - Solution +i18n: + disableAutoTranslation: true --- # Software Supply Chain Security of Alauda Container Platform with Tekton and Kyverno @@ -92,7 +94,7 @@ The flexibility of attestation verification is demonstrated through various vali Attestation types are standardized formats for recording and verifying various aspects of container images. These attestations are typically attached to images using tools like cosign and can be verified through policy engines like Kyverno. -##### SLSA provenance (Integrity attestation) +##### SLSA provenance (Integrity attestation) {#slsa-provenance-integrity-attestation} [SLSA Provenance](https://slsa.dev/provenance/v1) is a set of incrementally adoptable guidelines for supply chain security, established by industry consensus. It includes: - Build process information @@ -105,7 +107,7 @@ predicate types: - https://slsa.dev/provenance/v1 - https://slsa.dev/provenance/v0.2 -##### SBOM (Software bill of materials) +##### SBOM (Software bill of materials) {#sbom-software-bill-of-materials} [SBOM](https://www.ntia.gov/page/software-bill-materials) is a nested inventory for software, a list of ingredients that make up software components, including: - Software components @@ -121,7 +123,7 @@ predicate types: - https://spdx.dev/Document - https://cyclonedx.org/bom -##### Vulnerability scan results +##### Vulnerability scan results {#vulnerability-scan-results} [Cosign Vulnerability Scan results](https://github.com/sigstore/cosign/blob/main/specs/COSIGN_VULN_ATTESTATION_SPEC.md) record the security assessment of the software build process, including: - Scanner information (name, version) @@ -132,7 +134,7 @@ predicate types: predicate types: - https://cosign.sigstore.dev/attestation/vuln/v1 -##### Custom metadata +##### Custom metadata {#custom-metadata} Custom metadata can be added as needed to support specific security requirements. @@ -338,7 +340,7 @@ metadata: type: Opaque ``` -##### Get the signing public key +##### Get the signing public key {#get-the-signing-public-key} > If you don't have permission, you can ask the administrator to get the public key. @@ -347,7 +349,7 @@ $ export NAMESPACE= $ kubectl get secret -n $NAMESPACE signing-secrets -o jsonpath='{.data.cosign\.pub}' | base64 -d > cosign.pub ``` -##### Get the signing secret +##### Get the signing secret {#get-the-signing-secret} ```shell $ export NAMESPACE= @@ -407,7 +409,7 @@ $ kubectl patch tektonconfigs.operator.tekton.dev config --type=merge -p='{ > Essentially, Tekton Operator will synchronize the Chains configuration from the `TektonConfig` resource to the `TektonChains` resource, and finally reflect in the `chains-config` ConfigMap.
> You can view the configuration by `kubectl get configmaps -n chains-config -o yaml` -#### Registry Configuration +#### Registry Configuration {#registry-configuration} > This process needs to be done in the namespace where the image will be built and deployed. @@ -446,7 +448,7 @@ metadata: type: kubernetes.io/dockerconfigjson ``` -#### ServiceAccount Configuration +#### ServiceAccount Configuration {#serviceaccount-configuration} > This process needs to be done in the namespace where the image will be built and deployed. @@ -491,7 +493,7 @@ In our environment, the namespace is usually `kyverno`. - Used for validating images and enforcing security policies - Supports complex validation rules using JMESPath expressions -#### Tekton Chains Type Hinting +#### Tekton Chains Type Hinting {#tekton-chains-type-hinting} > More details about type hinting can be found in the [Tekton Chains Type Hinting](https://tekton.dev/docs/chains/slsa-provenance/#type-hinting) documentation. @@ -573,7 +575,7 @@ results: digest: {} ``` -## Chapter 1. Enforcing Image Signature: Automated Signing and Deployment Control +## Chapter 1. Enforcing Image Signature: Automated Signing and Deployment Control {#chapter-1-enforcing-image-signature-automated-signing-and-deployment-control} In ACP (Alauda Container Platform), you can use Tekton Chains to automatically sign the Tekton Pipeline built image, and use Kyverno to allow only signed images to be deployed. @@ -587,7 +589,7 @@ Please check if the prerequisites are completed, especially about this section: - [ServiceAccount Configuration](#serviceaccount-configuration) - [Get the signing public key](#get-the-signing-public-key) -### Step 2: Create a pipeline to generate the image +### Step 2: Create a pipeline to generate the image {#step-2-create-a-pipeline-to-generate-the-image} This is a Pipeline resource, which is used to generate the image. @@ -721,7 +723,7 @@ $ export NAMESPACE= $ kubectl apply -n $NAMESPACE -f chains.demo-1.pipeline.yaml ``` -### Step 3: Run the pipeline to generate the image +### Step 3: Run the pipeline to generate the image {#step-3-run-the-pipeline-to-generate-the-image} This is a PipelineRun resource, which is used to run the pipeline. @@ -787,7 +789,7 @@ $ kubectl get pipelinerun -n $NAMESPACE -w chains-demo-1- True Succeeded 2m 2m ``` -### Step 4: Wait for the PipelineRun to be signed +### Step 4: Wait for the PipelineRun to be signed {#step-4-wait-for-the-pipelinerun-to-be-signed} Wait for the PipelineRun has `chains.tekton.dev/signed: "true"` annotation. @@ -802,7 +804,7 @@ $ kubectl get pipelinerun -n $NAMESPACE $PIPELINERUN_NAME -o yaml | grep "chains Once the PipelineRun has `chains.tekton.dev/signed: "true"` annotation, means the image is signed. -### Step 5: Get the image from the PipelineRun +### Step 5: Get the image from the PipelineRun {#step-5-get-the-image-from-the-pipelinerun} ```shell # Get the image URI @@ -862,7 +864,7 @@ error during command execution: no signatures found ### Step 7: Verify the signature with Kyverno -#### Step 7.1: Create a Kyverno policy to allow only signed images to be deployed +#### Step 7.1: Create a Kyverno policy to allow only signed images to be deployed {#step-71-create-a-kyverno-policy-to-allow-only-signed-images-to-be-deployed} > This step requires cluster administrator privileges. @@ -1069,7 +1071,7 @@ $ kubectl patch tektonconfigs.operator.tekton.dev config --type=merge -p='{ To regenerate and obtain the image, follow these steps: - [Chapter 1: Run the pipeline to generate the image](#step-3-run-the-pipeline-to-generate-the-image) -- [Chapter 1: Wait for the pipeline to be signed](#step-4-wait-for-the-pipeline-to-be-signed) +- [Chapter 1: Wait for the pipeline to be signed](#step-4-wait-for-the-pipelinerun-to-be-signed) - [Chapter 1: Get the image from the pipelinerun](#step-5-get-the-image-from-the-pipelinerun) ### Step 3: (Optional) Verify the builder info with cosign @@ -1981,7 +1983,7 @@ Delete the policy. $ kubectl delete clusterpolicy verify-code-repository-material ``` -## Chapter 4. Preventing Deployment of Images with Critical Security Vulnerabilities +## Chapter 4. Preventing Deployment of Images with Critical Security Vulnerabilities {#chapter-4-preventing-deployment-of-images-with-critical-security-vulnerabilities} In ACP (Alauda Container Platform), you can use Tekton Pipeline to build and scan the image for vulnerabilities. @@ -2591,7 +2593,7 @@ Delete the policy. $ kubectl delete clusterpolicy reject-high-risk-image ``` -## Chapter 5. Base Image Allowlist Verification +## Chapter 5. Base Image Allowlist Verification {#chapter-5-base-image-allowlist-verification} If we want to allow only specific types of base images to be deployed, we can save that information into the image attestation after obtaining it. @@ -3310,7 +3312,7 @@ $ kubectl patch tektonconfigs.operator.tekton.dev config --type=merge -p='{ To regenerate and obtain the image, follow these steps: - [Chapter 1: Run the pipeline to generate the image](#step-3-run-the-pipeline-to-generate-the-image) -- [Chapter 1: Wait for the pipeline to be signed](#step-4-wait-for-the-pipeline-to-be-signed) +- [Chapter 1: Wait for the pipeline to be signed](#step-4-wait-for-the-pipelinerun-to-be-signed) ### Step 4: Get the rekor log index diff --git a/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md b/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md index e5eb456df..194fb1d02 100644 --- a/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md +++ b/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md @@ -2456,4 +2456,3 @@ main 3. 验证您的环境是否符合 [先决条件](#prerequisites) 4. 遵循 [kubectl Exec 最佳实践](#getting-started) 以避免常见命令错误 5. 检查 MySQL 错误日志:`kubectl logs -n -c mysql --tail=100` -``` 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..c0534eefc --- /dev/null +++ b/docs/zh/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md @@ -0,0 +1,11463 @@ +--- +products: + - Alauda Container Platform + - Alauda DevOps +kind: + - Solution +ProductsVersion: + - 4.3.x +id: KB260800021 +--- +# 使用 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:一个兼容 Artifact Hub 的 API,供 Tekton 的 hub resolver 使用;本文引用的 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 | 承载上述两者的平台(本文的验证环境) | + +**换版本就要重测。** 这些机制通常是向后兼容的,但 results 与参数契约会随 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)—— 流水线"看起来在跑模板",而关键步骤从未执行; +- **来源与目标越界**:从未审批的仓库拉取制品,把应用部署到未授权的命名空间; +- **不达标却照样发布**:覆盖率或漏洞数没达到门槛,流水线仍然一路走完发布阶段。 + +本文介绍如何在 Alauda Container Platform(ACP)上使用 **Kyverno** 对基于 **Tekton** 的流水线实施策略约束。它不是一份逐条规则的操作手册,而是聚焦于**机制**:Kyverno 在流水线生命周期中能看到什么、在什么时机看到、能采取哪些动作(拦截、审计、注入、取消)—— 以及如何在这些机制点之上,借助自定义 Task 与 Task results,搭建出贴合你所在组织的策略体系。 + +### 1.0 读完本文你将能够做什么 {#s1-0} + +在按 [§3](#s3) 准备好环境之后,你应当能够: + +- **判断某项治理诉求应当由哪一层承担** —— 哪些能在准入阶段被 Kyverno 拦截,哪些只能靠可信模板的构造来保证,哪些必须交给 RBAC 或事后审计([§1.4](#s1-4) 边界、[§2.3](#s2-3) 七条契约); +- **锁定模板与 Task 的身份**,使业务团队无法更改"用哪个模板、用哪个版本"([§4.1](#s4-1)); +- **校验门禁参数的实际取值**,让"把扫描开关设为 false""把阈值降到 0"这类改动在门禁 TaskRun 创建的那一刻就被拒绝([§4.2](#s4-2)); +- **约束来源与发布目标** —— 只从审批过的仓库/镜像仓库拉取材料,只向授权的命名空间发布([§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 | +| 项目管理员(维护项目级约束) | 命名空间级 `Policy`、项目侧收紧、权限边界 | [§1.3](#s1-3) 项目差异化与作用域安全(两层模型)→ [§5.1](#s5-1)–[§5.2](#s5-2) 作用域与 RBAC → [§4](#s4) Cookbook(按需取用;注意把演示中的跨命名空间作用域改写成你自己命名空间里的 `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(只有当你的运行被自动取消时才需要读 [§6.2.3](#s6-2-3)) | +| 通篇实操者(把整篇文档当实验来跑) | 策略与运行清单可直接复制粘贴;探针需要你自己按 [§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) 清理纪律(在创建任何东西之前先读:自建命名空间,加上对集群级名称冲突的预检查,才是事后能删干净的前提)** → [§3.3](#s3-3) 搭建夹具,并**随手记住你的实操 id** → [§4.0.1](#s4-0-1) 安装顺序 + **[§4.0.5](#s4-0-5) 各演示之间的相互干扰**("探针跑不起来"的头号原因)→ 你的目标章节(**每做完一节就立刻执行该节的"清理"** —— 不要攒到最后一起做)→ [§3.3](#s3-3) 的"最终清理",删掉那两个共享命名空间;如果你做过 [§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 controller(准入 webhook —— 执行 validate / mutate / 镜像验证)、background controller(扫描已有资源,执行 mutate-existing / generate)、reports controller(产出合规报告)、cleanup controller(周期性清理)。 +- **策略资源**:`ClusterPolicy` 是集群级资源,由平台管理员维护,既能跨整个集群匹配命名空间级资源,也能匹配集群级资源;`Policy` 是命名空间级资源,只作用于自身 `metadata.namespace` 内的资源 —— 让项目管理员自行维护本项目约束的正确载体。rule 不是独立的 Kubernetes 资源,它内嵌在策略的 `spec.rules` 中,每条 rule = `match/exclude`(选中哪些资源与操作)+ 可选的 `preconditions`(进一步过滤)+ 一个动作。 +- **动作类型**: + - `validate`:校验资源。在 `Enforce` 模式下于准入阶段拒绝;在 `Audit` 模式下放行请求,但把结果记入 **PolicyReport**; + - `mutate`:在准入阶段修改资源(注入默认值);其 **mutate-existing** 变体可以在某个触发事件发生时,修改集群中**已经存在**的其他资源; + - `generate`:在被触发时创建新资源; + - `verifyImages`:镜像签名验证(本文不涉及 —— 见配套文档《Software Supply Chain Security of ACP with Tekton and Kyverno》)。 +- **PolicyException**:可控豁免机制 —— 它把"谁可以绕过哪条规则"变成一个由 RBAC 管控的独立资源([§5.3](#s5-3))。 +- **工作方式**:策略加载后会被注册为准入 webhook;每一个匹配的 API 请求(CREATE/UPDATE/…)都会经过策略求值。审计结果与后台扫描结果都会落到 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)** | 决定这条规则是否适用于本次请求的条件:身份、终态、列表唯一性等等。不匹配意味着**跳过(放行)**,而不是拒绝 | 不是判据;把判据写成守卫等于放行一切 | +| **门禁 / 门禁 Task** | 流水线中给出质量结论的那个 Tekton Task(不达标就 `exit 1`),例如 `sonarqube-scanner`、`trivy-scanner` | 不是 Kyverno 的动作 | +| **DAG**(有向无环图) | 流水线各任务之间的依赖图。Tekton 依据 `runAfter` 以及任务之间的 result 引用推导出它:有依赖的任务按序执行,彼此独立的并行执行,且不允许成环。"门禁的 DAG 后继"指直接或间接依赖该门禁的那些任务;门禁失败时它们会被**跳过** —— 根本不会被创建 | 不包含 finally —— finally 不属于 DAG,它只在整个 DAG 结束之后才被调度(这个区别是 [§2.3](#s2-3) 结果形态表的关键) | +| **画像(profile)** | 针对某个**具体版本**的真实模板 / Task 所编写的一组判据 | 不是通用模板 | + +一句话串起来:**门禁 Task 的职责是拦住不达标的构建;Kyverno 的职责是确保该有门禁 Task 的时候它在,而且它的参数没有被改动**([§1.4](#s1-4))—— 并且要注意,"该在的时候它在"并不等于"保证它会执行":被 `when` / matrix 整体跳过的门禁根本不会产生 TaskRun,准入阶段看不到它,只有事后审计才能发现([§4.1.5](#s4-1-5))。 + +### 1.3 项目差异化与作用域安全 {#s1-3} + +不同项目几乎必然需要不同的约束:项目 A 把覆盖率门槛定在 80,项目 B 定在 60;而平台有一批谁都不能越过的红线。**差异化是硬需求 —— 但它的实现方式绝不能给策略开出一个后门。** 本文中的每条策略都遵循两层模型([§5](#s5) 中有详述与验证): + +- **平台基线**:一条覆盖**所有业务命名空间**的 `ClusterPolicy`,用**否定式的 `exclude`** 把平台自身的系统命名空间摘出去。基线**绝不能**依赖"这个命名空间带了某个标签" —— 否则新建的未打标命名空间、或者标签被改掉的命名空间,天然就逃出了基线。 +- **项目侧收紧**:项目管理员的主路径是在自己的项目命名空间里维护命名空间级的 `Policy` —— 他们不需要、也不应该被授予 `ClusterPolicy` 权限。若由平台团队集中为多个项目管理策略,可以用 `ClusterPolicy` + `namespaceSelector`(例如基于 `cpaas.io/project` 标签)来选中目标项目。 + +**本节描述的是目标治理模型,而不是本文演示素材的现状**:[§4](#s4) 中每条策略的作用域都硬编码到演示命名空间 `policy-poc`,以便统一安装与清理([§4](#s4) 引言、[§4.0.2](#s4-0-2))。**"覆盖所有业务命名空间"是你在生产部署时自己要改的** —— 照抄演示 YAML 不会覆盖任何真实项目,新建的命名空间当然也不会被自动纳入(这正是 [§3.6](#s3-6) 列出的第一个触发条件)。 + +与之配套的语义(同样只有在你按上述目标模型部署之后才成立):未分类的命名空间必然落入基线;当多条策略匹配到同一资源时,它们之间是**与(AND)**关系(必须条条通过;不存在"项目 `Policy` 覆盖或削弱平台基线"这样的优先级语义);而修改作用域标签本身的权限也必须受控([§5.0](#s5-0))。注意 `Policy` 的作用域是单个 Kubernetes 命名空间;如果一个 ACP 项目横跨多个命名空间,就要在每个命名空间里各部署一份 `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 能做到的: + +- **准入阶段的硬校验**:在 PipelineRun / TaskRun / Pod 创建时拦截 —— 模板身份不合规、门禁参数被关掉、镜像来源未授权;对象根本创建不出来,流水线以清晰的失败形态终止([§2.1](#s2-1)、[§4](#s4)); +- **审计可见性**:在资源状态更新时读取运行结果(覆盖率、漏洞数、扫描结论),把未达标的情况记入 PolicyReport([§4.4](#s4-4)); +- **注入默认值**:在准入阶段做 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、控制器无限重试(卡死),而不是失败([§2.2](#s2-2)、[§6.1.4](#s6-1-4))。 +- **远程引用的定义(hub / git resolver)根本不经过集群准入**:Kyverno 只能锁定其**身份**(哪个 catalog 条目、哪个 commit);对内容的信任来自外部治理(catalog 发布流程、仓库权限)。三个强度层级见 [§2.1](#s2-1)。 +- **它看不到被跳过的门禁**:当 `when` 表达式为假、或 matrix 展开为空时,该门禁**根本不会产生 TaskRun**,准入阶段没有对象可拒 —— "门禁必须执行"只能靠模板设计(不要给门禁加一个业务团队可以关掉的 `when`)加上 [§4.1.5](#s4-1-5) 中对 `status.skippedTasks` 的**事后 Audit** 来保证。它不是准入时刻的硬拦截。 +- **它看不出门禁与发布之间的接线是否正确**:门禁消费的是不是**目标任务**的 result([§2.3](#s2-3) 契约 4)、发布类任务是否排在门禁之后(契约 5)、finally 里是否藏了一个本应受门禁保护的副作用(契约 6)—— 这三条属于**模板设计的责任**。契约 4 / 5 / 6 在准入侧连现成的事后 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 报准入错误) | 准入拦截:模板 / 参数 / 入口不合规 | 报错信息本身就是策略消息(策略名、规则名、原因) | +| PipelineRun 失败,reason 为 `CreateRunFailed`;中途某个 Task 从未被创建 | 运行中途的准入拦截:某个门禁 Task 的实际参数不合规 | `kubectl describe pipelinerun`;condition 消息里带有完整的策略消息 | +| PipelineRun 失败,reason 为 `Failed`;门禁 Task 是红的 | 一次真实的质量门禁失败(覆盖率 / 漏洞数不达标)。**例外**:如果 `spec.status` 里是**某个取消值**(`Cancelled` / `CancelledRunFinally` / `StoppedRunFinally`),说明**确实有人 —— 或某条策略 —— 请求过取消**,只是该任务自身的失败在优先级上盖过了它;单看 `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 层面的准入拦截:该步骤的容器镜像不在批准列表中([§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))、父运行被取消([§4.2.2](#s4-2-2))、定义漂移([§4.6.2](#s4-6-2))、结果不达标([§4.6.1](#s4-6-1)) | 证据只存在于两个地方:第一种在那个门禁 TaskRun 上;后三种共用父运行的 `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} + +本章是全文的核心。后续所有内容都贯穿两个模型: + +- **模型一:生命周期的观测/动作矩阵([§2.1](#s2-1)–[§2.2](#s2-2))** —— Kyverno 在流水线生命周期中能看到什么、在什么时机看到、能做什么; +- **模型二:信任与硬门禁契约([§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 校验(必须包含门禁任务等)+ 锁定变更权限。**本文只用后者**,并且把它交给标准 RBAC 而不是策略([§4.1.2](#s4-1-2))—— **本文没有任何一条策略匹配 `Pipeline` / `Task` 定义资源**;为什么不做见 [§4.1](#s4-1) 引言(其中也包括"什么样的场景值得自建")。**覆盖分三个层级**:① 内联 / 集群内直接引用 —— 准入阶段可内省、可锁定;② hub / git **不可变引用**(固定版本 / commit SHA)—— 在集群内你只能锁定其**身份**;对内容的信任来自外部的 catalog / 仓库治理;③ hub / git **可变引用**(分支 / tag)—— 远端一移动,内容变更就自动生效;Kyverno 只能锁定"引用了哪个分支 / tag"。使用这一层级需要仓库侧的权限控制(受保护分支 / tag);否则就应当直接拒绝 | +| 2 | `PipelineRun` CREATE 准入 | `pipelineRef`(resolver 类型 + 全部 resolver 参数)、**带取值的 `spec.params`**、workspaces、标签、**`request.userInfo`**(创建者身份) | Enforce:模板身份白名单、PipelineRun 级参数契约、入口身份约束;mutate:注入默认值(超时 / 标签,[§4.2.6](#s4-2-6))。⚠️ 对于引用式流水线,此刻 `spec.pipelineSpec` 是**空的** —— 定义内容不可见,任务级参数同样不可见 | +| 3 | `PipelineRun/status` UPDATE(子资源) | resolver 解析后的 **`status.pipelineSpec`**(集群内唯一能内省被引用定义的地方)、`status.childReferences`、**`status.skippedTasks`**(每个被跳过任务的 `name` + `reason` + `whenExpressions`;`reason` 取值来自 Tekton 的 `SkippingReason` 枚举)、`status.pipelineResults`(只有完成后才有 —— 对准入而言早已太迟) | 已过准入 = 事后视角。**绝不要用 Enforce 拒绝**(会卡死,[§2.2](#s2-2))。正确用法:**作为纵深防御的 Audit**(已解析定义中缺少门禁任务 → 记入 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 准入 | `spec.taskRef`(resolver + kind/catalog/name/version/namespace)、标签(可见但**不可信**:`tekton.dev/pipeline` / `tekton.dev/pipelineTask` / `tekton.dev/pipelineRun` 都能通过 `taskRunSpecs` 覆盖 —— 可用作排障线索,绝不可用来定位可信画像或父运行)、`request.userInfo`、控制器写入的 ownerReference,以及 **`spec.params` = 展开后的实际参数值**(`$(params.x)` 已解析为具体值 —— **任务级的门禁参数无需在 PipelineRun 层暴露即可校验**);step 镜像仅在内联 taskSpec 时可见。⚠️ `tekton.dev/task` 在最终的 TaskRun 上可见,但在真正的 CREATE 准入时刻可能尚未写入,因此在这一阶段同样不能用作身份前置条件;父级身份必须由控制器 ownerReference + 一次 `apiCall` 查询实时父运行的 UID/`spec.pipelineRef` 推导得出 | Enforce:**门禁任务实际参数的校验**(拒绝 → 父运行以 `CreateRunFailed` 干净地失败,策略消息原样传递进运行 condition,[§4.2](#s4-2))、裸 TaskRun 封堵([§4.5.4](#s4-5-4))、taskRef 白名单。⚠️ 流水线未绑定的参数**不会出现**在 `spec.params` 里(此时生效的是 Task 定义中的默认值)—— 只有当 `spec.taskRef` 已被锁定到某个确切的 Task 版本、且其默认值可信时,策略才可以把"缺失"解读为那个可信默认值;身份不可信或默认值未知时必须失败关闭 | +| 5 | **Pod CREATE / 普通 UPDATE / `Pod/ephemeralcontainers` UPDATE 准入**(Tekton 执行 Pod、运行中的镜像更新,以及事后注入的调试容器) | CREATE 与普通 UPDATE 暴露实际的 step / sidecar / init 容器镜像、securityContext、标签(`tekton.dev/taskRun` 等)、volumes;子资源 UPDATE 暴露 `spec.ephemeralContainers` | **针对真正运行的镜像,这是可靠的硬拦截点**(执行镜像不合规 → TaskRun `PodCreationFailed`;普通 UPDATE 中不合规的 main/init 镜像、以及不合规的 ephemeral 镜像补丁,都会被同样拒绝,[§4.5.3](#s4-5-3))。**这一层能做的事**:镜像仓库白名单、要求使用 digest、禁止 privileged、镜像签名验证(verifyImages);**本文只提供镜像仓库前缀白名单**([§4.5.3](#s4-5-3))—— digest / privileged / 签名各自需要单独的策略;verifyImages 见配套文档 | +| 6 | `TaskRun/status` UPDATE(子资源) | **Task results**(object result 下钻 / 聚合字符串解析)与终态 —— **结果的唯一来源** | 一次运行会触发多次 UPDATE,因此必须加终态守卫([§4.4](#s4-4));只能用于 **Audit** 或作为 **mutate-existing 的触发器**(取消,[§4.6](#s4-6))—— **绝不要 Enforce**(会卡死);这类策略还必须声明 `failurePolicy: Ignore` —— 否则在 Kyverno 故障期间,API server 会代它拒绝这些状态回写([§3.7](#s3-7) 的分级) | +| 7 | Pod 状态 / 事件 | 运行时的失败现场 | 仅用于排障观测([§6](#s6));不承载任何策略动作 | +| 8 | 外部数据源 | `context.apiCall`(在准入期间查询集群内其他资源:Pipeline 定义、父 PipelineRun 等)、`context.imageRegistry`(读取镜像 config;用法见 [§4.5.2](#s4-5-2)) | apiCall 的 JMESPath 语法很严格([§6.1.7](#s6-1-7));imageRegistry 只能读取镜像仓库中已存在的镜像,而且它把外部网络调用放到了准入路径上(延迟与超时风险见 [§4.5.2](#s4-5-2))。**查询失败往哪个方向倒,由承载它的规则决定,而不是由机制本身决定**:在同步的 `validate` 规则上([§4.2.1](#s4-2-1)),一次无法完成的查询 —— 目标不可达、不存在或无权限 —— 会让规则报错、请求被拒绝(失败关闭);在 mutate-existing 规则上([§4.2.2](#s4-2-2) / [§4.6.1](#s4-6-1)),它运行在 background-controller 中、完全在准入判决之外,因此查询失败只会让补丁静默消失,而原始请求照常放行(失败开放)—— 见 [§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 只有准入这一个时刻,没有后台扫描兜底 | +| `mutate`(准入注入) | 注入默认超时 / 标签 / 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))。当由准入事件触发、并使用 `subjects` / `request.userInfo` 时,必须设置 `background: false`;只有当你确实需要策略更新时去扫描既有的触发资源、且规则未使用上述请求变量时,才启用 `background: true` | +| `generate` | 为新建的项目命名空间自动下发命名空间级 Policy 等 | 生命周期管理较复杂;本文不涉及(进阶内容) | +| `verifyImages` | 镜像签名 / 证明 | 见配套文档;它是 [§2.3](#s2-3) 中"身份"契约的信任前提之一 | + +**反面机制(务必牢记)**:把 `validate + Enforce` 挂到 `tekton.dev/v1/TaskRun/status` 或 `PipelineRun/status` 的 UPDATE 上,拦住的是 **Tekton 控制器的完成态回写** —— TaskRun 卡在 Running,控制器无限重试 `UpdateFailed`,流水线既不失败也不结束,直到有人介入(复现与恢复步骤见 [§6.1.4](#s6-1-4))。这是通往"我想让流水线失败"这条路上最容易踩的陷阱:**拒绝状态写入 ≠ 让它失败**。 + +### 2.3 信任与硬门禁契约 {#s2-3} + +**定位**:硬门禁(覆盖率门槛、漏洞阈值 —— "不达标就不许过")由**流水线内部的门禁 Task** 实现 —— 门禁读取前置任务的 results,不达标就 exit 1;流水线原生失败(`Failed`),而排在门禁之后(`runAfter`)的发布任务会被 DAG 跳过,**根本不会被创建**。Kyverno 的职责是**校验这组契约中可静态验证的部分**;其余部分由可信模板的构造(by construction)与外部治理来保证。 + +一个"无法绕过的硬门禁" = 以下七条契约同时成立。担保者分三类:**K** = Kyverno 可静态验证,**T** = 由可信模板的构造保证(成立于模板的构造方式,而非运行时检查),**E** = 外部治理。先看骨架: + +| # | 契约 | 一句话 | 担保者 | 详见 | +|---|---|---|---|---| +| 1 | 身份 | 门禁使用可信 Task,且引用不可变(固定版本 / digest) | 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 | 数据绑定 | 门禁消费的是目标任务的 results | 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):门禁使用可信 Task,且引用不可变(固定版本 / digest)。K 负责锁定引用身份([§4.1](#s4-1));step 镜像的完整性(digest / 签名)、镜像仓库的推送权限、外部扫描服务的凭据安全,属于外部信任面(E;镜像签名见 verifyImages / 配套文档)。 +2. **参数实际取值**(K):门禁开关、阈值、目标分支等,按**展开后的实际值**校验。校验位置 = **门禁 TaskRun 的 CREATE** —— 那一刻 `$(params.x)` 已经解析为具体值;身份由控制器写入的 `ownerReference` + 实时父运行 + `spec.taskRef` 推导(子级标签可被调用方伪造,不可用),模板作者无需做任何改动。响应方式:Enforce 拒绝(门禁 TaskRun 创建不出来 → 父运行以 `CreateRunFailed` 干净地失败)或取消父运行([§4.6](#s4-6));当模板已经在 PipelineRun 层暴露了这些参数时,在 PipelineRun CREATE 处**提前拦截**是一项可选的优化。完整推导与策略见 [§4.2.1](#s4-2-1)。 +3. **必须执行**(T + K 事后 Audit):门禁不会被 `when` 表达式 / matrix / 条件分支 / 参数默认值跳过。经典陷阱:扫描 URL 参数默认为空 + `when: sonarURL != ''` ⇒ 默认情况下扫描被整体跳过,门禁变成了可选项。⚠️ **被跳过的门禁不会产生 TaskRun** —— 契约 2 的准入校验对"不存在"是盲的(准入拦不住从未发生的事)。所以"必须执行"的根基在 T(模板不提供任何跳过路径);在 K 这一侧,对 **`status.skippedTasks`** 的事后 Audit(控制器会把每一次跳过连同其 `reason` 记入 PipelineRun status)可以判定门禁是否被绕过 —— 但它仍然是 Audit,拦不住当前这次运行。原因如何分类以及策略怎么写见 [§4.1.5](#s4-1-5)。 +4. **数据绑定**(T):门禁真正消费的是指定产出任务的 results(`$(tasks.scan.results.x)` 这条线接对了)。准入看不到表达式层面的绑定关系;由模板保证。 +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)): + +| 形态 | 触发条件 | 运行终态 reason | 下游发布任务 | finally | 失败如何呈现 | +|---|---|---|---|---|---| +| 准入拒绝门禁 TaskRun 的创建(契约 2 的响应) | 门禁的实际参数不合规 | `CreateRunFailed`(终态;这里的"不重试"指的是**不会无限重试**,并不承诺只尝试一次;它**还有一个前提** —— 见下方 info 块的最后一项) | 从未被创建(`skippedTasks` 为空) | **不执行** | Kyverno 的策略消息原样传入 PipelineRun 的 condition | +| 门禁任务 exit 1(主线硬门禁) | 结果不达标 | `Failed` | 被 DAG 跳过;列在 `skippedTasks` 中(reason 为 `PipelineRun was stopping`) | **执行** | 门禁任务的日志 + "Tasks Completed: N (Failed: 1)" | +| mutate-existing 取消([§4.6.1](#s4-6-1)) | 结果不达标(由 status 事件触发);result 缺失或格式错误会以同样方式触发,失败关闭(**判据方向的失败关闭 ≠ 投递保证**:取消是在后台异步投递的,那条链路断掉时它会静默地不发生 —— 见 [§3.7](#s3-7) 的"异步投递链"一行) | 通常是 `Cancelled`;当产出 result 的任务本身先失败时则是 `Failed`(失败结论优先于取消;`spec.status` 仍然是 `CancelledRunFinally`) | 运行中的会以 `TaskRunCancelled` 停止 | **执行** | 父运行的 `cancel-reason` 注解(由同一个补丁写入;文本中会写明触发的 TaskRun 与越界的 result 值)+ 事件;配上配套的 Audit 规则时还会有一条 PolicyReport 记录 | +| mutate-existing 自我取消([§4.6.2](#s4-6-2)) | 已解析定义发生漂移(回写进 `status` 的 `pipelineSpec` 与批准的身份不符) | `Cancelled` | 同上 | **执行** | 父运行的 `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` 注解 | +| **由准入 mutate 直接取消门禁 TaskRun 自身(deny 的同步替代方案,[§4.2.3](#s4-2-3))** | 门禁的实际参数不合规 | `Cancelled` | 被 DAG 跳过;列在 `skippedTasks` 中(reason 为 `PipelineRun was stopping`) | **执行** | TaskRun 的 condition 原样带有策略写入的 `statusMessage`(在 tkn / UI 中可见);PolicyReport 中没有违规记录 | + +:::info 为什么"准入拒绝门禁 TaskRun"会跳过 finally(一个已知的社区问题) + +该行为已在上游报告:https://github.com/tektoncd/pipeline/issues/10514 (*finally tasks are not executed when a child run creation is permanently rejected*;截至撰写时仍未关闭)。当前 ACP 版本所采用的社区 Pipelines 版本带有这个问题;在上游修复落地之前,下面的选型建议适用。机制如下: + +- **机制**:finally 只有在整个 DAG 结束之后才被调度,而"结束"要求每一个 DAG 任务都落到 succeeded / failed / **skipped** 三者之一。被准入拒绝的门禁 TaskRun **从未被创建**,所以那个节点永远到不了这三种状态中的任何一种 —— DAG 永远不算结束,finally 永远不会被调度,而控制器很快就把该运行置为 `CreateRunFailed` 终态。 +- **对比**:当门禁任务 exit 1 时,TaskRun 已被创建、已运行、已失败 —— 节点有了终态,DAG 可以结束,finally 照常执行。分界线是**门禁节点是否到达了终态**,而不是运行是否失败。 +- **如何识别这种形态**:运行是 `CreateRunFailed`,没有任何子 TaskRun,finally 从未被创建,且 `skippedTasks` 为空。 +- **为什么是终态而不是无限重试**:创建子运行失败时,控制器会先对错误做分类(上游 `pkg/reconciler/pipelinerun/pipelinerun.go` 中的 `handleRunCreationError`),**只有被判定为"永久性"的错误才会被写成 `CreateRunFailed`** —— 其余一律按可重试处理。准入拒绝之所以落进永久性这一类,是因为对于"webhook 拒绝但未给出状态码"的响应,API server 统一返回 400。**因此还存在另一种形态**:如果你的拒绝响应带上了已知的失败原因(例如 `Forbidden`),该错误可能被判为可重试 —— 症状就变成运行**卡在 Running、控制器反复重试创建同一个子运行**,而不是直接失败。看到这种卡住的形态时,别去查 DAG,去看拒绝响应的状态码和 reason。 +- **"永久性"不等于"只尝试了一次"**:错误一旦落进永久性这一类,运行就会直接终止,但控制器**并不保证它只发出过一次创建请求** —— 单次运行的 `TaskRunsCreationFailed` 事件可能带有大于 1 的 `count`(`Failed` / `InternalError` 会合并计数)。所以本节承诺的是运行**会很快到达终态**,而不是只发过一次请求:排障时**不要把 `count > 1` 当成异常** —— 需要警惕的是上一条描述的那种形态,即运行**卡在 Running、控制器反复重试创建同一个子运行**。要按运行精确计数,用 `kubectl get events -n --field-selector involvedObject.uid=`;按名字查询会把同名的历史运行遗留的旧事件一并算进来。 + +::: + +:::warning 选型提示:依赖 finally 做通知 / 清理的团队请注意 + +- 在准入拒绝这种形态下(`CreateRunFailed`),finally **不会执行**;按上面的对照表,只有当门禁任务已经落地并随后失败、或者运行被显式以 `CancelledRunFinally` 取消时,finally 才会执行。 +- 如果你的通知 / 清理必须在门禁参数被拦截时也照样触发,就不要把它单独挂在 finally 上 —— 改用**取消(RunFinally)**来替代 deny,有两条路线: + - **[§4.2.2](#s4-2-2)(取消父运行)**:在扫描 TaskRun CREATE 时触发 mutate-existing,把父 PipelineRun 的 `spec.status` 补丁为 `CancelledRunFinally` 并打上原因注解;运行以 `Cancelled` 终止,但 finally 照常执行(由结果不达标触发的取消见 [§4.6](#s4-6))。 + - **[§4.2.3](#s4-2-3)(另一种同步形态:取消门禁 TaskRun 自身)**:不动父运行,而是在准入期间把门禁 TaskRun 自身 mutate 为 `spec.status=TaskRunCancelled` —— 它在同一次准入过程中完成,没有竞态窗口,也不需要额外的 background-controller RBAC。 +- 三种形态的权衡见 [§4.2.3](#s4-2-3) 的对照表。 + +::: + +### 2.4 扩展模型:从自定义 Task 与 results 生长出策略 {#s2-4} + +除了平台内置的扫描 / 门禁能力,每个组织都有自己的检查(自研 linter、许可证扫描、安全基线、制品规范……)。扩展路径分三步: + +1. **让 Task 产出声明式的 results**:自定义 Task 把结论写成**可判定的 results** —— 一个数字(`error-count`)、一个枚举结论(`verdict: pass|fail`)、或一个结构化对象 —— 而不是"报告文件的路径"。Tekton 的 results 有三种声明类型 —— `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`(默认)**:单值最直接 —— 一个 result 放一个数字或一个枚举结论;策略用 `to_number` 转换或直接比较,零解析风险。 + - **聚合字符串(叠加在 `type: string` 之上的一种约定;属于兼容手段,不推荐)**:把若干字段以 `key=value` 拼接塞进一个 string result,策略侧再用 `split` + 正则 + `to_number` 拆开([§4.4.2](#s4-4-2))。**它确实能用** —— 但只在你要消费一份**暂时改不了的既有 Task 契约**时才这么做:文本格式不是稳定契约;字段顺序、分隔符、新增字段,以及"数量不可知"的哨兵值,都会造成静默失配 —— 而失配通常表现为**被误判为通过**。当契约由你掌控、且要聚合多个字段时,请用 `type: object`。 +2. **用于硬门禁**:Task 自行给出结论并 exit 1(或者紧随其后放一个读取该 result 的门禁任务)—— 由此进入 [§2.3](#s2-3) 的契约体系,接受身份锁定与参数校验; +3. **用于可见性 / 兜底**:由 Audit 策略把 result 读进 PolicyReport([§4.4](#s4-4));不达标的结果还可以额外触发自动取消([§4.6](#s4-6))。 + +**两层参数校验**(与契约 2 相同):主路径 = 在 TaskRun CREATE 处校验展开后的实际取值(对任何模板开箱即用,模板作者零设计义务);可选优化 = 当模板已经把开关 / 阈值暴露为 PipelineRun 级参数时,在 PipelineRun CREATE 处提前拦截。 + +**信任前提**:自定义 Task 与其他一切一样受契约 1 约束 —— 不可变引用 + 可信镜像。否则"推一个永远打印 pass 的脚本版本"就是成本最低的绕过方式。 + +Cookbook([§4](#s4))用一个虚构的、自包含的扫描任务(`policy-demo-scanner`)把这条扩展路径贯穿始终;来自平台 catalog 的真实 Task(如 sonarqube / trivy)则以画像小节的形式出现,配上它们真实的 result 契约。 + +### 2.5 残余风险台账(装完最小可用集之后,还剩哪些路径) {#s2-5} + +[§1.4](#s1-4) 讲了 Kyverno 管什么、不管什么,[§2.3](#s2-3) 讲了七条契约各自由谁担保,而每一节都带有自己的"本节未覆盖什么"说明。本节把它们合并成一张表:**假设你已按 [§4.0.1](#s4-0-1) 装好最小可用集并修正了作用域,这就是你手上实际拥有的保证与暴露面。** 该表同时也是本文的范围声明 —— 标 ❌ 的行是本文**明确不覆盖**的内容,它们不是遗漏。 + +图例:✅ = 准入阶段的硬 Enforce 拦截(各类白名单共同的前提是名单填全,见 [§4.0.7](#s4-0-7) —— 下面不再逐行重复);🟡 = 只有事后 Audit / 异步响应,或者能否拦截取决于 Kyverno 之外的条件(如模板设计);❌ = 本文不覆盖。 + +| # | 绕过或失效路径 | 覆盖情况 | 靠什么封堵 | +|---|---|---|---| +| 1 | 完全不走 Tekton:直接创建 Pod / Job / Deployment,或在别处使用部署凭据 | ❌ | 用 RBAC 收窄工作负载 API 与凭据([§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 | 引用坐标没变,但**远程定义的内容**被换掉了 | 🟡 | 只能锁定身份;对内容的信任来自 catalog / 仓库治理([§2.1](#s2-1) 的三个层级),并叠加 [§4.1.4](#s4-1-4) 的事后漂移 Audit | +| 5 | 门禁被 `when` / 空 matrix 跳过(根本没产生 TaskRun) | 🟡 | 准入没有对象可拒;只能依靠模板不提供跳过路径 + [§4.1.5](#s4-1-5) 中读取 `skippedTasks` 的事后 Audit | +| 6 | 门禁开关被关掉、阈值被调、通过 override 注入(`taskRunSpecs` / `taskRunTemplate`) | ✅ | 官方模板用 [§4.2.5](#s4-2-5) / [§4.2.4](#s4-2-4) 的真实画像,**改好作用域与占位符即可用**;自建模板用 [§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 的抓手)。**定义侧的准入路线本文没有采用**;其形态与代价见 [§4.1](#s4-1) 引言末尾 | +| 8 | 门禁消费的 result 不是目标任务的(接线接错,或被改接) | ❌ | 契约 4:准入看不到表达式层面的绑定;只有模板能保证 | +| 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 | 伪造的 result(扫描步骤自己写一个 `pass`) | 🟡 | 归入契约 1:不可变引用 + 可信镜像;[§4.6.1](#s4-6-1) 另有一处身份防伪检查 | +| 13 | 本该发生却没发生的取消(mutate-existing 的异步投递链断了) | 🟡 | 失败开放;按 [§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 | 新命名空间 / 新集群未被纳入治理 | 🟡 | 两者都会**被静默放行**:按 [§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` 就已经覆盖了。**真正会开口子的是"为保险起见把 `v1beta1` 也加进 `kinds`"** —— 从此转换不再发生,**跨版本被改名的字段路径**读出来是空的,依赖它们的判据会静默跳过(两个版本共有的路径仍能解析,所以这是**部分**失效 —— 更难被发现)。细节见 [§3.2](#s3-2)"API group-version 前提";`CustomRun` 是例外 —— 它只有 v1beta1 | +| 17 | `StepAction`(step 级远程引用)、Tekton Chains / provenance、资源配额与并发滥用 | ❌ | 不在本文范围内;不做分析、不给判据 —— 需要时各自用对应机制治理 | +| 18 | 判据所依赖的"实际取值"还有请求之外的来源(sonar 的 properties 文件可能来自被扫描的仓库或某个 workspace) | 🟡 | 准入只能看到请求本身。对于分支取值,[§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 文件把分析指向某个特性分支 + 门禁被显式关掉,这三者的组合 | 🟡 | 方向为失败关闭:① 按拒绝消息与 [§4.2.4](#s4-2-4) 第一个 warning 中的对照表改写成推荐形式即可放行;② 该次运行显式传入特性分支的取值。确实处于契约之外的历史形态,走 [§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)**:修改平台托管组件的配置要通过全局管理集群上的 `ModuleInfo`;该节的命令都显式带有 `--kubeconfig ` —— 请照原样书写,不要复用当前 context。 + +动手之前,先确认你当前的 context 指向目标集群;不要在全局集群上创建演示资源: +```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 的模块化机制安装,且都支持离网环境: + +- **Kyverno**:管理员视图 → **应用市场 → 集群插件** → 搜索 `kyverno` → 安装 **"Alauda Container Platform Compliance for Kyverno"**。安装完成后,Kyverno 由平台以 Helm / AppRelease 方式纳管,四个控制器部署在 `kyverno` 命名空间中。 +- **Tekton Pipelines**:管理员视图 → **应用市场 → OperatorHub** → 安装 **"Alauda DevOps Pipelines"**;此后由 `TektonConfig` 管理 Pipelines / Triggers / Chains 以及各 resolver 的开关。 + +产品文档:合规管理(Kyverno)的安装与配置、DevOps(Tekton)的安装 —— ACP 官方文档链接见 [§8.2](#s8-2)。 + +:::warning 不要直接在 Deployment 上改被纳管的配置 + +ACP 的 Kyverno 由平台模块(Helm / AppRelease)纳管,并且会被**周期性调和** —— 任何直接 `kubectl patch` 控制器 Deployment 所做的参数改动(例如手工加上 `--exceptionNamespace`),**都会在下一次调和时被还原**。所有控制器级别的配置,都必须通过平台模块的配置入口持久化(做法见 [§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 | 先看插件的安装状态(应用市场 → 集群插件)与 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**(平台调和会把它还原) | +| 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 会把它调和进 `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 与 package 名称是否与你环境实际发布的一致;如果端点指向公共 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 的权限)。**如果你想改用命名空间级的 Role,还必须把 `mutate.targets[].namespace` 从 `{{ request.namespace }}` 改成命名空间字面量** —— 否则 Kyverno 创建时的鉴权检查无法解析该变量,只认集群级权限,策略照样装不上(见 [§4.6](#s4-6) 引言)。**如果你不安装 [§4.2.2](#s4-2-2) / [§4.6](#s4-6) 的 mutate-existing 取消策略,这个权限就不需要 —— [§4.2.3](#s4-2-3) 的准入 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` 只存在于全局管理集群**,见 [§3.1.1](#s3-1-1) 的警告);**不要 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) 的命名空间级 `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 直接忽略的资源 | 过滤列表中**没有**任何一条覆盖到跑流水线的命名空间,也没有任何一条覆盖 `PipelineRun` / `TaskRun` / `Pod` | `kyverno` ConfigMap 中的 `resourceFilters` **先于任何策略**生效:被命中的请求既不会被拒绝,也不会进 PolicyReport,更不会有日志 —— 这是一条**完全静默**的豁免通道。出厂值通常会排除四个命名空间(**以上面命令实际读到的值为准**)—— `kyverno` / `kube-system` / `kube-public` / `kube-node-lease`:同一个违规 Pod 在 `policy-poc` 里被拒,在 `kube-system` 里却一路畅通。因此 ① 不要在被排除的命名空间里跑流水线;② 要清楚用 `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](#s5-3) 需要) {#s3-1-1} + +ACP 的 "Compliance for Kyverno" 插件**默认只带 `--enablePolicyException=true`,不带 `--exceptionNamespace`**。这个默认状态最具欺骗性:PolicyException 对象**能创建成功**,只会附带一条 `The exceptionNamespace flag is not set` 的告警 —— 但它**完全不起作用**:豁免已经放在那里了,目标资源却照样被拒。这两个参数必须一起配置,而且 Kyverno 只认 `--exceptionNamespace` 所指命名空间里的 PolicyException(这正是豁免权限被封闭起来的地方,[§5.3](#s5-3))。该参数**接受单个命名空间名,或者 `*`**(表示任意命名空间中的 PolicyException 都生效)—— **不支持多个命名空间**(已在 Kyverno 1.15 系列上确认;多命名空间列表的需求曾在上游提出 —— [kyverno#6980](https://github.com/kyverno/kyverno/issues/6980) —— 并于 2026-01 以 not-planned 关闭,原因是 informer 只有"单命名空间 / 整个集群"两种形态,实现复杂)。在多项目 / 多租户环境中,这个单值约束会落成两种做法之一: + +- **集中审批(本文采用)**:可信命名空间**属于审批方(平台)**;项目成员从不进入它 —— 豁免走申请-审批流程,由审批者身份代申请方签发(这正是 [§5.3](#s5-3) 演示的模型)。项目之间天然的隔离不受影响:这个命名空间不是项目共享的空间,而是审批流程的落点。**不要**让多个项目共用一个可信命名空间、各自自助签发豁免 —— RBAC 只能管住"谁可以创建 PolicyException",管不住"豁免的内容是否越界"(`spec.match` 可以写任意命名空间),于是项目 A 能创建一个把项目 B 的流水线豁免掉的例外。 +- **项目自治(`*`)**:各项目在自己的命名空间里创建 PolicyException,签发权限随项目 RBAC 走。这种模式下你**必须**再加一条元策略,限制 PolicyException **只能豁免其自身命名空间内的资源** —— 没有它,上面那个"内容越界"的问题在每个命名空间里都成立;同时每个项目里对 `policyexceptions` 的写权限都必须显式收紧 —— 默认角色不应带有它。 + +:::warning ModuleInfo 只存在于全局管理集群;业务集群上没有这个资源 +`ModulePlugin` / `ModuleConfig` / `ModuleInfo` 都是平台管理面对象,**只存在于全局管理集群**。在跑 Kyverno 的业务集群上执行 `kubectl get moduleinfo` 什么都查不到 —— 那个集群上连这个 CRD 都没有。因此**本节的定位与 patch 命令必须用全局集群的 kubeconfig 执行**;而第 4 点的三处确认中,② Deployment 的 args 与 ③ rollout 及 Pod 上实际生效的参数,必须在 **Kyverno 所在的集群**上执行。 + +还要注意,在 global 上,一个插件**对每个安装目标集群各有一个 `ModuleInfo`**,所以在断言"恰好命中一条"之前,必须先按目标集群收窄 —— 平台用 `cpaas.io/cluster-name` 标记投放目标;安装在全局集群自身上的实例可能不带这个标签,此时改用指向其 `Cluster` 对象的 ownerReference 来识别。 + +下面的命令按 Kyverno 与 Tekton 在同一集群编写,因此不涉及跨集群切换;如果你的环境把两者分开部署,请按上面的说明把命令拆到两侧执行。 + +::: + +正确的启用路径有四个要点: + +1. **绝不要直接 patch 控制器 Deployment 的 args** —— 平台调和会把它还原(见上面的警告)。 +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 时必须断言唯一性**:在全局集群上,按模块标签精确查询,再按目标集群标签收窄,然后硬断言恰好命中 1 条;不要靠版本号或 `global-` 前缀去猜,也不要默默取 `items[0]`。 +4. **改完之后,三处都要确认,一处都不能少**:① `AppRelease` 已经合并了 values;② Deployment 模板的 args 里带上了该参数;③ rollout 已经完成,而且**每一个 Ready 的 admission Pod** 实际运行的都是新参数。只看 Deployment 模板,或者只碰到一个新 Pod,都不足以证明高可用滚动更新期间所有在服务的实例都已经切换过来。 + +:::warning 单节点 / CPU 紧张的集群:配置对了,参数照样可能没生效 + +admission-controller 的 rollout 是**先起新 pod、再退旧 pod**(`maxUnavailable` 实际为 0);在 CPU 不足的节点上,新起的 pod 会 Pending,rollout 卡住,旧 pod 继续提供服务,症状就是 PolicyException 依然报 `exceptionNamespace flag is not set` —— 这不是配置错误。**判据只有一个:在服务的 pod 实际跑的是什么参数**(第 4 点的 ③);参数出现在 Deployment 模板上,不等于它出现在在服务的 pod 上。卡住时,腾出节点资源让 rollout 自行完成 —— 别指望删掉某一个旧 pod 就够了(新 pod 的实际资源请求未必等于模板中的值)。 + +::: + +⚠️ **先看清它当前指向哪里**:`--exceptionNamespace` **只接受一个取值**。如果集群上已经启用了它、并且指向另一个承载着真实豁免的命名空间,那么把它改成演示用的取值,会让**那些豁免立刻全部失效**(并且一直失效到你改回去为止)。这种情况下就不要改 —— 直接复用既有的可信命名空间来做 [§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 地址,必须是**你打算修改的那个全局集群**;如果不是,先修 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`(同一条策略内的所有 rule 共用;取值为 `Ignore` / `Fail`,默认 `Fail`;超时默认 `10`,范围 1–30 —— 依据 1.15 的 CRD)。旧的顶层 `spec.failurePolicy` / `spec.webhookTimeoutSeconds` 已废弃,新旧同时声明会在安装时被拒。`timeoutSeconds` 是**单次请求的总预算**,不是每条 rule 的配额 —— [§3.7](#s3-7) 中的外部调用必须塞进这个数字之内。 +- ⚠️ **读取生成侧对时机很敏感**:`kyverno-resource-validating-webhook-cfg`(真正管着 `PipelineRun` / `TaskRun` / `Pod` 的那一个)是 **Kyverno 依据已安装策略动态生成的** —— 在本文的策略一条都没装时,它的 `webhooks` 是空的;那时你能读到的 `Fail/10` 都属于 Kyverno **自身 CR** 的 webhook(policy / exception / cleanup / ttl)。**装完 [§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 results(`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 把 `v1` 作为这三者的存储版本与提供版本。**唯一的例外是 `CustomRun`**([§4.5.4](#s4-5-4) 与 [§5.3](#s5-3) 的入口封堵策略):Tekton 只在 `v1beta1` 中定义并注册这个类型 —— 它在 `v1` 里根本不存在 —— 所以那两处写 `tekton.dev/v1beta1/CustomRun` 不是疏漏,也不能顺手"统一成 v1"—— 一改规则就会**静默失配**。 + +**它们的 `v1beta1` 通常也仍在提供服务**:在上游 Tekton Pipelines 各版本发布的 CRD 中,`pipelineruns.tekton.dev` 与 `taskruns.tekton.dev` 的 **`v1beta1` 与 `v1` 都是 `served: true`**(只有 `v1` 是 `storage: true`)—— "两个版本同时可提交"是默认形态,而不是什么特殊配置。**但这并不构成绕过** —— 下面的警告解释了为什么(一句话:请求在到达 Kyverno 之前,API server 已经把它转换成 `v1` 了,**所以不要**因此把 `v1beta1` 加进 `kinds`)。上游 CRD 的证据不等于你环境里的那一份;安装之后,仍然建议确认一次实际提供的版本: +```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`,再送去准入**,字段名已经规范化(`spec.serviceAccountName` → `spec.taskRunTemplate.serviceAccountName`、`taskPodTemplate` → `podTemplate`,等等)。**反过来,只要 `v1beta1` 出现在 `kinds` 里,这次转换就不再发生**,送到准入的是原始的 `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 自身的准入会直接拒绝 `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 的下一次调和会把手工编辑的 ConfigMap 还原(与 [§3.1.1](#s3-1-1) 同样的纪律)。在验证环境上,两次读取返回的都是 `beta`。 + +**模板 → Task → result 契约版本矩阵。** Cookbook 中每一个真实画像都按版本固定:不同版本可能带有不同的 result 契约,跨版本套用会以**静默失配**收场。 + +**下面这张表是本文唯一的契约基线**:参数名、类型、默认值与 result 形态,以此处为准。**升级这些版本时的行动项在 [§3.6](#s3-6)(哪些判据受影响)与 [§3.8](#s3-8)(升级后要跑什么)。** 后续各节会就地重复与自己判据相关的那一两行(这样你可以边读边写策略),但**升级模板 / Task 版本时,你只需要回到这张表逐行重新核对** —— 不必再去翻各节零散的注记。矩阵中的模板与 Task 定义随 **Alauda Artifact Hub Shim v1.0.0** 一同发布(ACP 内置 hub:一个兼容 Artifact Hub 的 API,供 Tekton 的 hub resolver 使用);**更高版本的 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` | +| **独立画像**(不包含在上述模板中) | `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`(本文不治理构建/推送侧,见 [§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 那一组;workspaces 是 **12** 个而 java 是 16 个;`trivy-config` 两边都有);但**与 trivy 门禁相关的参数在两边逐字段完全一致**(sonar 侧的参数名也相同,只有 `sonarProperties` 的默认值不同,而这不影响判据),所以 [§4.2.5](#s4-2-5) 的门禁判据用一条规则就能覆盖两个模板 —— 只有构建输入与 workspace 白名单需要按模板拆开。 +4. **这两条流水线都不包含 `skopeo-copy`**:[§4.5.1](#s4-5-1) 是面向制品搬运场景的独立画像。 + +上表中的 Task 版本,以**你环境里模板实际固定的版本**为准;你策略里的字段名必须与目标版本的真实契约一致。 + +::: + +在旧版本上的降级方案:只有在 object results 不可用时,才退回到聚合字符串 result([§4.4.2](#s4-4-2) 的解析模式正是这种兜底形态)—— **这是降级路径,不是目标形态**。从 0.6 起,`trivy-scanner` 也发布了 object result,因此**请直接用 [§4.4.1](#s4-4-1) 的下钻模式消费 trivy 的 result**;[§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。集群清理不会碰这些本地文件 —— 是否留作证据由你决定。 +- **集群上**:本节创建的两个共享命名空间 `policy-poc` / `tekton-templates`;[§5.2](#s5-2) 探针块创建的命名空间(`proj-a` / `proj-b` / `rogue-ns` —— 以该节的创建循环为准);以及 [§5.3](#s5-3) 的 `policy-exempt-runs` / `policy-exceptions`(**只有当它们确实是本次实操亲手创建的,才会被打上实操 id 标签** —— 原本就存在的既不打标,也不会被清理触碰)。命名空间级的演示对象 —— `PipelineRun` / `TaskRun`、夹具 `Task` / `Pipeline` 对象、白名单类 `ConfigMap`、[§4.2.2](#s4-2-2) 与 [§5.3](#s5-3) 的 `Role` / `RoleBinding`、`PolicyException` —— 全都位于这些命名空间之内。除此之外,个别小节还会创建**集群级对象**:`ClusterPolicy` 以及 [§4.6](#s4-6) 的聚合 `ClusterRole` —— **删除命名空间并不会把它们一并带走**。 +- **清理落在哪里([§4.0.4](#s4-0-4) 的两条规则)**:集群级对象按创建时账本里的 UID 逐个删除,在各节收尾的"清理"中完成;命名空间在核对实操 id 标签之后删除,级联带走其中的一切([§5.2](#s5-2) / [§5.3](#s5-3) 的命名空间由它们各自的清理段落处理;`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) 执行它的回滚步骤。 + +::: + +后续各章共享的资源。先创建两个命名空间:`policy-poc` 承载业务侧的运行与探针,`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` 是一个 object result,只声明 `result` / `reportURL` / `taskID` / `projectID`;这四个 property 的真实 schema 都是空映射 `{}`,并没有额外的 `type: string`; +- `code-scan-metrics` 是一个 object result,其 schema 只声明了真实 0.7 必定具备的那个 property:`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 时不要保留它们。这里没有单独的门禁任务:夹具自身失败,就是拦住其后 `release` 的方式。 + +:::warning 替换占位符 + +把夹具中的 `` 替换成你的环境能拉到 busybox 的镜像仓库前缀。在生产环境中,请把 step 镜像固定到 digest —— 否则任何拥有镜像仓库推送权限的人,都能直接把扫描逻辑整个换掉(契约 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 +``` + +⚠️ **这些只是候选,不是答案**:平台命名空间能拉,不代表 `policy-poc` 也能拉(拉取凭据是按命名空间授予的),而且两条命令打印出来的都是**平台镜像**路径,其中未必有 `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`。因此: + +- **首次安装**:探针应当什么都不打印;如果有输出,按上面的提示换命名空间。 +- **重跑同一份实操**:那五个对象就是你上次建的。先确认它们确实是你的(`kubectl get -o yaml` —— 内容是不是这份夹具,命名空间的实操标签是不是你上次的 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 中途失败,或者两个命名空间还没创建(本节开头的那个循环)。 + +⚠️ **这两个共享命名空间必须是本次实操创建的**([§4.0.4](#s4-0-4) 的前置纪律 —— 清理依赖命名空间删除的级联,而级联的前提是里面没有别人的东西)。当上面的创建循环打印 `pre-existing` 时,说明这个集群上已经有人占用了该命名空间名 —— **不要在里面做演示**:把全文的 `policy-poc` / `tekton-templates` 换成你自己的名字(最终清理也用你的名字执行);或者先确认它是你自己上一次实操留下的(标签里的实操 id 就是你记下的那个),把那一轮收尾,再重新开始。 + +这个模板体现了 [§2.3](#s2-3) 各契约中属于模板侧的责任:门禁由扫描器自身承载(契约 3"必须执行" + 契约 4"消费真实的实际取值"在同一个任务内自洽),`release` 排在扫描器之后(契约 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` 是**由扫描任务产出的一个 Tekton task result** —— 它既不是 Pipeline 级的字段,也不是 Kyverno 的概念。先把这一点搞清楚;后面几章的"结果类"策略全都围绕它展开: + +- **谁产出它**:`scan` 任务(夹具中的 `policy-demo-scanner`)在其 step 脚本中把一段 JSON 写入 `$(results.code-scan-results.path)`; +- **它落在哪里**:Tekton 把它记录在**该任务对应 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 | 输入 | scan | release | finally notify | 扫描结论(scan 的 task result `code-scan-results.result`) | +|---|---|---|---|---|---| +| pass | `coverage=85` | ✅ 成功 | ✅ 执行 | ✅ 执行 | `Succeeded` | +| gate-fail | `coverage=30`(两个门禁开关都是 `true`) | ❌ 自身失败 | ⏭ 被跳过(reason 为 `PipelineRun was stopping`) | ✅ 执行 | `Failed` | +| gates-off | `coverage=30` + 两个门禁开关都是 `false` | ✅ 夹具成功 | ✅ 执行(**刻意暴露出来的绕过**) | ✅ 执行 | `Failed` | + +后两次运行与 `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` 迟迟不返回而是超时,通常说明运行卡在解析上(模板根本没建起来 —— 回到上面那五个对象的验证);当任一运行的终态与表格不符时,先确认夹具的 `demo*` 参数没有被改动过。 + +前两行是硬门禁的基线形态(扫描器自身失败 → `release` 被跳过 → finally 照常执行 —— 正是 [§2.3](#s2-3) 对照表中的第二行)。 + +第三行是**仅存在于夹具中的反面测试**,它那个 `Failed` 不是笔误 —— 这一行刻意把两件事拆开了:**结论**依然算出 `Failed`(`demoResult` 默认为 `Auto`;覆盖率 30 < 80 判为 `Failed`),但夹具只有在**至少一个门禁开关为 `true`** 时,才把失败结论转换成 `exit 1`。两个开关都关掉时,scan 成功退出,`release` 照样执行 —— 而 scan TaskRun 的 `code-scan-results.result` 就明明白白写着 `Failed`。**结论说不合规,流水线却一路全绿** —— 这正是"门禁开关被关掉"的危害形态,也正是 [§4.2.1](#s4-2-1) 必须在 TaskRun CREATE 时就拦住不合规开关值的原因:等结果出来的时候,发布早就跑完了。这一行只描述夹具的确定性行为;它并不声称真实的 SonarQube 服务在两个门禁都禁用时必然产出同样的组合。 + +#### 最终清理(通篇走完之后) + +各节收尾的"清理"只删除该节自己的策略与运行对象;**这两个共享命名空间要在整篇文档做完之后单独删除** —— 否则夹具会永远留在集群上: + +```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