mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-30 07:43:42 +00:00

Adds a new reconciler for ProxyGroups of type kube-apiserver that will provision a Tailscale Service for each replica to advertise. Adds two new condition types to the ProxyGroup, TailscaleServiceValid and TailscaleServiceConfigured, to post updates on the state of that reconciler in a way that's consistent with the service-pg reconciler. The created Tailscale Service name is configurable via a new ProxyGroup field spec.kubeAPISserver.ServiceName, which expects a string of the form "svc:<dns-label>". Lots of supporting changes were needed to implement this in a way that's consistent with other operator workflows, including: * Pulled containerboot's ensureServicesUnadvertised and certManager into kube/ libraries to be shared with k8s-proxy. Use those in k8s-proxy to aid Service cert sharing between replicas and graceful Service shutdown. * For certManager, add an initial wait to the cert loop to wait until the domain appears in the devices's netmap to avoid a guaranteed error on the first issue attempt when it's quick to start. * Made several methods in ingress-for-pg.go and svc-for-pg.go into functions to share with the new reconciler * Added a Resource struct to the owner refs stored in Tailscale Service annotations to be able to distinguish between Ingress- and ProxyGroup- based Services that need cleaning up in the Tailscale API. * Added a ListVIPServices method to the internal tailscale client to aid cleaning up orphaned Services * Support for reading config from a kube Secret, and partial support for config reloading, to prevent us having to force Pod restarts when config changes. * Fixed up the zap logger so it's possible to set debug log level. Updates #13358 Change-Id: Ia9607441157dd91fb9b6ecbc318eecbef446e116 Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
385 lines
13 KiB
Go
385 lines
13 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"go.uber.org/zap"
|
|
corev1 "k8s.io/api/core/v1"
|
|
rbacv1 "k8s.io/api/rbac/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"tailscale.com/internal/client/tailscale"
|
|
"tailscale.com/ipn/ipnstate"
|
|
tsoperator "tailscale.com/k8s-operator"
|
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
"tailscale.com/kube/k8s-proxy/conf"
|
|
"tailscale.com/kube/kubetypes"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/types/opt"
|
|
"tailscale.com/types/ptr"
|
|
)
|
|
|
|
func TestAPIServerProxyReconciler(t *testing.T) {
|
|
const (
|
|
pgName = "test-pg"
|
|
pgUID = "test-pg-uid"
|
|
ns = "operator-ns"
|
|
defaultDomain = "test-pg.ts.net"
|
|
)
|
|
pg := &tsapi.ProxyGroup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: pgName,
|
|
Generation: 1,
|
|
UID: pgUID,
|
|
},
|
|
Spec: tsapi.ProxyGroupSpec{
|
|
Type: tsapi.ProxyGroupTypeKubernetesAPIServer,
|
|
},
|
|
Status: tsapi.ProxyGroupStatus{
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: string(tsapi.ProxyGroupAvailable),
|
|
Status: metav1.ConditionTrue,
|
|
ObservedGeneration: 1,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
initialCfg := &conf.VersionedConfig{
|
|
Version: "v1alpha1",
|
|
ConfigV1Alpha1: &conf.ConfigV1Alpha1{
|
|
AuthKey: ptr.To("test-key"),
|
|
APIServerProxy: &conf.APIServerProxyConfig{
|
|
Enabled: opt.NewBool(true),
|
|
},
|
|
},
|
|
}
|
|
expectedCfg := *initialCfg
|
|
initialCfgB, err := json.Marshal(initialCfg)
|
|
if err != nil {
|
|
t.Fatalf("marshaling initial config: %v", err)
|
|
}
|
|
pgCfgSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: pgConfigSecretName(pgName, 0),
|
|
Namespace: ns,
|
|
Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeConfig),
|
|
},
|
|
Data: map[string][]byte{
|
|
// Existing config should be preserved.
|
|
kubetypes.KubeAPIServerConfigFile: initialCfgB,
|
|
},
|
|
}
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithObjects(pg, pgCfgSecret).
|
|
WithStatusSubresource(pg).
|
|
Build()
|
|
expectCfg := func(c *conf.VersionedConfig) {
|
|
t.Helper()
|
|
cBytes, err := json.Marshal(c)
|
|
if err != nil {
|
|
t.Fatalf("marshaling expected config: %v", err)
|
|
}
|
|
pgCfgSecret.Data[kubetypes.KubeAPIServerConfigFile] = cBytes
|
|
expectEqual(t, fc, pgCfgSecret)
|
|
}
|
|
|
|
ft := &fakeTSClient{}
|
|
ingressTSSvc := &tailscale.VIPService{
|
|
Name: "svc:some-ingress-hostname",
|
|
Comment: managedTSServiceComment,
|
|
Annotations: map[string]string{
|
|
// No resource field.
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id"}]}`,
|
|
},
|
|
Ports: []string{"tcp:443"},
|
|
Tags: []string{"tag:k8s"},
|
|
Addrs: []string{"5.6.7.8"},
|
|
}
|
|
ft.CreateOrUpdateVIPService(t.Context(), ingressTSSvc)
|
|
|
|
lc := &fakeLocalClient{
|
|
status: &ipnstate.Status{
|
|
CurrentTailnet: &ipnstate.TailnetStatus{
|
|
MagicDNSSuffix: "ts.net",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &KubeAPIServerTSServiceReconciler{
|
|
Client: fc,
|
|
tsClient: ft,
|
|
defaultTags: []string{"tag:k8s"},
|
|
tsNamespace: ns,
|
|
logger: zap.Must(zap.NewDevelopment()).Sugar(),
|
|
recorder: record.NewFakeRecorder(10),
|
|
lc: lc,
|
|
clock: tstest.NewClock(tstest.ClockOpts{}),
|
|
operatorID: "self-id",
|
|
}
|
|
|
|
// Create a Tailscale Service that will conflict with the initial config.
|
|
if err := ft.CreateOrUpdateVIPService(t.Context(), &tailscale.VIPService{
|
|
Name: "svc:" + pgName,
|
|
}); err != nil {
|
|
t.Fatalf("creating initial Tailscale Service: %v", err)
|
|
}
|
|
expectReconciled(t, r, "", pgName)
|
|
pg.ObjectMeta.Finalizers = []string{proxyPGFinalizerName}
|
|
tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyValid, metav1.ConditionFalse, reasonKubeAPIServerProxyInvalid, "", 1, r.clock, r.logger)
|
|
tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionFalse, reasonKubeAPIServerProxyNoBackends, "", 1, r.clock, r.logger)
|
|
expectEqual(t, fc, pg, omitPGStatusConditionMessages)
|
|
expectMissing[corev1.Secret](t, fc, ns, defaultDomain)
|
|
expectMissing[rbacv1.Role](t, fc, ns, defaultDomain)
|
|
expectMissing[rbacv1.RoleBinding](t, fc, ns, defaultDomain)
|
|
expectEqual(t, fc, pgCfgSecret) // Unchanged.
|
|
|
|
// Delete Tailscale Service; should see Service created and valid condition updated to true.
|
|
if err := ft.DeleteVIPService(t.Context(), "svc:"+pgName); err != nil {
|
|
t.Fatalf("deleting initial Tailscale Service: %v", err)
|
|
}
|
|
expectReconciled(t, r, "", pgName)
|
|
|
|
tsSvc, err := ft.GetVIPService(t.Context(), "svc:"+pgName)
|
|
if err != nil {
|
|
t.Fatalf("getting Tailscale Service: %v", err)
|
|
}
|
|
if tsSvc == nil {
|
|
t.Fatalf("expected Tailscale Service to be created, but got nil")
|
|
}
|
|
expectedTSSvc := &tailscale.VIPService{
|
|
Name: "svc:" + pgName,
|
|
Comment: managedTSServiceComment,
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"test-pg","uid":"test-pg-uid"}}]}`,
|
|
},
|
|
Ports: []string{"tcp:443"},
|
|
Tags: []string{"tag:k8s"},
|
|
Addrs: []string{"5.6.7.8"},
|
|
}
|
|
if !reflect.DeepEqual(tsSvc, expectedTSSvc) {
|
|
t.Fatalf("expected Tailscale Service to be %+v, got %+v", expectedTSSvc, tsSvc)
|
|
}
|
|
tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyValid, metav1.ConditionTrue, reasonKubeAPIServerProxyValid, "", 1, r.clock, r.logger)
|
|
tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionFalse, reasonKubeAPIServerProxyNoBackends, "", 1, r.clock, r.logger)
|
|
expectEqual(t, fc, pg, omitPGStatusConditionMessages)
|
|
|
|
expectedCfg.APIServerProxy.ServiceName = ptr.To(tailcfg.ServiceName("svc:" + pgName))
|
|
expectCfg(&expectedCfg)
|
|
|
|
expectEqual(t, fc, certSecret(pgName, ns, defaultDomain, pg))
|
|
expectEqual(t, fc, certSecretRole(pgName, ns, defaultDomain))
|
|
expectEqual(t, fc, certSecretRoleBinding(pg, ns, defaultDomain))
|
|
|
|
// Simulate certs being issued; should observe AdvertiseServices config change.
|
|
if err := populateTLSSecret(t.Context(), fc, pgName, defaultDomain); err != nil {
|
|
t.Fatalf("populating TLS Secret: %v", err)
|
|
}
|
|
expectReconciled(t, r, "", pgName)
|
|
|
|
expectedCfg.AdvertiseServices = []string{"svc:" + pgName}
|
|
expectCfg(&expectedCfg)
|
|
|
|
expectEqual(t, fc, pg, omitPGStatusConditionMessages) // Unchanged status.
|
|
|
|
// Simulate Pod prefs updated with advertised services; should see Configured condition updated to true.
|
|
mustCreate(t, fc, &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pg-0",
|
|
Namespace: ns,
|
|
Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeState),
|
|
},
|
|
Data: map[string][]byte{
|
|
"_current-profile": []byte("profile-foo"),
|
|
"profile-foo": []byte(`{"AdvertiseServices":["svc:test-pg"],"Config":{"NodeID":"node-foo"}}`),
|
|
},
|
|
})
|
|
expectReconciled(t, r, "", pgName)
|
|
tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionTrue, reasonKubeAPIServerProxyConfigured, "", 1, r.clock, r.logger)
|
|
pg.Status.URL = "https://" + defaultDomain
|
|
expectEqual(t, fc, pg, omitPGStatusConditionMessages)
|
|
|
|
// Rename the Tailscale Service - old one + cert resources should be cleaned up.
|
|
updatedServiceName := tailcfg.ServiceName("svc:test-pg-renamed")
|
|
updatedDomain := "test-pg-renamed.ts.net"
|
|
pg.Spec.KubeAPIServer = &tsapi.KubeAPIServerConfig{
|
|
Hostname: updatedServiceName.WithoutPrefix(),
|
|
}
|
|
mustUpdate(t, fc, "", pgName, func(p *tsapi.ProxyGroup) {
|
|
p.Spec.KubeAPIServer = pg.Spec.KubeAPIServer
|
|
})
|
|
expectReconciled(t, r, "", pgName)
|
|
_, err = ft.GetVIPService(t.Context(), "svc:"+pgName)
|
|
if !isErrorTailscaleServiceNotFound(err) {
|
|
t.Fatalf("Expected 404, got: %v", err)
|
|
}
|
|
tsSvc, err = ft.GetVIPService(t.Context(), updatedServiceName)
|
|
if err != nil {
|
|
t.Fatalf("Expected renamed svc, got error: %v", err)
|
|
}
|
|
expectedTSSvc.Name = updatedServiceName
|
|
if !reflect.DeepEqual(tsSvc, expectedTSSvc) {
|
|
t.Fatalf("expected Tailscale Service to be %+v, got %+v", expectedTSSvc, tsSvc)
|
|
}
|
|
// Check cfg and status reset until TLS certs are available again.
|
|
expectedCfg.APIServerProxy.ServiceName = ptr.To(updatedServiceName)
|
|
expectedCfg.AdvertiseServices = nil
|
|
expectCfg(&expectedCfg)
|
|
tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionFalse, reasonKubeAPIServerProxyNoBackends, "", 1, r.clock, r.logger)
|
|
pg.Status.URL = ""
|
|
expectEqual(t, fc, pg, omitPGStatusConditionMessages)
|
|
|
|
expectEqual(t, fc, certSecret(pgName, ns, updatedDomain, pg))
|
|
expectEqual(t, fc, certSecretRole(pgName, ns, updatedDomain))
|
|
expectEqual(t, fc, certSecretRoleBinding(pg, ns, updatedDomain))
|
|
expectMissing[corev1.Secret](t, fc, ns, defaultDomain)
|
|
expectMissing[rbacv1.Role](t, fc, ns, defaultDomain)
|
|
expectMissing[rbacv1.RoleBinding](t, fc, ns, defaultDomain)
|
|
|
|
// Check we get the new hostname in the status once ready.
|
|
if err := populateTLSSecret(t.Context(), fc, pgName, updatedDomain); err != nil {
|
|
t.Fatalf("populating TLS Secret: %v", err)
|
|
}
|
|
mustUpdate(t, fc, "operator-ns", "test-pg-0", func(s *corev1.Secret) {
|
|
s.Data["profile-foo"] = []byte(`{"AdvertiseServices":["svc:test-pg"],"Config":{"NodeID":"node-foo"}}`)
|
|
})
|
|
expectReconciled(t, r, "", pgName)
|
|
expectedCfg.AdvertiseServices = []string{updatedServiceName.String()}
|
|
expectCfg(&expectedCfg)
|
|
tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionTrue, reasonKubeAPIServerProxyConfigured, "", 1, r.clock, r.logger)
|
|
pg.Status.URL = "https://" + updatedDomain
|
|
|
|
// Delete the ProxyGroup and verify Tailscale Service and cert resources are cleaned up.
|
|
if err := fc.Delete(t.Context(), pg); err != nil {
|
|
t.Fatalf("deleting ProxyGroup: %v", err)
|
|
}
|
|
expectReconciled(t, r, "", pgName)
|
|
expectMissing[corev1.Secret](t, fc, ns, updatedDomain)
|
|
expectMissing[rbacv1.Role](t, fc, ns, updatedDomain)
|
|
expectMissing[rbacv1.RoleBinding](t, fc, ns, updatedDomain)
|
|
_, err = ft.GetVIPService(t.Context(), updatedServiceName)
|
|
if !isErrorTailscaleServiceNotFound(err) {
|
|
t.Fatalf("Expected 404, got: %v", err)
|
|
}
|
|
|
|
// Ingress Tailscale Service should not be affected.
|
|
svc, err := ft.GetVIPService(t.Context(), ingressTSSvc.Name)
|
|
if err != nil {
|
|
t.Fatalf("getting ingress Tailscale Service: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(svc, ingressTSSvc) {
|
|
t.Fatalf("expected ingress Tailscale Service to be unmodified %+v, got %+v", ingressTSSvc, svc)
|
|
}
|
|
}
|
|
|
|
func TestExclusiveOwnerAnnotations(t *testing.T) {
|
|
pg := &tsapi.ProxyGroup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "pg1",
|
|
UID: "pg1-uid",
|
|
},
|
|
}
|
|
const (
|
|
selfOperatorID = "self-id"
|
|
pg1Owner = `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg1","uid":"pg1-uid"}}]}`
|
|
)
|
|
|
|
for name, tc := range map[string]struct {
|
|
svc *tailscale.VIPService
|
|
wantErr string
|
|
}{
|
|
"no_svc": {
|
|
svc: nil,
|
|
},
|
|
"empty_svc": {
|
|
svc: &tailscale.VIPService{},
|
|
wantErr: "likely a resource created by something other than the Tailscale Kubernetes operator",
|
|
},
|
|
"already_owner": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: pg1Owner,
|
|
},
|
|
},
|
|
},
|
|
"already_owner_name_updated": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"old-pg1-name","uid":"pg1-uid"}}]}`,
|
|
},
|
|
},
|
|
},
|
|
"preserves_existing_annotations": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
"existing": "annotation",
|
|
ownerAnnotation: pg1Owner,
|
|
},
|
|
},
|
|
},
|
|
"owned_by_another_operator": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"operator-2"}]}`,
|
|
},
|
|
},
|
|
wantErr: "already owned by other operator(s)",
|
|
},
|
|
"owned_by_an_ingress": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id"}]}`, // Ingress doesn't set Resource field (yet).
|
|
},
|
|
},
|
|
wantErr: "does not reference an owning resource",
|
|
},
|
|
"owned_by_another_pg": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg2","uid":"pg2-uid"}}]}`,
|
|
},
|
|
},
|
|
wantErr: "already owned by another resource",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
got, err := exclusiveOwnerAnnotations(pg, "self-id", tc.svc)
|
|
if tc.wantErr != "" {
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Errorf("exclusiveOwnerAnnotations() error = %v, wantErr %v", err, tc.wantErr)
|
|
}
|
|
} else if diff := cmp.Diff(pg1Owner, got[ownerAnnotation]); diff != "" {
|
|
t.Errorf("exclusiveOwnerAnnotations() mismatch (-want +got):\n%s", diff)
|
|
}
|
|
if tc.svc == nil {
|
|
return // Don't check annotations being preserved.
|
|
}
|
|
for k, v := range tc.svc.Annotations {
|
|
if k == ownerAnnotation {
|
|
continue
|
|
}
|
|
if got[k] != v {
|
|
t.Errorf("exclusiveOwnerAnnotations() did not preserve annotation %q: got %q, want %q", k, got[k], v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func omitPGStatusConditionMessages(p *tsapi.ProxyGroup) {
|
|
for i := range p.Status.Conditions {
|
|
// Don't bother validating the message.
|
|
p.Status.Conditions[i].Message = ""
|
|
}
|
|
}
|