Skip to content

Commit 68997e0

Browse files
authored
cmd/k8s-operator,k8s-operator: allow users to set custom labels for the optional ServiceMonitor (tailscale#14475)
* cmd/k8s-operator,k8s-operator: allow users to set custom labels for the optional ServiceMonitor Updates tailscale#14381 Signed-off-by: Irbe Krumina <[email protected]>
1 parent d8579a4 commit 68997e0

File tree

15 files changed

+389
-101
lines changed

15 files changed

+389
-101
lines changed

cmd/k8s-operator/connector_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
203203
pc := &tsapi.ProxyClass{
204204
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
205205
Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{
206-
Labels: map[string]string{"foo": "bar"},
206+
Labels: tsapi.Labels{"foo": "bar"},
207207
Annotations: map[string]string{"bar.io/foo": "some-val"},
208208
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
209209
}

cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ spec:
9999
enable:
100100
description: If Enable is set to true, a Prometheus ServiceMonitor will be created. Enable can only be set to true if metrics are enabled.
101101
type: boolean
102+
labels:
103+
description: |-
104+
Labels to add to the ServiceMonitor.
105+
Labels must be valid Kubernetes labels.
106+
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
107+
type: object
108+
additionalProperties:
109+
type: string
110+
maxLength: 63
111+
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
102112
x-kubernetes-validations:
103113
- rule: '!(has(self.serviceMonitor) && self.serviceMonitor.enable && !self.enable)'
104114
message: ServiceMonitor can only be enabled if metrics are enabled
@@ -133,6 +143,8 @@ spec:
133143
type: object
134144
additionalProperties:
135145
type: string
146+
maxLength: 63
147+
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
136148
pod:
137149
description: Configuration for the proxy Pod.
138150
type: object
@@ -1062,6 +1074,8 @@ spec:
10621074
type: object
10631075
additionalProperties:
10641076
type: string
1077+
maxLength: 63
1078+
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
10651079
nodeName:
10661080
description: |-
10671081
Proxy Pod's node name.

cmd/k8s-operator/deploy/manifests/operator.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,16 @@ spec:
563563
enable:
564564
description: If Enable is set to true, a Prometheus ServiceMonitor will be created. Enable can only be set to true if metrics are enabled.
565565
type: boolean
566+
labels:
567+
additionalProperties:
568+
maxLength: 63
569+
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
570+
type: string
571+
description: |-
572+
Labels to add to the ServiceMonitor.
573+
Labels must be valid Kubernetes labels.
574+
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
575+
type: object
566576
required:
567577
- enable
568578
type: object
@@ -592,6 +602,8 @@ spec:
592602
type: object
593603
labels:
594604
additionalProperties:
605+
maxLength: 63
606+
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
595607
type: string
596608
description: |-
597609
Labels that will be added to the StatefulSet created for the proxy.
@@ -1522,6 +1534,8 @@ spec:
15221534
type: array
15231535
labels:
15241536
additionalProperties:
1537+
maxLength: 63
1538+
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
15251539
type: string
15261540
description: |-
15271541
Labels that will be added to the proxy Pod.

cmd/k8s-operator/ingress_test.go

Lines changed: 67 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
295295
pc := &tsapi.ProxyClass{
296296
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
297297
Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{
298-
Labels: map[string]string{"foo": "bar"},
298+
Labels: tsapi.Labels{"foo": "bar"},
299299
Annotations: map[string]string{"bar.io/foo": "some-val"},
300300
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
301301
}
@@ -424,45 +424,14 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
424424
func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
425425
pc := &tsapi.ProxyClass{
426426
ObjectMeta: metav1.ObjectMeta{Name: "metrics", Generation: 1},
427-
Spec: tsapi.ProxyClassSpec{
428-
Metrics: &tsapi.Metrics{
429-
Enable: true,
430-
ServiceMonitor: &tsapi.ServiceMonitor{Enable: true},
431-
},
432-
},
427+
Spec: tsapi.ProxyClassSpec{},
433428
Status: tsapi.ProxyClassStatus{
434429
Conditions: []metav1.Condition{{
435430
Status: metav1.ConditionTrue,
436431
Type: string(tsapi.ProxyClassReady),
437432
ObservedGeneration: 1,
438433
}}},
439434
}
440-
crd := &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: serviceMonitorCRD}}
441-
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
442-
fc := fake.NewClientBuilder().
443-
WithScheme(tsapi.GlobalScheme).
444-
WithObjects(pc, tsIngressClass).
445-
WithStatusSubresource(pc).
446-
Build()
447-
ft := &fakeTSClient{}
448-
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
449-
zl, err := zap.NewDevelopment()
450-
if err != nil {
451-
t.Fatal(err)
452-
}
453-
ingR := &IngressReconciler{
454-
Client: fc,
455-
ssr: &tailscaleSTSReconciler{
456-
Client: fc,
457-
tsClient: ft,
458-
tsnetServer: fakeTsnetServer,
459-
defaultTags: []string{"tag:k8s"},
460-
operatorNamespace: "operator-ns",
461-
proxyImage: "tailscale/tailscale",
462-
},
463-
logger: zl.Sugar(),
464-
}
465-
// 1. Enable metrics- expect metrics Service to be created
466435
ing := &networkingv1.Ingress{
467436
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
468437
ObjectMeta: metav1.ObjectMeta{
@@ -491,8 +460,7 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
491460
},
492461
},
493462
}
494-
mustCreate(t, fc, ing)
495-
mustCreate(t, fc, &corev1.Service{
463+
svc := &corev1.Service{
496464
ObjectMeta: metav1.ObjectMeta{
497465
Name: "test",
498466
Namespace: "default",
@@ -504,11 +472,38 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
504472
Name: "http"},
505473
},
506474
},
507-
})
508-
475+
}
476+
crd := &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: serviceMonitorCRD}}
477+
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
478+
fc := fake.NewClientBuilder().
479+
WithScheme(tsapi.GlobalScheme).
480+
WithObjects(pc, tsIngressClass, ing, svc).
481+
WithStatusSubresource(pc).
482+
Build()
483+
ft := &fakeTSClient{}
484+
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
485+
zl, err := zap.NewDevelopment()
486+
if err != nil {
487+
t.Fatal(err)
488+
}
489+
ingR := &IngressReconciler{
490+
Client: fc,
491+
ssr: &tailscaleSTSReconciler{
492+
Client: fc,
493+
tsClient: ft,
494+
tsnetServer: fakeTsnetServer,
495+
defaultTags: []string{"tag:k8s"},
496+
operatorNamespace: "operator-ns",
497+
proxyImage: "tailscale/tailscale",
498+
},
499+
logger: zl.Sugar(),
500+
}
509501
expectReconciled(t, ingR, "default", "test")
510-
511502
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
503+
serveConfig := &ipn.ServeConfig{
504+
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
505+
Web: map[ipn.HostPort]*ipn.WebServerConfig{"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://1.2.3.4:8080/"}}}},
506+
}
512507
opts := configOpts{
513508
stsName: shortName,
514509
secretName: fullName,
@@ -517,27 +512,51 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
517512
parentType: "ingress",
518513
hostname: "default-test",
519514
app: kubetypes.AppIngressResource,
520-
enableMetrics: true,
521515
namespaced: true,
522516
proxyType: proxyTypeIngressResource,
517+
serveConfig: serveConfig,
518+
resourceVersion: "1",
523519
}
524-
serveConfig := &ipn.ServeConfig{
525-
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
526-
Web: map[ipn.HostPort]*ipn.WebServerConfig{"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://1.2.3.4:8080/"}}}},
527-
}
528-
opts.serveConfig = serveConfig
529520

