From 1464eb2edb933f3e474f186ba13328135078f864 Mon Sep 17 00:00:00 2001 From: Sridhar Gaddam Date: Fri, 6 Dec 2024 20:44:27 +0530 Subject: [PATCH] Implement ztunnel controller in Sail Operator This PR implements the ztunnel controller and the associated unit tests in Sail Operator. Related to: https://github.com/istio-ecosystem/sail-operator/issues/500 Signed-off-by: Sridhar Gaddam --- .../sailoperator.clusterserviceversion.yaml | 26 + chart/samples/ambient/istio-sample.yaml | 14 + chart/samples/ambient/istiocni-sample.yaml | 8 + .../samples/ambient/istioztunnel-sample.yaml | 8 + chart/templates/rbac/role.yaml | 26 + cmd/main.go | 8 + controllers/ztunnel/ztunnel_controller.go | 344 +++++++++++ .../ztunnel/ztunnel_controller_test.go | 538 ++++++++++++++++++ 8 files changed, 972 insertions(+) create mode 100644 chart/samples/ambient/istio-sample.yaml create mode 100644 chart/samples/ambient/istiocni-sample.yaml create mode 100644 chart/samples/ambient/istioztunnel-sample.yaml create mode 100644 controllers/ztunnel/ztunnel_controller.go create mode 100644 controllers/ztunnel/ztunnel_controller_test.go diff --git a/bundle/manifests/sailoperator.clusterserviceversion.yaml b/bundle/manifests/sailoperator.clusterserviceversion.yaml index 14ce27bcf..a3844a904 100644 --- a/bundle/manifests/sailoperator.clusterserviceversion.yaml +++ b/bundle/manifests/sailoperator.clusterserviceversion.yaml @@ -586,6 +586,32 @@ spec: - securitycontextconstraints verbs: - use + - apiGroups: + - sailoperator.io + resources: + - ztunnels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - sailoperator.io + resources: + - ztunnels/finalizers + verbs: + - update + - apiGroups: + - sailoperator.io + resources: + - ztunnels/status + verbs: + - get + - patch + - update serviceAccountName: sail-operator deployments: - label: diff --git a/chart/samples/ambient/istio-sample.yaml b/chart/samples/ambient/istio-sample.yaml new file mode 100644 index 000000000..f805a585d --- /dev/null +++ b/chart/samples/ambient/istio-sample.yaml @@ -0,0 +1,14 @@ +apiVersion: sailoperator.io/v1alpha1 +kind: Istio +metadata: + name: default +spec: + version: v1.24.0 + namespace: istio-system + profile: ambient + updateStrategy: + type: InPlace + inactiveRevisionDeletionGracePeriodSeconds: 30 + values: + pilot: + trustedZtunnelNamespace: "ztunnel" diff --git a/chart/samples/ambient/istiocni-sample.yaml b/chart/samples/ambient/istiocni-sample.yaml new file mode 100644 index 000000000..e38a59e58 --- /dev/null +++ b/chart/samples/ambient/istiocni-sample.yaml @@ -0,0 +1,8 @@ +apiVersion: sailoperator.io/v1alpha1 +kind: IstioCNI +metadata: + name: default +spec: + version: v1.24.0 + profile: ambient + namespace: istio-cni diff --git a/chart/samples/ambient/istioztunnel-sample.yaml b/chart/samples/ambient/istioztunnel-sample.yaml new file mode 100644 index 000000000..241dd81b3 --- /dev/null +++ b/chart/samples/ambient/istioztunnel-sample.yaml @@ -0,0 +1,8 @@ +apiVersion: sailoperator.io/v1alpha1 +kind: ZTunnel +metadata: + name: default +spec: + version: v1.24.0 + namespace: ztunnel + profile: ambient diff --git a/chart/templates/rbac/role.yaml b/chart/templates/rbac/role.yaml index 28c2a0bb6..292b78e65 100644 --- a/chart/templates/rbac/role.yaml +++ b/chart/templates/rbac/role.yaml @@ -209,3 +209,29 @@ rules: - securitycontextconstraints verbs: - use +- apiGroups: + - sailoperator.io + resources: + - ztunnels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - sailoperator.io + resources: + - ztunnels/finalizers + verbs: + - update +- apiGroups: + - sailoperator.io + resources: + - ztunnels/status + verbs: + - get + - patch + - update diff --git a/cmd/main.go b/cmd/main.go index 873648e9e..740faecbb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -25,6 +25,7 @@ import ( "github.com/istio-ecosystem/sail-operator/controllers/istiorevision" "github.com/istio-ecosystem/sail-operator/controllers/istiorevisiontag" "github.com/istio-ecosystem/sail-operator/controllers/webhook" + "github.com/istio-ecosystem/sail-operator/controllers/ztunnel" "github.com/istio-ecosystem/sail-operator/pkg/config" "github.com/istio-ecosystem/sail-operator/pkg/enqueuelogger" "github.com/istio-ecosystem/sail-operator/pkg/helm" @@ -165,6 +166,13 @@ func main() { os.Exit(1) } + err = ztunnel.NewReconciler(reconcilerCfg, mgr.GetClient(), mgr.GetScheme(), chartManager). + SetupWithManager(mgr) + if err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ZTunnel") + os.Exit(1) + } + err = webhook.NewReconciler(mgr.GetClient(), mgr.GetScheme()). SetupWithManager(mgr) if err != nil { diff --git a/controllers/ztunnel/ztunnel_controller.go b/controllers/ztunnel/ztunnel_controller.go new file mode 100644 index 000000000..dc8b7917f --- /dev/null +++ b/controllers/ztunnel/ztunnel_controller.go @@ -0,0 +1,344 @@ +// Copyright Istio Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ztunnel + +import ( + "context" + "errors" + "fmt" + "path" + "reflect" + + "github.com/go-logr/logr" + "github.com/istio-ecosystem/sail-operator/api/v1alpha1" + "github.com/istio-ecosystem/sail-operator/pkg/config" + "github.com/istio-ecosystem/sail-operator/pkg/constants" + "github.com/istio-ecosystem/sail-operator/pkg/enqueuelogger" + "github.com/istio-ecosystem/sail-operator/pkg/errlist" + "github.com/istio-ecosystem/sail-operator/pkg/helm" + "github.com/istio-ecosystem/sail-operator/pkg/istiovalues" + "github.com/istio-ecosystem/sail-operator/pkg/kube" + "github.com/istio-ecosystem/sail-operator/pkg/predicate" + "github.com/istio-ecosystem/sail-operator/pkg/reconciler" + "github.com/istio-ecosystem/sail-operator/pkg/validation" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "istio.io/istio/pkg/ptr" +) + +// Reconciler reconciles the ZTunnel object +type Reconciler struct { + client.Client + Config config.ReconcilerConfig + Scheme *runtime.Scheme + ChartManager *helm.ChartManager +} + +const ( + ztunnelChart = "ztunnel" +) + +func NewReconciler(cfg config.ReconcilerConfig, client client.Client, scheme *runtime.Scheme, chartManager *helm.ChartManager) *Reconciler { + return &Reconciler{ + Config: cfg, + Client: client, + Scheme: scheme, + ChartManager: chartManager, + } +} + +// +kubebuilder:rbac:groups=sailoperator.io,resources=ztunnels,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=sailoperator.io,resources=ztunnels/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=sailoperator.io,resources=ztunnels/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources="*",verbs="*" +// +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=clusterroles;clusterrolebindings;roles;rolebindings,verbs="*" +// +kubebuilder:rbac:groups="apps",resources=deployments;daemonsets,verbs="*" +// +kubebuilder:rbac:groups="apiextensions.k8s.io",resources=customresourcedefinitions,verbs=get;list;watch +// +kubebuilder:rbac:groups="security.openshift.io",resources=securitycontextconstraints,resourceNames=privileged,verbs=use + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.1/pkg/reconcile +func (r *Reconciler) Reconcile(ctx context.Context, ztunnel *v1alpha1.ZTunnel) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + reconcileErr := r.doReconcile(ctx, ztunnel) + + log.Info("Reconciliation done. Updating status.") + statusErr := r.updateStatus(ctx, ztunnel, reconcileErr) + + return ctrl.Result{}, errors.Join(reconcileErr, statusErr) +} + +func (r *Reconciler) Finalize(ctx context.Context, ztunnel *v1alpha1.ZTunnel) error { + return r.uninstallHelmChart(ctx, ztunnel) +} + +func (r *Reconciler) doReconcile(ctx context.Context, ztunnel *v1alpha1.ZTunnel) error { + log := logf.FromContext(ctx) + if err := r.validate(ctx, ztunnel); err != nil { + return err + } + + log.Info("Installing ztunnel Helm chart") + return r.installHelmChart(ctx, ztunnel) +} + +func (r *Reconciler) validate(ctx context.Context, ztunnel *v1alpha1.ZTunnel) error { + if ztunnel.Spec.Version == "" { + return reconciler.NewValidationError("spec.version not set") + } + if ztunnel.Spec.Namespace == "" { + return reconciler.NewValidationError("spec.namespace not set") + } + if err := validation.ValidateTargetNamespace(ctx, r.Client, ztunnel.Spec.Namespace); err != nil { + return err + } + return nil +} + +func (r *Reconciler) installHelmChart(ctx context.Context, ztunnel *v1alpha1.ZTunnel) error { + ownerReference := metav1.OwnerReference{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: v1alpha1.ZTunnelKind, + Name: ztunnel.Name, + UID: ztunnel.UID, + Controller: ptr.Of(true), + BlockOwnerDeletion: ptr.Of(true), + } + + // get userValues from ztunnel.spec.values + userValues := ztunnel.Spec.Values + + // apply image digests from configuration, if not already set by user + userValues = applyImageDigests(ztunnel, userValues, config.Config) + + // apply userValues on top of defaultValues from profiles + mergedHelmValues, err := istiovalues.ApplyProfilesAndPlatform( + r.Config.ResourceDirectory, ztunnel.Spec.Version, r.Config.Platform, r.Config.DefaultProfile, ztunnel.Spec.Profile, helm.FromValues(userValues)) + if err != nil { + return fmt.Errorf("failed to apply profile: %w", err) + } + + _, err = r.ChartManager.UpgradeOrInstallChart(ctx, r.getChartDir(ztunnel), mergedHelmValues, ztunnel.Spec.Namespace, ztunnelChart, ownerReference) + if err != nil { + return fmt.Errorf("failed to install/update Helm chart %q: %w", ztunnelChart, err) + } + return nil +} + +func (r *Reconciler) getChartDir(ztunnel *v1alpha1.ZTunnel) string { + return path.Join(r.Config.ResourceDirectory, ztunnel.Spec.Version, "charts", ztunnelChart) +} + +func applyImageDigests(ztunnel *v1alpha1.ZTunnel, values *v1alpha1.ZTunnelValues, config config.OperatorConfig) *v1alpha1.ZTunnelValues { + imageDigests, digestsDefined := config.ImageDigests[ztunnel.Spec.Version] + // if we don't have default image digests defined for this version, it's a no-op + if !digestsDefined { + return values + } + + if values == nil { + values = &v1alpha1.ZTunnelValues{} + } + + // set image digest unless any part of the image has been configured by the user + if values.ZTunnel == nil { + values.ZTunnel = &v1alpha1.ZTunnelConfig{} + } + if values.ZTunnel.Image == nil && values.ZTunnel.Hub == nil && values.ZTunnel.Tag == nil { + values.ZTunnel.Image = &imageDigests.ZTunnelImage + } + return values +} + +func (r *Reconciler) uninstallHelmChart(ctx context.Context, ztunnel *v1alpha1.ZTunnel) error { + _, err := r.ChartManager.UninstallChart(ctx, ztunnelChart, ztunnel.Spec.Namespace) + if err != nil { + return fmt.Errorf("failed to uninstall Helm chart %q: %w", ztunnelChart, err) + } + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + logger := mgr.GetLogger().WithName("ctrlr").WithName("ztunnel") + + // mainObjectHandler handles the ZTunnel watch events + mainObjectHandler := wrapEventHandler(logger, &handler.EnqueueRequestForObject{}) + + // ownedResourceHandler handles resources that are owned by the ZTunnel CR + ownedResourceHandler := wrapEventHandler(logger, + handler.EnqueueRequestForOwner(r.Scheme, r.RESTMapper(), &v1alpha1.ZTunnel{}, handler.OnlyControllerOwner())) + + namespaceHandler := wrapEventHandler(logger, handler.EnqueueRequestsFromMapFunc(r.mapNamespaceToReconcileRequest)) + + return ctrl.NewControllerManagedBy(mgr). + WithOptions(controller.Options{ + LogConstructor: func(req *reconcile.Request) logr.Logger { + log := logger + if req != nil { + log = log.WithValues("ztunnel", req.Name) + } + return log + }, + }). + + // we use the Watches function instead of For(), so that we can wrap the handler so that events that cause the object to be enqueued are logged + Watches(&v1alpha1.ZTunnel{}, mainObjectHandler).Named("ztunnel"). + + // namespaced resources + Watches(&corev1.ConfigMap{}, ownedResourceHandler). + Watches(&appsv1.DaemonSet{}, ownedResourceHandler). + Watches(&corev1.ResourceQuota{}, ownedResourceHandler). + + // We use predicate.IgnoreUpdate() so that we skip the reconciliation when a pull secret is added to the ServiceAccount. + // This is necessary so that we don't remove the newly-added secret. + // TODO: this is a temporary hack until we implement the correct solution on the Helm-render side + Watches(&corev1.ServiceAccount{}, ownedResourceHandler, builder.WithPredicates(predicate.IgnoreUpdate())). + + // cluster-scoped resources + // +lint-watches:ignore: Namespace (not present in charts, but must be watched to reconcile ZTunnel when its namespace is created) + Watches(&corev1.Namespace{}, namespaceHandler). + Watches(&rbacv1.ClusterRole{}, ownedResourceHandler). + Watches(&rbacv1.ClusterRoleBinding{}, ownedResourceHandler). + Complete(reconciler.NewStandardReconcilerWithFinalizer[*v1alpha1.ZTunnel](r.Client, r.Reconcile, r.Finalize, constants.FinalizerName)) +} + +func (r *Reconciler) determineStatus(ctx context.Context, ztunnel *v1alpha1.ZTunnel, reconcileErr error) (v1alpha1.ZTunnelStatus, error) { + var errs errlist.Builder + reconciledCondition := r.determineReconciledCondition(reconcileErr) + readyCondition, err := r.determineReadyCondition(ctx, ztunnel) + errs.Add(err) + + status := *ztunnel.Status.DeepCopy() + status.ObservedGeneration = ztunnel.Generation + status.SetCondition(reconciledCondition) + status.SetCondition(readyCondition) + status.State = deriveState(reconciledCondition, readyCondition) + return status, errs.Error() +} + +func (r *Reconciler) updateStatus(ctx context.Context, ztunnel *v1alpha1.ZTunnel, reconcileErr error) error { + var errs errlist.Builder + + status, err := r.determineStatus(ctx, ztunnel, reconcileErr) + if err != nil { + errs.Add(fmt.Errorf("failed to determine status: %w", err)) + } + + if !reflect.DeepEqual(ztunnel.Status, status) { + if err := r.Client.Status().Patch(ctx, ztunnel, kube.NewStatusPatch(status)); err != nil { + errs.Add(fmt.Errorf("failed to patch status: %w", err)) + } + } + return errs.Error() +} + +func deriveState(reconciledCondition, readyCondition v1alpha1.ZTunnelCondition) v1alpha1.ZTunnelConditionReason { + if reconciledCondition.Status != metav1.ConditionTrue { + return reconciledCondition.Reason + } else if readyCondition.Status != metav1.ConditionTrue { + return readyCondition.Reason + } + return v1alpha1.ZTunnelReasonHealthy +} + +func (r *Reconciler) determineReconciledCondition(err error) v1alpha1.ZTunnelCondition { + c := v1alpha1.ZTunnelCondition{Type: v1alpha1.ZTunnelConditionReconciled} + + if err == nil { + c.Status = metav1.ConditionTrue + } else { + c.Status = metav1.ConditionFalse + c.Reason = v1alpha1.ZTunnelReasonReconcileError + c.Message = fmt.Sprintf("error reconciling resource: %v", err) + } + return c +} + +func (r *Reconciler) determineReadyCondition(ctx context.Context, ztunnel *v1alpha1.ZTunnel) (v1alpha1.ZTunnelCondition, error) { + c := v1alpha1.ZTunnelCondition{ + Type: v1alpha1.ZTunnelConditionReady, + Status: metav1.ConditionFalse, + } + + ds := appsv1.DaemonSet{} + if err := r.Client.Get(ctx, r.getDaemonSetKey(ztunnel), &ds); err == nil { + if ds.Status.CurrentNumberScheduled == 0 { + c.Reason = v1alpha1.ZTunnelDaemonSetNotReady + c.Message = "no ztunnel pods are currently scheduled" + } else if ds.Status.NumberReady < ds.Status.CurrentNumberScheduled { + c.Reason = v1alpha1.ZTunnelDaemonSetNotReady + c.Message = "not all ztunnel pods are ready" + } else { + c.Status = metav1.ConditionTrue + } + } else if apierrors.IsNotFound(err) { + c.Reason = v1alpha1.ZTunnelDaemonSetNotReady + c.Message = "ztunnel DaemonSet not found" + } else { + c.Status = metav1.ConditionUnknown + c.Reason = v1alpha1.ZTunnelReasonReadinessCheckFailed + c.Message = fmt.Sprintf("failed to get readiness: %v", err) + return c, fmt.Errorf("get failed: %w", err) + } + return c, nil +} + +func (r *Reconciler) getDaemonSetKey(ztunnel *v1alpha1.ZTunnel) client.ObjectKey { + return client.ObjectKey{ + Namespace: ztunnel.Spec.Namespace, + Name: "ztunnel", + } +} + +func (r *Reconciler) mapNamespaceToReconcileRequest(ctx context.Context, ns client.Object) []reconcile.Request { + log := logf.FromContext(ctx) + + // Check if any ZTunnel references this namespace in .spec.namespace + ztunnelList := v1alpha1.ZTunnelList{} + if err := r.Client.List(ctx, &ztunnelList); err != nil { + log.Error(err, "failed to list ZTunnels") + return nil + } + + var requests []reconcile.Request + for _, ztunnel := range ztunnelList.Items { + if ztunnel.Spec.Namespace == ns.GetName() { + requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: ztunnel.Name}}) + } + } + return requests +} + +func wrapEventHandler(logger logr.Logger, handler handler.EventHandler) handler.EventHandler { + return enqueuelogger.WrapIfNecessary(v1alpha1.ZTunnelKind, logger, handler) +} diff --git a/controllers/ztunnel/ztunnel_controller_test.go b/controllers/ztunnel/ztunnel_controller_test.go new file mode 100644 index 000000000..5458253e4 --- /dev/null +++ b/controllers/ztunnel/ztunnel_controller_test.go @@ -0,0 +1,538 @@ +// Copyright Istio Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ztunnel + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/istio-ecosystem/sail-operator/api/v1alpha1" + "github.com/istio-ecosystem/sail-operator/pkg/config" + "github.com/istio-ecosystem/sail-operator/pkg/scheme" + "github.com/istio-ecosystem/sail-operator/pkg/test/util/supportedversion" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "istio.io/istio/pkg/ptr" +) + +const ( + ztunnelNamespace = "ztunnel" +) + +func TestValidate(t *testing.T) { + cfg := newReconcilerTestConfig(t) + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: ztunnelNamespace, + }, + } + + testCases := []struct { + name string + ztunnel *v1alpha1.ZTunnel + objects []client.Object + expectErr string + }{ + { + name: "success", + ztunnel: &v1alpha1.ZTunnel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.ZTunnelSpec{ + Version: supportedversion.Default, + Namespace: ztunnelNamespace, + }, + }, + objects: []client.Object{ns}, + expectErr: "", + }, + { + name: "no version", + ztunnel: &v1alpha1.ZTunnel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.ZTunnelSpec{ + Namespace: ztunnelNamespace, + }, + }, + objects: []client.Object{ns}, + expectErr: "spec.version not set", + }, + { + name: "no namespace", + ztunnel: &v1alpha1.ZTunnel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.ZTunnelSpec{ + Version: supportedversion.Default, + }, + }, + objects: []client.Object{ns}, + expectErr: "spec.namespace not set", + }, + { + name: "namespace not found", + ztunnel: &v1alpha1.ZTunnel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.ZTunnelSpec{ + Version: supportedversion.Default, + Namespace: ztunnelNamespace, + }, + }, + objects: []client.Object{}, + expectErr: fmt.Sprintf(`namespace %q doesn't exist`, ztunnelNamespace), + }, + { + name: "namespace is being deleted", + ztunnel: &v1alpha1.ZTunnel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.ZTunnelSpec{ + Version: supportedversion.Default, + Namespace: ztunnelNamespace, + }, + }, + objects: []client.Object{&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: ztunnelNamespace, + DeletionTimestamp: &metav1.Time{ + Time: time.Now(), + }, + Finalizers: []string{ + "sail-operator", + }, + }, + }}, + expectErr: fmt.Sprintf(`namespace %q is being deleted`, ztunnelNamespace), + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(tc.objects...).Build() + r := NewReconciler(cfg, cl, scheme.Scheme, nil) + + err := r.validate(context.TODO(), tc.ztunnel) + if tc.expectErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.expectErr)) + } + }) + } +} + +func TestDeriveState(t *testing.T) { + testCases := []struct { + name string + reconciledCondition v1alpha1.ZTunnelCondition + readyCondition v1alpha1.ZTunnelCondition + expectedState v1alpha1.ZTunnelConditionReason + }{ + { + name: "healthy", + reconciledCondition: newCondition(v1alpha1.ZTunnelConditionReconciled, metav1.ConditionTrue, ""), + readyCondition: newCondition(v1alpha1.ZTunnelConditionReady, metav1.ConditionTrue, ""), + expectedState: v1alpha1.ZTunnelReasonHealthy, + }, + { + name: "not reconciled", + reconciledCondition: newCondition(v1alpha1.ZTunnelConditionReconciled, metav1.ConditionFalse, v1alpha1.ZTunnelReasonReconcileError), + readyCondition: newCondition(v1alpha1.ZTunnelConditionReady, metav1.ConditionTrue, ""), + expectedState: v1alpha1.ZTunnelReasonReconcileError, + }, + { + name: "not ready", + reconciledCondition: newCondition(v1alpha1.ZTunnelConditionReconciled, metav1.ConditionTrue, ""), + readyCondition: newCondition(v1alpha1.ZTunnelConditionReady, metav1.ConditionFalse, v1alpha1.ZTunnelDaemonSetNotReady), + expectedState: v1alpha1.ZTunnelDaemonSetNotReady, + }, + { + name: "readiness unknown", + reconciledCondition: newCondition(v1alpha1.ZTunnelConditionReconciled, metav1.ConditionTrue, ""), + readyCondition: newCondition(v1alpha1.ZTunnelConditionReady, metav1.ConditionUnknown, v1alpha1.ZTunnelReasonReadinessCheckFailed), + expectedState: v1alpha1.ZTunnelReasonReadinessCheckFailed, + }, + { + name: "not reconciled nor ready", + reconciledCondition: newCondition(v1alpha1.ZTunnelConditionReconciled, metav1.ConditionFalse, v1alpha1.ZTunnelReasonReconcileError), + readyCondition: newCondition(v1alpha1.ZTunnelConditionReady, metav1.ConditionFalse, v1alpha1.ZTunnelDaemonSetNotReady), + expectedState: v1alpha1.ZTunnelReasonReconcileError, // reconcile reason takes precedence over ready reason + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + result := deriveState(tc.reconciledCondition, tc.readyCondition) + g.Expect(result).To(Equal(tc.expectedState)) + }) + } +} + +func newCondition(condType v1alpha1.ZTunnelConditionType, status metav1.ConditionStatus, reason v1alpha1.ZTunnelConditionReason) v1alpha1.ZTunnelCondition { + return v1alpha1.ZTunnelCondition{ + Type: condType, + Status: status, + Reason: reason, + } +} + +func TestDetermineReadyCondition(t *testing.T) { + cfg := newReconcilerTestConfig(t) + + testCases := []struct { + name string + clientObjects []client.Object + interceptors interceptor.Funcs + expected v1alpha1.ZTunnelCondition + expectErr bool + }{ + { + name: "ZTunnel ready", + clientObjects: []client.Object{ + &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ztunnel", + Namespace: ztunnelNamespace, + }, + Status: appsv1.DaemonSetStatus{ + CurrentNumberScheduled: 1, + NumberReady: 1, + }, + }, + }, + expected: v1alpha1.ZTunnelCondition{ + Type: v1alpha1.ZTunnelConditionReady, + Status: metav1.ConditionTrue, + }, + }, + { + name: "ZTunnel not ready", + clientObjects: []client.Object{ + &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ztunnel", + Namespace: ztunnelNamespace, + }, + Status: appsv1.DaemonSetStatus{ + CurrentNumberScheduled: 1, + NumberReady: 0, + }, + }, + }, + expected: v1alpha1.ZTunnelCondition{ + Type: v1alpha1.ZTunnelConditionReady, + Status: metav1.ConditionFalse, + Reason: v1alpha1.ZTunnelDaemonSetNotReady, + Message: "not all ztunnel pods are ready", + }, + }, + { + name: "ZTunnel pods not scheduled", + clientObjects: []client.Object{ + &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ztunnel", + Namespace: ztunnelNamespace, + }, + Status: appsv1.DaemonSetStatus{ + CurrentNumberScheduled: 0, + NumberReady: 0, + }, + }, + }, + expected: v1alpha1.ZTunnelCondition{ + Type: v1alpha1.ZTunnelConditionReady, + Status: metav1.ConditionFalse, + Reason: v1alpha1.ZTunnelDaemonSetNotReady, + Message: "no ztunnel pods are currently scheduled", + }, + }, + { + name: "ZTunnel daemonSet not found", + clientObjects: []client.Object{}, + expected: v1alpha1.ZTunnelCondition{ + Type: v1alpha1.ZTunnelConditionReady, + Status: metav1.ConditionFalse, + Reason: v1alpha1.ZTunnelDaemonSetNotReady, + Message: "ztunnel DaemonSet not found", + }, + }, + { + name: "client error on get", + clientObjects: []client.Object{}, + interceptors: interceptor.Funcs{ + Get: func(_ context.Context, _ client.WithWatch, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + return fmt.Errorf("simulated error") + }, + }, + expected: v1alpha1.ZTunnelCondition{ + Type: v1alpha1.ZTunnelConditionReady, + Status: metav1.ConditionUnknown, + Reason: v1alpha1.ZTunnelReasonReadinessCheckFailed, + Message: "failed to get readiness: simulated error", + }, + expectErr: true, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(tt.clientObjects...).WithInterceptorFuncs(tt.interceptors).Build() + + r := NewReconciler(cfg, cl, scheme.Scheme, nil) + + ztunnel := &v1alpha1.ZTunnel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ztunnel", + }, + Spec: v1alpha1.ZTunnelSpec{ + Namespace: ztunnelNamespace, + }, + } + + result, err := r.determineReadyCondition(context.TODO(), ztunnel) + if tt.expectErr { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(result.Type).To(Equal(tt.expected.Type)) + g.Expect(result.Status).To(Equal(tt.expected.Status)) + g.Expect(result.Reason).To(Equal(tt.expected.Reason)) + g.Expect(result.Message).To(Equal(tt.expected.Message)) + }) + } +} + +func TestApplyImageDigests(t *testing.T) { + testCases := []struct { + name string + config config.OperatorConfig + input *v1alpha1.ZTunnel + expectValues *v1alpha1.ZTunnelValues + }{ + { + name: "no-config", + config: config.OperatorConfig{ + ImageDigests: map[string]config.IstioImageConfig{}, + }, + input: &v1alpha1.ZTunnel{ + Spec: v1alpha1.ZTunnelSpec{ + Version: "v1.24.0", + Values: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Image: ptr.Of("ztunnel-test"), + }, + }, + }, + }, + expectValues: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Image: ptr.Of("ztunnel-test"), + }, + }, + }, + { + name: "no-user-values", + config: config.OperatorConfig{ + ImageDigests: map[string]config.IstioImageConfig{ + "v1.24.0": { + ZTunnelImage: "ztunnel-test", + }, + }, + }, + input: &v1alpha1.ZTunnel{ + Spec: v1alpha1.ZTunnelSpec{ + Version: "v1.24.0", + Values: &v1alpha1.ZTunnelValues{}, + }, + }, + expectValues: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Image: ptr.Of("ztunnel-test"), + }, + }, + }, + { + name: "user-supplied-image", + config: config.OperatorConfig{ + ImageDigests: map[string]config.IstioImageConfig{ + "v1.24.0": { + ZTunnelImage: "ztunnel-test", + }, + }, + }, + input: &v1alpha1.ZTunnel{ + Spec: v1alpha1.ZTunnelSpec{ + Version: "v1.24.0", + Values: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Image: ptr.Of("ztunnel-custom"), + }, + }, + }, + }, + expectValues: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Image: ptr.Of("ztunnel-custom"), + }, + }, + }, + { + name: "user-supplied-hub-tag", + config: config.OperatorConfig{ + ImageDigests: map[string]config.IstioImageConfig{ + "v1.24.0": { + ZTunnelImage: "ztunnel-test", + }, + }, + }, + input: &v1alpha1.ZTunnel{ + Spec: v1alpha1.ZTunnelSpec{ + Version: "v1.24.0", + Values: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Hub: ptr.Of("docker.io/istio"), + Tag: ptr.Of("1.24.0"), + }, + }, + }, + }, + expectValues: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Hub: ptr.Of("docker.io/istio"), + Tag: ptr.Of("1.24.0"), + }, + }, + }, + { + name: "version-without-defaults", + config: config.OperatorConfig{ + ImageDigests: map[string]config.IstioImageConfig{ + "v1.24.0": { + ZTunnelImage: "ztunnel-test", + }, + }, + }, + input: &v1alpha1.ZTunnel{ + Spec: v1alpha1.ZTunnelSpec{ + Version: "v1.24.1", + Values: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Hub: ptr.Of("docker.io/istio"), + Tag: ptr.Of("1.24.1"), + }, + }, + }, + }, + expectValues: &v1alpha1.ZTunnelValues{ + ZTunnel: &v1alpha1.ZTunnelConfig{ + Hub: ptr.Of("docker.io/istio"), + Tag: ptr.Of("1.24.1"), + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := applyImageDigests(tc.input, tc.input.Spec.Values, tc.config) + if diff := cmp.Diff(tc.expectValues, result); diff != "" { + t.Errorf("unexpected merge result; diff (-expected, +actual):\n%v", diff) + } + }) + } +} + +func TestDetermineStatus(t *testing.T) { + cfg := newReconcilerTestConfig(t) + + tests := []struct { + name string + reconcileErr error + }{ + { + name: "no error", + reconcileErr: nil, + }, + { + name: "reconcile error", + reconcileErr: fmt.Errorf("some reconcile error"), + }, + } + + ctx := context.TODO() + cl := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + r := NewReconciler(cfg, cl, scheme.Scheme, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + ztunnel := &v1alpha1.ZTunnel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ztunnel", + Generation: 123, + }, + } + + status, err := r.determineStatus(ctx, ztunnel, tt.reconcileErr) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(status.ObservedGeneration).To(Equal(ztunnel.Generation)) + + reconciledCondition := r.determineReconciledCondition(tt.reconcileErr) + readyCondition, err := r.determineReadyCondition(ctx, ztunnel) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(status.State).To(Equal(deriveState(reconciledCondition, readyCondition))) + g.Expect(normalize(status.GetCondition(v1alpha1.ZTunnelConditionReconciled))).To(Equal(normalize(reconciledCondition))) + g.Expect(normalize(status.GetCondition(v1alpha1.ZTunnelConditionReady))).To(Equal(normalize(readyCondition))) + }) + } +} + +func normalize(condition v1alpha1.ZTunnelCondition) v1alpha1.ZTunnelCondition { + condition.LastTransitionTime = metav1.Time{} + return condition +} + +func newReconcilerTestConfig(t *testing.T) config.ReconcilerConfig { + return config.ReconcilerConfig{ + ResourceDirectory: t.TempDir(), + Platform: config.PlatformKubernetes, + DefaultProfile: "", + } +}