diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index c76a4236e..95842e123 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -655,7 +655,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ k8s.io/utils/pointer from k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1+ k8s.io/utils/ptr from k8s.io/client-go/tools/cache+ k8s.io/utils/trace from k8s.io/client-go/tools/cache - sigs.k8s.io/controller-runtime/pkg/builder from tailscale.com/cmd/k8s-operator + sigs.k8s.io/controller-runtime/pkg/builder from tailscale.com/cmd/k8s-operator+ sigs.k8s.io/controller-runtime/pkg/cache from sigs.k8s.io/controller-runtime/pkg/cluster+ sigs.k8s.io/controller-runtime/pkg/cache/internal from sigs.k8s.io/controller-runtime/pkg/cache sigs.k8s.io/controller-runtime/pkg/certwatcher from sigs.k8s.io/controller-runtime/pkg/metrics/server+ @@ -748,10 +748,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/store/kubestore from tailscale.com/cmd/k8s-operator tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+ - tailscale.com/k8s-operator from tailscale.com/cmd/k8s-operator + tailscale.com/k8s-operator from tailscale.com/cmd/k8s-operator+ tailscale.com/k8s-operator/api-proxy from tailscale.com/cmd/k8s-operator tailscale.com/k8s-operator/apis from tailscale.com/k8s-operator/apis/v1alpha1 tailscale.com/k8s-operator/apis/v1alpha1 from tailscale.com/cmd/k8s-operator+ + tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet + tailscale.com/k8s-operator/reconciler/tailnet from tailscale.com/cmd/k8s-operator tailscale.com/k8s-operator/sessionrecording from tailscale.com/k8s-operator/api-proxy tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+ diff --git a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml index 5eb920a6f..930eef852 100644 --- a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml +++ b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml @@ -37,6 +37,9 @@ rules: - apiGroups: ["tailscale.com"] resources: ["dnsconfigs", "dnsconfigs/status"] verbs: ["get", "list", "watch", "update"] +- apiGroups: ["tailscale.com"] + resources: ["tailnets", "tailnets/status"] + verbs: ["get", "list", "watch", "update"] - apiGroups: ["tailscale.com"] resources: ["recorders", "recorders/status"] verbs: ["get", "list", "watch", "update"] diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml index 59766a7bf..71c0b89b1 100644 --- a/cmd/k8s-operator/deploy/manifests/operator.yaml +++ b/cmd/k8s-operator/deploy/manifests/operator.yaml @@ -5297,6 +5297,16 @@ rules: - list - watch - update + - apiGroups: + - tailscale.com + resources: + - tailnets + - tailnets/status + verbs: + - get + - list + - watch + - update - apiGroups: - tailscale.com resources: diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index 816fea566..030effce8 100644 --- a/cmd/k8s-operator/operator.go +++ b/cmd/k8s-operator/operator.go @@ -53,6 +53,7 @@ import ( "tailscale.com/ipn/store/kubestore" apiproxy "tailscale.com/k8s-operator/api-proxy" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" + "tailscale.com/k8s-operator/reconciler/tailnet" "tailscale.com/kube/kubetypes" "tailscale.com/tsnet" "tailscale.com/tstime" @@ -324,6 +325,17 @@ func runReconcilers(opts reconcilerOpts) { startlog.Fatalf("could not create manager: %v", err) } + tailnetOptions := tailnet.ReconcilerOptions{ + Client: mgr.GetClient(), + TailscaleNamespace: opts.tailscaleNamespace, + Clock: tstime.DefaultClock{}, + Logger: opts.log, + } + + if err = tailnet.NewReconciler(tailnetOptions).Register(mgr); err != nil { + startlog.Fatalf("could not register tailnet reconciler: %v", err) + } + svcFilter := handler.EnqueueRequestsFromMapFunc(serviceHandler) svcChildFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("svc")) // If a ProxyClass changes, enqueue all Services labeled with that diff --git a/k8s-operator/conditions.go b/k8s-operator/conditions.go index ae465a728..4e20cf02d 100644 --- a/k8s-operator/conditions.go +++ b/k8s-operator/conditions.go @@ -13,6 +13,7 @@ import ( xslices "golang.org/x/exp/slices" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/tstime" ) @@ -91,6 +92,14 @@ func SetProxyGroupCondition(pg *tsapi.ProxyGroup, conditionType tsapi.ConditionT pg.Status.Conditions = conds } +// SetTailnetCondition ensures that Tailnet status has a condition with the +// given attributes. LastTransitionTime gets set every time condition's status +// changes. +func SetTailnetCondition(tn *tsapi.Tailnet, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, clock tstime.Clock, logger *zap.SugaredLogger) { + conds := updateCondition(tn.Status.Conditions, conditionType, status, reason, message, tn.Generation, clock, logger) + tn.Status.Conditions = conds +} + func updateCondition(conds []metav1.Condition, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []metav1.Condition { newCondition := metav1.Condition{ Type: string(conditionType), diff --git a/k8s-operator/reconciler/reconciler.go b/k8s-operator/reconciler/reconciler.go new file mode 100644 index 000000000..b1d9181a6 --- /dev/null +++ b/k8s-operator/reconciler/reconciler.go @@ -0,0 +1,37 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package reconciler provides utilities for working with Kubernetes resources within controller reconciliation +// loops. +package reconciler + +import ( + "slices" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // FinalizerName is the common finalizer used across all Tailscale Kubernetes resources. + FinalizerName = "tailscale.com/finalizer" +) + +// SetFinalizer adds the finalizer to the resource if not already present. +func SetFinalizer(obj client.Object) { + if idx := slices.Index(obj.GetFinalizers(), FinalizerName); idx >= 0 { + return + } + + obj.SetFinalizers(append(obj.GetFinalizers(), FinalizerName)) +} + +// RemoveFinalizer removes the finalizer from the resource if present. +func RemoveFinalizer(obj client.Object) { + idx := slices.Index(obj.GetFinalizers(), FinalizerName) + if idx < 0 { + return + } + + finalizers := obj.GetFinalizers() + obj.SetFinalizers(append(finalizers[:idx], finalizers[idx+1:]...)) +} diff --git a/k8s-operator/reconciler/reconciler_test.go b/k8s-operator/reconciler/reconciler_test.go new file mode 100644 index 000000000..d9b5ee486 --- /dev/null +++ b/k8s-operator/reconciler/reconciler_test.go @@ -0,0 +1,40 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package reconciler_test + +import ( + "slices" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "tailscale.com/k8s-operator/reconciler" +) + +func TestFinalizers(t *testing.T) { + t.Parallel() + + object := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + }, + StringData: map[string]string{ + "hello": "world", + }, + } + + reconciler.SetFinalizer(object) + + if !slices.Contains(object.Finalizers, reconciler.FinalizerName) { + t.Fatalf("object does not have finalizer %q: %v", reconciler.FinalizerName, object.Finalizers) + } + + reconciler.RemoveFinalizer(object) + + if slices.Contains(object.Finalizers, reconciler.FinalizerName) { + t.Fatalf("object still has finalizer %q: %v", reconciler.FinalizerName, object.Finalizers) + } +} diff --git a/k8s-operator/reconciler/tailnet/mocks_test.go b/k8s-operator/reconciler/tailnet/mocks_test.go new file mode 100644 index 000000000..6a0ac42bb --- /dev/null +++ b/k8s-operator/reconciler/tailnet/mocks_test.go @@ -0,0 +1,43 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package tailnet_test + +import ( + "context" + "io" + + "tailscale.com/internal/client/tailscale" +) + +type ( + MockTailnetClient struct { + ErrorOnDevices bool + ErrorOnKeys bool + ErrorOnServices bool + } +) + +func (m MockTailnetClient) Devices(_ context.Context, _ *tailscale.DeviceFieldsOpts) ([]*tailscale.Device, error) { + if m.ErrorOnDevices { + return nil, io.EOF + } + + return nil, nil +} + +func (m MockTailnetClient) Keys(_ context.Context) ([]string, error) { + if m.ErrorOnKeys { + return nil, io.EOF + } + + return nil, nil +} + +func (m MockTailnetClient) ListVIPServices(_ context.Context) (*tailscale.VIPServiceList, error) { + if m.ErrorOnServices { + return nil, io.EOF + } + + return nil, nil +} diff --git a/k8s-operator/reconciler/tailnet/tailnet.go b/k8s-operator/reconciler/tailnet/tailnet.go new file mode 100644 index 000000000..6073a6fcc --- /dev/null +++ b/k8s-operator/reconciler/tailnet/tailnet.go @@ -0,0 +1,302 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package tailnet provides reconciliation logic for the Tailnet custom resource definition. It is responsible for +// ensuring the referenced OAuth credentials are valid and have the required scopes to be able to generate authentication +// keys, manage devices & manage VIP services. +package tailnet + +import ( + "context" + "errors" + "fmt" + "time" + + "go.uber.org/zap" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "tailscale.com/internal/client/tailscale" + "tailscale.com/ipn" + operatorutils "tailscale.com/k8s-operator" + tsapi "tailscale.com/k8s-operator/apis/v1alpha1" + "tailscale.com/k8s-operator/reconciler" + "tailscale.com/tstime" +) + +type ( + // The Reconciler type is a reconcile.TypedReconciler implementation used to manage the reconciliation of + // Tailnet custom resources. + Reconciler struct { + client.Client + + tailscaleNamespace string + clock tstime.Clock + logger *zap.SugaredLogger + clientFunc func(*tsapi.Tailnet, *corev1.Secret) TailscaleClient + } + + // The ReconcilerOptions type contains configuration values for the Reconciler. + ReconcilerOptions struct { + // The client for interacting with the Kubernetes API. + Client client.Client + // The namespace the operator is installed in. This reconciler expects Tailnet OAuth credentials to be stored + // in Secret resources within this namespace. + TailscaleNamespace string + // Controls which clock to use for performing time-based functions. This is typically modified for use + // in tests. + Clock tstime.Clock + // The logger to use for this Reconciler. + Logger *zap.SugaredLogger + // ClientFunc is a function that takes tailscale credentials and returns an implementation for the Tailscale + // HTTP API. This should generally be nil unless needed for testing. + ClientFunc func(*tsapi.Tailnet, *corev1.Secret) TailscaleClient + } + + // The TailscaleClient interface describes types that interact with the Tailscale HTTP API. + TailscaleClient interface { + Devices(context.Context, *tailscale.DeviceFieldsOpts) ([]*tailscale.Device, error) + Keys(ctx context.Context) ([]string, error) + ListVIPServices(ctx context.Context) (*tailscale.VIPServiceList, error) + } +) + +const reconcilerName = "tailnet-reconciler" + +// NewReconciler returns a new instance of the Reconciler type. It watches specifically for changes to Tailnet custom +// resources. The ReconcilerOptions can be used to modify the behaviour of the Reconciler. +func NewReconciler(options ReconcilerOptions) *Reconciler { + return &Reconciler{ + Client: options.Client, + tailscaleNamespace: options.TailscaleNamespace, + clock: options.Clock, + logger: options.Logger.Named(reconcilerName), + clientFunc: options.ClientFunc, + } +} + +// Register the Reconciler onto the given manager.Manager implementation. +func (r *Reconciler) Register(mgr manager.Manager) error { + return builder. + ControllerManagedBy(mgr). + For(&tsapi.Tailnet{}). + Named(reconcilerName). + Complete(r) +} + +// Reconcile is invoked when a change occurs to Tailnet resources within the cluster. On create/update, the Tailnet +// resource is validated ensuring that the specified Secret exists and contains valid OAuth credentials that have +// required permissions to perform all necessary functions by the operator. +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var tailnet tsapi.Tailnet + err := r.Get(ctx, req.NamespacedName, &tailnet) + switch { + case apierrors.IsNotFound(err): + return reconcile.Result{}, nil + case err != nil: + return reconcile.Result{}, fmt.Errorf("failed to get Tailnet %q: %w", req.NamespacedName, err) + } + + if !tailnet.DeletionTimestamp.IsZero() { + return r.delete(ctx, &tailnet) + } + + return r.createOrUpdate(ctx, &tailnet) +} + +func (r *Reconciler) delete(ctx context.Context, tailnet *tsapi.Tailnet) (reconcile.Result, error) { + reconciler.RemoveFinalizer(tailnet) + if err := r.Update(ctx, tailnet); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to remove finalizer from Tailnet %q: %w", tailnet.Name, err) + } + + return reconcile.Result{}, nil +} + +// Constants for condition reasons. +const ( + ReasonInvalidOAuth = "InvalidOAuth" + ReasonInvalidSecret = "InvalidSecret" + ReasonValid = "TailnetValid" +) + +func (r *Reconciler) createOrUpdate(ctx context.Context, tailnet *tsapi.Tailnet) (reconcile.Result, error) { + name := types.NamespacedName{Name: tailnet.Spec.Credentials.SecretName, Namespace: r.tailscaleNamespace} + + var secret corev1.Secret + err := r.Get(ctx, name, &secret) + + // The referenced Secret does not exist within the tailscale namespace, so we'll mark the Tailnet as not ready + // for use. + if apierrors.IsNotFound(err) { + operatorutils.SetTailnetCondition( + tailnet, + tsapi.TailnetReady, + metav1.ConditionFalse, + ReasonInvalidSecret, + fmt.Sprintf("referenced secret %q does not exist in namespace %q", name.Name, r.tailscaleNamespace), + r.clock, + r.logger, + ) + + if err = r.Status().Update(ctx, tailnet); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) + } + + return reconcile.Result{}, nil + } + + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get secret %q: %w", name, err) + } + + // We first ensure that the referenced secret contains the required fields. Otherwise, we set the Tailnet as + // invalid. The operator will not allow the use of this Tailnet while it is in an invalid state. + if ok := r.ensureSecret(tailnet, &secret); !ok { + if err = r.Status().Update(ctx, tailnet); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) + } + + return reconcile.Result{RequeueAfter: time.Minute / 2}, nil + } + + tsClient := r.createClient(ctx, tailnet, &secret) + + // Second, we ensure the OAuth credentials supplied in the secret are valid and have the required scopes to access + // the various API endpoints required by the operator. + if ok := r.ensurePermissions(ctx, tsClient, tailnet); !ok { + if err = r.Status().Update(ctx, tailnet); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) + } + + // We provide a requeue duration here as a user will likely want to go and modify their scopes and come back. + // This should save them having to delete and recreate the resource. + return reconcile.Result{RequeueAfter: time.Minute / 2}, nil + } + + operatorutils.SetTailnetCondition( + tailnet, + tsapi.TailnetReady, + metav1.ConditionTrue, + ReasonValid, + ReasonValid, + r.clock, + r.logger, + ) + + if err = r.Status().Update(ctx, tailnet); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err) + } + + reconciler.SetFinalizer(tailnet) + if err = r.Update(ctx, tailnet); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to add finalizer to Tailnet %q: %w", tailnet.Name, err) + } + + return reconcile.Result{}, nil +} + +// Constants for OAuth credential fields within the Secret referenced by the Tailnet. +const ( + clientIDKey = "client_id" + clientSecretKey = "client_secret" +) + +func (r *Reconciler) createClient(ctx context.Context, tailnet *tsapi.Tailnet, secret *corev1.Secret) TailscaleClient { + if r.clientFunc != nil { + return r.clientFunc(tailnet, secret) + } + + baseURL := ipn.DefaultControlURL + if tailnet.Spec.LoginURL != "" { + baseURL = tailnet.Spec.LoginURL + } + + credentials := clientcredentials.Config{ + ClientID: string(secret.Data[clientIDKey]), + ClientSecret: string(secret.Data[clientSecretKey]), + TokenURL: baseURL + "/api/v2/oauth/token", + } + + source := credentials.TokenSource(ctx) + httpClient := oauth2.NewClient(ctx, source) + + tsClient := tailscale.NewClient("-", nil) + tsClient.UserAgent = "tailscale-k8s-operator" + tsClient.HTTPClient = httpClient + tsClient.BaseURL = baseURL + + return tsClient +} + +func (r *Reconciler) ensurePermissions(ctx context.Context, tsClient TailscaleClient, tailnet *tsapi.Tailnet) bool { + // Perform basic list requests here to confirm that the OAuth credentials referenced on the Tailnet resource + // can perform the basic operations required for the operator to function. This has a caveat of only performing + // read actions, as we don't want to create arbitrary keys and VIP services. However, it will catch when a user + // has completely forgotten an entire scope that's required. + var errs error + if _, err := tsClient.Devices(ctx, nil); err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to list devices: %w", err)) + } + + if _, err := tsClient.Keys(ctx); err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to list auth keys: %w", err)) + } + + if _, err := tsClient.ListVIPServices(ctx); err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to list VIP services: %w", err)) + } + + if errs != nil { + operatorutils.SetTailnetCondition( + tailnet, + tsapi.TailnetReady, + metav1.ConditionFalse, + ReasonInvalidOAuth, + errs.Error(), + r.clock, + r.logger, + ) + + return false + } + + return true +} + +func (r *Reconciler) ensureSecret(tailnet *tsapi.Tailnet, secret *corev1.Secret) bool { + var message string + + switch { + case len(secret.Data) == 0: + message = fmt.Sprintf("Secret %q is empty", secret.Name) + case len(secret.Data[clientIDKey]) == 0: + message = fmt.Sprintf("Secret %q is missing the client_id field", secret.Name) + case len(secret.Data[clientSecretKey]) == 0: + message = fmt.Sprintf("Secret %q is missing the client_secret field", secret.Name) + } + + if message == "" { + return true + } + + operatorutils.SetTailnetCondition( + tailnet, + tsapi.TailnetReady, + metav1.ConditionFalse, + ReasonInvalidSecret, + message, + r.clock, + r.logger, + ) + + return false +} diff --git a/k8s-operator/reconciler/tailnet/tailnet_test.go b/k8s-operator/reconciler/tailnet/tailnet_test.go new file mode 100644 index 000000000..a5aea7233 --- /dev/null +++ b/k8s-operator/reconciler/tailnet/tailnet_test.go @@ -0,0 +1,409 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package tailnet_test + +import ( + "testing" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + tsapi "tailscale.com/k8s-operator/apis/v1alpha1" + "tailscale.com/k8s-operator/reconciler/tailnet" + "tailscale.com/tstest" +) + +func TestReconciler_Reconcile(t *testing.T) { + t.Parallel() + clock := tstest.NewClock(tstest.ClockOpts{}) + logger, err := zap.NewDevelopment() + if err != nil { + t.Fatal(err) + } + + tt := []struct { + Name string + Request reconcile.Request + Tailnet *tsapi.Tailnet + Secret *corev1.Secret + ExpectsError bool + ExpectedConditions []metav1.Condition + ClientFunc func(*tsapi.Tailnet, *corev1.Secret) tailnet.TailscaleClient + }{ + { + Name: "ignores unknown tailnet requests", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + }, + { + Name: "invalid status for missing secret", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionFalse, + Reason: tailnet.ReasonInvalidSecret, + Message: `referenced secret "test" does not exist in namespace "tailscale"`, + }, + }, + }, + { + Name: "invalid status for empty secret", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionFalse, + Reason: tailnet.ReasonInvalidSecret, + Message: `Secret "test" is empty`, + }, + }, + }, + { + Name: "invalid status for missing client id", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + Data: map[string][]byte{ + "client_secret": []byte("test"), + }, + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionFalse, + Reason: tailnet.ReasonInvalidSecret, + Message: `Secret "test" is missing the client_id field`, + }, + }, + }, + { + Name: "invalid status for missing client secret", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + Data: map[string][]byte{ + "client_id": []byte("test"), + }, + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionFalse, + Reason: tailnet.ReasonInvalidSecret, + Message: `Secret "test" is missing the client_secret field`, + }, + }, + }, + { + Name: "invalid status for bad devices scope", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + Data: map[string][]byte{ + "client_id": []byte("test"), + "client_secret": []byte("test"), + }, + }, + ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient { + return &MockTailnetClient{ErrorOnDevices: true} + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionFalse, + Reason: tailnet.ReasonInvalidOAuth, + Message: `failed to list devices: EOF`, + }, + }, + }, + { + Name: "invalid status for bad services scope", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + Data: map[string][]byte{ + "client_id": []byte("test"), + "client_secret": []byte("test"), + }, + }, + ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient { + return &MockTailnetClient{ErrorOnServices: true} + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionFalse, + Reason: tailnet.ReasonInvalidOAuth, + Message: `failed to list VIP services: EOF`, + }, + }, + }, + { + Name: "invalid status for bad keys scope", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "test", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + Data: map[string][]byte{ + "client_id": []byte("test"), + "client_secret": []byte("test"), + }, + }, + ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient { + return &MockTailnetClient{ErrorOnKeys: true} + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionFalse, + Reason: tailnet.ReasonInvalidOAuth, + Message: `failed to list auth keys: EOF`, + }, + }, + }, + { + Name: "ready when valid and scopes are correct", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "default", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + Data: map[string][]byte{ + "client_id": []byte("test"), + "client_secret": []byte("test"), + }, + }, + ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient { + return &MockTailnetClient{} + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionTrue, + Reason: tailnet.ReasonValid, + Message: tailnet.ReasonValid, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + builder := fake.NewClientBuilder().WithScheme(tsapi.GlobalScheme) + if tc.Tailnet != nil { + builder = builder.WithObjects(tc.Tailnet).WithStatusSubresource(tc.Tailnet) + } + if tc.Secret != nil { + builder = builder.WithObjects(tc.Secret) + } + + fc := builder.Build() + opts := tailnet.ReconcilerOptions{ + Client: fc, + Clock: clock, + Logger: logger.Sugar(), + ClientFunc: tc.ClientFunc, + TailscaleNamespace: "tailscale", + } + + reconciler := tailnet.NewReconciler(opts) + _, err = reconciler.Reconcile(t.Context(), tc.Request) + if tc.ExpectsError && err == nil { + t.Fatalf("expected error, got none") + } + + if !tc.ExpectsError && err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(tc.ExpectedConditions) == 0 { + return + } + + var tn tsapi.Tailnet + if err = fc.Get(t.Context(), tc.Request.NamespacedName, &tn); err != nil { + t.Fatal(err) + } + + if len(tn.Status.Conditions) != len(tc.ExpectedConditions) { + t.Fatalf("expected %v condition(s), got %v", len(tc.ExpectedConditions), len(tn.Status.Conditions)) + } + + for i, expected := range tc.ExpectedConditions { + actual := tn.Status.Conditions[i] + + if actual.Type != expected.Type { + t.Errorf("expected %v, got %v", expected.Type, actual.Type) + } + + if actual.Status != expected.Status { + t.Errorf("expected %v, got %v", expected.Status, actual.Status) + } + + if actual.Reason != expected.Reason { + t.Errorf("expected %v, got %v", expected.Reason, actual.Reason) + } + + if actual.Message != expected.Message { + t.Errorf("expected %v, got %v", expected.Message, actual.Message) + } + } + + if err = fc.Delete(t.Context(), &tn); err != nil { + t.Fatal(err) + } + + if _, err = reconciler.Reconcile(t.Context(), tc.Request); err != nil { + t.Fatal(err) + } + + err = fc.Get(t.Context(), tc.Request.NamespacedName, &tn) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected not found error, got %v", err) + } + }) + } +}