第 23 / 23 篇

附录:随书实验完整代码

以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。

cmd/operator/main.go

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)
    }
}

go.mod

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
)

go.sum

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=

lab/README.md

# 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.

lab/collect_evidence.py

#!/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')

lab/controller.go

// 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
}

lab/crd-v1beta1.yaml

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: true

lab/crd.yaml

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: 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: true

lab/example.yaml

apiVersion: 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 gateway

lab/integration_test.go

package 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"))
}

lab/run-kind.sh

#!/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