530-
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
531-
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil)
521+
// 1. Enable metrics- expect metrics Service to be created
522+
mustUpdate(t, fc, "", "metrics", func(proxyClass *tsapi.ProxyClass) {
523+
proxyClass.Spec.Metrics = &tsapi.Metrics{Enable: true}
524+
})
525+
opts.enableMetrics = true
526+
527+
expectReconciled(t, ingR, "default", "test")
528+
532529
expectEqual(t, fc, expectedMetricsService(opts), nil)
533-
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
530+
534531
// 2. Enable ServiceMonitor - should not error when there is no ServiceMonitor CRD in cluster
535532
mustUpdate(t, fc, "", "metrics", func(pc *tsapi.ProxyClass) {
536-
pc.Spec.Metrics.ServiceMonitor = &tsapi.ServiceMonitor{Enable: true}
533+
pc.Spec.Metrics.ServiceMonitor = &tsapi.ServiceMonitor{Enable: true, Labels: tsapi.Labels{"foo": "bar"}}
537534
})
538535
expectReconciled(t, ingR, "default", "test")
536+
expectEqual(t, fc, expectedMetricsService(opts), nil)
537+
539538
// 3. Create ServiceMonitor CRD and reconcile- ServiceMonitor should get created
540539
mustCreate(t, fc, crd)
541540
expectReconciled(t, ingR, "default", "test")
541+
opts.serviceMonitorLabels = tsapi.Labels{"foo": "bar"}
542+
expectEqual(t, fc, expectedMetricsService(opts), nil)
543+
expectEqualUnstructured(t, fc, expectedServiceMonitor(t, opts))
544+
545+
// 4. Update ServiceMonitor CRD and reconcile- ServiceMonitor should get updated
546+
mustUpdate(t, fc, pc.Namespace, pc.Name, func(proxyClass *tsapi.ProxyClass) {
547+
proxyClass.Spec.Metrics.ServiceMonitor.Labels = nil
548+
})
549+
expectReconciled(t, ingR, "default", "test")
550+
opts.serviceMonitorLabels = nil
551+
opts.resourceVersion = "2"
552+
expectEqual(t, fc, expectedMetricsService(opts), nil)
542553
expectEqualUnstructured(t, fc, expectedServiceMonitor(t, opts))
554+
555+
// 5. Disable metrics - metrics resources should get deleted.
556+
mustUpdate(t, fc, pc.Namespace, pc.Name, func(proxyClass *tsapi.ProxyClass) {
557+
proxyClass.Spec.Metrics = nil
558+
})
559+
expectReconciled(t, ingR, "default", "test")
560+
expectMissing[corev1.Service](t, fc, "operator-ns", metricsResourceName(shortName))
561+
// ServiceMonitor gets garbage collected when the Service is deleted - we cannot test that here.
543562
}

