Skip to content

Commit c62a653

Browse files
committed
feat: implement HostedCluster lifecycle Phase 4 - HostedCluster Status Mirroring
1 parent 83b317a commit c62a653

5 files changed

Lines changed: 503 additions & 0 deletions

File tree

cmd/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ func main() {
243243
// Initialize Finalizer Manager
244244
finalizerManager := hostedcluster.NewFinalizerManager(mgr.GetClient())
245245

246+
// Initialize Status Syncer for HostedCluster status mirroring
247+
statusSyncer := hostedcluster.NewStatusSyncer(mgr.GetClient())
248+
246249
if err := (&controller.DPFHCPBridgeReconciler{
247250
Client: mgr.GetClient(),
248251
Scheme: mgr.GetScheme(),
@@ -254,6 +257,7 @@ func main() {
254257
HostedClusterManager: hostedClusterManager,
255258
NodePoolManager: nodePoolManager,
256259
FinalizerManager: finalizerManager,
260+
StatusSyncer: statusSyncer,
257261
}).SetupWithManager(mgr); err != nil {
258262
setupLog.Error(err, "unable to create controller", "controller", "DPFHCPBridge")
259263
os.Exit(1)

internal/controller/dpfhcpbridge_controller.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package controller
1919
import (
2020
"context"
2121

22+
hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
2223
"k8s.io/apimachinery/pkg/api/meta"
2324
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2425

@@ -56,6 +57,7 @@ type DPFHCPBridgeReconciler struct {
5657
HostedClusterManager *hostedcluster.HostedClusterManager
5758
NodePoolManager *hostedcluster.NodePoolManager
5859
FinalizerManager *hostedcluster.FinalizerManager
60+
StatusSyncer *hostedcluster.StatusSyncer
5961
}
6062

6163
const (
@@ -241,6 +243,18 @@ func (r *DPFHCPBridgeReconciler) Reconcile(ctx context.Context, req ctrl.Request
241243
log.V(1).Info("Skipping HostedCluster/NodePool creation - cluster already provisioned or being deleted", "phase", cr.Status.Phase)
242244
}
243245

246+
// Feature: HostedCluster Status Mirroring
247+
// Sync status from HostedCluster to DPFHCPBridge
248+
// This runs in all phases (Pending, Provisioning, Ready) to keep status up-to-date
249+
// Only syncs if hostedClusterRef is set (after HostedCluster creation)
250+
log.V(1).Info("Syncing status from HostedCluster")
251+
if result, err := r.StatusSyncer.SyncStatusFromHostedCluster(ctx, &cr); err != nil || result.Requeue || result.RequeueAfter > 0 {
252+
if err != nil {
253+
log.Error(err, "Status sync failed")
254+
}
255+
return result, err
256+
}
257+
244258
// Compute final phase from all conditions after features have updated them
245259
r.updatePhaseFromConditions(&cr)
246260

@@ -273,6 +287,11 @@ func (r *DPFHCPBridgeReconciler) SetupWithManager(mgr ctrl.Manager) error {
273287
handler.EnqueueRequestsFromMapFunc(r.secretToRequests),
274288
builder.WithPredicates(secretPredicate()),
275289
).
290+
Watches(
291+
&hyperv1.HostedCluster{},
292+
handler.EnqueueRequestsFromMapFunc(r.hostedClusterToRequests),
293+
builder.WithPredicates(hostedClusterPredicate()),
294+
).
276295
Named("dpfhcpbridge").
277296
Complete(r)
278297
}
@@ -478,6 +497,99 @@ func (r *DPFHCPBridgeReconciler) secretToRequests(ctx context.Context, obj clien
478497
return requests
479498
}
480499

500+
// hostedClusterPredicate filters HostedCluster events to watch for status changes
501+
func hostedClusterPredicate() predicate.Predicate {
502+
return predicate.Funcs{
503+
CreateFunc: func(e event.CreateEvent) bool {
504+
// Watch creation - reconcile to set initial status
505+
return true
506+
},
507+
UpdateFunc: func(e event.UpdateEvent) bool {
508+
// Only reconcile if status changed (not spec)
509+
// This prevents unnecessary reconciliations when we update the HostedCluster spec
510+
oldHC, oldOK := e.ObjectOld.(*hyperv1.HostedCluster)
511+
newHC, newOK := e.ObjectNew.(*hyperv1.HostedCluster)
512+
if !oldOK || !newOK {
513+
return false
514+
}
515+
516+
// Compare status conditions to detect changes
517+
return !conditionsEqual(oldHC.Status.Conditions, newHC.Status.Conditions)
518+
},
519+
DeleteFunc: func(e event.DeleteEvent) bool {
520+
// Watch deletion - reconcile to handle cleanup
521+
return true
522+
},
523+
}
524+
}
525+
526+
// conditionsEqual compares two condition slices for equality
527+
func conditionsEqual(oldConds, newConds []metav1.Condition) bool {
528+
if len(oldConds) != len(newConds) {
529+
return false
530+
}
531+
532+
// Create maps for O(n) comparison
533+
oldMap := make(map[string]metav1.Condition)
534+
for _, c := range oldConds {
535+
oldMap[c.Type] = c
536+
}
537+
538+
// Compare each new condition
539+
for _, newCond := range newConds {
540+
oldCond, exists := oldMap[newCond.Type]
541+
if !exists {
542+
return false
543+
}
544+
if oldCond.Status != newCond.Status ||
545+
oldCond.Reason != newCond.Reason ||
546+
oldCond.Message != newCond.Message {
547+
return false
548+
}
549+
}
550+
551+
return true
552+
}
553+
554+
// hostedClusterToRequests maps HostedCluster events to reconcile requests for DPFHCPBridge CRs
555+
// that own the HostedCluster (via labels)
556+
func (r *DPFHCPBridgeReconciler) hostedClusterToRequests(ctx context.Context, obj client.Object) []reconcile.Request {
557+
log := logf.FromContext(ctx)
558+
559+
hc, ok := obj.(*hyperv1.HostedCluster)
560+
if !ok {
561+
log.Error(nil, "Failed to convert object to HostedCluster", "object", obj)
562+
return []reconcile.Request{}
563+
}
564+
565+
// Extract DPFHCPBridge name and namespace from labels
566+
bridgeName := hc.Labels["dpfhcpbridge.provisioning.dpu.hcp.io/name"]
567+
bridgeNamespace := hc.Labels["dpfhcpbridge.provisioning.dpu.hcp.io/namespace"]
568+
569+
if bridgeName == "" || bridgeNamespace == "" {
570+
// HostedCluster not owned by DPFHCPBridge (no labels)
571+
log.V(2).Info("HostedCluster not owned by DPFHCPBridge, skipping",
572+
"hostedCluster", hc.Name,
573+
"namespace", hc.Namespace)
574+
return []reconcile.Request{}
575+
}
576+
577+
log.V(1).Info("HostedCluster changed, reconciling owning DPFHCPBridge",
578+
"hostedCluster", hc.Name,
579+
"namespace", hc.Namespace,
580+
"bridge", bridgeName,
581+
"bridgeNamespace", bridgeNamespace)
582+
583+
return []reconcile.Request{
584+
{
585+
NamespacedName: types.NamespacedName{
586+
Name: bridgeName,
587+
Namespace: bridgeNamespace,
588+
},
589+
},
590+
}
591+
}
592+
481593
// updatePhaseFromConditions computes the phase based on all conditions
482594
func (r *DPFHCPBridgeReconciler) updatePhaseFromConditions(cr *provisioningv1alpha1.DPFHCPBridge) {
483595
// Phase 1: Check for deletion (highest priority)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package hostedcluster
18+
19+
import (
20+
"context"
21+
"time"
22+
23+
hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
24+
apierrors "k8s.io/apimachinery/pkg/api/errors"
25+
"k8s.io/apimachinery/pkg/api/meta"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/types"
28+
ctrl "sigs.k8s.io/controller-runtime"
29+
"sigs.k8s.io/controller-runtime/pkg/client"
30+
logf "sigs.k8s.io/controller-runtime/pkg/log"
31+
32+
provisioningv1alpha1 "github.com/rh-ecosystem-edge/dpf-hcp-bridge-operator/api/v1alpha1"
33+
)
34+
35+
const (
36+
// RequeueDelayStatusPending is the delay before rechecking HostedCluster status
37+
RequeueDelayStatusPending = 10 * time.Second
38+
)
39+
40+
// StatusSyncer manages status synchronization from HostedCluster to DPFHCPBridge
41+
type StatusSyncer struct {
42+
client.Client
43+
}
44+
45+
// NewStatusSyncer creates a new StatusSyncer
46+
func NewStatusSyncer(c client.Client) *StatusSyncer {
47+
return &StatusSyncer{Client: c}
48+
}
49+
50+
// SyncStatusFromHostedCluster mirrors HostedCluster status conditions to DPFHCPBridge status
51+
// This function:
52+
// - Only syncs status when hostedClusterRef is set in DPFHCPBridge status
53+
// - Mirrors 7 HostedCluster conditions to DPFHCPBridge conditions
54+
// - Handles missing HostedCluster gracefully (may be creating or deleted)
55+
//
56+
// Returns ctrl.Result and error for reconciliation flow
57+
func (ss *StatusSyncer) SyncStatusFromHostedCluster(ctx context.Context, cr *provisioningv1alpha1.DPFHCPBridge) (ctrl.Result, error) {
58+
log := logf.FromContext(ctx)
59+
60+
// Only sync status if hostedClusterRef is set
61+
if cr.Status.HostedClusterRef == nil {
62+
log.V(1).Info("Skipping status sync - hostedClusterRef not set yet")
63+
return ctrl.Result{}, nil
64+
}
65+
66+
// Get HostedCluster
67+
hc := &hyperv1.HostedCluster{}
68+
hcKey := types.NamespacedName{
69+
Name: cr.Status.HostedClusterRef.Name,
70+
Namespace: cr.Status.HostedClusterRef.Namespace,
71+
}
72+
73+
if err := ss.Get(ctx, hcKey, hc); err != nil {
74+
if apierrors.IsNotFound(err) {
75+
// HostedCluster not found - may be creating or deleted
76+
// Don't fail, just log and skip sync
77+
log.V(1).Info("HostedCluster not found, skipping status sync",
78+
"hostedCluster", hcKey.String())
79+
return ctrl.Result{}, nil
80+
}
81+
82+
// Handle "no matches for kind" error (CRD not installed) gracefully
83+
if meta.IsNoMatchError(err) {
84+
log.V(1).Info("HostedCluster CRD not installed, skipping status sync",
85+
"hostedCluster", hcKey.String())
86+
return ctrl.Result{}, nil
87+
}
88+
89+
// Other errors (network, RBAC, API server issues) should be retried
90+
log.Error(err, "Failed to get HostedCluster for status sync",
91+
"hostedCluster", hcKey.String())
92+
return ctrl.Result{}, err
93+
}
94+
95+
// Check if HostedCluster status is populated yet
96+
if hc.Status.Conditions == nil || len(hc.Status.Conditions) == 0 {
97+
log.V(1).Info("HostedCluster status not yet populated, will retry",
98+
"hostedCluster", hcKey.String())
99+
// Requeue after a short delay to check again
100+
return ctrl.Result{RequeueAfter: RequeueDelayStatusPending}, nil
101+
}
102+
103+
log.V(1).Info("Syncing status from HostedCluster",
104+
"hostedCluster", hcKey.String(),
105+
"conditions", len(hc.Status.Conditions))
106+
107+
// Mirror conditions from HostedCluster to DPFHCPBridge
108+
ss.mirrorConditions(ctx, cr, hc)
109+
110+
log.V(1).Info("Status sync completed successfully",
111+
"hostedCluster", hcKey.String())
112+
113+
return ctrl.Result{}, nil
114+
}
115+
116+
// mirrorConditions mirrors the 7 specific HostedCluster conditions to DPFHCPBridge
117+
// This simply copies the condition status, reason, and message from HostedCluster to DPFHCPBridge
118+
// No additional logic - just mirroring
119+
func (ss *StatusSyncer) mirrorConditions(ctx context.Context, cr *provisioningv1alpha1.DPFHCPBridge, hc *hyperv1.HostedCluster) {
120+
log := logf.FromContext(ctx)
121+
122+
// Map of HostedCluster condition types to DPFHCPBridge condition types
123+
// Key: HostedCluster condition type
124+
// Value: DPFHCPBridge condition type
125+
// Only mirror the 7 conditions specified in the DPFHCPBridge API
126+
conditionMappings := map[string]string{
127+
string(hyperv1.HostedClusterAvailable): provisioningv1alpha1.HostedClusterAvailable,
128+
string(hyperv1.HostedClusterProgressing): provisioningv1alpha1.HostedClusterProgressing,
129+
string(hyperv1.HostedClusterDegraded): provisioningv1alpha1.HostedClusterDegraded,
130+
string(hyperv1.ValidReleaseInfo): provisioningv1alpha1.ValidReleaseInfo,
131+
string(hyperv1.ValidReleaseImage): provisioningv1alpha1.ValidReleaseImage,
132+
string(hyperv1.IgnitionEndpointAvailable): provisioningv1alpha1.IgnitionEndpointAvailable,
133+
string(hyperv1.IgnitionServerValidReleaseInfo): provisioningv1alpha1.IgnitionServerValidReleaseInfo,
134+
}
135+
136+
// Mirror each HostedCluster condition to DPFHCPBridge
137+
for hcCondType, dpfCondType := range conditionMappings {
138+
hcCond := meta.FindStatusCondition(hc.Status.Conditions, hcCondType)
139+
if hcCond != nil {
140+
// Found the condition, mirror it
141+
meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
142+
Type: dpfCondType,
143+
Status: hcCond.Status,
144+
Reason: hcCond.Reason,
145+
Message: hcCond.Message,
146+
ObservedGeneration: cr.Generation,
147+
})
148+
log.V(2).Info("Mirrored condition from HostedCluster",
149+
"conditionType", dpfCondType,
150+
"status", hcCond.Status,
151+
"reason", hcCond.Reason)
152+
}
153+
}
154+
}

0 commit comments

Comments
 (0)