Skip to content

Commit f8587e3

Browse files
authored
cmd/k8s-operator: fix port name change bug for egress ProxyGroup proxies (tailscale#14247)
Ensure that the ExternalName Service port names are always synced to the ClusterIP Service, to fix a bug where if users created a Service with a single unnamed port and later changed to 1+ named ports, the operator attempted to apply an invalid multi-port Service with an unnamed port. Also, fixes a small internal issue where not-yet Service status conditons were lost on a spec update. Updates tailscale#10102 Signed-off-by: Irbe Krumina <[email protected]>
1 parent 61dd266 commit f8587e3

File tree

3 files changed

+77
-24
lines changed

3 files changed

+77
-24
lines changed

cmd/k8s-operator/egress-services.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,8 @@ func (esr *egressSvcsReconciler) Reconcile(ctx context.Context, req reconcile.Re
136136
}
137137

138138
if !slices.Contains(svc.Finalizers, FinalizerName) {
139-
l.Infof("configuring tailnet service") // logged exactly once
140139
svc.Finalizers = append(svc.Finalizers, FinalizerName)
141-
if err := esr.Update(ctx, svc); err != nil {
140+
if err := esr.updateSvcSpec(ctx, svc); err != nil {
142141
err := fmt.Errorf("failed to add finalizer: %w", err)
143142
r := svcConfiguredReason(svc, false, l)
144143
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcConfigured, metav1.ConditionFalse, r, err.Error(), esr.clock, l)
@@ -198,7 +197,7 @@ func (esr *egressSvcsReconciler) maybeProvision(ctx context.Context, svc *corev1
198197
if svc.Spec.ExternalName != clusterIPSvcFQDN {
199198
l.Infof("Configuring ExternalName Service to point to ClusterIP Service %s", clusterIPSvcFQDN)
200199
svc.Spec.ExternalName = clusterIPSvcFQDN
201-
if err = esr.Update(ctx, svc); err != nil {
200+
if err = esr.updateSvcSpec(ctx, svc); err != nil {
202201
err = fmt.Errorf("error updating ExternalName Service: %w", err)
203202
return err
204203
}
@@ -222,6 +221,15 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
222221
found := false
223222
for _, wantsPM := range svc.Spec.Ports {
224223
if wantsPM.Port == pm.Port && strings.EqualFold(string(wantsPM.Protocol), string(pm.Protocol)) {
224+
// We don't use the port name to distinguish this port internally, but Kubernetes
225+
// require that, for Service ports with more than one name each port is uniquely named.
226+
// So we can always pick the port name from the ExternalName Service as at this point we
227+
// know that those are valid names because Kuberentes already validated it once. Note
228+
// that users could have changed an unnamed port to a named port and might have changed
229+
// port names- this should still work.
230+
// https://kubernetes.io/docs/concepts/services-networking/service/#multi-port-services
231+
// See also https://github.com/tailscale/tailscale/issues/13406#issuecomment-2507230388
232+
clusterIPSvc.Spec.Ports[i].Name = wantsPM.Name
225233
found = true
226234
break
227235
}
@@ -714,3 +722,13 @@ func epsPortsFromSvc(svc *corev1.Service) (ep []discoveryv1.EndpointPort) {
714722
}
715723
return ep
716724
}
725+
726+
// updateSvcSpec ensures that the given Service's spec is updated in cluster, but the local Service object still retains
727+
// the not-yet-applied status.
728+
// TODO(irbekrm): once we do SSA for these patch updates, this will no longer be needed.
729+
func (esr *egressSvcsReconciler) updateSvcSpec(ctx context.Context, svc *corev1.Service) error {
730+
st := svc.Status.DeepCopy()
731+
err := esr.Update(ctx, svc)
732+
svc.Status = *st
733+
return err
734+
}

cmd/k8s-operator/egress-services_test.go

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -105,28 +105,40 @@ func TestTailscaleEgressServices(t *testing.T) {
105105
condition(tsapi.ProxyGroupReady, metav1.ConditionTrue, "", "", clock),
106106
}
107107
})
108-
// Quirks of the fake client.
109-
mustUpdateStatus(t, fc, "default", "test", func(svc *corev1.Service) {
110-
svc.Status.Conditions = []metav1.Condition{}
108+
expectReconciled(t, esr, "default", "test")
109+
validateReadyService(t, fc, esr, svc, clock, zl, cm)
110+
})
111+
t.Run("service_retain_one_unnamed_port", func(t *testing.T) {
112+
svc.Spec.Ports = []corev1.ServicePort{{Protocol: "TCP", Port: 80}}
113+
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
114+
s.Spec.Ports = svc.Spec.Ports
111115
})
112116
expectReconciled(t, esr, "default", "test")
113-
// Verify that a ClusterIP Service has been created.
114-
name := findGenNameForEgressSvcResources(t, fc, svc)
115-
expectEqual(t, fc, clusterIPSvc(name, svc), removeTargetPortsFromSvc)
116-
clusterSvc := mustGetClusterIPSvc(t, fc, name)
117-
// Verify that an EndpointSlice has been created.
118-
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc), nil)
119-
// Verify that ConfigMap contains configuration for the new egress service.
120-
mustHaveConfigForSvc(t, fc, svc, clusterSvc, cm)
121-
r := svcConfiguredReason(svc, true, zl.Sugar())
122-
// Verify that the user-created ExternalName Service has Configured set to true and ExternalName pointing to the
123-
// CluterIP Service.
124-
svc.Status.Conditions = []metav1.Condition{
125-
condition(tsapi.EgressSvcConfigured, metav1.ConditionTrue, r, r, clock),
126-
}
127-
svc.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"}
128-
svc.Spec.ExternalName = fmt.Sprintf("%s.operator-ns.svc.cluster.local", name)
129-
expectEqual(t, fc, svc, nil)
117+
validateReadyService(t, fc, esr, svc, clock, zl, cm)
118+
})
119+
t.Run("service_add_two_named_ports", func(t *testing.T) {
120+
svc.Spec.Ports = []corev1.ServicePort{{Protocol: "TCP", Port: 80, Name: "http"}, {Protocol: "TCP", Port: 443, Name: "https"}}
121+
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
122+
s.Spec.Ports = svc.Spec.Ports
123+
})
124+
expectReconciled(t, esr, "default", "test")
125+
validateReadyService(t, fc, esr, svc, clock, zl, cm)
126+
})
127+
t.Run("service_add_udp_port", func(t *testing.T) {
128+
svc.Spec.Ports = append(svc.Spec.Ports, corev1.ServicePort{Port: 53, Protocol: "UDP", Name: "dns"})
129+
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
130+
s.Spec.Ports = svc.Spec.Ports
131+
})
132+
expectReconciled(t, esr, "default", "test")
133+
validateReadyService(t, fc, esr, svc, clock, zl, cm)
134+
})
135+
t.Run("service_change_protocol", func(t *testing.T) {
136+
svc.Spec.Ports = []corev1.ServicePort{{Protocol: "TCP", Port: 80, Name: "http"}, {Protocol: "TCP", Port: 443, Name: "https"}, {Port: 53, Protocol: "TCP", Name: "tcp_dns"}}
137+
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
138+
s.Spec.Ports = svc.Spec.Ports
139+
})
140+
expectReconciled(t, esr, "default", "test")
141+
validateReadyService(t, fc, esr, svc, clock, zl, cm)
130142
})
131143

