diff --git a/api/v1alpha1/cluster_types.go b/api/v1alpha1/cluster_types.go index eb6b6daf9..c3d657325 100644 --- a/api/v1alpha1/cluster_types.go +++ b/api/v1alpha1/cluster_types.go @@ -5,6 +5,7 @@ package v1alpha1 import ( "slices" + "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -54,6 +55,9 @@ const ( // MinTokenValidity contains maximum bearer token validity duration. MinTokenValidity = 24 + + // ServiceProxyDisabledKey is the annotation key used to indicate that the service proxy is disabled for a cluster. + ServiceProxyDisabledKey = "greenhouse.sap/service-proxy-disabled" ) // ClusterStatus defines the observed state of Cluster @@ -127,6 +131,25 @@ func (c *Cluster) CanBeSuspended() bool { return false } +// IsExposedServicesDisabled returns true if the exposed services are disabled for the cluster, false otherwise. The value of the annotation "greenhouse.sap/service-proxy-disabled" is used to determine if the exposed services are disabled. Returns true if the annotation is present and its value is "true". +func (c *Cluster) IsExposedServicesDisabled() bool { + if c == nil { + return false + } + + annotations := c.GetAnnotations() + if len(annotations) == 0 { + return false + } + + value, ok := annotations[ServiceProxyDisabledKey] + if !ok { + return false + } + + return strings.ToLower(value) == "true" +} + // GetSecretName returns the Kubernetes secret containing sensitive data for this cluster. // The secret is for internal usage only and its content must not be exposed to the user. func (c *Cluster) GetSecretName() string { diff --git a/api/v1alpha1/cluster_types_test.go b/api/v1alpha1/cluster_types_test.go new file mode 100644 index 000000000..566163b3f --- /dev/null +++ b/api/v1alpha1/cluster_types_test.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" +) + +var _ = Describe("Cluster.IsExposedServicesDisabled", func() { + DescribeTable("should return expected result based on annotation", + func(annotations map[string]string, expected bool) { + cluster := &greenhousev1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-namespace", + Annotations: annotations, + }, + } + Expect(cluster.IsExposedServicesDisabled()).To(Equal(expected)) + }, + Entry("no annotations", nil, false), + Entry("annotation not present", map[string]string{"some-other-annotation": "value"}, false), + Entry("annotation set to 'true'", map[string]string{greenhousev1alpha1.ServiceProxyDisabledKey: "true"}, true), + Entry("annotation set to 'True' (case-insensitive)", map[string]string{greenhousev1alpha1.ServiceProxyDisabledKey: "True"}, true), + Entry("annotation set to 'TRUE' (case-insensitive)", map[string]string{greenhousev1alpha1.ServiceProxyDisabledKey: "TRUE"}, true), + Entry("annotation set to 'false'", map[string]string{greenhousev1alpha1.ServiceProxyDisabledKey: "false"}, false), + Entry("annotation set to empty string", map[string]string{greenhousev1alpha1.ServiceProxyDisabledKey: ""}, false), + Entry("annotation set to arbitrary non-true value", map[string]string{greenhousev1alpha1.ServiceProxyDisabledKey: "yes"}, false), + ) + + It("should return false for a nil cluster", func() { + var cluster *greenhousev1alpha1.Cluster + Expect(cluster.IsExposedServicesDisabled()).To(BeFalse()) + }) +}) diff --git a/api/v1alpha1/plugin_types.go b/api/v1alpha1/plugin_types.go index 62c910583..357bc5fe8 100644 --- a/api/v1alpha1/plugin_types.go +++ b/api/v1alpha1/plugin_types.go @@ -140,6 +140,9 @@ const ( // ExposedServicesSyncedCondition reflects that the list of exposed services is up to date with the services defined in the Helm chart. ExposedServicesSyncedCondition greenhousemetav1alpha1.ConditionType = "ExposedServicesSynced" + // ExposedServicesDisabledReason is used as a reason for ExposedServicesSyncedCondition when exposed services are disabled for the cluster. + ExposedServicesDisabledReason greenhousemetav1alpha1.ConditionReason = "ExposedServicesDisabled" + // HelmReleaseDeployedCondition reflects that the HelmRelease has been successfully released on the target cluster. HelmReleaseDeployedCondition greenhousemetav1alpha1.ConditionType = "HelmReleaseDeployed" diff --git a/e2e/plugin/scenarios/flux_controller.go b/e2e/plugin/scenarios/flux_controller.go index aa3601162..60cf29216 100644 --- a/e2e/plugin/scenarios/flux_controller.go +++ b/e2e/plugin/scenarios/flux_controller.go @@ -172,6 +172,42 @@ func FluxControllerPodInfoByPlugin(ctx context.Context, adminClient, remoteClien g.Expect(deploymentList.Items[0].Spec.Replicas).To(PointTo(Equal(int32(1))), "the deployment should have 1 replica") }).Should(Succeed()) + By("Disabling exposed services for the cluster") + test.MustSetAnnotation(ctx, adminClient, remoteCluster, greenhousev1alpha1.ServiceProxyDisabledKey, "true") + + By("verifying exposed services are nil and condition reflects disabled state") + Eventually(func(g Gomega) { + err := adminClient.Get(ctx, client.ObjectKeyFromObject(plugin), plugin) + g.Expect(err).ToNot(HaveOccurred(), "failed to get Plugin") + + g.Expect(plugin.Status.ExposedServices).To(BeNil(), "ExposedServices should be nil when service-proxy is disabled") + + exposedServicesSyncedCondition := plugin.Status.GetConditionByType(greenhousev1alpha1.ExposedServicesSyncedCondition) + g.Expect(exposedServicesSyncedCondition).ToNot(BeNil(), "ExposedServicesSynced condition should be set") + g.Expect(exposedServicesSyncedCondition.IsTrue()).To(BeTrue(), "ExposedServicesSynced condition should be true") + g.Expect(exposedServicesSyncedCondition.Reason).To(Equal(greenhousev1alpha1.ExposedServicesDisabledReason), + "ExposedServicesSynced condition should have ExposedServicesDisabled reason") + }).Should(Succeed()) + + By("removing the service-proxy-disabled annotation from the cluster") + test.MustRemoveAnnotation(ctx, adminClient, remoteCluster, greenhousev1alpha1.ServiceProxyDisabledKey) + + By("verifying exposed services are fetched again after annotation removal") + Eventually(func(g Gomega) { + err := adminClient.Get(ctx, client.ObjectKeyFromObject(plugin), plugin) + g.Expect(err).ToNot(HaveOccurred(), "failed to get Plugin") + + // Exposed services should be fetched again once service-proxy is no longer disabled. + g.Expect(plugin.Status.ExposedServices).ToNot(BeNil(), + "ExposedServices should be populated again after service-proxy-disabled annotation removal") + exposedServicesSyncedCondition := plugin.Status.GetConditionByType(greenhousev1alpha1.ExposedServicesSyncedCondition) + g.Expect(exposedServicesSyncedCondition).ToNot(BeNil(), "ExposedServicesSynced condition should be set") + g.Expect(exposedServicesSyncedCondition.IsTrue()).To(BeTrue(), + "ExposedServicesSynced condition should be true after annotation removal when exposed services are synced again") + g.Expect(exposedServicesSyncedCondition.Reason).ToNot(Equal(greenhousev1alpha1.ExposedServicesDisabledReason), + "ExposedServicesSynced condition should no longer have ExposedServicesDisabled reason after annotation removal") + }).Should(Succeed()) + By("Deleting the plugin preset") test.EventuallyDeleted(ctx, adminClient, testPluginPreset) By("Verifying the HelmRelease is deleted") diff --git a/internal/controller/plugin/plugin_controller_flux.go b/internal/controller/plugin/plugin_controller_flux.go index 3d808b958..610a49130 100644 --- a/internal/controller/plugin/plugin_controller_flux.go +++ b/internal/controller/plugin/plugin_controller_flux.go @@ -198,7 +198,7 @@ func (r *PluginReconciler) ensureHelmRelease( WithTargetNamespace(plugin.Spec.ReleaseNamespace) if mirrorConfig != nil && len(mirrorConfig.RegistryMirrors) > 0 { - restClientGetter, err := initClientGetter(ctx, r.Client, r.kubeClientOpts, plugin) + restClientGetter, _, err := initClientGetter(ctx, r.Client, r.kubeClientOpts, plugin) if err != nil { return fmt.Errorf("failed to init client getter for Plugin %s: %w", plugin.Name, err) } @@ -244,7 +244,7 @@ func (r *PluginReconciler) ensureHelmRelease( func (r *PluginReconciler) computeReadyConditionFlux(ctx context.Context, plugin *greenhousev1alpha1.Plugin) greenhousemetav1alpha1.Condition { readyCondition := *plugin.Status.GetConditionByType(greenhousemetav1alpha1.ReadyCondition) - restClientGetter, err := initClientGetter(ctx, r.Client, r.kubeClientOpts, plugin) + restClientGetter, cluster, err := initClientGetter(ctx, r.Client, r.kubeClientOpts, plugin) if err != nil { util.UpdatePluginReconcileTotalMetric(plugin, util.MetricResultError, util.MetricReasonClusterAccessFailed) readyCondition.Status = metav1.ConditionFalse @@ -267,7 +267,7 @@ func (r *PluginReconciler) computeReadyConditionFlux(ctx context.Context, plugin plugin.Status.Weight = pluginDefinitionSpec.Weight plugin.Status.Description = pluginDefinitionSpec.Description - r.fetchReleaseStatus(ctx, restClientGetter, plugin, *pluginDefinitionSpec, &plugin.Status) + r.fetchReleaseStatus(ctx, restClientGetter, plugin, *pluginDefinitionSpec, &plugin.Status, cluster.IsExposedServicesDisabled()) if err := r.reconcileTechnicalLabels(ctx, plugin); err != nil { log.FromContext(ctx).Error(err, "failed to reconcile technical labels") @@ -315,6 +315,7 @@ func (r *PluginReconciler) fetchReleaseStatus(ctx context.Context, plugin *greenhousev1alpha1.Plugin, pluginDefinitionSpec greenhousev1alpha1.PluginDefinitionSpec, pluginStatus *greenhousev1alpha1.PluginStatus, + exposedServicesDisabled bool, ) { var ( @@ -340,21 +341,31 @@ func (r *PluginReconciler) fetchReleaseStatus(ctx context.Context, helmRelease := &helmv2.HelmRelease{} err := r.Get(ctx, types.NamespacedName{Name: plugin.Name, Namespace: plugin.Namespace}, helmRelease) if err != nil { + // helm release does not exist or cannot be accessed plugin.SetCondition(greenhousemetav1alpha1.FalseCondition(greenhousev1alpha1.ExposedServicesSyncedCondition, "", "failed to load Flux HelmRelease: "+err.Error())) plugin.SetCondition(greenhousemetav1alpha1.FalseCondition(greenhousev1alpha1.HelmReleaseDeployedCondition, "", "failed to load Flux HelmRelease: "+err.Error())) - } else { - helmSDKRelease, err := helm.GetReleaseForHelmChartFromPlugin(ctx, restClientGetter, plugin) - if err != nil { - plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( - greenhousev1alpha1.ExposedServicesSyncedCondition, "", "failed to fetch Helm release from remote cluster: "+err.Error())) - } else { - serviceList, err := getAllExposedServicesForPlugin(restClientGetter, helmSDKRelease, plugin) + } + if exposedServicesDisabled { + // exposed services are disabled for the cluster + plugin.SetCondition(greenhousemetav1alpha1.TrueCondition(greenhousev1alpha1.ExposedServicesSyncedCondition, greenhousev1alpha1.ExposedServicesDisabledReason, "Exposed services are disabled for cluster "+plugin.Spec.ClusterName)) + } + if err == nil { + // helm release exists + if !exposedServicesDisabled { + // exposed services are not disabled, attempt to fetch exposed services from the cluster + helmSDKRelease, err := helm.GetReleaseForHelmChartFromPlugin(ctx, restClientGetter, plugin) if err != nil { plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( - greenhousev1alpha1.ExposedServicesSyncedCondition, "", "failed to get exposed services: "+err.Error())) + greenhousev1alpha1.ExposedServicesSyncedCondition, "", "failed to fetch Helm release from remote cluster: "+err.Error())) } else { - exposedServices = serviceList - plugin.SetCondition(greenhousemetav1alpha1.TrueCondition(greenhousev1alpha1.ExposedServicesSyncedCondition, "", "Fetched exposed services successfully")) + serviceList, err := getAllExposedServicesForPlugin(restClientGetter, helmSDKRelease, plugin) + if err != nil { + plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( + greenhousev1alpha1.ExposedServicesSyncedCondition, "", "failed to get exposed services: "+err.Error())) + } else { + exposedServices = serviceList + plugin.SetCondition(greenhousemetav1alpha1.TrueCondition(greenhousev1alpha1.ExposedServicesSyncedCondition, "", "Fetched exposed services successfully")) + } } } diff --git a/internal/controller/plugin/util.go b/internal/controller/plugin/util.go index 88f3e04a9..c0a087d89 100644 --- a/internal/controller/plugin/util.go +++ b/internal/controller/plugin/util.go @@ -69,7 +69,7 @@ func InitPluginStatus(plugin *greenhousev1alpha1.Plugin) greenhousev1alpha1.Plug return plugin.Status } -// initClientGetter returns a RestClientGetter for the given Plugin. +// initClientGetter returns a RestClientGetter & Cluster for the given Plugin. // If the Plugin has a clusterName set, the RestClientGetter is initialized from the cluster secret. // Otherwise, the RestClientGetter is initialized with in-cluster config func initClientGetter( @@ -77,7 +77,7 @@ func initClientGetter( k8sClient client.Client, kubeClientOpts []clientutil.KubeClientOption, plugin *greenhousev1alpha1.Plugin, -) (genericclioptions.RESTClientGetter, error) { +) (genericclioptions.RESTClientGetter, *greenhousev1alpha1.Cluster, error) { // early return if spec.clusterName is not set if plugin.Spec.ClusterName == "" { @@ -86,14 +86,14 @@ func initClientGetter( err = fmt.Errorf("cannot access greenhouse cluster: %w", err) plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( greenhousev1alpha1.HelmReleaseCreatedCondition, greenhousev1alpha1.ClusterAccessFailedReason, err.Error())) - return nil, err + return nil, nil, err } c := plugin.Status.GetConditionByType(greenhousev1alpha1.HelmReleaseCreatedCondition) if c != nil && c.Reason == greenhousev1alpha1.ClusterAccessFailedReason { plugin.SetCondition(greenhousemetav1alpha1.TrueCondition( greenhousev1alpha1.HelmReleaseCreatedCondition, "", "")) } - return restClientGetter, nil + return restClientGetter, nil, nil } // get restClientGetter from cluster if clusterName is set @@ -103,7 +103,7 @@ func initClientGetter( err = fmt.Errorf("failed to get cluster %s: %w", plugin.Spec.ClusterName, err) plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( greenhousev1alpha1.HelmReleaseCreatedCondition, greenhousev1alpha1.ClusterAccessFailedReason, err.Error())) - return nil, err + return nil, nil, err } readyConditionInCluster := cluster.Status.GetConditionByType(greenhousemetav1alpha1.ReadyCondition) @@ -111,7 +111,7 @@ func initClientGetter( err = fmt.Errorf("cluster %s is not ready", plugin.Spec.ClusterName) plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( greenhousev1alpha1.HelmReleaseCreatedCondition, greenhousev1alpha1.ClusterAccessFailedReason, err.Error())) - return nil, err + return nil, nil, err } secret := corev1.Secret{} @@ -120,21 +120,21 @@ func initClientGetter( err = fmt.Errorf("failed to get secret for cluster %s: %w", plugin.Spec.ClusterName, err) plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( greenhousev1alpha1.HelmReleaseCreatedCondition, greenhousev1alpha1.ClusterAccessFailedReason, err.Error())) - return nil, err + return nil, nil, err } restClientGetter, err := clientutil.NewRestClientGetterFromSecret(&secret, plugin.Spec.ReleaseNamespace, kubeClientOpts...) if err != nil { err = fmt.Errorf("cannot access cluster %s: %w", plugin.Spec.ClusterName, err) plugin.SetCondition(greenhousemetav1alpha1.FalseCondition( greenhousev1alpha1.HelmReleaseCreatedCondition, greenhousev1alpha1.ClusterAccessFailedReason, err.Error())) - return nil, err + return nil, nil, err } c := plugin.Status.GetConditionByType(greenhousev1alpha1.HelmReleaseCreatedCondition) if c != nil && c.Reason == greenhousev1alpha1.ClusterAccessFailedReason { plugin.SetCondition(greenhousemetav1alpha1.TrueCondition( greenhousev1alpha1.HelmReleaseCreatedCondition, "", "")) } - return restClientGetter, nil + return restClientGetter, cluster, nil } func getPortForExposedService(o runtime.Object) (*corev1.ServicePort, error) {