diff --git a/Makefile b/Makefile index c9e5f7d..5fec160 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,7 @@ e2e: ## Run e2e tests (requires: make deploy-bink). V=1 for verbose. RUN= ARTIFACTS=$(ARTIFACTS) \ BINK_NODE_IMAGE_DIGEST=$$(skopeo inspect --tls-verify=false --format '{{.Digest}}' docker://localhost:5000/node:latest) \ BINK_NODE_IMAGE_UPDATE_DIGEST=$$(skopeo inspect --tls-verify=false docker://localhost:5000/node:update | jq -r '.Digest') \ - go test -timeout 10m -count=1 $(if $(V),-v) $(if $(RUN),-run $(RUN)) . + go test -timeout 20m -count=1 $(if $(V),-v) $(if $(RUN),-run $(RUN)) . ##@ Build @@ -117,6 +117,7 @@ deploy: manifests kustomize yq ## Deploy controller to the K8s cluster specified "$(KUSTOMIZE)" build config/default | \ "$(YQ)" '(select(.kind == "Deployment") | .spec.template.spec.containers[] | select(.name == "manager")).image = "$(IMG)"' | \ "$(YQ)" '(select(.kind == "DaemonSet") | .spec.template.spec.containers[] | select(.name == "daemon")).image = "$(IMG)"' | \ + $(if $(MANAGER_EXTRA_ARGS),"$(YQ)" '(select(.kind == "Deployment") | .spec.template.spec.containers[] | select(.name == "manager")).args += [$(MANAGER_EXTRA_ARGS)]' |) \ "$(KUBECTL)" apply --server-side -f - .PHONY: undeploy @@ -151,7 +152,8 @@ deploy-bink: start-bink build-update-image kustomize ## Deploy to a bink cluster # On re-deploy, restart the rollout to force a re-pull of the :latest tag. # On fresh deploy, skip the restart -- the pod is already pulling the correct image. @existed=$$(kubectl --kubeconfig $(KUBECONFIG_BINK) -n bootc-operator get deploy bootc-operator-controller-manager -o name 2>/dev/null || true) && \ - $(MAKE) deploy KUBECONFIG=$(abspath $(KUBECONFIG_BINK)) IMG=$(IMG_BINK) && \ + $(MAKE) deploy KUBECONFIG=$(abspath $(KUBECONFIG_BINK)) IMG=$(IMG_BINK) \ + MANAGER_EXTRA_ARGS='"--allow-insecure-registry","--tag-resolution-interval=10s"' && \ if [ -n "$$existed" ]; then \ kubectl --kubeconfig $(KUBECONFIG_BINK) -n bootc-operator rollout restart deployment/bootc-operator-controller-manager; \ fi diff --git a/api/v1alpha1/bootcnodepool_types.go b/api/v1alpha1/bootcnodepool_types.go index 552c9e9..1818307 100644 --- a/api/v1alpha1/bootcnodepool_types.go +++ b/api/v1alpha1/bootcnodepool_types.go @@ -45,6 +45,10 @@ const ( // digest ref, or a malformed nodeSelector). PoolInvalidSpec string = "InvalidSpec" + // PoolTagResolutionError means the controller failed + // resolve the tag. + PoolTagResolutionError string = "TagResolutionError" + // PoolHealthy means no issues. PoolHealthy string = "Healthy" ) @@ -159,6 +163,11 @@ type BootcNodePoolStatus struct { // +optional TargetDigest string `json:"targetDigest,omitempty"` + // nextTagResolutionTime is when the controller will next resolve a + // tag ref to a digest. Only set for tag-based image refs. + // +optional + NextTagResolutionTime *metav1.Time `json:"nextTagResolutionTime,omitempty"` + // deployedDigest is the last digest fully rolled out to all nodes in // the pool. // +optional diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b91adab..8467775 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -169,6 +169,10 @@ func (in *BootcNodePoolSpec) DeepCopy() *BootcNodePoolSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BootcNodePoolStatus) DeepCopyInto(out *BootcNodePoolStatus) { *out = *in + if in.NextTagResolutionTime != nil { + in, out := &in.NextTagResolutionTime, &out.NextTagResolutionTime + *out = (*in).DeepCopy() + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) diff --git a/cmd/controller/main.go b/cmd/controller/main.go index ecc710e..64537d7 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -5,6 +5,7 @@ package main import ( "flag" "os" + "time" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -16,6 +17,7 @@ import ( bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" "github.com/bootc-dev/bootc-operator/internal/controller" + "github.com/bootc-dev/bootc-operator/internal/registry" ) var ( @@ -31,7 +33,12 @@ func init() { func main() { var enableLeaderElection bool var probeAddr string + var tagResolutionInterval time.Duration + var allowInsecureRegistry bool flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.DurationVar(&tagResolutionInterval, "tag-resolution-interval", 5*time.Minute, "How often to re-resolve tag-based image refs.") + flag.BoolVar(&allowInsecureRegistry, "allow-insecure-registry", false, + "Allow falling back to HTTP when resolving tag-based image refs against registries that do not serve TLS.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -61,9 +68,11 @@ func main() { } if err := (&controller.BootcNodePoolReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - KubeClient: kubeClient, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + KubeClient: kubeClient, + TagResolver: ®istry.GGCRResolver{AllowInsecure: allowInsecureRegistry}, + TagResolutionInterval: tagResolutionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "bootcnodepool") os.Exit(1) diff --git a/config/crd/bases/node.bootc.dev_bootcnodepools.yaml b/config/crd/bases/node.bootc.dev_bootcnodepools.yaml index 9735eaf..7e8da3e 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodepools.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodepools.yaml @@ -243,6 +243,12 @@ spec: deployedDigest is the last digest fully rolled out to all nodes in the pool. type: string + nextTagResolutionTime: + description: |- + nextTagResolutionTime is when the controller will next resolve a + tag ref to a digest. Only set for tag-based image refs. + format: date-time + type: string nodeCount: description: nodeCount is the total number of nodes in this pool. format: int32 diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index d6c6bd6..2dc165c 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -255,7 +255,7 @@ the polling interval, proving fsnotify is working). ## Milestone 5: Tag Resolution & Pull Secrets -### 5a. Tag resolution +### 5a. Tag resolution ✅ - Registry client (e.g. `go-containerregistry`) to resolve tags to digests diff --git a/go.mod b/go.mod index 3ba4e9a..cd4f282 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/distribution/reference v0.6.0 github.com/fsnotify/fsnotify v1.9.0 github.com/go-logr/logr v1.4.3 + github.com/google/go-containerregistry v0.21.7 github.com/onsi/gomega v1.42.1 k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 @@ -22,6 +23,8 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/cli v29.5.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect @@ -38,6 +41,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect @@ -47,6 +51,7 @@ require ( github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -54,8 +59,9 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -63,7 +69,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect @@ -74,6 +80,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/apiextensions-apiserver v0.36.0 // indirect k8s.io/cli-runtime v0.36.2 // indirect k8s.io/component-base v0.36.2 // indirect diff --git a/go.sum b/go.sum index f33a4f5..244d6cf 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= +github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= @@ -56,6 +60,8 @@ github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnL github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= 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= @@ -69,8 +75,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm 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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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= @@ -104,6 +110,8 @@ github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -125,10 +133,13 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/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= @@ -156,12 +167,12 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -173,8 +184,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= @@ -189,6 +200,8 @@ 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= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index 7f0f3b3..999c062 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -45,6 +45,11 @@ type drainStatus struct { isStalled bool //nolint:unused // used by drain stall detection } +// TagResolver resolves a container image reference to a digest. +type TagResolver interface { + Resolve(ctx context.Context, ref string) (string, error) +} + // BootcNodePoolReconciler reconciles a BootcNodePool object type BootcNodePoolReconciler struct { client.Client @@ -52,6 +57,9 @@ type BootcNodePoolReconciler struct { KubeClient kubernetes.Interface Recorder events.EventRecorder + TagResolver TagResolver + TagResolutionInterval time.Duration + // drainCh receives events from drain goroutines to re-enqueue the // owning pool after a drain completes. drainCh chan event.GenericEvent @@ -239,13 +247,31 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques // the end. // Resolve the target digest from the image ref. - if err := r.resolveTargetDigest(&pool); err != nil { + resolveResult, err := r.resolveTargetDigest(ctx, &pool) + if err != nil { if isInvalidSpecError(err) { return r.setInvalidSpecCondition(ctx, &pool, err) } return ctrl.Result{}, fmt.Errorf("resolving target digest: %w", err) } + // complete handles the boilerplate exit logic for the happy path and writes + // the pool status if anything changed. + complete := func(result ctrl.Result) (ctrl.Result, error) { + if !reflect.DeepEqual(pool.Status, *statusOrig) { + if err := r.Status().Update(ctx, &pool); err != nil { + return ctrl.Result{}, fmt.Errorf("updating pool status: %w", err) + } + } + + return resolveResult, nil + } + + if pool.Status.TargetDigest == "" { + // First tag resolution failed — nothing to roll out yet. + return complete(resolveResult) + } + // Sync pool membership and retrieve BootcNodes we own. ownedBootcNodes, err := r.syncMembership(ctx, &pool) if err != nil { @@ -267,31 +293,56 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{}, fmt.Errorf("driving rollout: %w", err) } - // Write pool status once if anything changed. - if !reflect.DeepEqual(pool.Status, *statusOrig) { - if err := r.Status().Update(ctx, &pool); err != nil { - return ctrl.Result{}, fmt.Errorf("updating pool status: %w", err) - } - } - - return ctrl.Result{}, nil + return complete(resolveResult) } -// resolveTargetDigest parses the digest from the pool's image ref and -// sets pool.Status.TargetDigest. For digest refs (the only kind -// supported now), the digest is extracted directly. Tag resolution is -// deferred to Milestone 5. -func (r *BootcNodePoolReconciler) resolveTargetDigest(pool *bootcv1alpha1.BootcNodePool) error { +// resolveTargetDigest resolves the target digest from the pool's image +// ref. Digest refs are extracted directly. Tag refs are resolved via +// the registry, respecting the re-resolution interval. +func (r *BootcNodePoolReconciler) resolveTargetDigest(ctx context.Context, pool *bootcv1alpha1.BootcNodePool) (ctrl.Result, error) { + log := logf.FromContext(ctx) + ref, err := parseImageRef(pool.Spec.Image.Ref) if err != nil { - return newInvalidSpecError(fmt.Sprintf("invalid image ref %q: %v", pool.Spec.Image.Ref, err)) + return ctrl.Result{}, newInvalidSpecError(fmt.Sprintf("invalid image ref %q: %v", pool.Spec.Image.Ref, err)) } + digested, ok := ref.(reference.Digested) - if !ok { - return newInvalidSpecError(fmt.Sprintf("image ref %q has no digest (tag resolution not yet supported)", pool.Spec.Image.Ref)) + if ok { + pool.Status.TargetDigest = digested.Digest().String() + // Reset the NextTagResolutionTime in case we pass from a tag referenced image to a digested one. + // Otherwise, it simply a nop + pool.Status.NextTagResolutionTime = nil + return ctrl.Result{}, nil } - pool.Status.TargetDigest = digested.Digest().String() - return nil + + // Tag ref — check if resolution is due. + now := time.Now() + if pool.Status.NextTagResolutionTime != nil && now.Before(pool.Status.NextTagResolutionTime.Time) { + remaining := pool.Status.NextTagResolutionTime.Sub(now) + log.V(1).Info("Tag resolution not yet due", "remaining", remaining) + return ctrl.Result{RequeueAfter: remaining}, nil + } + + digest, err := r.TagResolver.Resolve(ctx, pool.Spec.Image.Ref) + if err != nil { + log.Error(err, "Failed to resolve tag", "ref", pool.Spec.Image.Ref) + apimeta.SetStatusCondition(&pool.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.PoolDegraded, + Status: metav1.ConditionTrue, + Reason: bootcv1alpha1.PoolTagResolutionError, + Message: err.Error(), + }) + } else { + if pool.Status.TargetDigest != digest { + log.Info("Resolved tag to new digest", "ref", pool.Spec.Image.Ref, "digest", digest) + } + pool.Status.TargetDigest = digest + } + + next := metav1.NewTime(now.Add(r.TagResolutionInterval)) + pool.Status.NextTagResolutionTime = &next + return ctrl.Result{RequeueAfter: r.TagResolutionInterval}, nil } // parseImageRef parses an image reference string into a named diff --git a/internal/registry/resolver.go b/internal/registry/resolver.go new file mode 100644 index 0000000..649507b --- /dev/null +++ b/internal/registry/resolver.go @@ -0,0 +1,42 @@ +package registry + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// GGCRResolver resolves image tags to digests using go-containerregistry. +type GGCRResolver struct { + // AllowInsecure enables fallback to HTTP when the HTTPS connection + // to the registry fails. + AllowInsecure bool +} + +func (r *GGCRResolver) Resolve(ctx context.Context, ref string) (string, error) { + parsed, err := name.ParseReference(ref) + if err != nil { + return "", fmt.Errorf("parsing reference %q: %w", ref, err) + } + if d, ok := parsed.(name.Digest); ok { + return d.DigestStr(), nil + } + desc, err := remote.Get(parsed, remote.WithContext(ctx)) + var tlsErr *tls.CertificateVerificationError + if (errors.As(err, &tlsErr) || errors.Is(err, http.ErrSchemeMismatch)) && r.AllowInsecure { + insecure, parseErr := name.ParseReference(ref, name.Insecure) + if parseErr != nil { + return "", fmt.Errorf("parsing reference %q: %w", ref, parseErr) + } + desc, err = remote.Get(insecure, remote.WithContext(ctx)) + } + if err != nil { + return "", fmt.Errorf("fetching manifest for %q: %w", ref, err) + } + return desc.Digest.String(), nil +} diff --git a/internal/registry/resolver_test.go b/internal/registry/resolver_test.go new file mode 100644 index 0000000..10a044e --- /dev/null +++ b/internal/registry/resolver_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "context" + "fmt" + "net/http/httptest" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + . "github.com/onsi/gomega" +) + +func TestResolveValidTag(t *testing.T) { + tags := []string{"latest", "v1.0", ""} + + g := NewWithT(t) + + srv := httptest.NewServer(registry.New()) + defer srv.Close() + + // Build a minimal OCI image (empty base + one random layer) to push. + layer, err := random.Layer(256, types.OCILayer) + g.Expect(err).NotTo(HaveOccurred()) + img, err := mutate.AppendLayers(empty.Image, layer) + g.Expect(err).NotTo(HaveOccurred()) + + for _, tag := range tags { + t.Run(tag, func(t *testing.T) { + var imageName string + if tag == "" { + imageName = fmt.Sprintf("%s/test/image", srv.Listener.Addr().String()) + } else { + imageName = fmt.Sprintf("%s/test/image:%s", srv.Listener.Addr().String(), tag) + } + ref, err := name.ParseReference(imageName) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(remote.Write(ref, img)).To(Succeed()) + + want, err := img.Digest() + g.Expect(err).NotTo(HaveOccurred()) + + resolver := &GGCRResolver{} + got, err := resolver.Resolve(context.Background(), ref.String()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(want.String())) + }) + } +} + +func TestResolveDigestRef(t *testing.T) { + g := NewWithT(t) + + resolver := &GGCRResolver{} + digest := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ref := "registry.example.com/test/image@" + digest + got, err := resolver.Resolve(context.Background(), ref) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(digest)) +} + +func TestResolveUnreachableRegistry(t *testing.T) { + g := NewWithT(t) + + resolver := &GGCRResolver{} + _, err := resolver.Resolve(context.Background(), "localhost:1/nonexistent/image:latest") + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("fetching manifest")) +} diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index 2b57b22..7577755 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -199,3 +199,85 @@ func TestUpdateReboot(t *testing.T) { t.Logf("Verified update-marker exists on host via daemon pod") } + +// TestTagResolution creates a pool with a tag-based image ref, verifies +// the controller resolves the tag to a digest, then retags the image +// and verifies re-resolution triggers a rollout. +func TestTagResolution(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + + env := e2eutil.New(t) + + ctx := context.Background() + + nodeName := env.AddNode(t) + + // The seed step already pushed node:latest with the original image. + // Create a pool using the tag ref. + pool := env.NewPool("tag", env.NodeImageTagRef()) + g.Expect(env.Client.Create(ctx, pool)).To(Succeed()) + + // Verify targetDigest is resolved to the original image digest. + g.Eventually(func(g Gomega) string { + var p bootcv1alpha1.BootcNodePool + g.Expect(env.Client.Get(ctx, client.ObjectKeyFromObject(pool), &p)).To(Succeed()) + return p.Status.TargetDigest + }).WithTimeout(1 * time.Minute).Should(Equal(env.NodeImageDigest())) + + t.Logf("Tag resolved to original digest %s", env.NodeImageDigest()) + + // Wait for node to reach Idle with the original image. + g.Eventually(func(g Gomega) bootcv1alpha1.BootcNodeStatus { + var bn bootcv1alpha1.BootcNode + g.Expect(env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn)).To(Succeed()) + return bn.Status + }).WithTimeout(3 * time.Minute).Should(And( + HaveField("Booted", And( + Not(BeNil()), + HaveField("ImageDigest", Equal(env.NodeImageDigest())), + )), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionTrue), + ))), + )) + + t.Logf("Node %q is Idle with original image", nodeName) + + // Retag node:latest to point at the update image. + e2eutil.RetagImage(t, + "localhost:5000/node@"+env.NodeImageUpdateDigest(), + "localhost:5000/node:latest", + ) + + t.Logf("Retagged node:latest to update digest %s", env.NodeImageUpdateDigest()) + + // Wait for the controller to re-resolve and pick up the new digest. + g.Eventually(func(g Gomega) string { + var p bootcv1alpha1.BootcNodePool + g.Expect(env.Client.Get(ctx, client.ObjectKeyFromObject(pool), &p)).To(Succeed()) + return p.Status.TargetDigest + }).WithTimeout(1 * time.Minute).Should(Equal(env.NodeImageUpdateDigest())) + + t.Logf("Tag re-resolved to update digest %s", env.NodeImageUpdateDigest()) + + // Wait for node to reach Idle with the update image. + g.Eventually(func(g Gomega) bootcv1alpha1.BootcNodeStatus { + var bn bootcv1alpha1.BootcNode + g.Expect(env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn)).To(Succeed()) + return bn.Status + }).WithTimeout(5 * time.Minute).Should(And( + HaveField("Booted", And( + Not(BeNil()), + HaveField("ImageDigest", Equal(env.NodeImageUpdateDigest())), + )), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionTrue), + ))), + )) + + t.Logf("Node %q is Idle with update image", nodeName) +} diff --git a/test/e2e/e2eutil/env.go b/test/e2e/e2eutil/env.go index 77efd88..aa55d83 100644 --- a/test/e2e/e2eutil/env.go +++ b/test/e2e/e2eutil/env.go @@ -18,6 +18,8 @@ import ( "testing" "time" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" . "github.com/onsi/gomega" //nolint:staticcheck corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" @@ -224,6 +226,12 @@ func (e *Env) NodeImageDigestedPullSpec() string { return e.nodeImageRegistry + "@" + e.nodeImageDigest } +// NodeImageTagRef returns the tag-based reference for the seeded node +// image (e.g. "registry.cluster.local:5000/node:latest"). +func (e *Env) NodeImageTagRef() string { + return e.nodeImageRegistry + ":latest" +} + // NodeImageDigest returns the manifest digest of the seeded node image. func (e *Env) NodeImageDigest() string { return e.nodeImageDigest @@ -243,6 +251,33 @@ func (e *Env) NodeImageUpdateDigest() string { return e.nodeImageUpdateDigest } +// RetagImage reads the image at srcRef from the localhost registry and +// tags it as dstTag. +func RetagImage(t *testing.T, srcRef, dstTag string) { + t.Helper() + + src, err := name.ParseReference(srcRef, name.Insecure) + if err != nil { + t.Fatalf("parsing src ref %q: %v", srcRef, err) + } + desc, err := remote.Get(src) + if err != nil { + t.Fatalf("fetching %q: %v", srcRef, err) + } + img, err := desc.Image() + if err != nil { + t.Fatalf("getting image from descriptor: %v", err) + } + + dst, err := name.ParseReference(dstTag, name.Insecure) + if err != nil { + t.Fatalf("parsing dst ref %q: %v", dstTag, err) + } + if err := remote.Write(dst, img); err != nil { + t.Fatalf("writing %q: %v", dstTag, err) + } +} + // cleanup gathers diagnostic logs, then deletes test-scoped resources // and bink nodes. func (e *Env) cleanup(t *testing.T) {