Skip to content

Commit ec398f3

Browse files
max06claude
andcommitted
Sync LoadBalancer status to virtual service, preserve host IP families
The service syncer never copied the host service's LoadBalancer ingress back to the virtual service, so in-cluster consumers (external-dns, anything reading svc status) waited forever on <pending>. It also re-submitted the virtual service's ipFamilies/ipFamilyPolicy on every host update; once the host expanded the service to dual-stack, every update was rejected with "must be 'SingleStack' to release the secondary cluster IP" and reconciliation looped. Preserve host-allocated fields (clusterIPs, IP families, healthCheckNodePort) on update, and copy the host LoadBalancer status to the virtual service, requeueing while ingress is still empty since host-side status changes emit no virtual-cluster watch event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bd8ed8a commit ec398f3

1 file changed

Lines changed: 47 additions & 2 deletions

File tree

k3k-kubelet/controller/syncer/service.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package syncer
22

33
import (
44
"context"
5+
"time"
56

7+
"k8s.io/apimachinery/pkg/api/equality"
68
"k8s.io/apimachinery/pkg/labels"
79
"k8s.io/apimachinery/pkg/types"
810
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
@@ -109,15 +111,58 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
109111
if err := r.HostClient.Get(ctx, types.NamespacedName{Name: syncedService.Name, Namespace: r.ClusterNamespace}, &hostService); err != nil {
110112
if apierrors.IsNotFound(err) {
111113
log.Info("creating the service for the first time on the host cluster")
112-
return reconcile.Result{}, r.HostClient.Create(ctx, syncedService)
114+
if err := r.HostClient.Create(ctx, syncedService); err != nil {
115+
return reconcile.Result{}, err
116+
}
117+
// requeue to pick up host-assigned status (e.g. LoadBalancer ingress)
118+
return reconcile.Result{RequeueAfter: 10 * time.Second}, nil
113119
}
114120

115121
return reconcile.Result{}, err
116122
}
117123

118124
log.Info("updating service on the host cluster")
119125

120-
return reconcile.Result{}, r.HostClient.Update(ctx, syncedService)
126+
// The host apiserver owns IP-family allocation: the host service may have been
127+
// expanded to dual-stack while the virtual service is single-stack. Re-submitting
128+
// the virtual family fields is rejected ("must be 'SingleStack' to release the
129+
// secondary cluster IP"), so preserve the host-allocated values on update.
130+
syncedService.Spec.ClusterIP = hostService.Spec.ClusterIP
131+
syncedService.Spec.ClusterIPs = hostService.Spec.ClusterIPs
132+
syncedService.Spec.IPFamilies = hostService.Spec.IPFamilies
133+
syncedService.Spec.IPFamilyPolicy = hostService.Spec.IPFamilyPolicy
134+
syncedService.Spec.HealthCheckNodePort = hostService.Spec.HealthCheckNodePort
135+
136+
if err := r.HostClient.Update(ctx, syncedService); err != nil {
137+
return reconcile.Result{}, err
138+
}
139+
140+
return r.syncStatus(ctx, &virtService, &hostService)
141+
}
142+
143+
// syncStatus copies the host service's LoadBalancer status back to the virtual
144+
// service so in-cluster consumers (e.g. external-dns) see the assigned ingress.
145+
// The controller only watches virtual services, so as long as the ingress is
146+
// empty it requeues to poll the host side.
147+
func (r *ServiceReconciler) syncStatus(ctx context.Context, virtService, hostService *corev1.Service) (reconcile.Result, error) {
148+
if virtService.Spec.Type != corev1.ServiceTypeLoadBalancer {
149+
return reconcile.Result{}, nil
150+
}
151+
152+
if !equality.Semantic.DeepEqual(virtService.Status.LoadBalancer, hostService.Status.LoadBalancer) {
153+
orig := virtService.DeepCopy()
154+
virtService.Status.LoadBalancer = hostService.Status.LoadBalancer
155+
156+
if err := r.VirtualClient.Status().Patch(ctx, virtService, ctrlruntimeclient.MergeFrom(orig)); err != nil {
157+
return reconcile.Result{}, err
158+
}
159+
}
160+
161+
if len(hostService.Status.LoadBalancer.Ingress) == 0 {
162+
return reconcile.Result{RequeueAfter: 30 * time.Second}, nil
163+
}
164+
165+
return reconcile.Result{}, nil
121166
}
122167

123168
func (r *ServiceReconciler) filterResources(object ctrlruntimeclient.Object) bool {

0 commit comments

Comments
 (0)