From f151210a54257294d09d7da119354f7c9c61e7fc Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 23 Jun 2026 14:19:19 +0000 Subject: [PATCH 01/10] controller: add tag resolver using go-containerregistry Introduce TagResolver interface and GGCRResolver implementation in internal/registry/ that resolves container image tags to digests via remote.Get. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- go.mod | 15 ++++++++++---- go.sum | 39 +++++++++++++++++++++++------------ internal/registry/resolver.go | 29 ++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 internal/registry/resolver.go diff --git a/go.mod b/go.mod index 70a8d79..55206ea 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/distribution/reference v0.6.0 github.com/go-logr/logr v1.4.3 + github.com/google/go-containerregistry v0.21.7 github.com/onsi/gomega v1.42.0 k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 @@ -21,6 +22,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,9 +69,9 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.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.39.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.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 96fe717..62d7c3f 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.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= github.com/onsi/gomega v1.42.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= 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,25 +167,25 @@ 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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +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/registry/resolver.go b/internal/registry/resolver.go new file mode 100644 index 0000000..a8d8983 --- /dev/null +++ b/internal/registry/resolver.go @@ -0,0 +1,29 @@ +package registry + +import ( + "context" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// TagResolver resolves a container image reference to a digest. +type TagResolver interface { + Resolve(ctx context.Context, ref string) (string, error) +} + +// GGCRResolver resolves image tags to digests using go-containerregistry. +type GGCRResolver struct{} + +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) + } + desc, err := remote.Get(parsed, remote.WithContext(ctx)) + if err != nil { + return "", fmt.Errorf("fetching manifest for %q: %w", ref, err) + } + return desc.Digest.String(), nil +} From 79fb9dcc3ec1a5bb305faeb0eb4d63a71f60b05c Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 23 Jun 2026 14:30:53 +0000 Subject: [PATCH 02/10] registry: add unit tests for GGCRResolver Test tag resolution against GGCR's in-memory registry, invalid reference parsing, and unreachable registry errors. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/registry/resolver_test.go | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 internal/registry/resolver_test.go diff --git a/internal/registry/resolver_test.go b/internal/registry/resolver_test.go new file mode 100644 index 0000000..a45fdb8 --- /dev/null +++ b/internal/registry/resolver_test.go @@ -0,0 +1,63 @@ +// 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) { + 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()) + + ref, err := name.ParseReference(fmt.Sprintf("%s/test/image:latest", srv.Listener.Addr().String())) + g.Expect(err).NotTo(HaveOccurred()) + // Push the image to the in-memory registry so the resolver can find it. + 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 TestResolveInvalidRef(t *testing.T) { + g := NewWithT(t) + + resolver := &GGCRResolver{} + _, err := resolver.Resolve(context.Background(), "") + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("parsing reference")) +} + +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")) +} From f6fc7e8b706ae0d1f7c7bdec5e81d056db0f153e Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 23 Jun 2026 14:32:27 +0000 Subject: [PATCH 03/10] api: add PoolRegistryError degraded reason New Degraded condition reason for when the controller fails to resolve a tag from the container registry. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- api/v1alpha1/bootcnodepool_types.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/v1alpha1/bootcnodepool_types.go b/api/v1alpha1/bootcnodepool_types.go index 552c9e9..577ae05 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" + // PoolRegistryError means the controller failed to contact the + // container registry to resolve a tag to a digest. + PoolRegistryError string = "RegistryError" + // PoolHealthy means no issues. PoolHealthy string = "Healthy" ) From 94b11a7da0a8c6c914b10831bc5e2ab496cc9c8a Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 23 Jun 2026 14:34:00 +0000 Subject: [PATCH 04/10] api: add NextTagResolutionTime status field Tracks when the controller will next resolve a tag-based image ref to a digest. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- api/v1alpha1/bootcnodepool_types.go | 5 +++++ api/v1alpha1/zz_generated.deepcopy.go | 4 ++++ config/crd/bases/node.bootc.dev_bootcnodepools.yaml | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/api/v1alpha1/bootcnodepool_types.go b/api/v1alpha1/bootcnodepool_types.go index 577ae05..df0d1b6 100644 --- a/api/v1alpha1/bootcnodepool_types.go +++ b/api/v1alpha1/bootcnodepool_types.go @@ -163,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/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 From c03cb391e5085dc21095fb3fd6c326d324a92de8 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 23 Jun 2026 15:19:33 +0000 Subject: [PATCH 05/10] controller: wire tag resolver and rewrite resolveTargetDigest Add TagResolver and TagResolutionInterval to the reconciler struct. Rewrite resolveTargetDigest to resolve tag refs via the registry with periodic re-resolution, and set Degraded/RegistryError on failure. Wire GGCRResolver and --tag-resolution-interval flag in main.go. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- cmd/controller/main.go | 12 +++- .../controller/bootcnodepool_controller.go | 70 +++++++++++++++---- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/cmd/controller/main.go b/cmd/controller/main.go index e6503aa..96a112c 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/jlebon/bootc-operator/api/v1alpha1" "github.com/jlebon/bootc-operator/internal/controller" + "github.com/jlebon/bootc-operator/internal/registry" ) var ( @@ -31,7 +33,9 @@ func init() { func main() { var enableLeaderElection bool var probeAddr string + var tagResolutionInterval time.Duration 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(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -61,9 +65,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{}, + TagResolutionInterval: tagResolutionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "bootcnodepool") os.Exit(1) diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index d1e1d7b..885e24a 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -35,6 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" + "github.com/jlebon/bootc-operator/internal/registry" ) // drainStatus tracks an in-progress drain goroutine for a single node. @@ -52,6 +53,9 @@ type BootcNodePoolReconciler struct { KubeClient kubernetes.Interface Recorder events.EventRecorder + TagResolver registry.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,12 +243,22 @@ 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) } + if pool.Status.TargetDigest == "" { + // First tag resolution failed — nothing to roll out yet. + 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 + } // Sync pool membership and retrieve BootcNodes we own. ownedBootcNodes, err := r.syncMembership(ctx, &pool) @@ -274,24 +288,56 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } - return ctrl.Result{}, nil + return resolveResult, nil } -// 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() + 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.Time.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.PoolRegistryError, + Message: err.Error(), + }) + next := metav1.NewTime(now.Add(r.TagResolutionInterval)) + pool.Status.NextTagResolutionTime = &next + return ctrl.Result{RequeueAfter: r.TagResolutionInterval}, nil + } + + 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 + // Requeue the tag resolution for the next interval + return ctrl.Result{RequeueAfter: r.TagResolutionInterval}, nil } // parseImageRef parses an image reference string into a named From 26e5c0e0d0273bc5663c9d1ae485b873fe9a5140 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 24 Jun 2026 07:25:12 +0000 Subject: [PATCH 06/10] e2e: add NodeImageTagRef helper Returns the tag-based image reference for the seeded node image, for use in tag resolution e2e tests. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- test/e2e/e2eutil/env.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/e2e/e2eutil/env.go b/test/e2e/e2eutil/env.go index 65d2311..1fc5172 100644 --- a/test/e2e/e2eutil/env.go +++ b/test/e2e/e2eutil/env.go @@ -224,6 +224,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 From 9d7a516539bc97058cf9976091c52d11baaf1339 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 24 Jun 2026 07:42:34 +0000 Subject: [PATCH 07/10] e2e: add TestTagResolution for tag-based image refs Create a pool with a tag ref, verify the controller resolves it to the correct digest, then retag to an update image and verify re-resolution triggers a rollout. The test patches the controller deployment to use a short tag-resolution-interval (10s) and restores the original args on cleanup. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- test/e2e/bootcnode_test.go | 171 +++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index 1b82178..c20e827 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -188,3 +190,172 @@ func TestUpdateReboot(t *testing.T) { t.Logf("Verified update-marker exists on host via daemon pod") } + +// retagImage reads the image at srcRef from the localhost registry and +// tags it as dstTag. Both refs use localhost:5000 (host-side registry). +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) + } +} + +const ( + controllerNamespace = "bootc-operator" + controllerDeployment = "bootc-operator-controller-manager" +) + +// setTagResolutionInterval patches the controller deployment to use +// the given interval and waits for the rollout to complete. The +// original args are restored in t.Cleanup. +func setTagResolutionInterval(t *testing.T, interval string) { + t.Helper() + + kubeconfigPath := os.Getenv("KUBECONFIG") + flag := "--tag-resolution-interval=" + interval + + // Read current args to restore later. + out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, + "-n", controllerNamespace, "get", "deploy", controllerDeployment, + "-o", "jsonpath={.spec.template.spec.containers[0].args}").CombinedOutput() + if err != nil { + t.Fatalf("reading deployment args: %s: %v", string(out), err) + } + originalArgs := string(out) + + // Patch args to include the interval flag. + patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081","%s"]}]}}}}`, flag) + if out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, + "-n", controllerNamespace, "patch", "deploy", controllerDeployment, + "--type=strategic", "-p", patch).CombinedOutput(); err != nil { + t.Fatalf("patching deployment: %s: %v", string(out), err) + } + + // Wait for rollout. + if out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, + "-n", controllerNamespace, "rollout", "status", "deploy/"+controllerDeployment, + "--timeout=2m").CombinedOutput(); err != nil { + t.Fatalf("waiting for rollout: %s: %v", string(out), err) + } + + t.Logf("Set --tag-resolution-interval=%s (was %s)", interval, originalArgs) + + t.Cleanup(func() { + // Restore original args. + patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081"]}]}}}}`) + if out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, + "-n", controllerNamespace, "patch", "deploy", controllerDeployment, + "--type=strategic", "-p", patch).CombinedOutput(); err != nil { + t.Logf("WARNING: restoring deployment args: %s: %v", string(out), err) + return + } + if out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, + "-n", controllerNamespace, "rollout", "status", "deploy/"+controllerDeployment, + "--timeout=2m").CombinedOutput(); err != nil { + t.Logf("WARNING: waiting for rollout after restore: %s: %v", string(out), err) + } + }) +} + +// 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() + + // Shorten the tag resolution interval so re-resolution happens quickly. + setTagResolutionInterval(t, "10s") + + 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. + 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) +} From e1d8e4fdd3c1561e9dfe925357f113e98903ec94 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 24 Jun 2026 14:55:49 +0000 Subject: [PATCH 08/10] controller: add --allow-insecure-registry flag Allow falling back to HTTP when resolving tag-based image refs against registries that do not serve TLS. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- cmd/controller/main.go | 5 ++++- internal/registry/resolver.go | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 96a112c..e45eb20 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -34,8 +34,11 @@ 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.") @@ -68,7 +71,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), KubeClient: kubeClient, - TagResolver: ®istry.GGCRResolver{}, + TagResolver: ®istry.GGCRResolver{AllowInsecure: allowInsecureRegistry}, TagResolutionInterval: tagResolutionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "bootcnodepool") diff --git a/internal/registry/resolver.go b/internal/registry/resolver.go index a8d8983..dc4e063 100644 --- a/internal/registry/resolver.go +++ b/internal/registry/resolver.go @@ -14,7 +14,11 @@ type TagResolver interface { } // GGCRResolver resolves image tags to digests using go-containerregistry. -type GGCRResolver struct{} +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) @@ -22,6 +26,13 @@ func (r *GGCRResolver) Resolve(ctx context.Context, ref string) (string, error) return "", fmt.Errorf("parsing reference %q: %w", ref, err) } desc, err := remote.Get(parsed, remote.WithContext(ctx)) + if err != nil && 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) } From 1ff1a860870ab521375d6c73f77b2366dc385b67 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 24 Jun 2026 14:55:53 +0000 Subject: [PATCH 09/10] e2e: rename setTagResolutionInterval to patchControllerTestFlags Generalize the helper to accept arbitrary extra flags via variadic args instead of hardcoding the tag-resolution-interval flag. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- test/e2e/bootcnode_test.go | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index c20e827..3ff49fc 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -4,6 +4,7 @@ package e2e import ( "context" + "encoding/json" "fmt" "os" "os/exec" @@ -223,16 +224,14 @@ const ( controllerDeployment = "bootc-operator-controller-manager" ) -// setTagResolutionInterval patches the controller deployment to use -// the given interval and waits for the rollout to complete. The -// original args are restored in t.Cleanup. -func setTagResolutionInterval(t *testing.T, interval string) { +// patchControllerTestFlags patches the controller deployment args for +// testing and waits for the rollout to complete. The original args +// are restored in t.Cleanup. +func patchControllerTestFlags(t *testing.T, extraFlags ...string) { t.Helper() kubeconfigPath := os.Getenv("KUBECONFIG") - flag := "--tag-resolution-interval=" + interval - // Read current args to restore later. out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, "-n", controllerNamespace, "get", "deploy", controllerDeployment, "-o", "jsonpath={.spec.template.spec.containers[0].args}").CombinedOutput() @@ -241,26 +240,29 @@ func setTagResolutionInterval(t *testing.T, interval string) { } originalArgs := string(out) - // Patch args to include the interval flag. - patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081","%s"]}]}}}}`, flag) + baseArgs := []string{"--leader-elect", "--health-probe-bind-address=:8081"} + allArgs := append(baseArgs, extraFlags...) + argsJSON, err := json.Marshal(allArgs) + if err != nil { + t.Fatalf("marshalling args: %v", err) + } + patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":%s}]}}}}`, argsJSON) if out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, "-n", controllerNamespace, "patch", "deploy", controllerDeployment, "--type=strategic", "-p", patch).CombinedOutput(); err != nil { t.Fatalf("patching deployment: %s: %v", string(out), err) } - // Wait for rollout. if out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, "-n", controllerNamespace, "rollout", "status", "deploy/"+controllerDeployment, "--timeout=2m").CombinedOutput(); err != nil { t.Fatalf("waiting for rollout: %s: %v", string(out), err) } - t.Logf("Set --tag-resolution-interval=%s (was %s)", interval, originalArgs) + t.Logf("Patched controller args to %s (was %s)", argsJSON, originalArgs) t.Cleanup(func() { - // Restore original args. - patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081"]}]}}}}`) + patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":%s}]}}}}`, originalArgs) if out, err := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, "-n", controllerNamespace, "patch", "deploy", controllerDeployment, "--type=strategic", "-p", patch).CombinedOutput(); err != nil { @@ -288,7 +290,7 @@ func TestTagResolution(t *testing.T) { ctx := context.Background() // Shorten the tag resolution interval so re-resolution happens quickly. - setTagResolutionInterval(t, "10s") + patchControllerTestFlags(t, "--tag-resolution-interval=10s") nodeName := env.AddNode(t) From 227abdd1360174b275451c31504a6e02224d9b80 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 24 Jun 2026 13:42:44 +0000 Subject: [PATCH 10/10] e2e: pass --allow-insecure-registry in tag resolution test The bink in-cluster registry serves HTTP only, so the test needs the insecure fallback when patching the controller deployment. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- test/e2e/bootcnode_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index 3ff49fc..16033b5 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -289,8 +289,9 @@ func TestTagResolution(t *testing.T) { ctx := context.Background() - // Shorten the tag resolution interval so re-resolution happens quickly. - patchControllerTestFlags(t, "--tag-resolution-interval=10s") + // Allow insecure registry access (the bink in-cluster registry serves HTTP only) + // and shorten the tag resolution interval so re-resolution happens quickly. + patchControllerTestFlags(t, "--allow-insecure-registry", "--tag-resolution-interval=10s") nodeName := env.AddNode(t)