diff --git a/.github/ISSUE_TEMPLATE/epic.yaml b/.github/ISSUE_TEMPLATE/epic.yaml new file mode 100644 index 000000000..d77640e0e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic.yaml @@ -0,0 +1,41 @@ +name: ✨ Epic +description: Create an epic +title: "[EPIC] -
ClusterSelector specifies a selector for clusters by name or by label with the option to exclude specific clusters.
+Field | +Description | +
---|---|
+clusterName + +string + + |
+
+ Name of a single Cluster to select. + |
+
+labelSelector + + +Kubernetes meta/v1.LabelSelector + + + |
+
+ LabelSelector is a label query over a set of Clusters. + |
+
+excludeList + +[]string + + |
+
+ ExcludeList is a list of Cluster names to exclude from LabelSelector query. + |
+
diff --git a/pkg/apis/greenhouse/v1alpha1/api_suite_test.go b/pkg/apis/greenhouse/v1alpha1/api_suite_test.go index a3314fcda..ba070272c 100644 --- a/pkg/apis/greenhouse/v1alpha1/api_suite_test.go +++ b/pkg/apis/greenhouse/v1alpha1/api_suite_test.go @@ -8,6 +8,8 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + + "github.com/cloudoperators/greenhouse/pkg/test" ) func TestAPI(t *testing.T) { @@ -16,7 +18,10 @@ func TestAPI(t *testing.T) { } var _ = BeforeSuite(func() { + test.TestBeforeSuite() }) var _ = AfterSuite(func() { + By("tearing down the test environment") + test.TestAfterSuite() }) diff --git a/pkg/apis/greenhouse/v1alpha1/types.go b/pkg/apis/greenhouse/v1alpha1/types.go index aa05f458e..72e075413 100644 --- a/pkg/apis/greenhouse/v1alpha1/types.go +++ b/pkg/apis/greenhouse/v1alpha1/types.go @@ -4,7 +4,14 @@ package v1alpha1 import ( + "context" "fmt" + "slices" + + 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" ) // HelmChartReference references a Helm Chart in a chart repository. @@ -48,3 +55,50 @@ type UIApplicationReference struct { // Version of the frontend application. Version string `json:"version"` } + +// ClusterSelector specifies a selector for clusters by name or by label with the option to exclude specific clusters. +type ClusterSelector struct { + // Name of a single Cluster to select. + Name string `json:"clusterName,omitempty"` + // LabelSelector is a label query over a set of Clusters. + LabelSelector metav1.LabelSelector `json:"labelSelector,omitempty"` + // ExcludeList is a list of Cluster names to exclude from LabelSelector query. + ExcludeList []string `json:"excludeList,omitempty"` +} + +// ListClusters returns the list of Clusters that match the ClusterSelector's Name or LabelSelector with applied ExcludeList. +// If the Name or LabelSelector does not return any cluster, an empty ClusterList is returned without error. +func (cs *ClusterSelector) ListClusters(ctx context.Context, c client.Client, namespace string) (*ClusterList, error) { + if cs.Name != "" { + cluster := new(Cluster) + err := c.Get(ctx, types.NamespacedName{Name: cs.Name, Namespace: namespace}, cluster) + if err != nil { + if apierrors.IsNotFound(err) { + return &ClusterList{}, nil + } + return nil, err + } + return &ClusterList{Items: []Cluster{*cluster}}, nil + } + + labelSelector, err := metav1.LabelSelectorAsSelector(&cs.LabelSelector) + if err != nil { + return nil, err + } + var clusters = new(ClusterList) + err = c.List(ctx, clusters, client.InNamespace(namespace), client.MatchingLabelsSelector{Selector: labelSelector}) + if err != nil { + return nil, err + } + if len(clusters.Items) == 0 || len(cs.ExcludeList) == 0 { + return clusters, nil + } + + clusters.Items = slices.DeleteFunc(clusters.Items, func(cluster Cluster) bool { + return slices.ContainsFunc(cs.ExcludeList, func(excludedClusterName string) bool { + return cluster.Name == excludedClusterName + }) + }) + + return clusters, nil +} diff --git a/pkg/apis/greenhouse/v1alpha1/types_test.go b/pkg/apis/greenhouse/v1alpha1/types_test.go new file mode 100644 index 000000000..832b3dca9 --- /dev/null +++ b/pkg/apis/greenhouse/v1alpha1/types_test.go @@ -0,0 +1,111 @@ +// 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" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/cloudoperators/greenhouse/pkg/apis/greenhouse/v1alpha1" + "github.com/cloudoperators/greenhouse/pkg/test" +) + +var ( + setup *test.TestSetup + clusterA *v1alpha1.Cluster + clusterB *v1alpha1.Cluster + clusterC *v1alpha1.Cluster + clusterD *v1alpha1.Cluster + clusterE *v1alpha1.Cluster + clusterF *v1alpha1.Cluster + clusterG *v1alpha1.Cluster +) + +var _ = Describe("ClusterSelector type's ListClusters method", Ordered, func() { + BeforeAll(func() { + setup = test.NewTestSetup(test.Ctx, test.K8sClient, "test-org") + + By("creating test clusters") + clusterA = setup.CreateCluster(test.Ctx, "cluster-a") + clusterB = setup.CreateCluster(test.Ctx, "cluster-b", test.WithLabel("group", "first")) + clusterC = setup.CreateCluster(test.Ctx, "cluster-c", test.WithLabel("group", "second")) + clusterD = setup.CreateCluster(test.Ctx, "cluster-d", test.WithLabel("group", "first")) + clusterE = setup.CreateCluster(test.Ctx, "cluster-e", test.WithLabel("group", "second")) + clusterF = setup.CreateCluster(test.Ctx, "cluster-f", test.WithLabel("group", "second")) + clusterG = setup.CreateCluster(test.Ctx, "cluster-g", test.WithLabel("group", "second")) + }) + + AfterAll(func() { + By("cleaning up test clusters") + test.EventuallyDeleted(test.Ctx, setup.Client, clusterA) + test.EventuallyDeleted(test.Ctx, setup.Client, clusterB) + test.EventuallyDeleted(test.Ctx, setup.Client, clusterC) + test.EventuallyDeleted(test.Ctx, setup.Client, clusterD) + test.EventuallyDeleted(test.Ctx, setup.Client, clusterE) + test.EventuallyDeleted(test.Ctx, setup.Client, clusterF) + test.EventuallyDeleted(test.Ctx, setup.Client, clusterG) + }) + + It("should return correct cluster by Name", func() { + By("setting up a ClusterSelector") + cs := new(v1alpha1.ClusterSelector) + cs.Name = "cluster-a" + + By("executing ListClusters method") + clusters, err := cs.ListClusters(test.Ctx, setup.Client, setup.Namespace()) + Expect(err).ToNot(HaveOccurred(), "there should be no error listing the clusters") + + By("checking returned clusters") + Expect(clusters.Items).To(HaveLen(1), "ListClusters should match exactly one cluster") + Expect(clusters.Items[0].Name).To(Equal("cluster-a"), "ListClusters should return cluster with name cluster-a") + }) + + It("should list all clusters matching LabelSelector", func() { + By("setting up a ClusterSelector") + cs := new(v1alpha1.ClusterSelector) + cs.LabelSelector = v1.LabelSelector{ + MatchLabels: map[string]string{ + "group": "first", + }, + } + + By("executing ListClusters method") + clusters, err := cs.ListClusters(test.Ctx, setup.Client, setup.Namespace()) + Expect(err).ToNot(HaveOccurred(), "there should be no error listing the clusters") + + By("checking returned clusters") + Expect(clusters.Items).To(HaveLen(2), "ListClusters should match exactly two clusters") + + clusterNames := make([]string, 0, 2) + for _, v := range clusters.Items { + clusterNames = append(clusterNames, v.Name) + } + Expect(clusterNames).To(ConsistOf("cluster-b", "cluster-d"), "ListClusters should return clusters with names cluster-b and cluster-d") + }) + + It("should list all clusters matching LabelSelector except for those in ExcludeList", func() { + By("setting up a ClusterSelector") + cs := new(v1alpha1.ClusterSelector) + cs.LabelSelector = v1.LabelSelector{ + MatchLabels: map[string]string{ + "group": "second", + }, + } + cs.ExcludeList = []string{"cluster-c", "cluster-f"} + + By("executing ListClusters method") + clusters, err := cs.ListClusters(test.Ctx, setup.Client, setup.Namespace()) + Expect(err).ToNot(HaveOccurred(), "there should be no error listing the clusters") + + By("checking returned clusters") + Expect(clusters.Items).To(HaveLen(2), "ListClusters should match exactly two clusters") + + clusterNames := make([]string, 0, 2) + for _, v := range clusters.Items { + clusterNames = append(clusterNames, v.Name) + } + Expect(clusterNames).To(ConsistOf("cluster-e", "cluster-g"), "ListClusters should return clusters with names cluster-e and cluster-g") + }) +}) diff --git a/pkg/apis/greenhouse/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/greenhouse/v1alpha1/zz_generated.deepcopy.go index 613021e25..0ff2e9e9b 100644 --- a/pkg/apis/greenhouse/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/greenhouse/v1alpha1/zz_generated.deepcopy.go @@ -384,6 +384,27 @@ func (in *ClusterOptionOverride) DeepCopy() *ClusterOptionOverride { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterSelector) DeepCopyInto(out *ClusterSelector) { + *out = *in + in.LabelSelector.DeepCopyInto(&out.LabelSelector) + if in.ExcludeList != nil { + in, out := &in.ExcludeList, &out.ExcludeList + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterSelector. +func (in *ClusterSelector) DeepCopy() *ClusterSelector { + if in == nil { + return nil + } + out := new(ClusterSelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterSpec) DeepCopyInto(out *ClusterSpec) { *out = *in