以托管网关实例贯穿资源收敛、删除清理与版本演进
2026-10-21
控制器最吸引人的地方,是让用户声明目标,再由软件持续修正现实。它最容易被低估的地方,也在这里:持续运行意味着任何一步都可能重复,任何读取都可能过时,删除和升级都必须跨越多个互不原子的写入。
本书围绕一个小而完整的项目展开:用户创建 GatewayInstance,Go 控制器创建 Deployment 和 Service,Envoy 返回声明的 HTTP 文本。我们故意让业务能力保持简单,把注意力放在对象身份、协调边界、状态真实性、冲突和恢复上。读者最终得到可以在真实 kind 集群执行的代码、原始测试流以及同源的 EPUB 和在线文章。
读者应已了解 Pod、Deployment、Service 和基础 Go 编程。本书不重讲容器入门,也不把安装命令排成运维百科。每章提出一个工程问题,沿同一条资源生命周期推导方案,再指出随书代码实际覆盖的边界。
实验是单 worker、单命名空间的教学控制器。它观察主资源,依靠周期复查发现子资源漂移;没有 leader election、复杂准入链、配置热更新、真实外部云 API 或 conversion webhook。Envoy 使用静态 direct-response,证明请求经过 Service 到达实际代理进程,不代表具备完整网关产品的认证、路由治理或吞吐能力。更新采用 Recreate,允许停机。
阅读时请同时打开 lab/controller.go 和
lab/integration_test.go。第一次可以只看状态转移和断言;第二次再核对重试闭包内的每一次读取。运行记录见下载区
validation.json;它记录执行环境、原始测试流哈希和精确场景,不能把绿色构建外推为生产高可用证明。
作者:Aias00。原创内容及教学代码保留所有权利;第三方软件依赖和官方引用各自适用原有许可。
设想平台团队允许业务创建一个网关实例。用户希望一个名称对应一组可以访问的代理进程,改变期望副本数后系统自动调整,误删 Service 后系统自动补回,删除实例后关联资源也得到清理。单次执行的安装脚本只承诺执行过一组命令;托管能力承诺在服务存续期间反复检查这些关系。
我们把托管入口命名为 GatewayInstance。它的 spec 只包含 replicas 和 message:前者限定在零到三个,后者决定 Envoy 对所有路径返回的正文。字段少不是为了回避工程问题,而是让资源身份和失败处理足够清晰。若一开始就加入证书、插件、路由、跨集群和配额,每次失败都难以确认究竟是哪层契约失效。
用户提交意图后并没有把 Kubernetes 所有权完全交给控制器。控制器必须说明自己管理哪些字段、哪些对象,以及哪些修改会被恢复。本文实现管理 Deployment 的副本、代理镜像与参数、部分容器字段、Pod 标签,以及 Service 的选择器和端口。它保留对象上的无关注解,不擅自接管一个同名但没有正确 owner UID 的 Service。
“网关已经部署”过于含糊。Deployment 创建成功,只能说明 API 已接受对象。Pod 被调度,不代表 Envoy 成功加载配置;HTTP 探针通过,也不能代表所有业务路由正确。不同层级的事实应分别命名,避免把一个方便检查的信号当成最终业务结果。
本项目的 Reconciled 条件表示控制器已经应用本次 generation 对应的子资源配置,不表示代理流量已经可用。实际 HTTP 验证是独立实验:等待 Deployment 观察其自身 generation,确认更新副本和可用副本达到期望,再由集群内客户端访问 Service DNS。这个区分使控制器可以及时报告资源协调进展,同时保留对业务验证的严格要求。
期望副本数可以为零。这表示用户暂时停止数据面,而不是删除实例。Service 和用于教学的外部登记仍然存在,finalizer 仍承担删除责任。若把零副本误当作删除,后续恢复就可能丢失原对象身份或稳定入口。生命周期状态与容量状态应分开设计。
| 对象或字段 | 谁决定 | 失败时的处理 |
|---|---|---|
| GatewayInstance.spec | 用户与准入规则 | 拒绝无效输入 |
| Deployment 托管字段 | 控制器 | 下次协调恢复 |
| Service ClusterIP | API server | 更新时保留 |
| Reconciled 条件 | 控制器 | 只确认实际处理的 generation |
| 外部登记 ConfigMap | finalizer 清理流程 | 清理完成才移除自身 finalizer |
这张表也是评审工具。发现实现将整个 ServiceSpec 覆盖为新值时,立即检查是否误删 API 分配的 ClusterIP。发现 status 直接引用最新对象 generation 时,检查它是否与本轮实际处理的 spec 相同。契约越具体,测试越容易提出可证伪的断言。
单实例运行不等于高可用。两个控制器进程即使都使用乐观锁,也可能同时调用外部 API;资源版本冲突只能保护同一个 Kubernetes 对象的条件写入。随书程序没有 leader election,因此部署说明必须要求一个活动进程,而不能靠“重复协调应该幂等”暗示可以任意增加控制器副本。
同样,Envoy 的 direct-response 是真实 HTTP 能力,却不是生产网关治理。它没有上游重试、限流、鉴权和动态路由。我们使用固定镜像 digest 和简单配置,让实验关注控制器能否将 spec 映射为可验证行为。扩展网关业务能力时,需要为每项新增承诺设计独立验证,而不是沿用一次成功响应作为总证明。
按用户操作列出路径,比按代码函数列出任务更可靠。创建需要补齐资源;更新需要区分旧状态和新意图;误删子资源需要恢复;控制器重启需要重新发现已有对象;删除需要先处理外部责任;版本演进需要保证旧对象依然可读。这些路径共同构成控制器的产品定义。
本章的练习是为证书字段写出同样的责任表。证书内容由谁写入、错误证书如何报告、旧证书何时撤销、Secret 删除后怎样恢复,不能仅用“支持 TLS”概括。一个新增字段真正的成本,通常藏在更新、删除和恢复路径中。
团队提出增加“暂停协调”字段,看起来只要在函数开头返回即可。仔细展开,会发现创建、更新和删除的含义都变了:暂停前已经存在的资源是否继续自愈,暂停期间用户删除主对象是否仍要清理,恢复后是否一次应用积压的所有变更?如果暂停也跳过删除分支,用户就可能得到永远无法删除的实例。
可以把暂停定义为只停止正常配置推进,删除责任仍然有效,并在状态中记录实际处理版本。这样用户提交新配置后,不会误读旧的成功条件为本次更新已完成。代价是用户不能用暂停阻止清理,应另有受控的运维流程。
这说明字段命名只是入口,关键是对每种生命周期状态定义行为。评审时把暂停值分别放入新建、稳定、更新、待删除和重启恢复现场,逐格说明预期,通常比先写实现更容易发现契约空白。本书没有实现暂停字段;此例提供需求展开的方法,避免把未实现能力混进当前发布。
GatewayInstance 的 CRD 位于 lab/crd.yaml。它使用
Namespaced 作用域,把实例和子资源放在同一命名空间。replicas
是整数并限定在零到三;message 是有长度限制的字符串,默认值为 gateway
ready。用户输入在进入存储前经过结构验证,控制器不必把任意 JSON
都解释成合理配置。
限制副本数在教学项目中兼有实验隔离价值:一个拼写错误不会创建大量 Pod。但它不是平台配额。多个实例仍可能分别申请三个副本;真正的租户资源约束需要 ResourceQuota、策略和容量管理配合。API 字段的局部范围验证不能代替聚合资源预算。
message 的上限也有明确用途。它最终进入 Envoy 启动参数,过长内容会放大对象大小和进程参数,不适合用作大文件分发。本项目把字符串先交给 JSON 编码器,再放入 YAML 的标量位置,避免引号、换行或冒号破坏配置。输入验证与输出编码解决不同问题,不能因为长度受限就手工拼接不受转义的文本。
CRD 开启 status 子资源。用户对主资源进行 Update 时,不应顺手改写控制器状态;控制器写 status 时,也不应覆盖用户最新 spec。API server 提供了子资源隔离,但这不自动解决语义竞态:状态写入者仍须知道自己处理的是哪一版意图。
假设控制器读到 generation 为七,创建了对应 Deployment。用户随后把 message 改为另一值,generation 变为八。控制器在写 status 前重新 GET 到最新对象,如果把八直接填入 observedGeneration,就会宣布尚未应用的配置已经处理。正确做法是携带本轮的七,检查当前意图仍与七一致;不一致时返回并重新协调。
随书代码采用后一种保守策略:任何中途 spec 变化都阻止本轮状态确认。真实 API 竞争测试在子资源处理后更新 spec,断言旧 observedGeneration 不会被错误推进,再由下一次协调应用新副本数。单靠 status 子资源权限无法捕获这个错误。
创建时省略 message,API server 会填入默认值。控制器从服务器读取的是默认后的结构,因此不必维护另一套隐含默认值。这样做还意味着默认值的变化需要作为 API 变更评审:已存储对象、更新对象以及不同客户端的观察结果可能不同,不能假定改 YAML 后所有历史实例都会同步改变。
CRD 不应暴露无法稳定兑现的底层配置。例如直接接受一整段 DeploymentSpec,表面灵活,实际让用户与控制器同时控制大量字段。后续控制器升级可能覆盖用户设置,或者因未知字段而失去收敛能力。先定义业务意图,再为确有需求的可配置项设计稳定含义,更容易维持向后兼容。
status 采用保留未知字段的简化结构,便于实验集中研究协调逻辑。生产 API 更适合给条件、计数和引用建立明确 schema,使客户端和准入链能理解结构。本书不把教学 schema 当作成熟 API 规范,升级为严格状态 schema 时也应保留已有字段的可读性。
名称只在命名空间内唯一,并且删除后可以重用。UID 代表一次对象生命。外部登记使用 UID 派生命名,owner references 也绑定 UID,防止旧实例删除或清理时误操作后来出现的同名实例。把名称当成永久业务主键,是控制器与外部系统集成时最常见的隐患之一。
跨命名空间 owner reference 有限制,因此本项目不会让一个命名空间的实例直接拥有另一个命名空间的 Secret。需要共享资源时,应另行定义引用权限、消费者计数和删除策略。把引用写成一个字符串并不会让垃圾回收器理解业务关系。
调试时先读取服务器实际返回的对象,确认默认值、generation、UID 与 resourceVersion。源码中的 YAML 是输入,服务器保存的对象才是协调起点。若准入 webhook 或策略改写了字段,控制器必须围绕最终对象工作,否则每轮都会与准入链互相改写。
练习可以从删除 message 默认值开始:先比较创建请求和服务器返回,再设计显式空字符串应该被接受、拒绝还是表示默认。不要急着加代码补空值;先把用户能依赖的 API 行为写清楚,然后再确定应该由 schema、默认规则还是控制器实现。
Go 的整数零值容易诱使实现把读取不到 replicas 和用户显式写零合并处理。前者是结构缺失,后者是合法停止数据面的意图。如果程序简单判断零就改成一,用户永远无法缩容到零;如果把缺失当零,又可能在不完整对象上意外停止服务。
本项目让 schema 要求 replicas 存在,服务器验证成功后控制器才读取。这减小了缺失歧义,但未来 schema 升级仍要考虑历史对象。若把字段改为可选,应明确缺省与零的区别,并使用能够表达存在性的类型或读取返回值。
字段单位也应进入名字或说明。超时用秒还是毫秒,容量是字节还是字符,副本数是否允许负数,都影响客户端长期兼容。先把它们写成可验证的约束,再考虑界面上的默认提示。表单默认值不能替代服务器规则,因为用户可以通过命令行和其他客户端绕过表单。
控制器不能假定收到创建事件时对象一定还存在。事件在队列中等待期间,用户可能已经删除或更新对象;网络重连还可能让观察方重新列出同一批对象。因此事件只告诉程序“这个键值得再看一次”,并不授予对历史事件内容的信任。
随书 Run 方法把命名空间和名称组成队列键。worker 取出键后调用 Reconcile,第一步始终是从 API server 读取当前 GatewayInstance。NotFound 表示当前没有工作对象,可以成功结束。程序不会按照旧 Add 事件携带的 spec 重新创建已删除的实例。
这种设计把控制器从命令消费者变成状态修复器。即使创建事件和更新事件被合并,只要最终键还能被处理,程序就能根据最新目标收敛。它也解释了为何不能把“每次事件都必须执行一次”当成业务保证;事件队列的目标是推进状态,不是充当不可丢失的交易日志。
非删除路径先确保 finalizer 存在,再确保外部登记,再创建或修复 Deployment、Service,最后写状态。每一步都可能失败,前面已经完成的 API 写入不会自动回滚。下一轮必须能够识别已有结果并继续,不能因为某个对象已经存在就把整轮当作失败。
顺序对恢复尤其重要。外部登记发生前先持久化 finalizer,确保控制器崩溃后对象删除仍会保留清理机会。反过来,先创建外部对象再添加 finalizer,会留下一个危险窗口:用户在两次写入之间删除主对象,后续没有任何 API 记录要求清理外部责任。
这并不构成跨系统原子事务。本项目的“外部登记”仍是同集群 ConfigMap,用它演示没有 owner reference 的资源如何交给 finalizer 管理。真实云 API 可能存在请求超时但创建成功的情况,需要额外的幂等键和权威查询。后续章节会明确区分已经验证的 Kubernetes 行为与外部系统扩展设计。
Reconcile 返回 nil 不意味着业务永久完成,只意味着本轮没有遇到需要重试的错误。即使资源刚刚收敛,用户或其他控制器也可能在下一秒改变它。成功后保留周期复查,是本项目补足子资源事件观察的方式。
协调函数不应长时间阻塞等待 Deployment 可用。它可以应用资源并报告 Reconciled,让数据面就绪在另一种状态或独立验证中体现。若在单 worker 内等待五分钟,其他实例的删除和修复也会停止。把一个对象的缓慢进展隔离为重新排队,比占住处理线程更公平。
随书 HTTP 实验为了得到完整证据,会等待数据面达到可验证状态;这是测试控制器的行为,不是生产 Reconcile 内部的等待逻辑。评审时要分清测试驱动程序和被测控制器,否则很容易把测试里的长轮询错误地搬进业务循环。
AlreadyExists、Conflict、Forbidden 和 NotFound 并不是同一类失败。创建子资源遇到 AlreadyExists,需要读取并核对所有权;更新遇到 Conflict,需要重读后重新计算;权限不足通常需要运维修复;主对象消失则意味着不必继续应用期望状态。简单地把所有错误吞掉,会让队列认为工作已成功,用户只能看到长期不变的状态。
当前教学实现把多数错误交给队列退避重试,没有完整事件和指标系统。书中后续给出生产演进方向,但不会声称程序已经实现全部可观测性。错误信息保留对象类别和冲突原因,是这一步最基本的诊断基础。
一旦 deletionTimestamp 出现,程序先进入删除分支,不再创建缺失的数据面资源。删除分支只负责清理外部登记,再移除自身 finalizer;Deployment 和 Service 的关联删除由 owner references 交给垃圾回收器处理。这个分工避免控制器在用户删除实例时不断把子资源补回来。
练习时可以在调用删除后手动移除 Service,再执行 Reconcile。期望结果是删除继续推进,而不是恢复 Service。把“正在删除”作为生命周期的高优先级状态,比在每一个创建函数里额外打补丁更容易推理。
假设 finalizer 和登记已写入,Deployment 创建成功,Service 创建因权限不足失败。此时不能把 Deployment 删除当成通用回滚:用户可能已经看到它,下一轮权限恢复后还要重新启动数据面,而且删除操作本身也可能失败。更稳定的恢复是保留可识别结果,在下一轮补齐缺失部分。
这要求每一步的成功能够从当前事实重新判断。登记通过固定名字与UID确认,Deployment通过名称与owner确认,Service同样需要归属验证。一个只有内存布尔变量的“已经完成”标记无法跨进程恢复,写进status也不能替代对实际资源的检查。
可以在纸上对每次API调用分别列出成功、明确失败、结果未知三个分支。Kubernetes名称唯一性和查询能力解决了很多恢复问题;外部接口未必具备同样语义。把这张表作为接入评审材料,有助于识别哪一步能自动重试,哪一步需要查询或人工处理,而不是把所有故障交给一个统一重试循环。
一个命名空间中已经存在名为 demo 的 Service,不代表新创建的 demo 网关实例可以接管它。它可能是业务自己创建的入口,也可能属于已经删除的旧实例。控制器必须用 owner reference 的 UID 核对身份,不能只比较名称和标签。
随书实现创建 Deployment 和 Service 时写入主对象的 APIVersion、Kind、Name、UID,并声明 controller 标志。后续读取同名对象,只有确认 controller owner 指向当前 UID 才修改托管字段。真实集群测试故意先创建一个无 owner 的 Service,协调必须返回明确错误,并保证原端口和所有权保持不变。
这类拒绝会使实例暂时无法收敛,但它把选择留给操作者。运维人员可以更名、删除冲突资源,或经过检查后执行显式迁移。程序自动“修复”一个归属不明的对象,通常比让实例显示冲突更危险。
owner reference 让垃圾回收器知道主对象与依赖对象的关系。主对象真正删除后,Deployment 和 Service 可以被异步清理。它不保证所有依赖瞬时消失,因此测试必须等待最终 NotFound,而不能在删除请求成功后立刻断言所有子资源已经不存在。
本项目额外保存一个没有 owner reference 的 ConfigMap,模拟外部登记。这个对象不会随主对象自动回收,必须经过 finalizer 路径清理。两类资源放在一起,能够观察所有权自动删除与业务清理责任的不同。一个错误做法是给外部登记也加 owner,然后把垃圾回收成功当成 finalizer 已正确执行的证据。
BlockOwnerDeletion 也不是任意情况下阻止所有删除的万能开关。它与删除传播策略、权限和垃圾回收规则共同作用。教学代码使用管理员测试凭据建立关系;生产控制器需要明确相应权限,并在受限角色下验证实际操作。
拥有 Deployment 不等于应该每轮覆盖整个对象。API server 会填默认值,其他组件可能维护注解或状态,准入 webhook 可能加入容器字段。把新构造的 Deployment 直接替换现有对象,容易丢失这些合法变化。
本项目从最新对象读取并只调整声明托管的字段,保留无关元数据。比较容器时只比较确实拥有的字段,避免 API 默认的探针超时和终止消息路径造成永久差异。Service 更新保留 ClusterIP 等由服务器分配的属性,恢复 selector 和 ports。
这里仍有明确限制:教学实现只考虑一个主容器,没有为注入 sidecar、多个字段管理者或不同准入策略建立全面的合并协议。生产系统若支持这些机制,需要定义稳定的扩展点,并采用字段管理、补丁或专门配置接口,不能把“保留未知字段”当成已经解决所有协作冲突。
Service 用实例标签寻找后端 Pod,Deployment 的 selector 也依赖同一组标签。标签变化如果只改一处,就可能得到运行正常却没有流量的 Pod。测试因此不只检查对象存在,还验证 Service 的选择器和 Pod 模板标签一致,并通过实际 HTTP 请求检验链路。
Deployment selector 是创建后不可随意修改的字段。设计标签时应选择稳定身份,而不是把 message、镜像版本或发布编号放进不可变 selector。需要区分发布批次时,额外标签更合适,不应改变实例归属关系。
外部登记名称使用主对象 UID。假设旧实例 demo 删除期间清理变慢,用户后来创建同名新实例;两次生命的 UID 不同,旧清理任务不能误删新登记。即使外部系统只允许人类友好的名称,也应将 UID 保存为幂等键或元数据,并在删除前核对。
练习是设计“用户提供已有 Service”模式。此时控制器不应自动把用户 Service 设为可垃圾回收的依赖,而应把引用、验证和删除策略写进 API 契约。托管创建与引用已有对象是两种不同产品能力,不能仅靠一个布尔开关掩盖它们的生命周期差异。
平台后来允许用户为Deployment创建HPA。HPA改变副本数,下一秒我们的控制器又把它恢复为spec.replicas;两边都在正确执行自己的逻辑,系统却持续振荡。这不是资源版本重试不足,而是两个参与者都认为自己拥有同一字段。
支持自动伸缩需要改变托管契约:启用后副本数应由HPA决定,实例API提供最小值、最大值与指标引用,控制器不再持续覆盖Deployment副本。切回固定副本模式时,也要定义如何删除HPA并接管字段,避免先后顺序造成短暂争夺。
本项目没有HPA模式,因此明确由控制器决定副本。用户手工扩容会被恢复,这是预期行为而非缺陷。一个成熟接口应提前告诉用户哪些直接修改不会持久存在,并把确有价值的配置暴露在主资源上。用文档和状态解释所有权,可以减少操作者绕过托管入口后遇到的困惑。
同一个队列键可能因主对象更新、状态变化、周期复查或进程重启而多次出现。幂等要求重复协调不会创建重复实例、破坏已经正确的资源,或把本来稳定的状态不断改写。它不是保证函数只执行一次,而是让多次执行仍指向同一个目标。
最简单的错误是每次使用 GenerateName 创建 Deployment。即使每次创建都成功,资源数量也会持续增长。稳定命名加 owner UID 检查,使同一次对象生命能够找到已有子资源;而外部登记采用 UID 派生名称,使不同对象生命不会误共享同一个副作用标识。
AlreadyExists 只是服务器报告名称冲突,并没有证明现有对象符合期望。正确流程是读取、核对身份、比较托管字段,再决定是否更新。直接忽略错误会把旧端口、错误镜像或外来对象当成成功收敛。
构造的期望对象只写了少量字段,服务器返回的对象却含有默认策略、协议、探针参数和终止消息字段。使用完整结构 DeepEqual 往往会认为它们永远不同,导致每秒 Update,产生资源版本增长、watch 事件和额外 API 压力。
随书实现对 Service 明确写入 TCP 协议,对容器只比较所属字段。判断是否需要更新时,不比较服务器自行维护的 resourceVersion、status 或默认终止消息路径。实际更新也基于新读取对象,保留无需改变的属性。代码评审应同时检查“怎么比较”和“怎么写回”,两者不一致仍可能造成反复更新。
有些字段天然不适合每轮生成新值。例如把当前时间写进 Pod 标签或注解,必然触发滚动更新;把协调时间无条件写入 status,也会让状态 watch 永远唤醒自身。时间应记录真正的状态转移,而不是证明进程刚刚运行过。
Reconciled 的类型、布尔状态和原因没有改变时,lastTransitionTime 应保持不变。observedGeneration 在处理新意图后可以推进,但不能仅因为重试就改时间。本项目先构造下一份 status,与已有结构比较,相同则不调用 UpdateStatus。
真实测试重复执行协调,断言 CR 的 resourceVersion 不变、Deployment 的 UID 和 generation 不变。Deployment 自身状态可能由内建控制器更新,所以不能把它所有 resourceVersion 变化都归罪于我们的控制器。测试应选择与自己承诺对应的观察量,避免把其他合法参与者的行为当成失败。
幂等还包含错误后的重入。第一轮创建 Deployment 后创建 Service 失败,第二轮应继续使用同一 Deployment 并补齐 Service。为了说明这种恢复,阅读代码时可以在每次 API 写入后画一条崩溃线:下一轮从主对象重新读取,是否仍能识别前一次已经完成的动作?
两个进程可能同时读取旧 Deployment,并分别改变不同字段。即使单独看每个操作都是幂等,后写者仍可能覆盖先写者。resourceVersion 的条件更新提供乐观并发保护,但冲突后必须重读;重复发送同一份过时对象不会变得正确。
同样,外部 API 的“同名查询后创建”可能有竞争窗口。真实系统需要服务端支持稳定幂等键,或者有可信的结果查询和唯一约束。本项目用同一个 Kubernetes API 的名称唯一性简化了这一点,并没有证明任意云 API 调用都能照搬。
测试会改坏 Service selector、端口、目标端口,以及 Envoy 参数和部分 Pod 标签,然后执行协调,确认声明托管的字段恢复。它与删除自愈互补:删除检验资源是否可重建,漂移检验已有资源是否可修正。只验证首次创建,会漏掉绝大部分持续托管错误。
练习时增加一个“不应被改变”的注解,再运行相同漂移测试。恢复所属字段和保留无关信息应同时成立。若修复方案是每次删除重建全部子资源,虽然容易得到正确配置,却破坏稳定入口、放大停机与更新成本,必须作为明确的产品策略评估。
初次协调在创建Service后崩溃,尚未写入状态。下一进程发现Deployment与Service都存在,但仍需要核对端口、选择器与代理参数,再确认本次generation。若仅凭两个名字存在就写成功,可能把其他操作者停机期间改坏的配置当成有效结果。
相反,如果每轮都无条件Update,即使内容相同,也会产生事件和资源版本变化。事件又唤醒主对象或相关控制器,构成不必要的反馈。幂等的实用目标不仅是不重复创建,还包括稳定配置时尽可能不写入,让变化信号更有诊断价值。
可以把一轮协调分成读取成本、必要写入和可避免写入三类。优化时先消除无意义写入,再考虑减少读取,因为过早缓存权威对象可能引入更难解释的并发窗口。当前项目优先可推理性,使用直接读取保证重试基于新对象,规模优化留给有明确负载证据的后续版本。
informer 负责列出并持续观察 GatewayInstance,事件处理器只把键加入队列。worker 才执行 API 请求和协调逻辑。若把耗时操作写进事件回调,会阻塞事件消费,使大量无关对象的观察延迟扩大,也难以控制失败重试。
随书使用 client-go 的动态 informer 和带类型的限速队列。动态客户端避免为这个小项目引入代码生成链,但对象字段读取需要自己核对类型。复杂成熟 API 往往更适合生成类型与客户端,以获得编译期约束;选动态实现不表示无结构 JSON 在所有规模下都更合适。
启动时必须等待缓存同步完成。虽然 Reconcile 通过直接 API GET 读取权威对象,主资源的初次发现仍依赖 informer 的 list 和事件分发。没有完成初始同步就宣称控制器已就绪,会让操作者无法区分“还未发现对象”与“已经处理全部对象”。
每次 Get 到键之后都应调用 Done,让队列知道本轮处理结束。失败时 AddRateLimited 将键按退避规则重新加入;成功时 Forget 清除累计失败计数。忘记 Done 会使键的后续处理受阻,忘记 Forget 则可能让已经恢复的对象仍带着很长重试延迟。
本项目只有一个 worker,因此同一进程内没有两个协调函数同时执行。但 API 仍可能被用户、Deployment 控制器和准入组件改变,单 worker 并不消除并发。队列提供工作调度,resourceVersion 提供对象写入保护,两者职责不同。
错误返回会被重试,但并不是每种错误都适合无限重试。生产系统可以把无效引用、无权限和外部暂时不可用分成不同状态与重试策略。当前实验保留统一退避,使代码保持可读;读者应把错误分类视为后续工程能力,而不是根据队列名称推断它已具备完整故障治理。
主资源变化能唤醒控制器,但误删 Service 不会改变 GatewayInstance。本项目没有为 Deployment 和 Service 建立额外 watch,而是在成功后每秒重排仍然存在的主资源,从而最终发现子资源缺失或漂移。它用额外 API 读取换取了较小实现规模。
这个方案适合少量教学实例,不适合直接复制到大规模集群。若有十万实例,即使没有任何变化,也会产生持续扫描压力。生产设计通常观察拥有的子资源,并根据 owner reference 映射回主键,同时保留较低频率的兜底复查。事件驱动和周期扫描可以互补,但频率需要与对象规模、恢复目标和 API 预算一起计算。
删除完成后不能继续每秒重排一个已经消失的键。Run 在成功后确认对象仍存在,NotFound 则停止周期安排。否则每删除一个实例都会永久增加一条无效工作,长时间运行后队列和 API 请求量可能被历史对象拖累。
watch 不是永久可靠的连接。网络中断、资源版本过旧和 API server 重启都会导致重新建立观察。informer 封装了大部分 list/watch 重连行为,但协调函数仍必须接受重复事件,并以当前对象为准。正确性不应依赖恰好收到某一次 Update。
控制器重启实验会真实终止 CLI 进程,在它停止期间改变 spec,再启动新进程。新 informer 的初始 list 必须发现已有实例,并把数据面副本推进到新的目标。这证明当前进程启动路径能恢复既有工作,不代表多实例故障切换或控制面断电后的完整恢复。
一个坏对象反复失败时,限速队列能降低它对 API server 的冲击;它不能自动限制每个外部依赖的并发,也不能保证租户公平。增加 worker 前,应先衡量队列长度、处理时延和外部 API 限额,再决定是否需要按租户或资源类型分区。
练习是估算一千个实例每秒兜底复查的请求量:一轮包含主对象、外部登记、Deployment、Service 和状态读取,实际成本远超过一千次请求。把协调次数乘以每轮请求数,再考虑 API 限流,比只观察 CPU 利用率更能反映控制器扩容的瓶颈。
假设外部登记查询需要两分钟,而当前只有一个worker。即使其余实例都正常,它们的删除与修复也会等待。提高worker数量可能改善吞吐,但同时放大外部连接数和竞争,不能作为唯一答案。
先给外部调用设置有界超时,再把未完成状态表达为稍后重试,避免在一次协调内无限等待。随后根据真实延迟分布决定worker并发,给每个依赖单独限制连接和请求速率。队列背压与外部调用预算应一起设计,否则系统会把积压从队列转移到远端服务。
随书代码的Kubernetes调用受context控制,测试也设置总截止时间;它没有实现独立外部HTTP依赖。因此不能把这一推演表述成已完成外部超时治理。扩展时应先增加一个可控慢服务,在有限并发下观察其他实例的完成时间,再根据证据确定隔离策略。
控制器读取 Deployment 后,需要一些时间计算期望变化。在这段时间里,用户可能添加注解,内建控制器可能更新状态,另一个工具可能调整配置。Update 请求携带的 resourceVersion 用于判断客户端是否还基于当前版本写入。服务器返回 Conflict,不是在说 API 不稳定,而是在拒绝一个可能覆盖更新的旧写入。
本项目对 Deployment 使用 RetryOnConflict。每次尝试都在闭包内重新 GET,再核对 owner UID 和托管字段,最后 Update。重试闭包若只包含 Update,却复用闭包外读取的对象,会反复提交同一个旧版本,直到重试耗尽。退避次数再多也无法弥补读取位置错误。
resourceVersion 是服务器定义的并发标识,不应当作可计算的整数序列使用。控制器只需要将读取到的值原样带回条件写入。generation 表示期望配置版本,resourceVersion 表示对象存储变更,两者不能互换:一次状态更新通常改变后者,却不代表用户提交了新 spec。
真实冲突测试先让实例收敛,再把副本数从一改为二。控制器读取待更新 Deployment 后,测试钩子启动另一条客户端路径,写入一个注解,使资源版本前进。随后用旧对象执行一次 Update,必须得到真实 API server 的 409,而不是由 fake client 人工返回的错误。
控制器自己的首次 Update 此时也会冲突。RetryOnConflict 重新读取带注解的新对象,重新应用副本目标,再成功写回。最终断言副本数为二,而且另一写入者的注解仍在。前者证明重试没有丢失本轮目标,后者证明它没有用旧对象覆盖并发变化。
钩子只用于确定性安排测试时序,正式 CLI 构造不会设置它。测试没有伪造服务器返回值,也没有替换客户端存储。这样可以同时获得可重复的竞争窗口和真实服务器的版本检查,避免依靠大量并发循环碰运气。
重试闭包里适合放可重复读取与条件写入,不适合塞入无法确认结果的外部扣费请求。Conflict 说明当前 Kubernetes 更新没有接受,并不能撤销闭包前面已经发生的外部副作用。如果每次重试都先创建云资源,就可能把一个元数据冲突放大为多次计费。
本项目的外部登记使用稳定名字和 ownerUID 检查,并且在 Deployment 重试闭包之外处理。它仍不是现实云 API 的完整模型,但足以让读者看清哪些步骤可以安全重做。未来接入真实外部系统时,应把幂等创建、结果查询和 Kubernetes 状态提交分开设计。
冲突次数也不应该无限增长。client-go 的有限重试处理短时竞争,耗尽后把错误返回给队列,等待下一轮。这样一个持续竞争对象不会永久占住单 worker。生产系统还应记录冲突计数及对象信息,以识别两个控制器互相覆盖同一字段的配置错误。
获取新版本不只是为了换一个 resourceVersion。对象可能已经进入删除阶段,owner reference 也可能被改变,用户 spec 可能提交了新的目标。每一轮都必须重新检查当前条件。否则程序虽然通过乐观锁,仍可能在语义上做错事。
当前代码的 Deployment 重试使用本轮捕获的主对象 spec,因此在主对象中途变化时,旧目标可能短暂被应用。状态写入前的 generation 检查会阻止错误确认,后续协调再推进新目标。它保证最终收敛和状态真实性,没有保证每个中间瞬间都跳过旧目标。若业务不允许这种短暂过渡,需要更强的版本栅栏或发布协议。
这种边界应在评审中说清楚。一个系统可以诚实地承诺“旧配置可能短暂出现,但不会被当成新配置确认”,而不能因为测试最终通过,就声称所有更新都是原子的。
给主资源加一条“处理中”注解,不能自动获得跨进程互斥。读改写之间仍存在竞争,进程崩溃后锁也可能遗留。Kubernetes Lease 和 leader election 有专门语义,但它们也不让外部调用拥有全局恰好一次属性。本项目不实现多副本控制器,避免把一个未证明的协调锁扩展为高可用承诺。
练习时让并发写入者修改控制器自己拥有的副本数,而非注解。你会看到重试成功仍可能把该写入覆盖回期望值,因为字段所有权本来属于控制器。并发保护保证不盲目覆盖未知版本,最终谁决定字段值仍由 API 契约决定。
主对象status不是单独存储的一份无关文档。用户更新spec或控制器自己改变finalizer,都可能让基于旧resourceVersion的UpdateStatus遭遇竞争。因此状态写入也放在重新读取的有限重试闭包中,而非假定只要分了子资源就无需并发保护。
重新读取后必须再次检查generation。否则第一次状态提交因冲突失败,第二次读到新spec,却沿用旧结论并确认新generation,竞态仍然存在。并发保护应该同时检查存储版本与业务版本,二者分别回答“对象是否被改过”和“结论基于哪份意图”。
测试中的另一写入者保留注解,说明元数据可以合法并存。若竞争持续来自同一托管字段,则应先修复所有权设计,而不是增加重试次数。把冲突视作诊断信号,能让乐观锁帮助暴露系统关系,而不是变成无限掩盖争夺的循环。
用户无法从控制器内存判断一次更新进行到了哪里,只能读取 status、事件和日志。状态如果过早报告成功,自动化发布系统可能继续推进下一阶段;如果状态长期不变,运维人员也不知道控制器在等待、失败还是已经停止。因此 status 不是装饰字段,而是另一个需要维护兼容性的接口。
本项目刻意把条件命名为 Reconciled,而非 Ready。ChildrenApplied 的含义是该 generation 的托管资源字段已经被应用。Deployment 可用和 HTTP 响应由集群行为及实验独立验证。读者如果要加入 Ready,应明确其检查范围:仅 Pod Ready、至少一个 Endpoint 可用,还是跨真实路由的业务探测。
一个条件通常需要 type、status、reason、observedGeneration 和 lastTransitionTime。reason 应保持便于程序判断的稳定标识,详细错误可放入消息或事件。把完整堆栈、随机错误文本甚至凭据写入 reason,不利于兼容,也可能把敏感信息暴露给所有能够读取对象的人。
顶层 observedGeneration 和条件中的 observedGeneration 都要指向控制器实际处理的意图。它不是“最近读过哪个对象”,而是“这个结论基于哪个 spec”。读取最新 generation 后直接填值,很容易在并发更新期间制造虚假成功。
真实竞争场景按三个阶段安排:先完成旧配置并记录旧 observedGeneration;接着在子资源已处理、状态尚未提交时改变 spec;最后检查本轮返回重试且旧状态仍保留。新一轮协调才允许把 observedGeneration 推进到新值,并同时验证 Deployment 副本数已更新。这组断言将状态与事实连接起来,单独检查字段数值递增是不够的。
本项目在 generation 不一致时直接放弃本轮状态确认。另一种设计是写入旧 generation 的条件,同时允许用户看到新 spec 尚未处理。两者都能正确表达事实,关键是不能把旧处理结果贴到新版本上。实际产品应根据客户端如何等待条件选择一致的接口规则。
状态、类型和原因保持一致时,lastTransitionTime 保留原值。每次协调都刷新时间,会让用户误以为状态一直在发生转移,也会造成额外写入。处理新 generation 但结果仍然相同时,可以更新观察版本而保留同一条件的转移时间;如果需要记录每次成功处理时间,应使用另一个语义明确的字段。
当前实现保存单个主条件:正常时为 Reconciled,清理阻塞时为 CleanupBlocked。这是为教学缩小状态表,不是推荐所有生产 Operator 只保留一条条件。更完整的系统通常需要独立表达 Accepted、Progressing、Available 和 Degraded,且不能让一种错误覆盖掉所有其他事实。
将条件设计成列表也不自动实现正确合并。多个控制器分别负责不同条件时,需要按 type 管理自己的项,保留其他项,并解决条件写入冲突。本书实现没有多状态写入者,因此不宣称具备这样的合并协议。
外部清理被阻塞时,主对象有 deletionTimestamp,finalizer 仍在,外部登记也存在。CleanupBlocked 与 ExternalUnavailable 让操作者看到删除未完成的原因。即使客户端发现删除请求已经返回成功,也能通过读取对象判断它仍处于待清理阶段。
解除阻塞后,控制器先删除登记再移除 finalizer。主对象可能很快消失,因此不能要求它再保存一份永久的 Deleted 条件。需要长期审计时,应使用事件、日志或外部审计存储,不能把即将删除的对象 status 当成唯一历史记录。
当 API server 不可用时,控制器可能既无法更新资源,也无法写失败条件。状态因此只能反映最近成功提交的观察,不能保证实时说明所有故障。监控必须同时观察控制器进程和 API 连接,而不能只根据对象条件判定系统健康。
练习时设计一个等待脚本:只有条件 type 为 Reconciled、status 为 True,且条件 observedGeneration 等于当前 metadata.generation 才算本次提交被处理。然后让用户在等待期间再次更新 spec,观察脚本必须追踪新目标,而不能被旧的 True 条件误导。
用户看到Reconciled=True后修改message,服务器首先保存新spec,控制器尚未执行。此时旧True仍然是合法历史结论,只是observedGeneration低于当前generation。客户端若只判断布尔值,会把“上一次已处理”错误理解为“这一次已处理”。
因此等待脚本应把版本匹配作为条件的一部分。类似地,删除时的CleanupBlocked=True描述清理障碍,而非数据面是否还在提供服务。不同事实应避免互相覆盖含义,界面也应同时显示意图版本和观察版本,让用户能看出等待发生在哪一层。
状态还可能因为控制器失联而长时间保留旧值。可以借助进程监控或显式的观察时间判断新鲜度,但不能用每秒刷新条件来伪造健康。状态接口应承认它是最近一次持久观察,实时健康则由独立信号补充。
仅创建一个 Deployment 和 Service,最多证明 Kubernetes 接受了资源关系。若容器没有监听端口,或者 Service targetPort 配错,控制器仍可能报告 ChildrenApplied。因此完整项目需要至少一条穿过实际数据面的请求,才能证明 spec 确实映射成了用户看得见的行为。
本项目采用 Envoy v1.33.2 的固定多架构镜像 digest。配置只包含一个监听八零八零端口的 HTTP connection manager、一条匹配所有路径的路由和 direct-response。用户修改 message,就会改变响应正文。没有引入上游应用,使故障定位能够集中在控制器、代理配置和 Service 连接上。
这是实际代理进程和真实 HTTP 请求,但没有验证上游负载均衡、动态配置推送、TLS 或完整 Gateway API。本书始终把这条教学网关链路与成熟网关产品区分。它足以支撑控制器工程实验,也保留了后续加入路由对象的清晰扩展方向。
message 可能含中文、引号或换行。手工将字符串拼进 YAML,容易让内容变成结构的一部分。代码先调用 JSON 编码器,利用 JSON 字符串也是合法 YAML 标量的性质,把结果放进 inline_string。配置生成的安全性建立在确定的编码规则上,不依赖用户恰好输入简单英文。
配置作为 Envoy 的启动参数写入容器模板。message 改变会使 Pod 模板发生变化,Deployment 负责替换 Pod。这个方案适合小配置和清晰实验,不适合把大量路由规则塞进进程参数。成熟场景可以改为受控 ConfigMap 或 xDS,但需要额外处理配置版本、引用一致性和热更新失败。
本项目选择 Recreate,旧副本先停止,新副本随后启动。它避免测试把旧新响应混合误判为未收敛,却明确允许停机。将策略改成 RollingUpdate 后,需要重新设计兼容窗口与验证方法,不能仅改一个枚举就宣称实现无损发布。
kubectl port-forward service/name 通常解析服务后选择一个 Pod,它适合调试,但不能独立证明 ClusterIP 转发和集群 DNS 正常。本项目创建固定 digest 的 BusyBox 客户端 Pod,在其中运行 wget,访问完整 Service DNS 名称与八零端口。
请求路径经过客户端 Pod 的解析与网络、Service 虚拟地址和 Envoy 容器监听端口。它比从 runner 直连 Pod 更接近工作负载真实访问方式。测试保存修改前响应、修改后三次响应、Envoy 实际 imageID 和明确传输路径,读者可以据此判断证据范围。
集群网络的一次成功仍不证明负载或故障切换能力。单节点 kind 的流量路径与多节点生产集群不同,CNI、kube-proxy 模式和网络策略也可能不同。实验结论应写成“该固定环境下链路成立”,而非“所有 Kubernetes 网络环境都能无损服务”。
容器配置 HTTP readinessProbe,只有 Envoy 能处理本地请求时 Pod 才成为就绪后端。实验随后检查 Deployment 已观察其 generation,更新副本和可用副本均为一,再发请求。它避免只看到旧 Pod 可用就提前检查新配置。
用户将副本数改为零时,Reconciled 仍可为真,但 Service 不应再有可服务后端。一个成熟 Operator 可以单独报告可用数量,让用户区分意图执行正确和服务可用。不能把所有零后端都视为控制器错误,因为这可能正是用户声明的目标。
测试会改坏 Service 端口和 selector,也会改坏 Envoy 参数与 Pod 标签。修复必须同时恢复入口与工作负载配置。只补 selector 不补 targetPort,可能让流量找到正确 Pod 后仍连错端口;只修容器参数不修标签,则可能让健康代理永远接不到流量。
练习是加入 spec.responseStatus,并限定为二百到五百九十九。先修改 schema 和配置生成,再为不同返回码增加真实 HTTP 断言。最后重新定义 readinessProbe 是否仍应访问同一路径:如果用户有意返回五百,探针可能把正常执行用户配置的实例判为不就绪,这说明业务响应与健康检查接口需要分离。
客户端从同一Service连续得到三次新正文,说明更新后的配置在该环境中已可访问。它没有测量并发容量、长连接排空或旧Pod终止时的请求损失。Recreate本来允许中断,因此把这条记录写成“无损升级通过”会直接超出证据。
若后续研究滚动发布,需要让客户端在整个更新窗口持续发送带序号请求,记录失败、延迟和响应版本,并设计连接排空行为。即使失败率为零,也要说明请求速率、连接复用、样本时间和集群拓扑。性能与可用性结论需要自己的实验,而不是借用功能测试的绿色结果。
当前HTTP记录保留修改前后正文和实际镜像身份,足以让读者复查功能链路。将验证范围限制在数据真正覆盖的地方,不会削弱项目价值;它让后续实验知道还缺哪些观察,而不是在笼统的“全部成功”下失去方向。
有 finalizer 的对象收到删除请求后,API server 设置 deletionTimestamp,但保留对象,等待清理者移除相应 finalizer。客户端收到成功响应,不代表外部责任已经消失。它只表示删除流程已经被接受,后续可能因依赖不可用而停留很久。
本项目在非删除路径最前面持久化自身 finalizer,再创建没有 owner reference 的外部登记 ConfigMap。这样清理责任先于副作用出现,进程崩溃后仍能通过主对象恢复。若创建登记后才添加 finalizer,用户恰好在两次写入之间删除对象,就可能没有任何记录要求清理登记。
finalizer 是责任声明,不是工作进程的锁。它不会自动调用清理代码,也不会保证只有一个清理者活动。程序仍要使用稳定身份、幂等删除和明确错误处理,并确保只移除自己管理的那一项。
删除分支读取外部登记,核对 ownerUID 与当前主对象 UID 一致,然后删除登记。NotFound 可以视为清理已完成,因为前一次调用可能已经成功。随后重新读取主对象,在冲突重试闭包中移除自己的 finalizer,再让对象真正删除。
若主对象还存在其他团队添加的 finalizer,我们必须保留它们。把 finalizers 整个置空,会绕过其他清理责任。当前代码逐项过滤自己的名称,正是为了保持这一边界。即使这样,也不能根据自己移除成功就断言主对象马上消失,因为其他清理者可能尚未完成。
Deployment 和 Service 不在手工外部清理路径里,它们通过 owner reference 交给垃圾回收器。测试分别检查登记已经不存在,以及主对象删除后子资源最终被回收。把这两种断言分开,可以发现“主对象删了但外部责任遗漏”的错误。
真实集群测试通过专用 cleanup-blocked 注解模拟外部服务不可用。程序保留 finalizer,并写 CleanupBlocked 条件。测试确认 deletionTimestamp 存在、登记仍在、对象没有提前删除,再移除阻塞注解并重新协调,最后确认清理完成。
这里模拟的是应用层依赖失败,没有伪造 Kubernetes API。它证明控制流能保留责任并在条件解除后继续,但没有模拟真实云服务的连接超时、权限吊销或结果未知。这些问题要用真实外部接口和专门故障实验继续验证,不能把一条注解开关扩大为完整灾难恢复证据。
测试开关不应该未经限制进入生产 API。真实产品若需要运维暂停删除,应设计带权限控制和审计的流程。教学注解的价值是确定性制造清理窗口,方便阅读和复现,不是建议任何租户都能阻塞平台清理。
面对卡住的对象,直接 patch 掉 finalizer 很诱人。这样对象可能立即消失,但外部资源、账单或权限仍存在,而且控制器丢失最主要的清理入口。操作手册应先确认外部责任,保存 UID 和资源标识,执行补偿,再决定是否移除。
如果确实需要强制删除,要把“对象已从 Kubernetes 移除”和“业务责任已完成”作为两个结论分别记录。前者可以由 API GET NotFound 证明,后者需要外部系统证据。一个绿色 kubectl 命令不能把两者合并。
进入删除分支后,控制器停止创建缺失 Deployment 和 Service。否则用户删子资源、控制器补资源、垃圾回收再删除,可能形成噪声和延迟。生命周期状态应该先于日常配置修复判断,使删除成为明确的单向流程。
练习时在对象已标记删除且清理被阻塞的期间,手工删掉 Service,再运行协调。观察 Service 不会被补回,而登记和 finalizer 仍保留。这个场景检验的不是正常创建能力,而是程序是否尊重用户已经表达的终止意图。
登记删除成功后,移除finalizer的Update因为竞争失败。对象仍保留,下一轮再次GET登记得到NotFound,应继续移除finalizer。若把NotFound当成严重错误,清理流程反而会永久卡在已经成功的动作上。
另一方面,若登记存在但ownerUID不匹配,就不能把它视作等价的已清理结果。身份冲突和不存在是两种不同事实:前者需要调查,后者通常表示本次责任已完成。错误分类直接决定恢复安全性,不能只根据HTTP状态是否属于四百范围统一处理。
对多个外部对象,清理顺序还需要考虑依赖。例如先撤销公网入口再删除后端,可避免入口继续指向无效目标。每一步都应可重复,并保存足够身份。finalizer只保留入口,不替我们决定这张依赖图。
云负载均衡、DNS 记录和证书都可能由 Operator 创建,但它们不属于 Kubernetes 垃圾回收器理解的对象图。删除主资源后,平台仍需要承担撤销这些对象的责任。finalizer 提供保留清理入口的机制,真正的幂等与身份验证仍由应用协议负责。
本项目用无 owner reference 的 ConfigMap 模拟外部登记。它仍由同一个真实 API server 保存,因此不存在网络服务之间的独立故障域。我们使用它观察清理顺序、UID 绑定和卡住恢复,不能据此声称验证了云 API 的创建超时或跨系统原子性。
登记名包含主对象 UID,数据里再次保存 ownerUID。删除前核对二者,避免拿到一个被篡改或意外复用的登记就直接清理。双重身份检查也方便故障诊断:名称帮助定位,内容帮助确认归属,但它不替代真实外部系统的认证和权限控制。
控制器调用外部创建接口,连接在返回前中断。外部系统可能尚未执行,也可能已经成功;重试创建可能产生重复,不重试又可能永远无法收敛。Kubernetes 的 resourceVersion 只保护后续对象写入,无法回答远端是否完成副作用。
成熟接入应向外部接口传递稳定幂等键,并在超时后按该键查询权威结果。键可以包含主对象 UID 和操作类型,但不能在每轮随机生成。若远端既不幂等也不可查询,未知结果必须暴露为需要人工判断的状态,而不是靠本地日志猜测。
外部对象标识写回 status 也有提交窗口:远端创建成功后,状态更新可能失败。只根据 status 空白判断“从未创建”会导致重复。恢复路径必须能够通过稳定身份重新发现外部对象;将标识只存在内存里更无法承受进程重启。
RetryOnConflict 可能执行多次。把外部创建、计费或通知放在其中,会将一个本地版本冲突变成多次远端动作。可以把可重做的对象条件写入留在闭包内,把外部协议独立成具有幂等和查询能力的步骤,再由持久状态连接各步。
这并不意味着所有操作都要建复杂工作流。有些外部接口原生支持按固定名称更新,且有明确唯一约束,可以用简单的查询和条件写入收敛。选择取决于副作用成本、结果可知性和撤销能力,而不是“Operator 必须无状态”这种口号。
无状态进程的意思是进程退出后可以从持久事实恢复,不表示系统没有状态。主对象、外部资源、幂等键和审计记录本身就是状态载体。把状态藏在某个不被承认的外部角落,只会让恢复更难推理。
远端删除接口如果只接受人类名称,可能误删同名新对象。应查询远端对象元数据,核对主对象 UID 或不可变业务标识,再决定是否删除。遇到身份不一致时,保留 finalizer 并报告冲突通常比静默跳过更安全。
本项目遇到登记 ownerUID 不匹配会返回错误,不会覆盖或删除该记录。这个失败意味着需要人工介入,不能通过增加重试次数消除。将此类错误区分为身份冲突,能够帮助操作者避免把配置问题误判成短时 API 抖动。
实际云接入还要考虑凭据失效、跨租户误用、资源所属账户变化和删除权限不足。清理者需要的权限应在资源存续期间得到维护,而不能等到删除时才发现旧凭据已撤销。运维设计应保存足够的非秘密身份信息,帮助替换凭据后继续清理。
练习是为外部 DNS 接口画出创建、查询、删除三个操作的身份与重试表。分别回答:请求超时如何查结果、同名新实例如何区分、删除权限丢失如何恢复。只有这些答案成立,finalizer 才真正承担了可恢复的外部责任。
假设云控制台里的负载均衡器被改名,但UID标记仍在。控制器应该按稳定身份定位,而不是重新创建同名对象。若运维人员彻底移除归属标记,程序需要明确是拒绝接管、报告丢失,还是允许经过授权的修复,不能在未知归属下直接删除。
这种策略与Kubernetes子资源相似,但外部接口可能缺乏条件更新。先查再写存在竞争窗口,需要外部版本号、ETag或服务端幂等机制补足。如果接口不提供这些能力,应把局限写进托管契约并收紧操作权限,不能假设本地重试就能消除竞争。
随书登记的ownerUID检查演示了最小身份防线。真实系统还要记录账户、区域和资源类型,避免相同标识在不同作用域下含义不同。资源引用应结构化保存,别把多个字段简单拼接成无法消歧的字符串。
队列、缓存和局部变量都在控制器进程退出时消失。一个可恢复的实现必须能够重新列出主对象,读取现有子资源与外部登记,再继续协调。若某一步只在 Add 事件回调执行一次,进程停机期间创建的对象就可能永久漏处理。
随书 CLI 启动 informer,等待同步,再处理初始 list 产生的键。Reconcile 根据服务器当前对象判断是否需要创建或更新,不依赖过去执行过哪些函数。外部登记和 finalizer 都持久存在,因此重新启动的进程能够分辨正常资源、待删除资源和已完成的中间步骤。
这里的恢复来源是服务器实际保存的事实,不是恢复旧内存快照。把整个队列写到磁盘通常没有必要,因为它可能含有已经过时的键;更重要的是重新发现所有仍有责任的对象,并让每个步骤可重入。
进程重启测试会编译 cmd/operator
为独立二进制,启动它管理一个测试命名空间,等待 Deployment
出现,然后用操作系统真实终止进程。控制器停止期间,测试把主对象副本数改为三。重新启动新的
CLI 进程后,最终 Deployment 必须达到三副本。
这个测试与在同一进程中重新调用构造函数不同。它丢弃了旧队列、旧 informer 和所有局部对象,实际经历了启动与缓存同步路径。测试保存子进程输出,并在清理时终止自己创建的进程,不影响其他实验任务。
它仍没有验证节点宕机、API server 数据丢失或多个控制器竞争。SIGKILL 终止应用进程,不等于集群断电;新进程能够读取完整 API 数据,也不意味着 etcd 备份恢复已经得到证明。把故障注入的层级标清楚,读者才能正确复用结论。
如果进程在添加 finalizer 后、创建 Deployment 前退出,新进程会看到已有 finalizer 并继续。若在创建 Deployment 后、创建 Service 前退出,则保留同一 Deployment 并补齐 Service。若在删除外部登记后、移除 finalizer 前退出,下一轮将 NotFound 视为清理完成,再移除 finalizer。
这些路径之所以能恢复,是因为每一步都有稳定身份和可重复判断。它们不是依赖某个全局“事务成功”标志。Kubernetes API 的单对象条件更新很强,但多对象流程仍需要通过可重入步骤连接。
本项目没有为每条指令都注入进程崩溃;真实进程测试覆盖停机期间更新和启动重发现,代码与删除测试共同说明其他重入路径。更严格的生产验证可以加入每个持久化边界的进程门闩,逐一保留原始证据,不能把当前覆盖范围描述为全指令故障证明。
CLI 监听 SIGINT 和 SIGTERM,通过 context 结束 informer 和队列。理想情况下 worker 停止取得新键,正在进行的 API 请求接受取消。退出过程不能把完成了一半的资源当作需要手工回滚的异常,下一进程应能继续。
现实运维还需要设置终止宽限时间,避免无限等待不可取消的依赖。优雅退出改善重复工作和日志完整性,但不能作为正确性的唯一基础;容器也可能被直接杀死。控制器应该在不优雅退出后仍保持持久责任可恢复。
控制器停机时,已运行 Envoy 通常继续处理请求,但新 spec 不会被推进,删除也可能等待 finalizer。用户需要分别观察控制平面和数据面。将控制器进程健康误当成代理可用,或把代理仍可服务误当成控制器正常,都会掩盖故障。
练习是停止控制器后删除 Service,再创建一个新的 GatewayInstance,确认旧实例的修复和新实例的创建都会在重新启动后发生。记录恢复时延来自 informer 初始 list、队列排队还是子资源启动,而不是只给出一个总秒数。
进程停机期间用户删除一个带finalizer的实例。重新启动后,初始list必须包含仍在删除的对象,并使它进入清理分支。如果观察代码过滤掉所有有deletionTimestamp的对象,正常创建测试仍会成功,删除却永远无法恢复。
因此“只处理活跃对象”的优化必须谨慎。生命周期中的待删除也是活跃责任,只是目标从创建资源变为消除外部影响。生产恢复演练可以在清理阻塞时终止进程,恢复依赖后启动新进程,检查它完成相同责任,进一步扩展当前测试。
同样,升级程序后不能轻易更名finalizer。旧对象仍携带旧名称,新程序若只认识新名称,就会遗留无法处理的删除入口。变更清理协议时应保留兼容处理,并在迁移完成前同时识别历史标记。
控制器需要读写主资源、更新状态、维护 Deployment 和 Service、管理模拟外部登记。它并不需要读取任意 Secret,也不需要修改其他命名空间的网关。权限设计应从这些实际调用倒推,避免用 cluster-admin 把所有错误隐藏起来。
Namespaced CRD 不会自动把控制器限制在某个命名空间。程序如果持有集群管理员 kubeconfig,仍能操作其他命名空间。本项目 CLI 要求显式 kubeconfig 和 namespace,使实验目标清楚;真正的隔离由服务器验证凭据及 RBAC,而不是靠命令行参数自觉约束。
教学程序在集群外运行,kind 测试管理员负责建 CRD、命名空间和测试角色。受限权限实验则使用 service account 身份的 impersonation 请求,由真实 API server 执行授权判断。它没有测试 Pod 内投射令牌轮换,也不代表已经完成生产部署凭据方案。
读取和观察 GatewayInstance 需要 get、list、watch;添加 finalizer 通过主资源 Update 需要相应写权限;状态更新走 gatewayinstances/status。owner reference 使用 BlockOwnerDeletion 时,还需要满足相关权限检查。权限条目必须与实际 API 路径一致,不能只写一个主资源名字就假定所有子资源自动允许。
Deployment 需要 get、create、update,Service 和登记需要相应查询与维护能力。当前删除 Deployment、Service 主要由垃圾回收完成,控制器不必为了方便获得任意工作负载删除权限。某些安装或升级工具确实需要更多能力,应把安装者与运行时控制器角色分开。
CRD 本身是集群范围对象。普通实例协调者无需修改它,版本升级应由专门发布流程完成。把 CRD 更新权限交给日常控制器,既扩大误操作范围,也使 API 兼容变更难以审计。
真实权限测试创建只绑定到测试命名空间的 Role 和 RoleBinding。受限客户端必须完成本命名空间的正常协调,包括 status 和 finalizer 操作。随后使用同一身份访问另一个命名空间的 GatewayInstance,必须得到 Forbidden;读取未授权 Secrets 也必须被拒绝。
只测拒绝是不够的:一个完全没有权限的客户端当然会拒绝所有操作,但它也无法工作。只测成功也不够:管理员身份能够成功,并不能证明最小权限设计成立。正向能力和负向边界应同时存在,才能说明授权范围与实际需要相符。
这里的 impersonation 由管理员客户端发起,用于测试服务器授权规则。真实部署通常使用绑定到 Pod 的 service account token;两者认证路径不同,因此我们只将结果表述为 RBAC 规则验证,不声称验证了全部认证和令牌生命周期。
控制器错误中可能包含外部连接信息,status 又通常可以被较多人读取。不要把令牌、完整 kubeconfig 或带秘密参数的 URL 写入条件。即使程序没有主动读取 Secret,也可能在错误链里意外输出请求地址和头部,需要对日志接口进行独立审查。
message 在本项目中是公开 HTTP 正文,不适合存放秘密。若未来增加后端凭据引用,应让 API 接受 Secret 引用而非原文,并明确谁能够创建或更新该引用。引用一个 Secret 名称也可能造成权限委托,需要防止低权限用户借高权限控制器访问别的租户秘密。
从单命名空间扩展到多租户控制器时,需要同时调整 informer 范围、RoleBinding、资源引用规则和审计。只把 namespace 参数改成全部,可能让原本安全的假设失效。跨命名空间 owner reference、共享外部身份和统一队列公平性都需要重新设计。
练习是移除 status 更新权限,运行真实测试并观察返回路径。子资源可能已经创建,但状态写入失败,队列会重试。不要通过吞掉错误让测试通过;应确认错误可以定位到具体权限,并保持已创建资源在后续恢复权限后继续收敛。
升级控制器后增加一种资源调用,应先在受限角色测试中得到明确拒绝,再添加最小规则并证明通过。这样能确认新权限对应实际需要,而不是顺手加入通配符。删除旧能力时也应回收权限,防止角色随版本持续膨胀。
不要把一次Forbidden简单解释为账号无效。认证回答身份是谁,授权回答该身份能做什么。日志与操作手册保留这一区别,可以减少为了修复一条缺失规则而替换管理员凭据的冲动。
一个网关没有响应,可能是控制器未处理 spec,也可能是 Deployment 未就绪、Service 选择器错误、集群网络异常或 Envoy 配置失败。直接重启控制器会改变现场,却未必解决问题。诊断第一步应按照契约分层收集事实。
先检查主对象的 generation 和 observedGeneration。两者不同,说明当前意图尚未被确认;相同且 Reconciled 为 True,只说明所属资源已应用。再检查 Deployment 的 generation 与其 status.observedGeneration、更新和可用副本,以及 Pod 事件。最后用集群内客户端验证 Service 请求。每个观察量回答不同问题。
删除卡住则从 deletionTimestamp、finalizers、CleanupBlocked 条件和模拟外部登记入手。对象已经消失时,status 不能再提供历史,必须依靠日志和保留证据。诊断流程应先保存现场,再决定是否做不可逆的强制删除。
生产日志至少应包含命名空间、名称、UID、本轮处理 generation、动作和错误类型。只有名称很难区分删除重建前后的两次生命;只有 request ID 又难以串联多轮协调。对外部调用还应记录不包含秘密的幂等键或资源引用,帮助判断结果未知窗口。
当前教学控制器没有完整结构化日志和指标服务,主要证据来自真实 API 对象、测试输出和原始 JSON 测试流。本章给出的日志字段是明确的生产演进建议,不能据此把现有代码描述为已提供成熟运维监控平台。
加入日志时也要避免每秒为已收敛对象打印大段详情。周期复查放大日志量,容易让关键失败淹没。成功路径可采用采样或低详细级别,状态变化和错误保留清晰记录;敏感字段则应在进入日志接口前完成过滤。
队列长度反映待处理工作,但无法单独解释原因。处理时延升高可能来自 API 限流,也可能是某个外部调用阻塞。冲突计数持续增加可能意味着合法竞争,也可能是两个控制器争夺同一字段。指标需要结合对象和错误类别,才能支持行动。
值得关注的信号包括协调耗时、成功与错误次数、重试次数、队列等待时间、未观察新 generation 的时长和删除阻塞时长。对高基数的实例名称不宜直接作为所有指标标签,否则监控存储成本会随对象数量失控。对象级细节更适合日志和查询接口。
本项目没有把指标阈值写成统一答案。十个实例每秒扫描与十万实例事件驱动的基线差异很大。阈值应根据处理预算和服务目标制定,并通过实际负载验证,而不是复制一组看起来专业的数字。
CI 每次保留 Kubernetes 版本、节点镜像 digest、Go 版本、原始 go test JSON、HTTP 响应和 kind 日志。成功摘要便于阅读,原始流则允许重新核对哪些场景真正执行、是否跳过,以及哪个断言先失败。没有原始证据的通过计数很难独立审计。
失败运行也要上传 artifact。若只在成功后收集证据,最需要诊断的一次运行反而没有现场。测试退出后清理自己的 kind 集群,但先导出日志与资源列表,使资源释放和故障分析可以同时完成。
故意把 Service targetPort 改为错误值,然后先停止控制器,保存对象和失败请求。主对象的旧 Reconciled 可能仍为 True,因为控制器尚未重新观察漂移;这不代表状态撒谎,它描述的是最近一次成功观察。重新启动后,周期复查恢复端口,实际请求重新成功。
练习是为这段过程写一份故障记录:触发操作、受影响层、观察证据、恢复动作和证据局限。不要只写“重启后恢复”。如果无法解释恢复来自重启重发现还是后续周期检查,下一次故障仍只能依赖偶然操作。
诊断记录应包含触发时间、采集时间和实际源码提交。对象截图只代表某一刻,稍后自动自愈可能已经改变现场。原始命令输出比手工概述更容易复核,但同样需要过滤秘密和设置保留期限。
将故障时间线按用户动作、API接受、控制器处理、数据面响应四个阶段排列,可以看出延迟出现在哪个环节。总恢复时间适合服务目标,分段时间更适合定位下一步该优化哪里。
字符串编码、期望对象构造和条件合并适合快速单元测试。资源版本冲突、默认值、status 子资源、垃圾回收和 CRD 版本行为必须交给真实 API server。fake client 对很多服务器行为只做近似,不能因为内存对象更新成功就推断真实集群也会接受。
本项目直接用固定 kind 节点镜像执行集成套件。它付出启动成本,换取真实准入、默认、etcd 存储和内建控制器参与。测试环境仍是单节点容器化集群,不覆盖生产网络拓扑、控制面高可用和真实云依赖。
所有场景位于一个父测试之下,按名字独立记录。每个场景创建自己的命名空间并注册清理,避免不同实例名称相同导致资源串扰。CRD 版本切换安排在末尾,因为它改变整个测试集群的全局 API,不能与前面假定 alpha 存储版本的场景随意并行。
首次收敛测试检查副本数、Service selector 和 observedGeneration。拒绝无关 Service 的测试则检查错误返回,同时确认原端口和 owner reference 未被修改。后者是一条负向事实:错误不应伴随越权接管。
冲突测试要求真实 409,并检查并发注解保留。状态竞态测试要求不能提前确认新 generation。finalizer 测试既检查最终删除,也检查阻塞期间对象与登记仍存在。每条测试都从一个可能的错误实现出发,确保断言能够把它识别出来,而非只重复函数返回值。
HTTP 测试不只检查 Pod Ready。它从独立客户端 Pod 请求 Service DNS,记录修改前后正文及镜像身份。这样端口、配置生成和资源关系至少有一条端到端证据。三次请求是配置收敛检查,不是性能测试或统计可靠性基准。
真实集群是异步系统。创建 Deployment 后立刻检查 Pod,通常会看到尚未完成的中间状态。固定睡眠既浪费时间,也容易在慢 runner 上失败。本项目通过有截止时间的 eventually 轮询具体条件,在条件满足时立即继续,超时则保留失败证据。
等待条件本身也要准确。只看 availableReplicas 为一,可能仍是旧版本 Pod;因此 HTTP 变更测试同时检查 Deployment 已观察其 generation,并且更新副本数达到目标。对垃圾回收则等待 API 返回 NotFound,不能把删除请求接受当作完成。
测试不能把超时改成无限循环来提高通过率。失败需要明确终点,才能让 CI 上传现场并释放集群。若环境变化导致稳定超时,应先查原始事件和日志,确认是资源预算、镜像拉取还是控制逻辑,再决定是否调整截止时间。
Go 的无测试命令包会产生包级 skip 事件,它不等于集成场景被跳过。证据收集器拒绝带 Test 名称的 skip 和所有 fail,再从源码提取命名子场景,与实际 pass 集合逐一比较。仅判断退出码为零,可能漏掉测试因为环境缺失主动 Skip 的情况。
KUBECONFIG 或独立二进制缺失时,套件直接失败,不把未执行的真实实验包装为通过。inventory 的固定数量还要求添加或删除场景时显式更新证据规则,促使维护者重新评审验证范围。
发布流水线构建实验 ZIP 后,将它解压到独立目录,再用其中的脚本创建另一套真实 kind 集群并执行同样场景。这能发现源码目录隐含依赖、遗漏 YAML 或 go.sum,以及路径只在仓库布局下成立的问题。
第二次执行成本较高,但它直接验证读者下载得到的入口,而不是仅相信打包清单。练习是故意从归档清单移除 beta CRD,再运行独立包验证,确认版本场景因缺文件失败;随后恢复清单。测试覆盖发布路径,才能把“可下载”提升为“可独立复现”。
测试能够证明已经提出的断言,却无法自动发现所有遗漏要求。审稿时应反向检查每项书中承诺是否有对应场景,以及每个场景是否真正经过所描述路径。例如声明真实重启,就必须有独立进程终止;声明服务转发,就应确认请求没有绕开Service。
还要检查清理代码只删除自己创建的环境,尤其是失败路径。可复现实验不应以污染读者集群为代价,测试隔离本身也属于交付质量。
API served 版本决定客户端能使用哪些路径,storage 版本决定新写入使用何种持久表示,status.storedVersions 记录可能仍存在于存储中的历史版本。三者有关联,但不是同一开关。把 beta 设为 storage,并不会自动重写所有 alpha 历史对象。
本项目第二份 CRD 增加 v1beta1,并将它设为唯一 storage 版本,同时保留 v1alpha1 served。两个版本 schema 相同,转换使用默认的 None 策略,主要变化是请求和响应的版本标识。它没有定义字段重命名或结构转换,不需要 conversion webhook。
如果两个版本字段语义不同,默认转换并不能凭空推导对应关系。删除字段、改变单位或拆分结构时,应设计转换协议、兼容策略和回退路径。仅保持 JSON 看起来相近,不代表用户依赖的语义仍然一致。
测试先以 alpha 创建网关并收敛,再应用包含 alpha 与 beta 的 CRD。随后通过 beta 路径读取同一对象,核对 UID 不变,修改副本并写回,再让仍使用 alpha 客户端的控制器继续协调。最后读取 CRD,确认 storedVersions 同时包含 alpha 与 beta。
这条路径证明新版本被服务、写入版本切换后旧客户端仍能处理同结构对象,以及对象身份没有因版本变化而重建。它没有完成所有存量对象的重写,也没有删除 alpha served。测试日志明确保留两个历史版本,避免把尚未完成的过程写成已迁移成功。
通过 beta Update 的这一对象会按当前 storage 版本写入,但单个对象重写不能代表整个集群迁移。其他命名空间、分页列表中的历史对象或同时发生的写入都需要覆盖。没有全量核对和控制并发,就不应清除 CRD 的历史 storedVersions 记录。
实际迁移通常需要先升级所有理解新版本的控制器和客户端,再启用新 storage,使用专门迁移工具或受控读取重写覆盖全部对象,核查存储状态,最后才考虑去除旧版本。每一步都需要可观察进度和回滚判断,不能靠一次 kubectl apply 把整条流程压扁。
storageVersionHash 可以辅助识别服务端存储版本的一致性,但它不是每个历史对象已被重写的证明。读取对象时 API server 也可能按请求版本转换,因此从 beta GET 成功不能反推出底层只剩 beta。证据必须对应真正需要证明的存储事实。
本书不直接访问 kind 的 etcd 来制造一个看似完整的迁移报告。教学范围停在同结构 served/storage 切换与一次明确重写,完整全量迁移作为后续独立项目设计。这样读者不会误把一段缩短的示例用在不可逆的生产 API 删除上。
修改 CRD 会影响集群中所有实例,即使应用程序代码没有变化。发布计划应先确认旧控制器能否读取新对象、新控制器能否处理旧对象,以及准入策略如何变化。把 schema 收紧也可能让历史对象在下一次更新时被拒绝,需要提前扫描。
控制器镜像回滚并不一定让 CRD 回滚安全。存储里已经写入新结构后,旧程序可能丢字段或误解释状态。尤其是删除字段、改变枚举和单位转换,必须有双向兼容或明确的不可回退边界。版本号只是标识,兼容性来自具体协议。
弃用旧 served 版本前,应记录使用方、告警和迁移期限。自动化客户端可能埋在脚本、GitOps 清单或其他控制器中,不能因为交互式 kubectl 已改用 beta 就判断没有旧依赖。较长窗口还需要保证文档示例与实际服务版本一致。
练习是把 message 改名为 responseBody,先在纸上写出 alpha 到 beta 和反向转换规则。考虑空值、默认值以及新旧客户端同时更新。只有这些规则可解释并能实际往返,才适合实现 conversion webhook;单纯复制字段名不等于完整版本工程。
删除旧served版本和清除历史storedVersions都应发生在证据充分之后。前者可能立即让旧客户端失败,后者是在声明历史存储已经迁移。顺序弄反会使后续恢复更难,而不是让迁移更快。
版本实验的摘要刻意保留未完成项,帮助使用者判断下一步。一个明确写着尚未全量迁移的结果,比把所有阶段压成“升级成功”更适合工程决策。
一本工程书同时包含解释、可执行代码和运行证据。如果 EPUB 来自本地重建、在线页来自另一提交、实验 ZIP 又遗漏配置,读者很难判断哪个结果可信。发布设计应从同一份源码构建所有产物,并把精确提交和流水线记录连接起来。
本项目 Markdown 按编号排序,构建程序生成分章网页、完整下载稿和 EPUB。教学代码从实际 lab 与 cmd 目录读取,生成代码附录并进入实验 ZIP。这样修复一次代码后,不必手工复制到三种文档形式,也能通过哈希与全文比对发现内容偏离。
生成附录不能替代正文。正文负责解释为什么按这个顺序操作、哪里存在竞态,以及实验没有证明什么;代码附录让离线读者查完整实现。仅把仓库代码塞进 EPUB,无法形成一条可理解的工程学习路径。
真实 kind 套件证明固定环境下命名场景执行成功。发布校验比较原稿、网页和 EPUB 的章节全文与元数据,并检查归档文件。EPUBCheck 验证出版格式规范。浏览器检查字号、目录、搜索、主题、移动宽度和无 JavaScript 阅读。这些门槛互补,任何一项都不能替代事实审稿。
例如 EPUBCheck 不会发现 observedGeneration 解释错误,绿色单元测试也不会发现封面作者写错。作者身份、权利说明、官方来源和实验边界需要独立阅读核对。发布说明应写出每类证据的范围,而不是用“全部验证通过”隐藏不同验证的对象。
本项目作者为 Aias00,个人版权保留。技术背景不构成任何组织的著作署名或授权。第三方官方文档只保存链接、取得时间、哈希与原创摘要,全文缓存被忽略,不把第三方网页整份打包分发。
Pages 与 Release 的 EPUB 必须来自同一次成功流水线的 book-downloads artifact。先让真实实验、格式和浏览器门槛通过,再部署 Pages,再以该 artifact 创建版本发布。不能为了补一个 Release 下载链接而在本地重新构建,看起来相同的文件也可能包含不同元数据或代码。
发布后实际下载 Pages 和 Release 文件,与 CI artifact 比较字节和 SHA256。只检查 URL 存在或 HTTP 状态为二百,无法发现缓存仍在提供旧版本。内容一致性需要检查文件本身,格式验证则应该对最终下载到的 EPUB 再执行一次。
已发布资产不应被静默覆盖。修正文稿或实验后,应形成新的源码提交和补丁版本,保留旧版本供读者对照。运行证据、源提交和资产哈希组成版本身份,三者变化都应有可查记录。
交付记录加入公开验证链接,通常不改变书稿与代码。工作流通过路径过滤避免文档提交重新触发昂贵 kind 实验和重建;必要的证据回填使用明确的跳过标记。这样可以记录最终发布现场,而不制造一套与已发布资产不同的新构建。
但路径过滤也需要评审。若构建会读取 README,却把 README 排除触发路径,就可能造成产物漂移。本项目实验包不依赖仓库首页说明,而包含自身 lab/README,构建输入与触发路径保持对应。
阅读器默认浅色纸张,允许切换低饱和深色,并与其他书共享主题存储键。所有文章正文由静态 HTML 提供,JavaScript 只增强搜索、字号和交互。移动端检查覆盖每章与无脚本路径,避免只有首页好看而代码附录横向撑破页面。
练习是从一台没有仓库的机器下载 ZIP 和 EPUB,分别运行实验入口与离线阅读。只有读者实际得到的产物可用,源码目录中的成功才完成了最后一段交付。
读者应能找到这份EPUB来自哪个提交、实验在哪次运行执行、公开文件是否与验证文件一致。交付记录把这三个答案直接连接起来,避免让使用者根据日期和文件名猜测。
如果只有运行号而没有原始测试流,后续无法重新判断验证范围;如果只有哈希而没有源码身份,文件虽然可校验却难以维护。证据链的价值来自各环节能互相核对。
在独立 kind 集群运行,不连接生产环境。固定 Go、kind、Kubernetes 和 Envoy 版本,并先记录实际服务器版本与镜像 digest。基线不是追逐最新版本,而是让不同读者能够复现同一组 API 行为。升级依赖应产生新的验证记录,不应直接替换旧证据。
运行 bash lab/run-kind.sh 会创建属于本次执行的集群,应用
CRD,构建控制器并执行场景,最后导出现场和删除自身集群。脚本不复用已有同名环境,也不把清理命令指向用户的当前默认集群。独立命名与显式
kubeconfig,是可重复实验的基础。
自动套件适合回归,手动演练则适合理解。先创建实例,读取默认后的对象,再观察 finalizer、登记、Deployment、Service 和状态。接着从客户端 Pod 访问 Service,确认资源关系确实支撑 HTTP 行为,然后再进行破坏操作。
第一种是子资源误删。删除 Service 后,主资源 spec 不变,周期复查应重新创建入口。记录它的新 UID 与保留的主对象 UID,理解自愈并非恢复原对象生命,而是恢复期望关系。Service ClusterIP 可能改变,因此依赖稳定名称比缓存旧 IP 更可靠。
第二种是配置漂移。将 targetPort 改错,观察请求失败,再恢复控制器运行,确认托管字段被修复。不要只检查 YAML 已回到目标值,还要验证真实请求。这个路径能帮助区分“配置看起来正确”和“数据面已经完成更新”。
第三种是控制器停机。停止进程后更新副本或创建新实例,再启动。旧数据面可能继续服务,但新目标应等待重发现后推进。把控制平面恢复时延和数据面服务中断分开记录,避免用其中一个指标解释另一个。
设置实验阻塞注解后删除实例。保存 UID、deletionTimestamp、finalizer、CleanupBlocked 条件和登记内容。确认对象没有消失,也没有在删除路径重新创建缺失 Service。随后解除注解,重新协调,等待登记和主对象消失,再观察垃圾回收完成。
如果现实外部系统不可用,不应直接照搬解除注解的动作。需要先恢复依赖或执行经过审核的补偿,确认不会留下收费资源、DNS 指向或访问权限。实验开关只是故障注入工具,生产处置必须由真实责任和证据驱动。
强制移除 finalizer 应是明确的运维决策,附带残留资源清单和后续补偿负责人。对象不见了只证明 Kubernetes 层的删除完成,不能把它写成业务清理已经成功。
检查新旧 schema 的差异、默认值、served 和 storage 设置;确认旧控制器是否能理解新对象,状态字段是否兼容,回滚是否会丢失新数据。对同结构版本切换,可以沿随书测试验证双版本访问;对结构变更,则需要新的转换与存量迁移项目。
同时检查运行权限是否增加,镜像是否固定,探针是否仍符合业务语义,以及代码是否开始依赖本地文件。任何变化都可能穿过书稿、实验包和部署三条交付路径,因此不能只跑编译后就宣布升级完成。
本项目没有生产容器打包、leader election 和多集群控制面。要真正作为平台服务值守,需要补齐部署配置、资源预算、指标、告警、凭据轮换和容量验证。这些是清楚的后续工程任务,不影响当前单实例教学契约,但不能靠文档措辞假装已经具备。
有三个自然方向。第一是加入 Route 对象和上游教学服务,研究多对象依赖、引用授权与配置汇总;第二是为子资源添加 watch,比较事件驱动与周期扫描的成本;第三是实现 conversion webhook 和受控存量迁移,验证双向兼容。
每个方向都应带来新的可证伪场景,而不只是更多 YAML。路由对象要验证删除传播与错误引用,事件驱动要验证丢失事件后的兜底恢复,版本迁移要验证旧对象和旧客户端。沿着恢复问题扩展,项目才会越来越接近真实工程。
用自己的话写出四个不变量:不接管无关对象,不提前确认未处理 generation,不在清理完成前移除自身 finalizer,不把版本切换写成全量迁移。再从测试中分别找到直接证据。若某个结论只有解释却没有可观察证据,就把它保留为设计目标,继续完善实验,而不要将它升级为已经兑现的能力。
准备生产化时,将每项缺口写成可验收目标,例如双进程故障切换、受限凭据轮换、持续请求下的更新错误率,以及完整存量迁移。不要使用“加强稳定性”这类无法判断完成的描述。
沿着现有项目逐项增加证据,可以保留已经理解的资源生命周期。每一步都清楚说明新增承诺及限制,工程知识才能从教学演练自然延伸到团队值守。
把Recreate、周期复查和同结构版本切换写成经过选择的教学边界,而不是隐藏实现细节。接手者才能判断何时需要替换策略,以及替换后哪些旧测试仍有效。技术交接不只保存最终配置,还应保存为什么选择这条路径、目前证据能支撑到哪里。
故障演练之后将新发现转成可重复断言,再更新运行手册和发布记录。这样每次值守积累都会进入系统,而不会只停留在某位维护者的记忆里。
用户应提交受控 Secret 引用或证书请求意图,不能让普通用户通过高权限控制器读取任意命名空间秘密。证书无效时保留旧有效配置还是停止服务,需要明确产品策略。Secret 删除后的行为、外部签发撤销以及旧证书清理,都属于字段契约的一部分。
replicas 为零是合法停止数据面的意图,缺失则由当前 schema 拒绝。message 的空字符串被最小长度规则拒绝,省略时由服务器默认。客户端默认只是便利,服务器 schema 才是不同调用入口共享的规则。
主对象已有 deletionTimestamp 后,不应补回被删除的 Service。协调进入清理分支,维护外部登记与 finalizer 的责任。阻塞解除后先清登记,再移除自身 finalizer,其他 finalizer 保留。
两者不能同时持续控制 Deployment.spec.replicas。支持 HPA 模式时,主资源需声明模式与边界,控制器让出该字段,并定义退出 HPA 模式的接管顺序。当前实现不支持 HPA,手工改副本会被修复回主资源意图。
必须同时检查条件类型、True 状态和 observedGeneration 等于 metadata.generation。旧 True 只证明旧意图已处理。若等待期间用户再次更新 spec,客户端应重新追踪当前版本,或明确只等待提交时捕获的版本。
port-forward 到 Service 可以绕过 ClusterIP 数据路径,不适合作为唯一转发证据。随书测试从客户端 Pod 请求完整 Service DNS,通过实际集群网络到达 Envoy。它证明固定环境的功能链路,不覆盖性能或无损更新。
需要 schema 范围验证、配置编码、真实 HTTP 状态断言,并分离健康检查语义。用户选择返回五百不一定意味着进程不健康,不能直接让业务路径的返回码决定基础就绪。
创建使用稳定 UID/操作键;超时后按键查询权威结果;删除前核对资源账户与 UID。没有幂等和结果查询的接口,未知状态应保留责任并要求人工判断。finalizer 不能替代远端协议。
子资源可能已创建,但 UpdateStatus 得到 Forbidden。应报告具体权限缺失并返回错误,权限恢复后再次协调已有资源。吞掉错误会掩盖未确认状态,也使队列误判成功。
alpha.message 与 beta.responseBody 的双向转换需要考虑默认、缺失和显式空值。若新字段表达能力更强,反向转换可能有信息损失,必须定义兼容限制。当前同结构 None 转换示例不证明这些规则已经实现。
无关对象拒绝接管由 RefuseUnrelatedChild 验证;不提前确认新 generation 由 ConcurrentSpecCannotBeAcknowledgedEarly 验证;清理前保留责任由 BlockedFinalizerRecovery 验证;CRDServedStorageUpgrade 明确保留历史 storedVersions,且没有声称全量迁移。精确场景集合以发布下载的 validation.json 为准。
以下均为一手资料。获取时间、原始响应字节数和 SHA256 保存在下载区 sources.json。原始网页缓存被 Git 忽略,不随 EPUB 或实验包分发。正文解释为作者独立撰写,链接用来核对 API 和机制,不表示第三方为本书背书。
Kubernetes 文档网页会更新,实验使用的客户端与服务器版本则固定记录。发生差异时,应先核对适用版本。外部系统幂等、生产 HA 和完整存量迁移属于本书明确未实测的延伸问题,不能从官方机制介绍直接推定本项目已经实现。
| 术语 | 本书中的准确含义 |
|---|---|
| Reconcile | 基于当前持久对象修正声明托管的关系 |
| Operator | 将应用生命周期与运维逻辑建立在控制器模式上 |
| CRD | 为 API server 增加自定义资源结构及服务版本 |
| generation | 用户意图版本,不能代替资源版本 |
| observedGeneration | 某项结论实际处理的意图版本 |
| resourceVersion | API server 定义的对象并发标识,不应自行计算 |
| UID | 一次对象生命的身份,名称重用不会沿用 UID |
| owner reference | 垃圾回收理解的主从关系 |
| finalizer | 对象真正删除前必须履行的责任声明 |
| informer | 封装 list/watch、缓存与事件分发的客户端机制 |
| workqueue | 对键进行去重、调度、重试与退避的工作队列 |
| Reconciled | 本项目表示托管子资源字段已应用,非流量 Ready |
| CleanupBlocked | True 表示模拟外部记录清理受到阻塞 |
| served | 该 API 版本能否被客户端访问 |
| storage | 新写入采用的持久版本,同一 CRD 只有一个 |
| storedVersions | 可能仍存在的历史持久版本记录 |
| None conversion | 本书同结构版本之间的默认转换策略 |
| Recreate | 更新先停止旧副本再启动新副本,允许停机 |
| direct-response | Envoy 路由直接生成响应,不需要上游服务器 |
阅读路径:先读第 1—5 章理解契约、API 与幂等,再读第 6—12 章理解调度、并发、状态和恢复,第 13—18 章负责权限、验证、版本及交付。完整代码附录与实验 ZIP 同源,必要时可从章节跳回源码核对。
以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。
package main
import (
"context"
"flag"
"github.com/Aias00/kubernetes-operator-book/lab"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"log"
"os/signal"
"syscall"
)
func main() {
kubeconfig := flag.String("kubeconfig", "", "explicit kubeconfig path")
namespace := flag.String("namespace", "default", "one watched namespace")
flag.Parse()
if *kubeconfig == "" {
log.Fatal("--kubeconfig required: this teaching CLI does not infer a production cluster")
}
cfg, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
if err != nil {
log.Fatal(err)
}
k, err := kubernetes.NewForConfig(cfg)
if err != nil {
log.Fatal(err)
}
d, err := dynamic.NewForConfig(cfg)
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := lab.New(k, d).Run(ctx, *namespace); err != nil {
log.Fatal(err)
}
}module github.com/Aias00/kubernetes-operator-book
go 1.24.0
require (
k8s.io/api v0.32.4
k8s.io/apimachinery v0.32.4
k8s.io/client-go v0.32.4
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/time v0.7.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.32.4 h1:kw8Y/G8E7EpNy7gjB8gJZl3KJkNz8HM2YHrZPtAZsF4=
k8s.io/api v0.32.4/go.mod h1:5MYFvLvweRhyKylM3Es/6uh/5hGp0dg82vP34KifX4g=
k8s.io/apimachinery v0.32.4 h1:8EEksaxA7nd7xWJkkwLDN4SvWS5ot9g6Z/VZb3ju25I=
k8s.io/apimachinery v0.32.4/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/client-go v0.32.4 h1:zaGJS7xoYOYumoWIFXlcVrsiYioRPrXGO7dBfVC5R6M=
k8s.io/client-go v0.32.4/go.mod h1:k0jftcyYnEtwlFW92xC7MTtFv5BNcZBr+zn9jPlT9Ic=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
# Real kind operator laboratory
Go 1.24.3; client-go v0.32.4; kind v0.27.0; Kubernetes v1.32.2 pinned image digest. Linux amd64 with Docker is required. The script creates and deletes only its own named cluster. Never use a production kubeconfig.
```sh
bash lab/run-kind.sh
```
The script downloads kind/kubectl and checks their official checksums, creates the cluster, installs `lab/crd.yaml`, builds `cmd/operator`, and runs `go vet` plus the real API `go test -race -count=1 -json ./...` suite. `evidence/runtime.json` records environment and the exact test inventory; `evidence/tests.jsonl` is the original raw stream. Missing KUBECONFIG or subprocess binary is a failure, never an integration skip.
17 named scenarios plus the parent test: initial convergence; idempotence; deleted child healing; spec/generation; genuine 409 and retry; status stability; actual subprocess kill/restart; finalizer cleanup and blocked recovery; owner-reference GC; foreign resource refusal; generation race; owned-field drift; preservation of unowned env/resources/probe timeout during spec update; scoped service-account RBAC; real HTTP via in-cluster Service DNS/ClusterIP; same-schema CRD served/storage switch.
The HTTP fixture uses Envoy v1.33.2 at a fixed digest and a separate BusyBox client Pod. It checks default direct-response, then a spec.message change and three resulting responses. Recreate permits downtime. Deployment readiness uses real Envoy HTTP. This is not a benchmark or complete production gateway.
The external record is a ConfigMap deliberately without owner references, bound by ownerUID and cleaned by the finalizer. It models external lifecycle responsibility; it is not an actual cloud dependency. `cleanup-blocked` is an explicit deterministic teaching fault. CLI has one worker and one namespace; child healing relies on one-second periodic revisit rather than child watches. No leader election, HA, conversion webhook, lossless rollout or completed all-object storage migration is claimed.
The version scenario retains alpha served, enables beta storage with identical schemas, rewrites one object through beta and confirms both historical storedVersions remain. It does not remove alpha or falsely mark all storage migrated.
Copyright © 2026 Aias00. All rights reserved. Third-party dependencies retain their own licenses.
#!/usr/bin/env python3
from pathlib import Path
import json,os,re,sys,hashlib
root=Path(__file__).resolve().parents[1]; out=Path(sys.argv[1]); events=[json.loads(x) for x in (out/'tests.jsonl').read_text().splitlines()]
expected={'TestRealCluster/'+x for x in re.findall(r't.Run\("(\w+)"', (root/'lab/integration_test.go').read_text())}|{'TestRealCluster'}
passed=[x['Test'] for x in events if x.get('Test') and x['Action']=='pass']
assert len(expected)==18,(len(expected),expected)
assert len(passed)==len(expected) and set(passed)==expected,(passed,expected)
assert not [x for x in events if x['Action']=='fail' or (x['Action']=='skip' and x.get('Test'))]
report={'status':'PASS','run_url':os.getenv('BOOK_RUN_URL','local real kind run'),'source_commit':os.getenv('GITHUB_SHA','working-tree'),'go':(out/'go-version.txt').read_text().strip(),'kind':(out/'kind-version.txt').read_text().strip(),'tool_binaries':(out/'tool-binary-sha256.txt').read_text().splitlines(),'kubernetes':json.loads((out/'kubernetes-version.json').read_text()),'node_image':(out/'node-image-digest.txt').read_text().strip(),'tests':sorted(expected),'test_count':len(expected),'tests_jsonl_sha256':hashlib.sha256((out/'tests.jsonl').read_bytes()).hexdigest(),'http':json.loads((out/'http.json').read_text()),'scope':'Single worker; real Envoy direct-response through Service, Deployment/Service ownership and CRD same-schema served/storage switch. No HA, zero-downtime claim, conversion webhook or completed storage migration.'}
(out/'runtime.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n');print('PASS: all 17 named real-cluster scenarios plus parent; zero test skips or failures')// Package lab implements a deliberately single-worker GatewayInstance controller.
package lab
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/dynamic/dynamicinformer"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/util/retry"
"k8s.io/client-go/util/workqueue"
)
const EnvoyImage = "envoyproxy/envoy:v1.33.2@sha256:e2baf0b155d0f54881b54621fdd997e3ec81dc6454f299d4e590afe18256a176"
var GVR = schema.GroupVersionResource{Group: "gateway.book.example", Version: "v1alpha1", Resource: "gatewayinstances"}
const Finalizer = "gateway.book.example/external-cleanup"
// Controller's hooks are deterministic test seams; the update hook may run on each retry.
// Production construction leaves BeforeDeploymentUpdate nil.
type Controller struct {
Kube kubernetes.Interface
Dynamic dynamic.Interface
BeforeDeploymentUpdate func(*appsv1.Deployment)
BeforeStatus func()
}
func New(k kubernetes.Interface, d dynamic.Interface) *Controller {
return &Controller{Kube: k, Dynamic: d}
}
func (c *Controller) Run(ctx context.Context, namespace string) error {
factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(c.Dynamic, 0, namespace, nil)
informer := factory.ForResource(GVR).Informer()
queue := workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]())
defer queue.ShutDown()
enqueue := func(obj interface{}) {
if key, err := cache.MetaNamespaceKeyFunc(obj); err == nil {
queue.Add(key)
}
}
_, err := informer.AddEventHandler(cache.ResourceEventHandlerFuncs{AddFunc: enqueue, UpdateFunc: func(_, cur interface{}) { enqueue(cur) }, DeleteFunc: enqueue})
if err != nil {
return err
}
factory.Start(ctx.Done())
if !cache.WaitForCacheSync(ctx.Done(), informer.HasSynced) {
return fmt.Errorf("cache sync interrupted")
}
go func() { <-ctx.Done(); queue.ShutDown() }()
for {
key, stop := queue.Get()
if stop {
return nil
}
parts := strings.SplitN(key, "/", 2)
if len(parts) == 2 {
if err := c.Reconcile(ctx, parts[0], parts[1]); err != nil {
queue.AddRateLimited(key)
} else {
queue.Forget(key)
if _, e := c.Dynamic.Resource(GVR).Namespace(parts[0]).Get(ctx, parts[1], metav1.GetOptions{}); !apierrors.IsNotFound(e) {
queue.AddAfter(key, time.Second)
}
}
}
queue.Done(key)
}
}
func (c *Controller) Reconcile(ctx context.Context, ns, name string) error {
api := c.Dynamic.Resource(GVR).Namespace(ns)
obj, err := api.Get(ctx, name, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return nil
}
if err != nil {
return err
}
if obj.GetDeletionTimestamp() != nil {
if !contains(obj.GetFinalizers(), Finalizer) {
return nil
}
if obj.GetAnnotations()["gateway.book.example/cleanup-blocked"] == "true" {
return c.status(ctx, ns, name, obj.GetGeneration(), "CleanupBlocked", true, "ExternalUnavailable")
}
record, err := c.Kube.CoreV1().ConfigMaps(ns).Get(ctx, externalName(obj), metav1.GetOptions{})
if err == nil {
if record.Data["ownerUID"] != string(obj.GetUID()) {
return fmt.Errorf("external record identity mismatch")
}
err = c.Kube.CoreV1().ConfigMaps(ns).Delete(ctx, record.Name, metav1.DeleteOptions{})
}
if err != nil && !apierrors.IsNotFound(err) {
return err
}
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
cur, e := api.Get(ctx, name, metav1.GetOptions{})
if e != nil {
return e
}
out := []string{}
for _, f := range cur.GetFinalizers() {
if f != Finalizer {
out = append(out, f)
}
}
cur.SetFinalizers(out)
_, e = api.Update(ctx, cur, metav1.UpdateOptions{})
return e
})
}
if !contains(obj.GetFinalizers(), Finalizer) {
err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
cur, e := api.Get(ctx, name, metav1.GetOptions{})
if e != nil {
return e
}
if !contains(cur.GetFinalizers(), Finalizer) {
cur.SetFinalizers(append(cur.GetFinalizers(), Finalizer))
_, e = api.Update(ctx, cur, metav1.UpdateOptions{})
}
return e
})
if err != nil {
return err
}
obj, err = api.Get(ctx, name, metav1.GetOptions{})
if err != nil {
return err
}
}
// External resource intentionally has no owner reference: the finalizer owns its lifecycle.
record := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: externalName(obj), Namespace: ns}, Data: map[string]string{"ownerUID": string(obj.GetUID())}}
_, err = c.Kube.CoreV1().ConfigMaps(ns).Create(ctx, record, metav1.CreateOptions{})
if apierrors.IsAlreadyExists(err) {
existing, e := c.Kube.CoreV1().ConfigMaps(ns).Get(ctx, record.Name, metav1.GetOptions{})
if e != nil {
return e
}
if existing.Data["ownerUID"] != string(obj.GetUID()) {
return fmt.Errorf("external record identity mismatch")
}
} else if err != nil {
return err
}
replicas, _, _ := unstructured.NestedInt64(obj.Object, "spec", "replicas")
message, _, _ := unstructured.NestedString(obj.Object, "spec", "message")
encoded, _ := json.Marshal(message)
config := fmt.Sprintf(`static_resources:
listeners:
- name: ingress
address: {socket_address: {address: 0.0.0.0, port_value: 8080}}
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress
route_config:
name: direct
virtual_hosts:
- name: all
domains: ["*"]
routes:
- match: {prefix: "/"}
direct_response:
status: 200
body: {inline_string: %s}
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
`, encoded)
labels := map[string]string{"app.kubernetes.io/instance": name, "app.kubernetes.io/managed-by": "gateway-book"}
yes := true
owner := metav1.OwnerReference{APIVersion: obj.GetAPIVersion(), Kind: "GatewayInstance", Name: name, UID: obj.GetUID(), Controller: &yes, BlockOwnerDeletion: &yes}
desired := appsv1.DeploymentSpec{Replicas: ptr(int32(replicas)), Strategy: appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}, Selector: &metav1.LabelSelector{MatchLabels: labels}, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: labels}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "gateway", Image: EnvoyImage, Args: []string{"--config-yaml", config, "--log-level", "warning"}, Ports: []corev1.ContainerPort{{ContainerPort: 8080}}, ReadinessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/", Port: intstr.FromInt32(8080)}}, PeriodSeconds: 1, InitialDelaySeconds: 1}, ImagePullPolicy: corev1.PullIfNotPresent}}}}}
// API defaults must be preserved; only owned fields are compared/changed.
err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
dep, e := c.Kube.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
if apierrors.IsNotFound(e) {
_, e = c.Kube.AppsV1().Deployments(ns).Create(ctx, &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, OwnerReferences: []metav1.OwnerReference{owner}}, Spec: desired}, metav1.CreateOptions{})
return e
}
if e != nil {
return e
}
if !owned(dep.OwnerReferences, obj.GetUID()) {
return fmt.Errorf("refuse to adopt unrelated Deployment")
}
if *dep.Spec.Replicas == *desired.Replicas && ownedContainerEqual(dep.Spec.Template.Spec.Containers[0], desired.Template.Spec.Containers[0]) && reflect.DeepEqual(dep.Spec.Template.Labels, labels) && dep.Spec.Strategy.Type == appsv1.RecreateDeploymentStrategyType {
return nil
}
dep.Spec.Replicas = desired.Replicas
dep.Spec.Template.Labels = labels
dep.Spec.Strategy = desired.Strategy
curContainer := &dep.Spec.Template.Spec.Containers[0]
target := desired.Template.Spec.Containers[0]
curContainer.Name = target.Name
curContainer.Image = target.Image
curContainer.Args = target.Args
curContainer.Ports = target.Ports
curContainer.ImagePullPolicy = target.ImagePullPolicy
if curContainer.ReadinessProbe == nil {
curContainer.ReadinessProbe = target.ReadinessProbe.DeepCopy()
}
if curContainer.ReadinessProbe.HTTPGet == nil {
curContainer.ReadinessProbe.Exec = nil
curContainer.ReadinessProbe.TCPSocket = nil
curContainer.ReadinessProbe.GRPC = nil
curContainer.ReadinessProbe.HTTPGet = target.ReadinessProbe.HTTPGet.DeepCopy()
}
curContainer.ReadinessProbe.HTTPGet.Path = target.ReadinessProbe.HTTPGet.Path
curContainer.ReadinessProbe.HTTPGet.Port = target.ReadinessProbe.HTTPGet.Port
curContainer.ReadinessProbe.PeriodSeconds = target.ReadinessProbe.PeriodSeconds
curContainer.ReadinessProbe.InitialDelaySeconds = target.ReadinessProbe.InitialDelaySeconds
if c.BeforeDeploymentUpdate != nil {
c.BeforeDeploymentUpdate(dep.DeepCopy())
}
_, e = c.Kube.AppsV1().Deployments(ns).Update(ctx, dep, metav1.UpdateOptions{})
return e
})
if err != nil {
return err
}
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, OwnerReferences: []metav1.OwnerReference{owner}}, Spec: corev1.ServiceSpec{Selector: labels, Ports: []corev1.ServicePort{{Protocol: corev1.ProtocolTCP, Port: 80, TargetPort: intstr.FromInt32(8080)}}}}
_, err = c.Kube.CoreV1().Services(ns).Create(ctx, svc, metav1.CreateOptions{})
if apierrors.IsAlreadyExists(err) {
existing, e := c.Kube.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
if e != nil {
return e
}
if !owned(existing.OwnerReferences, obj.GetUID()) {
return fmt.Errorf("refuse to adopt unrelated Service")
}
if !reflect.DeepEqual(existing.Spec.Selector, labels) || !reflect.DeepEqual(existing.Spec.Ports, svc.Spec.Ports) {
existing.Spec.Selector = labels
existing.Spec.Ports = svc.Spec.Ports
_, err = c.Kube.CoreV1().Services(ns).Update(ctx, existing, metav1.UpdateOptions{})
} else {
err = nil
}
}
if err != nil {
return err
}
if c.BeforeStatus != nil {
c.BeforeStatus()
}
return c.status(ctx, ns, name, obj.GetGeneration(), "Reconciled", true, "ChildrenApplied")
}
func (c *Controller) status(ctx context.Context, ns, name string, appliedGeneration int64, kind string, ok bool, reason string) error {
api := c.Dynamic.Resource(GVR).Namespace(ns)
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
cur, err := api.Get(ctx, name, metav1.GetOptions{})
if err != nil {
return err
}
if cur.GetGeneration() != appliedGeneration {
return fmt.Errorf("spec changed during reconciliation; retry before acknowledgement")
}
status := "False"
if ok {
status = "True"
}
old, _, _ := unstructured.NestedSlice(cur.Object, "status", "conditions")
transition := time.Now().UTC().Format(time.RFC3339)
if len(old) == 1 {
if m, ok := old[0].(map[string]interface{}); ok && m["type"] == kind && m["status"] == status && m["reason"] == reason {
transition, _ = m["lastTransitionTime"].(string)
}
}
cond := []interface{}{map[string]interface{}{"type": kind, "status": status, "reason": reason, "observedGeneration": cur.GetGeneration(), "lastTransitionTime": transition}}
next := map[string]interface{}{"observedGeneration": cur.GetGeneration(), "conditions": cond}
if reflect.DeepEqual(cur.Object["status"], next) {
return nil
}
cur.Object["status"] = next
_, err = api.UpdateStatus(ctx, cur, metav1.UpdateOptions{})
return err
})
}
func externalName(obj *unstructured.Unstructured) string { return "external-" + string(obj.GetUID()) }
func contains(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
func owned(owners []metav1.OwnerReference, uid types.UID) bool {
for _, o := range owners {
if o.UID == uid && o.Controller != nil && *o.Controller {
return true
}
}
return false
}
func ptr[T any](v T) *T { return &v }
// Compare only fields explicitly owned by this teaching controller; API defaults
// (termination message path, probe timeout/threshold) remain server-managed.
func ownedContainerEqual(a, b corev1.Container) bool {
if a.Name != b.Name || a.Image != b.Image || a.ImagePullPolicy != b.ImagePullPolicy || !reflect.DeepEqual(a.Args, b.Args) || len(a.Ports) != 1 || a.Ports[0].ContainerPort != 8080 {
return false
}
if a.ReadinessProbe == nil || a.ReadinessProbe.HTTPGet == nil {
return false
}
return a.ReadinessProbe.HTTPGet.Path == b.ReadinessProbe.HTTPGet.Path && a.ReadinessProbe.HTTPGet.Port == b.ReadinessProbe.HTTPGet.Port && a.ReadinessProbe.PeriodSeconds == b.ReadinessProbe.PeriodSeconds && a.ReadinessProbe.InitialDelaySeconds == b.ReadinessProbe.InitialDelaySeconds
}apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: gatewayinstances.gateway.book.example
spec:
group: gateway.book.example
scope: Namespaced
names:
plural: gatewayinstances
singular: gatewayinstance
kind: GatewayInstance
shortNames: [gwi]
versions:
- name: v1alpha1
served: true
storage: false
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [replicas]
properties:
message:
type: string
default: gateway ready
minLength: 1
maxLength: 120
replicas:
type: integer
minimum: 0
maximum: 3
status:
type: object
x-kubernetes-preserve-unknown-fields: true
- name: v1beta1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [replicas]
properties:
message:
type: string
default: gateway ready
minLength: 1
maxLength: 120
replicas:
type: integer
minimum: 0
maximum: 3
status:
type: object
x-kubernetes-preserve-unknown-fields: trueapiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: gatewayinstances.gateway.book.example
spec:
group: gateway.book.example
scope: Namespaced
names:
plural: gatewayinstances
singular: gatewayinstance
kind: GatewayInstance
shortNames: [gwi]
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [replicas]
properties:
message:
type: string
default: gateway ready
minLength: 1
maxLength: 120
replicas:
type: integer
minimum: 0
maximum: 3
status:
type: object
x-kubernetes-preserve-unknown-fields: trueapiVersion: v1
kind: Namespace
metadata:
name: operator-demo
---
apiVersion: gateway.book.example/v1alpha1
kind: GatewayInstance
metadata:
name: demo
namespace: operator-demo
spec:
replicas: 1
message: hello from the managed gatewaypackage lab
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/retry"
)
func TestRealCluster(t *testing.T) {
config := os.Getenv("KUBECONFIG")
if config == "" {
t.Fatal("real kind KUBECONFIG is required; this suite must never silently skip")
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
cfg, err := clientcmd.BuildConfigFromFlags("", config)
must(t, err)
k, err := kubernetes.NewForConfig(cfg)
must(t, err)
d, err := dynamic.NewForConfig(cfg)
must(t, err)
version, err := k.Discovery().ServerVersion()
must(t, err)
t.Logf("REAL_APISERVER %s", version.GitVersion)
if !strings.HasPrefix(version.GitVersion, "v1.32.") {
t.Fatalf("unexpected server %s", version.GitVersion)
}
newFixture := func(t *testing.T) (string, string, *Controller) {
t.Helper()
ns, err := k.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "operator-book-"}}, metav1.CreateOptions{})
must(t, err)
t.Cleanup(func() { _ = k.CoreV1().Namespaces().Delete(context.Background(), ns.Name, metav1.DeleteOptions{}) })
obj := &unstructured.Unstructured{Object: map[string]interface{}{"apiVersion": "gateway.book.example/v1alpha1", "kind": "GatewayInstance", "metadata": map[string]interface{}{"name": "demo", "namespace": ns.Name}, "spec": map[string]interface{}{"replicas": int64(1)}}}
_, err = d.Resource(GVR).Namespace(ns.Name).Create(ctx, obj, metav1.CreateOptions{})
must(t, err)
return ns.Name, "demo", New(k, d)
}
get := func(t *testing.T, ns, name string) *unstructured.Unstructured {
o, e := d.Resource(GVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
return o
}
updateReplicas := func(t *testing.T, ns, name string, n int64) {
must(t, retry.RetryOnConflict(retry.DefaultRetry, func() error {
o, e := d.Resource(GVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if e != nil {
return e
}
_ = unstructured.SetNestedField(o.Object, n, "spec", "replicas")
_, e = d.Resource(GVR).Namespace(ns).Update(ctx, o, metav1.UpdateOptions{})
return e
}))
}
t.Run("InitialConvergence", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if *dep.Spec.Replicas != 1 {
t.Fatal("replicas")
}
svc, e := k.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if svc.Spec.Selector["app.kubernetes.io/instance"] != name || dep.Spec.Template.Labels["unwanted"] != "" {
t.Fatal("selector")
}
o := get(t, ns, name)
g, _, _ := unstructured.NestedInt64(o.Object, "status", "observedGeneration")
if g != o.GetGeneration() {
t.Fatal("observedGeneration")
}
})
t.Run("IdempotentReconcile", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
before, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
o := get(t, ns, name)
for i := 0; i < 4; i++ {
must(t, c.Reconcile(ctx, ns, name))
}
after, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if before.UID != after.UID || before.Generation != after.Generation || o.GetResourceVersion() != get(t, ns, name).GetResourceVersion() {
t.Fatal("reconcile wrote unchanged owned state")
}
})
t.Run("DeletedChildrenSelfHeal", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
old, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
must(t, k.AppsV1().Deployments(ns).Delete(ctx, name, metav1.DeleteOptions{}))
must(t, k.CoreV1().Services(ns).Delete(ctx, name, metav1.DeleteOptions{}))
must(t, c.Reconcile(ctx, ns, name))
cur, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if old.UID == cur.UID {
t.Fatal("deployment not recreated")
}
_, e = k.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
})
t.Run("SpecAndGeneration", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
before := get(t, ns, name).GetGeneration()
updateReplicas(t, ns, name, 2)
must(t, c.Reconcile(ctx, ns, name))
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
o := get(t, ns, name)
g, _, _ := unstructured.NestedInt64(o.Object, "status", "observedGeneration")
if *dep.Spec.Replicas != 2 || o.GetGeneration() <= before || g != o.GetGeneration() {
t.Fatal("new spec not acknowledged")
}
})
t.Run("RealResourceVersionConflictRetry", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
updateReplicas(t, ns, name, 2)
var once sync.Once
injected := false
c.BeforeDeploymentUpdate = func(stale *appsv1.Deployment) {
once.Do(func() {
fresh, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
fresh.Annotations = map[string]string{"other-writer": "preserved"}
_, e = k.AppsV1().Deployments(ns).Update(ctx, fresh, metav1.UpdateOptions{})
must(t, e)
_, e = k.AppsV1().Deployments(ns).Update(ctx, stale, metav1.UpdateOptions{})
if !apierrors.IsConflict(e) {
t.Fatalf("expected genuine API 409, got %v", e)
}
injected = true
})
}
must(t, c.Reconcile(ctx, ns, name))
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if !injected || *dep.Spec.Replicas != 2 || dep.Annotations["other-writer"] != "preserved" {
t.Fatal("retry lost intent or concurrent writer")
}
})
t.Run("StatusConditionNoChurn", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
o := get(t, ns, name)
conds, _, _ := unstructured.NestedSlice(o.Object, "status", "conditions")
cond := conds[0].(map[string]interface{})
if cond["type"] != "Reconciled" || cond["status"] != "True" || cond["reason"] != "ChildrenApplied" || cond["lastTransitionTime"] == "" {
t.Fatal(cond)
}
must(t, c.Reconcile(ctx, ns, name))
after := get(t, ns, name)
if o.GetResourceVersion() != after.GetResourceVersion() {
t.Fatal("status churn")
}
})
t.Run("ActualProcessRestartResync", func(t *testing.T) {
ns, name, _ := newFixture(t)
binary := os.Getenv("OPERATOR_BINARY")
if binary == "" {
t.Fatal("compiled operator subprocess required")
}
start := func() *exec.Cmd {
cmd := exec.CommandContext(ctx, binary, "--kubeconfig", config, "--namespace", ns)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(t, cmd.Start())
return cmd
}
first := start()
t.Cleanup(func() { _ = first.Process.Kill() })
eventually(t, ctx, func() bool { _, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{}); return e == nil })
must(t, first.Process.Kill())
_ = first.Wait()
updateReplicas(t, ns, name, 3)
second := start()
defer func() { _ = second.Process.Kill(); _ = second.Wait() }()
eventually(t, ctx, func() bool {
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
return e == nil && *dep.Spec.Replicas == 3
})
})
t.Run("FinalizerCleanup", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
o := get(t, ns, name)
ext := externalName(o)
must(t, d.Resource(GVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}))
must(t, c.Reconcile(ctx, ns, name))
_, e := k.CoreV1().ConfigMaps(ns).Get(ctx, ext, metav1.GetOptions{})
if !apierrors.IsNotFound(e) {
t.Fatal("external record remains", e)
}
_, e = d.Resource(GVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if !apierrors.IsNotFound(e) {
t.Fatal("CR still exists", e)
}
})
t.Run("BlockedFinalizerRecovery", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
o := get(t, ns, name)
o.SetAnnotations(map[string]string{"gateway.book.example/cleanup-blocked": "true"})
_, e := d.Resource(GVR).Namespace(ns).Update(ctx, o, metav1.UpdateOptions{})
must(t, e)
must(t, d.Resource(GVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}))
must(t, c.Reconcile(ctx, ns, name))
blocked := get(t, ns, name)
if blocked.GetDeletionTimestamp() == nil || !contains(blocked.GetFinalizers(), Finalizer) {
t.Fatal("deletion did not remain pending")
}
_, e = k.CoreV1().ConfigMaps(ns).Get(ctx, externalName(blocked), metav1.GetOptions{})
must(t, e)
conds, _, _ := unstructured.NestedSlice(blocked.Object, "status", "conditions")
if conds[0].(map[string]interface{})["reason"] != "ExternalUnavailable" || conds[0].(map[string]interface{})["type"] != "CleanupBlocked" || conds[0].(map[string]interface{})["status"] != "True" {
t.Fatal("blocked condition missing")
}
blocked.SetAnnotations(nil)
_, e = d.Resource(GVR).Namespace(ns).Update(ctx, blocked, metav1.UpdateOptions{})
must(t, e)
must(t, c.Reconcile(ctx, ns, name))
_, e = d.Resource(GVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if !apierrors.IsNotFound(e) {
t.Fatal("recovery incomplete")
}
})
t.Run("OwnerReferencesAndGarbageCollection", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
o := get(t, ns, name)
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
svc, e := k.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if !owned(dep.OwnerReferences, o.GetUID()) || !owned(svc.OwnerReferences, o.GetUID()) {
t.Fatal("wrong owner UID")
}
must(t, d.Resource(GVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}))
must(t, c.Reconcile(ctx, ns, name))
eventually(t, ctx, func() bool {
_, a := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
_, b := k.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
return apierrors.IsNotFound(a) && apierrors.IsNotFound(b)
})
})
t.Run("RefuseUnrelatedChild", func(t *testing.T) {
ns, name, c := newFixture(t)
_, e := k.CoreV1().Services(ns).Create(ctx, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: name}, Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 90}}}}, metav1.CreateOptions{})
must(t, e)
if e = c.Reconcile(ctx, ns, name); e == nil || !strings.Contains(e.Error(), "unrelated Service") {
t.Fatal("unowned resource was adopted", e)
}
svc, e := k.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if svc.Spec.Ports[0].Port != 90 || len(svc.OwnerReferences) != 0 {
t.Fatal("collision mutated foreign object")
}
})
t.Run("ConcurrentSpecCannotBeAcknowledgedEarly", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
old := get(t, ns, name).GetGeneration()
var once sync.Once
c.BeforeStatus = func() { once.Do(func() { updateReplicas(t, ns, name, 2) }) }
if e := c.Reconcile(ctx, ns, name); e == nil {
t.Fatal("concurrent generation should request retry")
}
o := get(t, ns, name)
g, _, _ := unstructured.NestedInt64(o.Object, "status", "observedGeneration")
if g != old || o.GetGeneration() <= g {
t.Fatal("new generation acknowledged before applying children")
}
c.BeforeStatus = nil
must(t, c.Reconcile(ctx, ns, name))
o = get(t, ns, name)
g, _, _ = unstructured.NestedInt64(o.Object, "status", "observedGeneration")
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if g != o.GetGeneration() || *dep.Spec.Replicas != 2 {
t.Fatal("retry did not acknowledge actual applied spec")
}
})
t.Run("OwnedFieldsDriftRecovery", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
dep.Spec.Template.Spec.Containers[0].Args = []string{"--version"}
dep.Spec.Template.Labels["unwanted"] = "mutated"
_, e = k.AppsV1().Deployments(ns).Update(ctx, dep, metav1.UpdateOptions{})
must(t, e)
svc, e := k.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
svc.Spec.Selector = map[string]string{"wrong": "selector"}
svc.Spec.Ports[0].Port = 90
svc.Spec.Ports[0].TargetPort = intstr.FromInt32(9090)
_, e = k.CoreV1().Services(ns).Update(ctx, svc, metav1.UpdateOptions{})
must(t, e)
must(t, c.Reconcile(ctx, ns, name))
dep, e = k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
svc, e = k.CoreV1().Services(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
if dep.Spec.Template.Spec.Containers[0].Args[0] != "--config-yaml" || dep.Spec.Template.Labels["app.kubernetes.io/managed-by"] != "gateway-book" || svc.Spec.Ports[0].Port != 80 || svc.Spec.Ports[0].TargetPort.IntVal != 8080 || svc.Spec.Selector["app.kubernetes.io/instance"] != name || dep.Spec.Template.Labels["unwanted"] != "" {
t.Fatal("owned drift not restored")
}
})
t.Run("ScopedRBACAllowsOwnNamespaceOnly", func(t *testing.T) {
ns, name, _ := newFixture(t)
_, e := k.CoreV1().ServiceAccounts(ns).Create(ctx, &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "operator"}}, metav1.CreateOptions{})
must(t, e)
role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "operator", Namespace: ns}, Rules: []rbacv1.PolicyRule{{APIGroups: []string{GVR.Group}, Resources: []string{"gatewayinstances", "gatewayinstances/status", "gatewayinstances/finalizers"}, Verbs: []string{"get", "list", "watch", "update", "patch"}}, {APIGroups: []string{"apps"}, Resources: []string{"deployments"}, Verbs: []string{"get", "create", "update"}}, {APIGroups: []string{""}, Resources: []string{"services", "configmaps"}, Verbs: []string{"get", "create", "update", "delete"}}}}
_, e = k.RbacV1().Roles(ns).Create(ctx, role, metav1.CreateOptions{})
must(t, e)
_, e = k.RbacV1().RoleBindings(ns).Create(ctx, &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "operator", Namespace: ns}, Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: "operator", Namespace: ns}}, RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: "operator"}}, metav1.CreateOptions{})
must(t, e)
limited := rest.CopyConfig(cfg)
limited.Impersonate.UserName = "system:serviceaccount:" + ns + ":operator"
lk, e := kubernetes.NewForConfig(limited)
must(t, e)
ld, e := dynamic.NewForConfig(limited)
must(t, e)
controller := New(lk, ld)
must(t, controller.Reconcile(ctx, ns, name))
other, oname, _ := newFixture(t)
if e = controller.Reconcile(ctx, other, oname); !apierrors.IsForbidden(e) {
t.Fatal("cross-namespace should be forbidden", e)
}
_, e = lk.CoreV1().Secrets(ns).List(ctx, metav1.ListOptions{})
if !apierrors.IsForbidden(e) {
t.Fatal("ungranted Secrets access", e)
}
})
t.Run("UnownedContainerFieldsSurviveSpecUpdate", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
dep.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{{Name: "EXTERNAL_SETTING", Value: "preserved"}}
dep.Spec.Template.Spec.Containers[0].ReadinessProbe.TimeoutSeconds = 7
dep.Spec.Template.Spec.Containers[0].Resources.Requests = corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("10m")}
_, e = k.AppsV1().Deployments(ns).Update(ctx, dep, metav1.UpdateOptions{})
must(t, e)
updateReplicas(t, ns, name, 2)
must(t, c.Reconcile(ctx, ns, name))
dep, e = k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
container := dep.Spec.Template.Spec.Containers[0]
if len(container.Env) != 1 || container.Env[0].Value != "preserved" || container.ReadinessProbe.TimeoutSeconds != 7 || container.Resources.Requests.Cpu().String() != "10m" || *dep.Spec.Replicas != 2 {
t.Fatal("non-owned container settings lost during update", container)
}
})
t.Run("GatewayServiceHTTPAndSpecChange", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
client := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "http-client", Namespace: ns}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "client", Image: "busybox:1.36.1@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662", Command: []string{"sh", "-c", "sleep 600"}}}}}
_, e := k.CoreV1().Pods(ns).Create(ctx, client, metav1.CreateOptions{})
must(t, e)
waitReady := func() {
eventually(t, ctx, func() bool {
dep, e := k.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
if e != nil || dep.Status.ObservedGeneration != dep.Generation || dep.Status.AvailableReplicas != 1 || dep.Status.UpdatedReplicas != 1 {
return false
}
pod, e := k.CoreV1().Pods(ns).Get(ctx, "http-client", metav1.GetOptions{})
if e != nil {
return false
}
for _, cond := range pod.Status.Conditions {
if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
return true
}
}
return false
})
}
request := func() string {
out, e := exec.CommandContext(ctx, "kubectl", "-n", ns, "exec", "http-client", "--", "wget", "-T", "5", "-qO-", "http://"+name+"."+ns+".svc.cluster.local:80/").CombinedOutput()
if e != nil {
t.Fatalf("real Service request: %v %s", e, out)
}
return string(out)
}
waitReady()
before := request()
if before != "gateway ready" {
t.Fatal("unexpected original HTTP response", before)
}
o := get(t, ns, name)
_ = unstructured.SetNestedField(o.Object, "updated gateway response", "spec", "message")
_, e = d.Resource(GVR).Namespace(ns).Update(ctx, o, metav1.UpdateOptions{})
must(t, e)
must(t, c.Reconcile(ctx, ns, name))
waitReady()
for i := 0; i < 3; i++ {
if body := request(); body != "updated gateway response" {
t.Fatal("HTTP configuration did not converge", body)
}
}
pods, e := k.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{LabelSelector: "app.kubernetes.io/instance=" + name})
must(t, e)
ids := []string{}
for _, pod := range pods.Items {
for _, status := range pod.Status.ContainerStatuses {
ids = append(ids, status.ImageID)
}
}
if len(ids) == 0 {
t.Fatal("no real Envoy runtime image")
}
path := filepath.Join(os.Getenv("OPERATOR_ROOT"), "evidence", "http.json")
report := map[string]interface{}{"transport": "in-cluster busybox wget -> Service DNS/ClusterIP -> Envoy:8080", "before": before, "after": "updated gateway response", "post_update_requests": 3, "envoy_image": EnvoyImage, "actual_image_ids": ids, "rollout_strategy": "Recreate; downtime possible"}
raw, e := json.MarshalIndent(report, "", " ")
must(t, e)
must(t, os.WriteFile(path, append(raw, '\n'), 0600))
})
t.Run("CRDServedStorageUpgrade", func(t *testing.T) {
ns, name, c := newFixture(t)
must(t, c.Reconcile(ctx, ns, name))
root := os.Getenv("OPERATOR_ROOT")
if root == "" {
t.Fatal("OPERATOR_ROOT required")
}
out, e := exec.CommandContext(ctx, "kubectl", "apply", "-f", filepath.Join(root, "lab/crd-v1beta1.yaml")).CombinedOutput()
if e != nil {
t.Fatalf("upgrade: %v %s", e, out)
}
beta := schema.GroupVersionResource{Group: GVR.Group, Version: "v1beta1", Resource: GVR.Resource}
b, e := d.Resource(beta).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
must(t, e)
oldUID := b.GetUID()
_ = unstructured.SetNestedField(b.Object, int64(2), "spec", "replicas")
_, e = d.Resource(beta).Namespace(ns).Update(ctx, b, metav1.UpdateOptions{})
must(t, e)
must(t, c.Reconcile(ctx, ns, name))
alpha := get(t, ns, name)
if alpha.GetUID() != oldUID {
t.Fatal("identity changed across version")
}
raw, e := exec.CommandContext(ctx, "kubectl", "get", "crd", "gatewayinstances.gateway.book.example", "-o", "json").Output()
must(t, e)
var crd map[string]interface{}
must(t, json.Unmarshal(raw, &crd))
stored, _, _ := unstructured.NestedStringSlice(crd, "status", "storedVersions")
if !contains(stored, "v1alpha1") || !contains(stored, "v1beta1") {
t.Fatalf("both stored versions expected, got %v", stored)
}
t.Logf("STORED_VERSIONS %v; alpha remains served, no migration or conversion webhook claimed", stored)
})
}
func must(t *testing.T, e error) {
t.Helper()
if e != nil {
t.Fatal(e)
}
}
func eventually(t *testing.T, ctx context.Context, f func() bool) {
t.Helper()
deadline := time.Now().Add(100 * time.Second)
for time.Now().Before(deadline) {
if f() {
return
}
select {
case <-ctx.Done():
t.Fatal(ctx.Err())
case <-time.After(100 * time.Millisecond):
}
}
t.Fatal(fmt.Errorf("condition not reached before deadline"))
}#!/usr/bin/env bash
# Creates only its own disposable cluster; Docker and Linux amd64 are required.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p .tools evidence
curl -fLsS --retry 3 https://github.com/kubernetes-sigs/kind/releases/download/v0.27.0/kind-linux-amd64 -o .tools/kind
curl -fLsS --retry 3 https://github.com/kubernetes-sigs/kind/releases/download/v0.27.0/kind-linux-amd64.sha256sum -o .tools/kind.sha256sum
(cd .tools && sed 's/kind-linux-amd64/kind/' kind.sha256sum | sha256sum -c -)
curl -fLsS --retry 3 https://dl.k8s.io/release/v1.32.2/bin/linux/amd64/kubectl -o .tools/kubectl
curl -fLsS --retry 3 https://dl.k8s.io/release/v1.32.2/bin/linux/amd64/kubectl.sha256 -o .tools/kubectl.sha256
printf '%s .tools/kubectl\n' "$(cat .tools/kubectl.sha256)" | sha256sum -c -
chmod +x .tools/kind .tools/kubectl
export PATH="$PWD/.tools:$PATH"
export KUBECONFIG="$PWD/.tools/kubeconfig"
task_cluster="operator-book-${GITHUB_RUN_ID:-local}-$$"
if kind get clusters | grep -qx "$task_cluster"; then echo 'Refusing existing cluster' >&2; exit 1; fi
cleanup(){ kubectl get gatewayinstances,deployments,services -A -o wide > evidence/objects.txt 2>&1 || true; kind export logs evidence/kind-logs --name "$task_cluster" >/dev/null 2>&1 || true; kind delete cluster --name "$task_cluster"; }
trap cleanup EXIT
kind create cluster --name "$task_cluster" --wait 180s --image kindest/node:v1.32.2@sha256:f226345927d7e348497136874b6d207e0b32cc52154ad8323129352923a3142f
kind version > evidence/kind-version.txt
sha256sum .tools/kind .tools/kubectl > evidence/tool-binary-sha256.txt
kubectl version -o json > evidence/kubernetes-version.json
docker image inspect --format '{{index .RepoDigests 0}}' kindest/node:v1.32.2@sha256:f226345927d7e348497136874b6d207e0b32cc52154ad8323129352923a3142f > evidence/node-image-digest.txt
go version > evidence/go-version.txt
kubectl apply -f lab/crd.yaml
kubectl wait --for=condition=Established crd/gatewayinstances.gateway.book.example --timeout=30s
go build -o .tools/operator ./cmd/operator
export OPERATOR_BINARY="$PWD/.tools/operator"
export OPERATOR_ROOT="$PWD"
go vet ./...
go test -race -count=1 -json ./... | tee evidence/tests.jsonl
python3 lab/collect_evidence.py evidence