cmd/k8s-operator/metrics_resources.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package main
88
import (
99
"context"
1010
"fmt"
11+
"reflect"
1112

1213
"go.uber.org/zap"
1314
corev1 "k8s.io/api/core/v1"
@@ -115,15 +116,15 @@ func reconcileMetricsResources(ctx context.Context, logger *zap.SugaredLogger, o
115116
return maybeCleanupServiceMonitor(ctx, cl, opts.proxyStsName, opts.tsNamespace)
116117
}
117118

118-
logger.Info("ensuring ServiceMonitor for metrics Service %s/%s", metricsSvc.Namespace, metricsSvc.Name)
119-
svcMonitor, err := newServiceMonitor(metricsSvc)
119+
logger.Infof("ensuring ServiceMonitor for metrics Service %s/%s", metricsSvc.Namespace, metricsSvc.Name)
120+
svcMonitor, err := newServiceMonitor(metricsSvc, pc.Spec.Metrics.ServiceMonitor)
120121
if err != nil {
121122
return fmt.Errorf("error creating ServiceMonitor: %w", err)
122123
}
123-
// We don't use createOrUpdate here because that does not work with unstructured types. We also do not update
124-
// the ServiceMonitor because it is not expected that any of its fields would change. Currently this is good
125-
// enough, but in future we might want to add logic to create-or-update unstructured types.
126-
err = cl.Get(ctx, client.ObjectKeyFromObject(metricsSvc), svcMonitor.DeepCopy())
124+
125+
// We don't use createOrUpdate here because that does not work with unstructured types.
126+
existing := svcMonitor.DeepCopy()
127+
err = cl.Get(ctx, client.ObjectKeyFromObject(metricsSvc), existing)
127128
if apierrors.IsNotFound(err) {
128129
if err := cl.Create(ctx, svcMonitor); err != nil {
129130
return fmt.Errorf("error creating ServiceMonitor: %w", err)
@@ -133,6 +134,13 @@ func reconcileMetricsResources(ctx context.Context, logger *zap.SugaredLogger, o
133134
if err != nil {
134135
return fmt.Errorf("error getting ServiceMonitor: %w", err)
135136
}
137+
// Currently, we only update labels on the ServiceMonitor as those are the only values that can change.
138+
if !reflect.DeepEqual(existing.GetLabels(), svcMonitor.GetLabels()) {
139+
existing.SetLabels(svcMonitor.GetLabels())
140+
if err := cl.Update(ctx, existing); err != nil {
141+
return fmt.Errorf("error updating ServiceMonitor: %w", err)
142+
}
143+
}
136144
return nil
137145
}
138146

@@ -165,9 +173,13 @@ func maybeCleanupServiceMonitor(ctx context.Context, cl client.Client, stsName,
165173
// newServiceMonitor takes a metrics Service created for a proxy and constructs and returns a ServiceMonitor for that
166174
// proxy that can be applied to the kube API server.
167175
// The ServiceMonitor is returned as Unstructured type - this allows us to avoid importing prometheus-operator API server client/schema.
168-
func newServiceMonitor(metricsSvc *corev1.Service) (*unstructured.Unstructured, error) {
176+
func newServiceMonitor(metricsSvc *corev1.Service, spec *tsapi.ServiceMonitor) (*unstructured.Unstructured, error) {
169177
sm := serviceMonitorTemplate(metricsSvc.Name, metricsSvc.Namespace)
170178
sm.ObjectMeta.Labels = metricsSvc.Labels
179+
if spec != nil && len(spec.Labels) > 0 {
180+
sm.ObjectMeta.Labels = mergeMapKeys(sm.ObjectMeta.Labels, spec.Labels.Parse())
181+
}
182+
171183
sm.ObjectMeta.OwnerReferences = []metav1.OwnerReference{*metav1.NewControllerRef(metricsSvc, corev1.SchemeGroupVersion.WithKind("Service"))}
172184
sm.Spec = ServiceMonitorSpec{
173185
Selector: metav1.LabelSelector{MatchLabels: metricsSvc.Labels},
@@ -270,3 +282,14 @@ type metricsOpts struct {
270282
func isNamespacedProxyType(typ string) bool {
271283
return typ == proxyTypeIngressResource || typ == proxyTypeIngressService
272284
}
285+
286+
func mergeMapKeys(a, b map[string]string) map[string]string {
287+
m := make(map[string]string, len(a)+len(b))
288+
for key, val := range b {
289+
m[key] = val
290+
}
291+
for key, val := range a {
292+
m[key] = val
293+
}
294+
return m
295+
}

0 commit comments

Comments
 (0)