@@ -10,6 +10,8 @@ import (
1010 "testing"
1111 "time"
1212
13+ "github.com/google/go-containerregistry/pkg/name"
14+ "github.com/google/go-containerregistry/pkg/v1/remote"
1315 . "github.com/onsi/gomega"
1416 corev1 "k8s.io/api/core/v1"
1517 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -188,3 +190,172 @@ func TestUpdateReboot(t *testing.T) {
188190
189191 t .Logf ("Verified update-marker exists on host via daemon pod" )
190192}
193+
194+ // retagImage reads the image at srcRef from the localhost registry and
195+ // tags it as dstTag. Both refs use localhost:5000 (host-side registry).
196+ func retagImage (t * testing.T , srcRef , dstTag string ) {
197+ t .Helper ()
198+
199+ src , err := name .ParseReference (srcRef , name .Insecure )
200+ if err != nil {
201+ t .Fatalf ("parsing src ref %q: %v" , srcRef , err )
202+ }
203+ desc , err := remote .Get (src )
204+ if err != nil {
205+ t .Fatalf ("fetching %q: %v" , srcRef , err )
206+ }
207+ img , err := desc .Image ()
208+ if err != nil {
209+ t .Fatalf ("getting image from descriptor: %v" , err )
210+ }
211+
212+ dst , err := name .ParseReference (dstTag , name .Insecure )
213+ if err != nil {
214+ t .Fatalf ("parsing dst ref %q: %v" , dstTag , err )
215+ }
216+ if err := remote .Write (dst , img ); err != nil {
217+ t .Fatalf ("writing %q: %v" , dstTag , err )
218+ }
219+ }
220+
221+ const (
222+ controllerNamespace = "bootc-operator"
223+ controllerDeployment = "bootc-operator-controller-manager"
224+ )
225+
226+ // setTagResolutionInterval patches the controller deployment to use
227+ // the given interval and waits for the rollout to complete. The
228+ // original args are restored in t.Cleanup.
229+ func setTagResolutionInterval (t * testing.T , interval string ) {
230+ t .Helper ()
231+
232+ kubeconfigPath := os .Getenv ("KUBECONFIG" )
233+ flag := "--tag-resolution-interval=" + interval
234+
235+ // Read current args to restore later.
236+ out , err := exec .Command ("kubectl" , "--kubeconfig" , kubeconfigPath ,
237+ "-n" , controllerNamespace , "get" , "deploy" , controllerDeployment ,
238+ "-o" , "jsonpath={.spec.template.spec.containers[0].args}" ).CombinedOutput ()
239+ if err != nil {
240+ t .Fatalf ("reading deployment args: %s: %v" , string (out ), err )
241+ }
242+ originalArgs := string (out )
243+
244+ // Patch args to include the interval flag.
245+ patch := fmt .Sprintf (`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081","%s"]}]}}}}` , flag )
246+ if out , err := exec .Command ("kubectl" , "--kubeconfig" , kubeconfigPath ,
247+ "-n" , controllerNamespace , "patch" , "deploy" , controllerDeployment ,
248+ "--type=strategic" , "-p" , patch ).CombinedOutput (); err != nil {
249+ t .Fatalf ("patching deployment: %s: %v" , string (out ), err )
250+ }
251+
252+ // Wait for rollout.
253+ if out , err := exec .Command ("kubectl" , "--kubeconfig" , kubeconfigPath ,
254+ "-n" , controllerNamespace , "rollout" , "status" , "deploy/" + controllerDeployment ,
255+ "--timeout=2m" ).CombinedOutput (); err != nil {
256+ t .Fatalf ("waiting for rollout: %s: %v" , string (out ), err )
257+ }
258+
259+ t .Logf ("Set --tag-resolution-interval=%s (was %s)" , interval , originalArgs )
260+
261+ t .Cleanup (func () {
262+ // Restore original args.
263+ patch := fmt .Sprintf (`{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081"]}]}}}}` )
264+ if out , err := exec .Command ("kubectl" , "--kubeconfig" , kubeconfigPath ,
265+ "-n" , controllerNamespace , "patch" , "deploy" , controllerDeployment ,
266+ "--type=strategic" , "-p" , patch ).CombinedOutput (); err != nil {
267+ t .Logf ("WARNING: restoring deployment args: %s: %v" , string (out ), err )
268+ return
269+ }
270+ if out , err := exec .Command ("kubectl" , "--kubeconfig" , kubeconfigPath ,
271+ "-n" , controllerNamespace , "rollout" , "status" , "deploy/" + controllerDeployment ,
272+ "--timeout=2m" ).CombinedOutput (); err != nil {
273+ t .Logf ("WARNING: waiting for rollout after restore: %s: %v" , string (out ), err )
274+ }
275+ })
276+ }
277+
278+ // TestTagResolution creates a pool with a tag-based image ref, verifies
279+ // the controller resolves the tag to a digest, then retags the image
280+ // and verifies re-resolution triggers a rollout.
281+ func TestTagResolution (t * testing.T ) {
282+ g := NewWithT (t )
283+ g .SetDefaultEventuallyTimeout (pollTimeout )
284+ g .SetDefaultEventuallyPollingInterval (pollInterval )
285+
286+ env := e2eutil .New (t )
287+
288+ ctx := context .Background ()
289+
290+ // Shorten the tag resolution interval so re-resolution happens quickly.
291+ setTagResolutionInterval (t , "10s" )
292+
293+ nodeName := env .AddNode (t )
294+
295+ // The seed step already pushed node:latest with the original image.
296+ // Create a pool using the tag ref.
297+ pool := env .NewPool ("tag" , env .NodeImageTagRef ())
298+ g .Expect (env .Client .Create (ctx , pool )).To (Succeed ())
299+
300+ // Verify targetDigest is resolved to the original image digest.
301+ g .Eventually (func (g Gomega ) string {
302+ var p bootcv1alpha1.BootcNodePool
303+ g .Expect (env .Client .Get (ctx , client .ObjectKeyFromObject (pool ), & p )).To (Succeed ())
304+ return p .Status .TargetDigest
305+ }).WithTimeout (1 * time .Minute ).Should (Equal (env .NodeImageDigest ()))
306+
307+ t .Logf ("Tag resolved to original digest %s" , env .NodeImageDigest ())
308+
309+ // Wait for node to reach Idle with the original image.
310+ g .Eventually (func (g Gomega ) bootcv1alpha1.BootcNodeStatus {
311+ var bn bootcv1alpha1.BootcNode
312+ g .Expect (env .Client .Get (ctx , client.ObjectKey {Name : nodeName }, & bn )).To (Succeed ())
313+ return bn .Status
314+ }).WithTimeout (3 * time .Minute ).Should (And (
315+ HaveField ("Booted" , And (
316+ Not (BeNil ()),
317+ HaveField ("ImageDigest" , Equal (env .NodeImageDigest ())),
318+ )),
319+ HaveField ("Conditions" , ContainElement (And (
320+ HaveField ("Type" , bootcv1alpha1 .NodeIdle ),
321+ HaveField ("Status" , metav1 .ConditionTrue ),
322+ ))),
323+ ))
324+
325+ t .Logf ("Node %q is Idle with original image" , nodeName )
326+
327+ // Retag node:latest to point at the update image.
328+ retagImage (t ,
329+ "localhost:5000/node@" + env .NodeImageUpdateDigest (),
330+ "localhost:5000/node:latest" ,
331+ )
332+
333+ t .Logf ("Retagged node:latest to update digest %s" , env .NodeImageUpdateDigest ())
334+
335+ // Wait for the controller to re-resolve and pick up the new digest.
336+ g .Eventually (func (g Gomega ) string {
337+ var p bootcv1alpha1.BootcNodePool
338+ g .Expect (env .Client .Get (ctx , client .ObjectKeyFromObject (pool ), & p )).To (Succeed ())
339+ return p .Status .TargetDigest
340+ }).WithTimeout (1 * time .Minute ).Should (Equal (env .NodeImageUpdateDigest ()))
341+
342+ t .Logf ("Tag re-resolved to update digest %s" , env .NodeImageUpdateDigest ())
343+
344+ // Wait for node to reach Idle with the update image.
345+ g .Eventually (func (g Gomega ) bootcv1alpha1.BootcNodeStatus {
346+ var bn bootcv1alpha1.BootcNode
347+ g .Expect (env .Client .Get (ctx , client.ObjectKey {Name : nodeName }, & bn )).To (Succeed ())
348+ return bn .Status
349+ }).WithTimeout (5 * time .Minute ).Should (And (
350+ HaveField ("Booted" , And (
351+ Not (BeNil ()),
352+ HaveField ("ImageDigest" , Equal (env .NodeImageUpdateDigest ())),
353+ )),
354+ HaveField ("Conditions" , ContainElement (And (
355+ HaveField ("Type" , bootcv1alpha1 .NodeIdle ),
356+ HaveField ("Status" , metav1 .ConditionTrue ),
357+ ))),
358+ ))
359+
360+ t .Logf ("Node %q is Idle with update image" , nodeName )
361+ }
0 commit comments