附录:随书实验完整代码
以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。
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: truelab/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: truelab/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 gatewaylab/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