@@ -35,6 +35,7 @@ import (
3535 "sigs.k8s.io/controller-runtime/pkg/source"
3636
3737 bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1"
38+ "github.com/jlebon/bootc-operator/internal/registry"
3839)
3940
4041// drainStatus tracks an in-progress drain goroutine for a single node.
@@ -52,6 +53,9 @@ type BootcNodePoolReconciler struct {
5253 KubeClient kubernetes.Interface
5354 Recorder events.EventRecorder
5455
56+ TagResolver registry.TagResolver
57+ TagResolutionInterval time.Duration
58+
5559 // drainCh receives events from drain goroutines to re-enqueue the
5660 // owning pool after a drain completes.
5761 drainCh chan event.GenericEvent
@@ -239,12 +243,22 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques
239243 // the end.
240244
241245 // Resolve the target digest from the image ref.
242- if err := r .resolveTargetDigest (& pool ); err != nil {
246+ resolveResult , err := r .resolveTargetDigest (ctx , & pool )
247+ if err != nil {
243248 if isInvalidSpecError (err ) {
244249 return r .setInvalidSpecCondition (ctx , & pool , err )
245250 }
246251 return ctrl.Result {}, fmt .Errorf ("resolving target digest: %w" , err )
247252 }
253+ if pool .Status .TargetDigest == "" {
254+ // First tag resolution failed — nothing to roll out yet.
255+ if ! reflect .DeepEqual (pool .Status , * statusOrig ) {
256+ if err := r .Status ().Update (ctx , & pool ); err != nil {
257+ return ctrl.Result {}, fmt .Errorf ("updating pool status: %w" , err )
258+ }
259+ }
260+ return resolveResult , nil
261+ }
248262
249263 // Sync pool membership and retrieve BootcNodes we own.
250264 ownedBootcNodes , err := r .syncMembership (ctx , & pool )
@@ -274,24 +288,56 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques
274288 }
275289 }
276290
277- return ctrl. Result {} , nil
291+ return resolveResult , nil
278292}
279293
280- // resolveTargetDigest parses the digest from the pool's image ref and
281- // sets pool.Status.TargetDigest. For digest refs (the only kind
282- // supported now), the digest is extracted directly. Tag resolution is
283- // deferred to Milestone 5.
284- func (r * BootcNodePoolReconciler ) resolveTargetDigest (pool * bootcv1alpha1.BootcNodePool ) error {
294+ // resolveTargetDigest resolves the target digest from the pool's image
295+ // ref. Digest refs are extracted directly. Tag refs are resolved via
296+ // the registry, respecting the re-resolution interval.
297+ func (r * BootcNodePoolReconciler ) resolveTargetDigest (ctx context.Context , pool * bootcv1alpha1.BootcNodePool ) (ctrl.Result , error ) {
298+ log := logf .FromContext (ctx )
299+
285300 ref , err := parseImageRef (pool .Spec .Image .Ref )
286301 if err != nil {
287- return newInvalidSpecError (fmt .Sprintf ("invalid image ref %q: %v" , pool .Spec .Image .Ref , err ))
302+ return ctrl. Result {}, newInvalidSpecError (fmt .Sprintf ("invalid image ref %q: %v" , pool .Spec .Image .Ref , err ))
288303 }
304+
289305 digested , ok := ref .(reference.Digested )
290- if ! ok {
291- return newInvalidSpecError (fmt .Sprintf ("image ref %q has no digest (tag resolution not yet supported)" , pool .Spec .Image .Ref ))
306+ if ok {
307+ pool .Status .TargetDigest = digested .Digest ().String ()
308+ return ctrl.Result {}, nil
292309 }
293- pool .Status .TargetDigest = digested .Digest ().String ()
294- return nil
310+
311+ // Tag ref — check if resolution is due.
312+ now := time .Now ()
313+ if pool .Status .NextTagResolutionTime != nil && now .Before (pool .Status .NextTagResolutionTime .Time ) {
314+ remaining := pool .Status .NextTagResolutionTime .Time .Sub (now )
315+ log .V (1 ).Info ("Tag resolution not yet due" , "remaining" , remaining )
316+ return ctrl.Result {RequeueAfter : remaining }, nil
317+ }
318+
319+ digest , err := r .TagResolver .Resolve (ctx , pool .Spec .Image .Ref )
320+ if err != nil {
321+ log .Error (err , "Failed to resolve tag" , "ref" , pool .Spec .Image .Ref )
322+ apimeta .SetStatusCondition (& pool .Status .Conditions , metav1.Condition {
323+ Type : bootcv1alpha1 .PoolDegraded ,
324+ Status : metav1 .ConditionTrue ,
325+ Reason : bootcv1alpha1 .PoolRegistryError ,
326+ Message : err .Error (),
327+ })
328+ next := metav1 .NewTime (now .Add (r .TagResolutionInterval ))
329+ pool .Status .NextTagResolutionTime = & next
330+ return ctrl.Result {RequeueAfter : r .TagResolutionInterval }, nil
331+ }
332+
333+ if pool .Status .TargetDigest != digest {
334+ log .Info ("Resolved tag to new digest" , "ref" , pool .Spec .Image .Ref , "digest" , digest )
335+ }
336+ pool .Status .TargetDigest = digest
337+ next := metav1 .NewTime (now .Add (r .TagResolutionInterval ))
338+ pool .Status .NextTagResolutionTime = & next
339+ // Requeue the tag resolution for the next interval
340+ return ctrl.Result {RequeueAfter : r .TagResolutionInterval }, nil
295341}
296342
297343// parseImageRef parses an image reference string into a named
0 commit comments