132144
t.Run("delete_external_name_service", func(t *testing.T) {
@@ -143,6 +155,29 @@ func TestTailscaleEgressServices(t *testing.T) {
143155
})
144156
}
145157

158+
func validateReadyService(t *testing.T, fc client.WithWatch, esr *egressSvcsReconciler, svc *corev1.Service, clock *tstest.Clock, zl *zap.Logger, cm *corev1.ConfigMap) {
159+
expectReconciled(t, esr, "default", "test")
160+
// Verify that a ClusterIP Service has been created.
161+
name := findGenNameForEgressSvcResources(t, fc, svc)
162+
expectEqual(t, fc, clusterIPSvc(name, svc), removeTargetPortsFromSvc)
163+
clusterSvc := mustGetClusterIPSvc(t, fc, name)
164+
// Verify that an EndpointSlice has been created.
165+
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc), nil)
166+
// Verify that ConfigMap contains configuration for the new egress service.
167+
mustHaveConfigForSvc(t, fc, svc, clusterSvc, cm)
168+
r := svcConfiguredReason(svc, true, zl.Sugar())
169+
// Verify that the user-created ExternalName Service has Configured set to true and ExternalName pointing to the
170+
// CluterIP Service.
171+
svc.Status.Conditions = []metav1.Condition{
172+
condition(tsapi.EgressSvcValid, metav1.ConditionTrue, "EgressSvcValid", "EgressSvcValid", clock),
173+
condition(tsapi.EgressSvcConfigured, metav1.ConditionTrue, r, r, clock),
174+
}
175+
svc.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"}
176+
svc.Spec.ExternalName = fmt.Sprintf("%s.operator-ns.svc.cluster.local", name)
177+
expectEqual(t, fc, svc, nil)
178+
179+
}
180+
146181
func condition(typ tsapi.ConditionType, st metav1.ConditionStatus, r, msg string, clock tstime.Clock) metav1.Condition {
147182
return metav1.Condition{
148183
Type: string(typ),

cmd/k8s-operator/testutils_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ func removeHashAnnotation(sts *appsv1.StatefulSet) {
650650
func removeTargetPortsFromSvc(svc *corev1.Service) {
651651
newPorts := make([]corev1.ServicePort, 0)
652652
for _, p := range svc.Spec.Ports {
653-
newPorts = append(newPorts, corev1.ServicePort{Protocol: p.Protocol, Port: p.Port})
653+
newPorts = append(newPorts, corev1.ServicePort{Protocol: p.Protocol, Port: p.Port, Name: p.Name})
654654
}
655655
svc.Spec.Ports = newPorts
656656
}

0 commit comments

Comments
 (0)