From 4d3e79c8cc23dc4d8bab0f77bec8b44e45e5319e Mon Sep 17 00:00:00 2001 From: Matthew Booth Date: Fri, 1 Mar 2024 01:01:08 +0000 Subject: [PATCH] Add framework for api validation tests using envtest --- .../apivalidations/neutronfilters_test.go | 176 ++++++++++++++++++ .../apivalidations/openstackcluster_test.go | 62 ++++++ .../apivalidations/openstackmachine_test.go | 57 ++++++ test/e2e/suites/apivalidations/suite_test.go | 159 ++++++++++++++++ 4 files changed, 454 insertions(+) create mode 100644 test/e2e/suites/apivalidations/neutronfilters_test.go create mode 100644 test/e2e/suites/apivalidations/openstackcluster_test.go create mode 100644 test/e2e/suites/apivalidations/openstackmachine_test.go create mode 100644 test/e2e/suites/apivalidations/suite_test.go diff --git a/test/e2e/suites/apivalidations/neutronfilters_test.go b/test/e2e/suites/apivalidations/neutronfilters_test.go new file mode 100644 index 0000000000..540cb815c0 --- /dev/null +++ b/test/e2e/suites/apivalidations/neutronfilters_test.go @@ -0,0 +1,176 @@ +/* +Copyright 2024 The Kubernetes 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 apivalidations + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" +) + +var _ = Describe("Neutron filter API validations", func() { + var ( + namespace *corev1.Namespace + cluster *infrav1.OpenStackCluster + machine *infrav1.OpenStackMachine + ) + + BeforeEach(func() { + namespace = createNamespace() + + // Initialise a basic machine object in the correct namespace + machine = &infrav1.OpenStackMachine{} + machine.Namespace = namespace.Name + machine.GenerateName = "machine-" + + // Initialise a basic cluster object in the correct namespace + cluster = &infrav1.OpenStackCluster{} + cluster.Namespace = namespace.Name + cluster.GenerateName = "cluster-" + }) + + DescribeTable("Allow valid neutron filter tags", func(tags []infrav1.FilterByNeutronTags) { + // Specify the given neutron tags in every filter it is + // possible to specify them in, then create the + // resulting object. It should be valid. + + securityGroups := make([]infrav1.SecurityGroupFilter, len(tags)) + for i := range tags { + securityGroups[i].FilterByNeutronTags = tags[i] + } + machine.Spec.SecurityGroups = securityGroups + + ports := make([]infrav1.PortOpts, len(tags)) + for i := range tags { + port := &ports[i] + port.Network = &infrav1.NetworkFilter{FilterByNeutronTags: tags[i]} + port.FixedIPs = []infrav1.FixedIP{{Subnet: &infrav1.SubnetFilter{FilterByNeutronTags: tags[i]}}} + port.SecurityGroups = []infrav1.SecurityGroupFilter{{FilterByNeutronTags: tags[i]}} + } + Expect(k8sClient.Create(ctx, machine)).To(Succeed(), "OpenStackMachine creation should succeed") + + // Maximum of 2 subnets are supported + nSubnets := min(len(tags), 2) + subnets := make([]infrav1.SubnetFilter, nSubnets) + for i := 0; i < nSubnets; i++ { + subnets[i].FilterByNeutronTags = tags[i] + } + cluster.Spec.Subnets = subnets + if len(tags) > 0 { + cluster.Spec.Network = infrav1.NetworkFilter{FilterByNeutronTags: tags[0]} + cluster.Spec.ExternalNetwork = infrav1.NetworkFilter{FilterByNeutronTags: tags[0]} + cluster.Spec.Router = &infrav1.RouterFilter{FilterByNeutronTags: tags[0]} + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed(), "OpenStackCluster creation should succeed") + }, + Entry("empty list", nil), + Entry("single tag", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{"foo"}}, + }), + Entry("multiple filters, multiple tags", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{"foo", "bar"}}, + {TagsAny: []infrav1.NeutronTag{"foo", "bar"}}, + {NotTags: []infrav1.NeutronTag{"foo", "bar"}}, + {NotTagsAny: []infrav1.NeutronTag{"foo", "bar"}}, + }), + ) + + DescribeTable("Disallow invalid neutron filter tags", func(tags []infrav1.FilterByNeutronTags) { + { + machine := machine.DeepCopy() + securityGroups := make([]infrav1.SecurityGroupFilter, len(tags)) + for i := range tags { + securityGroups[i].FilterByNeutronTags = tags[i] + } + machine.Spec.SecurityGroups = securityGroups + Expect(k8sClient.Create(ctx, machine)).NotTo(Succeed(), "OpenStackMachine creation should fail with invalid security group neutron tags") + } + + for i := range tags { + { + machine := machine.DeepCopy() + machine.Spec.Ports = []infrav1.PortOpts{ + {Network: &infrav1.NetworkFilter{FilterByNeutronTags: tags[i]}}, + } + Expect(k8sClient.Create(ctx, machine)).NotTo(Succeed(), "OpenStackMachine creation should fail with invalid port network neutron tags") + } + { + machine := machine.DeepCopy() + machine.Spec.Ports = []infrav1.PortOpts{ + {FixedIPs: []infrav1.FixedIP{{Subnet: &infrav1.SubnetFilter{FilterByNeutronTags: tags[i]}}}}, + } + Expect(k8sClient.Create(ctx, machine)).NotTo(Succeed(), "OpenStackMachine creation should fail with invalid port subnet neutron tags") + } + { + machine := machine.DeepCopy() + machine.Spec.Ports = []infrav1.PortOpts{ + {SecurityGroups: []infrav1.SecurityGroupFilter{{FilterByNeutronTags: tags[i]}}}, + } + Expect(k8sClient.Create(ctx, machine)).NotTo(Succeed(), "OpenStackMachine creation should fail with invalid port security group neutron tags") + } + } + + if len(tags) > 0 { + tag := tags[0] + + { + cluster := cluster.DeepCopy() + cluster.Spec.Subnets = []infrav1.SubnetFilter{{FilterByNeutronTags: tag}} + Expect(k8sClient.Create(ctx, cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail with invalid subnet neutron tags") + } + + { + cluster := cluster.DeepCopy() + cluster.Spec.Network = infrav1.NetworkFilter{FilterByNeutronTags: tag} + Expect(k8sClient.Create(ctx, cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail with invalid network neutron tags") + } + + { + cluster := cluster.DeepCopy() + cluster.Spec.ExternalNetwork = infrav1.NetworkFilter{FilterByNeutronTags: tag} + Expect(k8sClient.Create(ctx, cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail with invalid external network neutron tags") + } + + { + cluster := cluster.DeepCopy() + cluster.Spec.Router = &infrav1.RouterFilter{FilterByNeutronTags: tag} + Expect(k8sClient.Create(ctx, cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail with invalid router neutron tags") + } + } + }, + Entry("contains leading comma", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{",foo"}}, + }), + Entry("contains trailing comma", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{"foo,"}}, + }), + Entry("contains comma in middle", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{"foo,bar"}}, + }), + Entry("contains multiple commas", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{"foo,,bar"}}, + }), + Entry("empty tag", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{""}}, + }), + Entry("second tag is invalid", []infrav1.FilterByNeutronTags{ + {Tags: []infrav1.NeutronTag{"foo", ""}}, + }), + ) +}) diff --git a/test/e2e/suites/apivalidations/openstackcluster_test.go b/test/e2e/suites/apivalidations/openstackcluster_test.go new file mode 100644 index 0000000000..00fd97197b --- /dev/null +++ b/test/e2e/suites/apivalidations/openstackcluster_test.go @@ -0,0 +1,62 @@ +/* +Copyright 2024 The Kubernetes 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 apivalidations + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" +) + +var _ = Describe("OpenStackCluster API validations", func() { + var cluster *infrav1.OpenStackCluster + var namespace *corev1.Namespace + + BeforeEach(func() { + namespace = createNamespace() + + // Initialise a basic cluster object in the correct namespace + cluster = &infrav1.OpenStackCluster{} + cluster.Namespace = namespace.Name + cluster.GenerateName = "cluster-" + }) + + It("should allow the smallest permissible cluster spec", func() { + Expect(k8sClient.Create(ctx, cluster)).To(Succeed(), "OpenStackCluster creation should succeed") + }) + + It("should only allow controlPlaneEndpoint to be set once", func() { + By("Creating a bare cluster") + Expect(k8sClient.Create(ctx, cluster)).To(Succeed(), "OpenStackCluster creation should succeed") + + By("Setting the control plane endpoint") + cluster.Spec.ControlPlaneEndpoint.Host = "foo" + cluster.Spec.ControlPlaneEndpoint.Port = 1234 + Expect(k8sClient.Update(ctx, cluster)).To(Succeed(), "Setting control plane endpoint should succeed") + + By("Modifying the control plane endpoint") + cluster.Spec.ControlPlaneEndpoint.Host = "bar" + Expect(k8sClient.Update(ctx, cluster)).NotTo(Succeed(), "Updating control plane endpoint should fail") + }) + + It("should allow an empty managed security groups definition", func() { + cluster.Spec.ManagedSecurityGroups = &infrav1.ManagedSecurityGroups{} + Expect(k8sClient.Create(ctx, cluster)).To(Succeed(), "OpenStackCluster creation should succeed") + }) +}) diff --git a/test/e2e/suites/apivalidations/openstackmachine_test.go b/test/e2e/suites/apivalidations/openstackmachine_test.go new file mode 100644 index 0000000000..00f262cc0b --- /dev/null +++ b/test/e2e/suites/apivalidations/openstackmachine_test.go @@ -0,0 +1,57 @@ +/* +Copyright 2024 The Kubernetes 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 apivalidations + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/pointer" + + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" +) + +var _ = Describe("OpenStackMachine API validations", func() { + var namespace *corev1.Namespace + var machine *infrav1.OpenStackMachine + + BeforeEach(func() { + namespace = createNamespace() + + // Initialise a basic machine object in the correct namespace + machine = &infrav1.OpenStackMachine{} + machine.Namespace = namespace.Name + machine.GenerateName = "machine-" + }) + + It("should allow the smallest permissible machine spec", func() { + Expect(k8sClient.Create(ctx, machine)).To(Succeed(), "OpenStackMachine creation should succeed") + }) + + It("should only allow the providerID to be set once", func() { + By("Creating a bare machine") + Expect(k8sClient.Create(ctx, machine)).To(Succeed(), "OpenStackMachine creation should succeed") + + By("Setting the providerID") + machine.Spec.ProviderID = pointer.String("foo") + Expect(k8sClient.Update(ctx, machine)).To(Succeed(), "Setting providerID should succeed") + + By("Modifying the providerID") + machine.Spec.ProviderID = pointer.String("bar") + Expect(k8sClient.Update(ctx, machine)).NotTo(Succeed(), "Updating providerID should fail") + }) +}) diff --git a/test/e2e/suites/apivalidations/suite_test.go b/test/e2e/suites/apivalidations/suite_test.go new file mode 100644 index 0000000000..65a8e69136 --- /dev/null +++ b/test/e2e/suites/apivalidations/suite_test.go @@ -0,0 +1,159 @@ +/* +Copyright 2024 The Kubernetes 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 apivalidations + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/discovery" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + testScheme *runtime.Scheme + ctx = context.Background() + mgrCancel context.CancelFunc + mgrDone chan struct{} +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "API Validation Suite") +} + +var _ = BeforeSuite(func() { + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + // NOTE: These are the bare CRDs without conversion webhooks + filepath.Join("..", "..", "..", "..", "config", "crd", "bases"), + }, + ErrorIfCRDPathMissing: true, + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{ + filepath.Join("..", "..", "..", "..", "config", "webhook"), + }, + }, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred(), "test environment should start") + Expect(cfg).NotTo(BeNil(), "test environment should return a configuration") + DeferCleanup(func() error { + By("tearing down the test environment") + return testEnv.Stop() + }) + + testScheme = scheme.Scheme + Expect(infrav1.AddToScheme(testScheme)).To(Succeed()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: testScheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // CEL requires Kube 1.25 and above, so check for the minimum server version. + discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) + Expect(err).ToNot(HaveOccurred()) + + serverVersion, err := discoveryClient.ServerVersion() + Expect(err).ToNot(HaveOccurred()) + + Expect(serverVersion.Major).To(Equal("1")) + + minorInt, err := strconv.Atoi(serverVersion.Minor) + Expect(err).ToNot(HaveOccurred()) + Expect(minorInt).To(BeNumerically(">=", 25), fmt.Sprintf("This test suite requires a Kube API server of at least version 1.25, current version is 1.%s", serverVersion.Minor)) + + komega.SetClient(k8sClient) + komega.SetContext(ctx) + + By("Setting up manager and webhooks") + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: testScheme, + Metrics: server.Options{ + BindAddress: "0", + }, + WebhookServer: webhook.NewServer(webhook.Options{ + Port: testEnv.WebhookInstallOptions.LocalServingPort, + Host: testEnv.WebhookInstallOptions.LocalServingHost, + CertDir: testEnv.WebhookInstallOptions.LocalServingCertDir, + }), + }) + Expect(err).ToNot(HaveOccurred(), "Manager setup should succeed") + + Expect((&infrav1.OpenStackMachineTemplateWebhook{}).SetupWebhookWithManager(mgr)).To(Succeed(), "OpenStackMachineTemplate webhook should be registered with manager") + Expect((&infrav1.OpenStackMachineTemplateList{}).SetupWebhookWithManager(mgr)).To(Succeed(), "OpenStackMachineTemplateList webhook should be registered with manager") + Expect((&infrav1.OpenStackCluster{}).SetupWebhookWithManager(mgr)).To(Succeed(), "OpenStackCluster webhook should be registered with manager") + Expect((&infrav1.OpenStackClusterTemplate{}).SetupWebhookWithManager(mgr)).To(Succeed(), "OpenStackClusterTemplate webhook should be registered with manager") + Expect((&infrav1.OpenStackMachine{}).SetupWebhookWithManager(mgr)).To(Succeed(), "OpenStackMachine webhook should be registered with manager") + Expect((&infrav1.OpenStackMachineList{}).SetupWebhookWithManager(mgr)).To(Succeed(), "OpenStackMachineList webhook should be registered with manager") + Expect((&infrav1.OpenStackClusterList{}).SetupWebhookWithManager(mgr)).To(Succeed(), "OpenStackClusterList webhook should be registered with manager") + + By("Starting manager") + var mgrCtx context.Context + mgrDone = make(chan struct{}) + mgrCtx, mgrCancel = context.WithCancel(context.Background()) + + go func() { + defer GinkgoRecover() + defer close(mgrDone) + Expect(mgr.Start(mgrCtx)).To(Succeed(), "Manager should start") + }() + DeferCleanup(func() { + By("Tearing down manager") + mgrCancel() + Eventually(mgrDone).Should(BeClosed(), "Manager should stop") + }) +}) + +func createNamespace() *corev1.Namespace { + By("Creating namespace") + namespace := corev1.Namespace{} + namespace.GenerateName = "test-" + Expect(k8sClient.Create(ctx, &namespace)).To(Succeed(), "Namespace creation should succeed") + DeferCleanup(func() { + By("Deleting namespace") + Expect(k8sClient.Delete(ctx, &namespace, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed(), "Namespace deletion should succeed") + }) + By(fmt.Sprintf("Using namespace %s", namespace.Name)) + return &namespace +}