diff --git a/pkg/cloud/client.go b/pkg/cloud/client.go index 1dd88489..5ce99cd7 100644 --- a/pkg/cloud/client.go +++ b/pkg/cloud/client.go @@ -54,11 +54,11 @@ type Config struct { func NewClient(ccPath string) (Client, error) { c := &client{config: Config{VerifySSL: true}} if rawCfg, err := ini.Load(ccPath); err != nil { - return nil, errors.Wrapf(err, "reading config at path %s:", ccPath) + return nil, errors.Wrapf(err, "reading config at path %s", ccPath) } else if g := rawCfg.Section("Global"); len(g.Keys()) == 0 { return nil, errors.New("section Global not found") } else if err = rawCfg.Section("Global").StrictMapTo(&c.config); err != nil { - return nil, errors.Wrapf(err, "parsing [Global] section from config at path %s:", ccPath) + return nil, errors.Wrapf(err, "parsing [Global] section from config at path %s", ccPath) } // The client returned from NewAsyncClient works in a synchronous way. On the other hand, @@ -71,7 +71,7 @@ func NewClient(ccPath string) (Client, error) { if err != nil && strings.Contains(strings.ToLower(err.Error()), "i/o timeout") { return c, errors.Wrap(err, "timeout while checking CloudStack API Client connectivity") } - return c, errors.Wrap(err, "checking CloudStack API Client connectivity:") + return c, errors.Wrap(err, "checking CloudStack API Client connectivity") } // NewClientFromSpec generates a new client from an existing client. @@ -94,7 +94,7 @@ func (origC *client) NewClientFromSpec(cfg Config) (Client, error) { if err != nil && strings.Contains(strings.ToLower(err.Error()), "i/o timeout") { return newC, errors.Wrap(err, "timeout while checking CloudStack API Client connectivity") } - return newC, errors.Wrap(err, "checking CloudStack API Client connectivity:") + return newC, errors.Wrap(err, "checking CloudStack API Client connectivity") } func NewClientFromCSAPIClient(cs *cloudstack.CloudStackClient) Client { diff --git a/pkg/cloud/helpers.go b/pkg/cloud/helpers.go index bb7c5cec..d9c32900 100644 --- a/pkg/cloud/helpers.go +++ b/pkg/cloud/helpers.go @@ -24,6 +24,7 @@ import ( type set func(string) type setArray func([]string) +type setInt func(int64) func setIfNotEmpty(str string, setFn set) { if str != "" { @@ -37,6 +38,12 @@ func setArrayIfNotEmpty(strArray []string, setFn setArray) { } } +func setIntIfPositive(num int64, setFn setInt) { + if num > 0 { + setFn(num) + } +} + func CompressAndEncodeString(str string) (string, error) { buf := &bytes.Buffer{} gzipWriter := gzip.NewWriter(buf) diff --git a/pkg/cloud/instance.go b/pkg/cloud/instance.go index 6a8a9a55..61ae17a1 100644 --- a/pkg/cloud/instance.go +++ b/pkg/cloud/instance.go @@ -139,35 +139,55 @@ func (c *client) ResolveTemplate( // disk offering name matches name provided in spec. // If disk offering ID is not provided, the disk offering name is used to retrieve disk offering ID. func (c *client) ResolveDiskOffering(csMachine *infrav1.CloudStackMachine) (diskOfferingID string, retErr error) { - if len(csMachine.Spec.DiskOffering.ID) > 0 { - csDiskOffering, count, err := c.cs.DiskOffering.GetDiskOfferingByID(csMachine.Spec.DiskOffering.ID) + diskOfferingID = csMachine.Spec.DiskOffering.ID + if len(csMachine.Spec.DiskOffering.Name) > 0 { + diskID, count, err := c.cs.DiskOffering.GetDiskOfferingID(csMachine.Spec.DiskOffering.Name) if err != nil { return "", multierror.Append(retErr, errors.Wrapf( - err, "could not get DiskOffering by ID %s", csMachine.Spec.DiskOffering.ID)) + err, "could not get DiskOffering ID from %s", csMachine.Spec.DiskOffering.Name)) } else if count != 1 { return "", multierror.Append(retErr, errors.Errorf( - "expected 1 DiskOffering with UUID %s, but got %d", csMachine.Spec.DiskOffering.ID, count)) - } - - if len(csMachine.Spec.DiskOffering.Name) > 0 && csMachine.Spec.DiskOffering.Name != csDiskOffering.Name { + "expected 1 DiskOffering with name %s, but got %d", csMachine.Spec.DiskOffering.Name, count)) + } else if len(csMachine.Spec.DiskOffering.ID) > 0 && diskID != csMachine.Spec.DiskOffering.ID { + return "", multierror.Append(retErr, errors.Errorf( + "diskOffering ID %s does not match ID %s returned using name %s", + csMachine.Spec.DiskOffering.ID, diskID, csMachine.Spec.DiskOffering.Name)) + } else if len(diskID) == 0 { return "", multierror.Append(retErr, errors.Errorf( - "diskOffering name %s does not match name %s returned using UUID %s", - csMachine.Spec.DiskOffering.Name, csDiskOffering.Name, csMachine.Spec.DiskOffering.ID)) + "empty diskOffering ID %s returned using name %s", + diskID, csMachine.Spec.DiskOffering.Name)) } - return csMachine.Spec.DiskOffering.ID, nil + diskOfferingID = diskID } - if len(csMachine.Spec.DiskOffering.Name) == 0 { + if len(diskOfferingID) == 0 { return "", nil } - diskID, count, err := c.cs.DiskOffering.GetDiskOfferingID(csMachine.Spec.DiskOffering.Name) + + return verifyDiskoffering(csMachine, c, diskOfferingID, retErr) +} + +func verifyDiskoffering(csMachine *infrav1.CloudStackMachine, c *client, diskOfferingID string, retErr error) (string, error) { + csDiskOffering, count, err := c.cs.DiskOffering.GetDiskOfferingByID(diskOfferingID) if err != nil { return "", multierror.Append(retErr, errors.Wrapf( - err, "could not get DiskOffering ID from %s", csMachine.Spec.DiskOffering.Name)) + err, "could not get DiskOffering by ID %s", diskOfferingID)) } else if count != 1 { return "", multierror.Append(retErr, errors.Errorf( - "expected 1 DiskOffering with name %s, but got %d", csMachine.Spec.DiskOffering.Name, count)) + "expected 1 DiskOffering with UUID %s, but got %d", diskOfferingID, count)) + } + + if csDiskOffering.Iscustomized && csMachine.Spec.DiskOffering.CustomSize == 0 { + return "", multierror.Append(retErr, errors.Errorf( + "diskOffering with UUID %s is customized, disk size can not be 0 GB", + diskOfferingID)) + } + + if !csDiskOffering.Iscustomized && csMachine.Spec.DiskOffering.CustomSize > 0 { + return "", multierror.Append(retErr, errors.Errorf( + "diskOffering with UUID %s is not customized, disk size can not be specified", + diskOfferingID)) } - return diskID, nil + return diskOfferingID, nil } // GetOrCreateVMInstance CreateVMInstance will fetch or create a VM instance, and @@ -205,6 +225,7 @@ func (c *client) GetOrCreateVMInstance( setIfNotEmpty(csMachine.Name, p.SetName) setIfNotEmpty(csMachine.Name, p.SetDisplayname) setIfNotEmpty(diskOfferingID, p.SetDiskofferingid) + setIntIfPositive(csMachine.Spec.DiskOffering.CustomSize, p.SetSize) setIfNotEmpty(csMachine.Spec.SSHKey, p.SetKeypair) diff --git a/pkg/cloud/isolated_network.go b/pkg/cloud/isolated_network.go index c0cbda6f..137cf2b0 100644 --- a/pkg/cloud/isolated_network.go +++ b/pkg/cloud/isolated_network.go @@ -22,7 +22,6 @@ import ( "github.com/apache/cloudstack-go/v2/cloudstack" capcv1 "github.com/aws/cluster-api-provider-cloudstack/api/v1beta1" - infrav1 "github.com/aws/cluster-api-provider-cloudstack/api/v1beta1" "github.com/hashicorp/go-multierror" "github.com/pkg/errors" ) @@ -38,7 +37,7 @@ type IsoNetworkIface interface { AssignVMToLoadBalancerRule(isoNet *capcv1.CloudStackIsolatedNetwork, instanceID string) error DeleteNetwork(capcv1.Network) error - DisposeIsoNetResources(*capcv1.CloudStackZone, *infrav1.CloudStackIsolatedNetwork, *infrav1.CloudStackCluster) error + DisposeIsoNetResources(*capcv1.CloudStackZone, *capcv1.CloudStackIsolatedNetwork, *capcv1.CloudStackCluster) error } // getOfferingID fetches an offering id. @@ -61,7 +60,7 @@ func (c *client) AssociatePublicIPAddress( // Check specified IP address is available or get an unused one if not specified. publicAddress, err := c.GetPublicIP(zone, isoNet, csCluster) if err != nil { - return errors.Wrapf(err, "fetching a public IP address:") + return errors.Wrapf(err, "fetching a public IP address") } isoNet.Spec.ControlPlaneEndpoint.Host = publicAddress.Ipaddress csCluster.Spec.ControlPlaneEndpoint.Host = publicAddress.Ipaddress @@ -78,14 +77,14 @@ func (c *client) AssociatePublicIPAddress( p.SetNetworkid(isoNet.Spec.ID) if _, err := c.cs.Address.AssociateIpAddress(p); err != nil { return errors.Wrapf(err, - "associating public IP address with ID %s to netowrk with ID %s:", + "associating public IP address with ID %s to network with ID %s", publicAddress.Id, isoNet.Spec.ID) } else if err := c.AddClusterTag(ResourceTypeIPAddress, publicAddress.Id, csCluster); err != nil { return errors.Wrapf(err, - "adding tag to public IP address with ID %s:", publicAddress.Id) + "adding tag to public IP address with ID %s", publicAddress.Id) } else if err := c.AddCreatedByCAPCTag(ResourceTypeIPAddress, isoNet.Status.PublicIPID); err != nil { return errors.Wrapf(err, - "adding tag to public IP address with ID %s:", publicAddress.Id) + "adding tag to public IP address with ID %s", publicAddress.Id) } return nil } @@ -102,7 +101,7 @@ func (c *client) CreateIsolatedNetwork(zone *capcv1.CloudStackZone, isoNet *capc p := c.cs.Network.NewCreateNetworkParams(isoNet.Spec.Name, isoNet.Spec.Name, offeringID, zone.Spec.ID) resp, err := c.cs.Network.CreateNetwork(p) if err != nil { - return errors.Wrapf(err, "creating network with name %s:", isoNet.Spec.Name) + return errors.Wrapf(err, "creating network with name %s", isoNet.Spec.Name) } isoNet.Spec.ID = resp.Id return c.AddCreatedByCAPCTag(ResourceTypeNetwork, isoNet.Spec.ID) @@ -186,7 +185,7 @@ func (c *client) ResolveLoadBalancerRuleDetails( p.SetPublicipid(isoNet.Status.PublicIPID) loadBalancerRules, err := c.cs.LoadBalancer.ListLoadBalancerRules(p) if err != nil { - return errors.Wrap(err, "listing load balancer rules:") + return errors.Wrap(err, "listing load balancer rules") } for _, rule := range loadBalancerRules.LoadBalancerRules { if rule.Publicport == strconv.Itoa(int(isoNet.Spec.ControlPlaneEndpoint.Port)) { @@ -218,7 +217,7 @@ func (c *client) GetOrCreateLoadBalancerRule( // Check if rule exists. if err := c.ResolveLoadBalancerRuleDetails(zone, isoNet, csCluster); err == nil || !strings.Contains(strings.ToLower(err.Error()), "no load balancer rule found") { - return errors.Wrap(err, "resolving load balancer rule details:") + return errors.Wrap(err, "resolving load balancer rule details") } p := c.cs.LoadBalancer.NewCreateLoadBalancerRuleParams( @@ -240,13 +239,13 @@ func (c *client) GetOrCreateLoadBalancerRule( func (c *client) GetOrCreateIsolatedNetwork( zone *capcv1.CloudStackZone, isoNet *capcv1.CloudStackIsolatedNetwork, - csCluster *infrav1.CloudStackCluster, + csCluster *capcv1.CloudStackCluster, ) error { // Get or create the isolated network itself and resolve details into passed custom resources. net := isoNet.Network() if err := c.ResolveNetwork(net); err != nil { // Doesn't exist, create isolated network. if err = c.CreateIsolatedNetwork(zone, isoNet); err != nil { - return errors.Wrap(err, "creating a new isolated network:") + return errors.Wrap(err, "creating a new isolated network") } } isoNet.Spec.ID = net.ID @@ -254,21 +253,21 @@ func (c *client) GetOrCreateIsolatedNetwork( // Tag the created network. networkID := isoNet.Spec.ID if err := c.AddClusterTag(ResourceTypeNetwork, networkID, csCluster); err != nil { - return errors.Wrapf(err, "tagging network with id %s:", networkID) + return errors.Wrapf(err, "tagging network with id %s", networkID) } // Associate Public IP with CloudStackIsolatedNetwork if err := c.AssociatePublicIPAddress(zone, isoNet, csCluster); err != nil { - return errors.Wrapf(err, "associating public IP address to csCluster:") + return errors.Wrapf(err, "associating public IP address to csCluster") } // Setup a load balancing rule to map VMs to Public IP. if err := c.GetOrCreateLoadBalancerRule(zone, isoNet, csCluster); err != nil { - return errors.Wrap(err, "getting or creating load balancing rule:") + return errors.Wrap(err, "getting or creating load balancing rule") } // Open the Isolated Network on endopint port. - return errors.Wrap(c.OpenFirewallRules(isoNet), "opening the isolated network's firewall:") + return errors.Wrap(c.OpenFirewallRules(isoNet), "opening the isolated network's firewall") } // AssignVMToLoadBalancerRule assigns a VM instance to a load balancing rule (specifying lb membership). @@ -296,14 +295,14 @@ func (c *client) AssignVMToLoadBalancerRule(isoNet *capcv1.CloudStackIsolatedNet // DeleteNetwork deletes an isolated network. func (c *client) DeleteNetwork(net capcv1.Network) error { _, err := c.cs.Network.DeleteNetwork(c.cs.Network.NewDeleteNetworkParams(net.ID)) - return errors.Wrapf(err, "deleting network with id %s:", net.ID) + return errors.Wrapf(err, "deleting network with id %s", net.ID) } // DisposeIsoNetResources cleans up isolated network resources. func (c *client) DisposeIsoNetResources( - zone *infrav1.CloudStackZone, - isoNet *infrav1.CloudStackIsolatedNetwork, - csCluster *infrav1.CloudStackCluster, + zone *capcv1.CloudStackZone, + isoNet *capcv1.CloudStackIsolatedNetwork, + csCluster *capcv1.CloudStackCluster, ) (retError error) { if isoNet.Status.PublicIPID != "" { if err := c.DeleteClusterTag(ResourceTypeIPAddress, csCluster.Status.PublicIPID, csCluster); err != nil { diff --git a/pkg/cloud/tags.go b/pkg/cloud/tags.go index 9dbb05df..62c2eba3 100644 --- a/pkg/cloud/tags.go +++ b/pkg/cloud/tags.go @@ -58,7 +58,7 @@ func (c *client) IsCapcManaged(resourceType ResourceType, resourceID string) (bo tags, err := c.GetTags(resourceType, resourceID) if err != nil { return false, errors.Wrapf(err, - "checking if %s with ID: %s is tagged as CAPC managed:", resourceType, resourceID) + "checking if %s with ID: %s is tagged as CAPC managed", resourceType, resourceID) } _, CreatedByCAPC := tags[CreatedByCAPCTagName] return CreatedByCAPC, nil diff --git a/pkg/cloud/user_credentials.go b/pkg/cloud/user_credentials.go index 562eb3f3..878d87b5 100644 --- a/pkg/cloud/user_credentials.go +++ b/pkg/cloud/user_credentials.go @@ -74,8 +74,8 @@ func (c *client) ResolveDomain(domain *Domain) error { tokens = append([]string{rootDomain}, tokens...) } else { tokens[0] = rootDomain - domain.Path = strings.Join(tokens, domainDelimiter) } + domain.Path = strings.Join(tokens, domainDelimiter) } // Set present search/list parameters. @@ -130,7 +130,7 @@ func (c *client) ResolveDomain(domain *Domain) error { func (c *client) ResolveAccount(account *Account) error { // Resolve domain prior to any account resolution activity. if err := c.ResolveDomain(&account.Domain); err != nil { - return errors.Wrapf(err, "resolving domain %s details:", account.Domain.Name) + return errors.Wrapf(err, "resolving domain %s details", account.Domain.Name) } p := c.cs.Account.NewListAccountsParams() diff --git a/test/unit/cloud/affinity_groups_test.go b/test/unit/cloud/affinity_groups_test.go new file mode 100644 index 00000000..a8fc2a1a --- /dev/null +++ b/test/unit/cloud/affinity_groups_test.go @@ -0,0 +1,99 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "errors" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/aws/cluster-api-provider-cloudstack/test/dummies" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AffinityGroup Unit Tests", func() { + var ( // Declare shared vars. + mockCtrl *gomock.Controller + mockClient *cloudstack.CloudStackClient + ags *cloudstack.MockAffinityGroupServiceIface + client cloud.Client + ) + + BeforeEach(func() { + // Setup new mock services. + mockCtrl = gomock.NewController(GinkgoT()) + mockClient = cloudstack.NewMockClient(mockCtrl) + ags = mockClient.AffinityGroup.(*cloudstack.MockAffinityGroupServiceIface) + client = cloud.NewClientFromCSAPIClient(mockClient) + dummies.SetDummyVars() + }) + + AfterEach(func() { + mockCtrl.Finish() + }) + + It("fetches an affinity group", func() { + dummies.AffinityGroup.ID = "" // Force name fetching. + ags.EXPECT().GetAffinityGroupByName(dummies.AffinityGroup.Name).Return(&cloudstack.AffinityGroup{}, 1, nil) + + Ω(client.GetOrCreateAffinityGroup(dummies.AffinityGroup)).Should(Succeed()) + }) + It("creates an affinity group", func() { + dummies.SetDummyDomainAndAccount() + dummies.SetDummyDomainID() + ags.EXPECT().GetAffinityGroupByID(dummies.AffinityGroup.ID).Return(nil, -1, errors.New("FakeError")) + ags.EXPECT().NewCreateAffinityGroupParams(dummies.AffinityGroup.Name, dummies.AffinityGroup.Type). + Return(&cloudstack.CreateAffinityGroupParams{}) + ags.EXPECT().CreateAffinityGroup(ParamMatch(And(NameEquals(dummies.AffinityGroup.Name)))). + Return(&cloudstack.CreateAffinityGroupResponse{}, nil) + + Ω(client.GetOrCreateAffinityGroup(dummies.AffinityGroup)).Should(Succeed()) + }) + + Context("AffinityGroup Integ Tests", func() { + client, connectionErr := cloud.NewClient("../../cloud-config") + + BeforeEach(func() { + if connectionErr != nil { // Only do these tests if an actual ACS instance is available via cloud-config. + Skip("Could not connect to ACS instance.") + } + dummies.AffinityGroup.ID = "" // Force name fetching. + }) + AfterEach(func() { + mockCtrl.Finish() + }) + + It("Creates an affinity group.", func() { + Ω(client.GetOrCreateAffinityGroup(dummies.AffinityGroup)).Should(Succeed()) + }) + It("Associates an affinity group.", func() { + if err := client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "", + ); err != nil { + Skip("Could not create VM." + err.Error()) + } + Ω(client.GetOrCreateAffinityGroup(dummies.AffinityGroup)).Should(Succeed()) + Ω(client.AssociateAffinityGroup(dummies.CSMachine1, *dummies.AffinityGroup)).Should(Succeed()) + }) + It("Deletes an affinity group.", func() { + Ω(client.DeleteAffinityGroup(dummies.AffinityGroup)).Should(Succeed()) + Ω(client.FetchAffinityGroup(dummies.AffinityGroup)).ShouldNot(Succeed()) + }) + }) +}) diff --git a/test/unit/cloud/client_test.go b/test/unit/cloud/client_test.go new file mode 100644 index 00000000..0537c1fa --- /dev/null +++ b/test/unit/cloud/client_test.go @@ -0,0 +1,56 @@ +/* +Copyright 2022 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 cloud_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/ini.v1" +) + +// Example cloud-config ini structure. +type Global struct { + APIURL string `ini:"api-url"` + VerifySSL bool `ini:"verify-ssl"` +} + +var _ = Describe("Instance", func() { + + var () + + BeforeEach(func() { + // This test fixture is useful for development, but the actual method of parsing is confinded to the client's + // new client method. The parsing used here is more of a schema, and we don't need to test another library's + // abilities to parse said schema. + Skip("Dev test suite.") + }) + + AfterEach(func() { + }) + + Context("When fetching an INI config.", func() { + It("Handles the positive case.", func() { + cfg := &Global{} + rawCfg, err := ini.Load("../../cloud-config") + Ω(rawCfg.Section("Global")).ShouldNot(BeNil()) + Ω(err).ShouldNot(HaveOccurred()) + Ω(rawCfg.Section("Global").MapTo(cfg)).Should(Succeed()) + Ω(cfg.VerifySSL).Should(BeFalse()) + Ω(cfg.APIURL).ShouldNot(BeEmpty()) + }) + }) +}) diff --git a/test/unit/cloud/cloud_suite_test.go b/test/unit/cloud/cloud_suite_test.go new file mode 100644 index 00000000..61f4cc74 --- /dev/null +++ b/test/unit/cloud/cloud_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCloud(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Cloud Suite") +} diff --git a/test/unit/cloud/helpers_test.go b/test/unit/cloud/helpers_test.go new file mode 100644 index 00000000..41f01bdf --- /dev/null +++ b/test/unit/cloud/helpers_test.go @@ -0,0 +1,123 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "path" + "reflect" + + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" +) + +const ( + FixturePath = "test/fixtures/cloud-config-files" +) + +var _ = Describe("Helpers", func() { + + Context("For a configuration with the 'Global' section missing", func() { + It("Gets API configuration", func() { + filepath := getConfigPath("cloud-config-no-global") + + client, err := cloud.NewClient(filepath) + + Ω(client).Should(BeNil()) + Ω(err.Error()).Should(ContainSubstring("section Global not found")) + }) + }) + + It("should compress and encode string", func() { + str := "Hello World" + + compressedAndEncodedData, err := cloud.CompressAndEncodeString(str) + + compressedData, _ := base64.StdEncoding.DecodeString(compressedAndEncodedData) + reader, _ := gzip.NewReader(bytes.NewReader(compressedData)) + result, _ := ioutil.ReadAll(reader) + + Ω(err).Should(BeNil()) + Ω(string(result)).Should(Equal(str)) + }) +}) + +func getConfigPath(filename string) string { + dir, _ := os.Getwd() + return path.Join(dir, FixturePath, filename) +} + +// This matcher is used to make gomega matching compatible with gomock parameter matching. +// It's pretty awesome! +// +// This sort of hacks the gomock interface to inject a gomega matcher. +// +// Gomega matchers are far more flexible than gomock matchers, but they normally can't be used on parameters. + +type paramMatcher struct { + matcher types.GomegaMatcher +} + +func ParamMatch(matcher types.GomegaMatcher) gomock.Matcher { + return paramMatcher{matcher} +} + +func (p paramMatcher) String() string { + return "a gomega matcher to match, and said matcher should have panicked before this message was printed." +} + +func (p paramMatcher) Matches(x interface{}) (retVal bool) { + return Ω(x).Should(p.matcher) +} + +// This generates translating matchers. +// +// The CloudStack Go API uses param interfaces that can't be accessed except through builtin methods. +// +// This generates translation matchers: +// +// Essentially it will generate a matcher that checks the value from p.Get() is Equal to an input String. +// +// DomainIDEquals = FieldMatcherGenerator("GetDomainid") +// p := &CreateNewSomethingParams{Domainid: "FakeDomainID"} +// Ω(p).DomainIDEquals("FakeDomainID") +func FieldMatcherGenerator(fetchFunc string) func(string) types.GomegaMatcher { + return (func(expected string) types.GomegaMatcher { + return WithTransform( + func(x interface{}) string { + meth := reflect.ValueOf(x).MethodByName(fetchFunc) + fmt.Println(meth.Call(nil)[0]) + + return meth.Call(nil)[0].String() + }, Equal(expected)) + }) +} + +var ( + DomainIDEquals = FieldMatcherGenerator("GetDomainid") + AccountEquals = FieldMatcherGenerator("GetAccount") + IDEquals = FieldMatcherGenerator("GetId") + NameEquals = FieldMatcherGenerator("GetName") +) diff --git a/test/unit/cloud/instance_test.go b/test/unit/cloud/instance_test.go new file mode 100644 index 00000000..f031a2ba --- /dev/null +++ b/test/unit/cloud/instance_test.go @@ -0,0 +1,518 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "fmt" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/aws/cluster-api-provider-cloudstack/api/v1beta1" + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/aws/cluster-api-provider-cloudstack/test/dummies" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + + . "github.com/onsi/gomega" + "github.com/pkg/errors" + "k8s.io/utils/pointer" +) + +var _ = Describe("Instance", func() { + const ( + unknownErrorMessage = "unknown err" + offeringFakeID = "123" + templateFakeID = "456" + executableFilter = "executable" + diskOfferingFakeID = "789" + ) + + notFoundError := errors.New("no match found") + unknownError := errors.New(unknownErrorMessage) + + var ( + mockCtrl *gomock.Controller + mockClient *cloudstack.CloudStackClient + vms *cloudstack.MockVirtualMachineServiceIface + sos *cloudstack.MockServiceOfferingServiceIface + dos *cloudstack.MockDiskOfferingServiceIface + ts *cloudstack.MockTemplateServiceIface + vs *cloudstack.MockVolumeServiceIface + client cloud.Client + ) + + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + mockClient = cloudstack.NewMockClient(mockCtrl) + vms = mockClient.VirtualMachine.(*cloudstack.MockVirtualMachineServiceIface) + sos = mockClient.ServiceOffering.(*cloudstack.MockServiceOfferingServiceIface) + dos = mockClient.DiskOffering.(*cloudstack.MockDiskOfferingServiceIface) + ts = mockClient.Template.(*cloudstack.MockTemplateServiceIface) + vs = mockClient.Volume.(*cloudstack.MockVolumeServiceIface) + client = cloud.NewClientFromCSAPIClient(mockClient) + + dummies.SetDummyVars() + dummies.SetDummyClusterStatus() + dummies.SetDummyCSMachineStatuses() + }) + + AfterEach(func() { + mockCtrl.Finish() + }) + + Context("when fetching a VM instance", func() { + It("Handles an unknown error when fetching by ID", func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, -1, unknownError) + Ω(client.ResolveVMInstanceDetails(dummies.CSMachine1)).To(MatchError(unknownErrorMessage)) + }) + + It("Handles finding more than one VM instance by ID", func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, 2, nil) + Ω(client.ResolveVMInstanceDetails(dummies.CSMachine1)). + Should(MatchError("found more than one VM Instance with ID " + *dummies.CSMachine1.Spec.InstanceID)) + }) + + It("sets dummies.CSMachine1 spec and status values when VM instance found by ID", func() { + vmsResp := &cloudstack.VirtualMachinesMetric{Id: *dummies.CSMachine1.Spec.InstanceID} + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(vmsResp, 1, nil) + Ω(client.ResolveVMInstanceDetails(dummies.CSMachine1)).Should(Succeed()) + Ω(dummies.CSMachine1.Spec.ProviderID).Should(Equal(pointer.StringPtr("cloudstack:///" + vmsResp.Id))) + Ω(dummies.CSMachine1.Spec.InstanceID).Should(Equal(pointer.StringPtr(vmsResp.Id))) + }) + + It("handles an unknown error when fetching by name", func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, -1, notFoundError) + vms.EXPECT().GetVirtualMachinesMetricByName(dummies.CSMachine1.Name).Return(nil, -1, unknownError) + + Ω(client.ResolveVMInstanceDetails(dummies.CSMachine1)).Should(MatchError(unknownErrorMessage)) + }) + + It("handles finding more than one VM instance by Name", func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, -1, notFoundError) + vms.EXPECT().GetVirtualMachinesMetricByName(dummies.CSMachine1.Name).Return(nil, 2, nil) + + Ω(client.ResolveVMInstanceDetails(dummies.CSMachine1)).Should( + MatchError("found more than one VM Instance with name " + dummies.CSMachine1.Name)) + }) + + It("sets dummies.CSMachine1 spec and status values when VM instance found by Name", func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, -1, notFoundError) + vms.EXPECT().GetVirtualMachinesMetricByName(dummies.CSMachine1.Name). + Return(&cloudstack.VirtualMachinesMetric{Id: *dummies.CSMachine1.Spec.InstanceID}, -1, nil) + + Ω(client.ResolveVMInstanceDetails(dummies.CSMachine1)).Should(Succeed()) + Ω(dummies.CSMachine1.Spec.ProviderID).Should(Equal( + pointer.StringPtr(fmt.Sprintf("cloudstack:///%s", *dummies.CSMachine1.Spec.InstanceID)))) + Ω(dummies.CSMachine1.Spec.InstanceID).Should(Equal(pointer.StringPtr(*dummies.CSMachine1.Spec.InstanceID))) + }) + }) + + Context("when creating a VM instance", func() { + vmMetricResp := &cloudstack.VirtualMachinesMetric{} + + expectVMNotFound := func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, -1, notFoundError) + vms.EXPECT().GetVirtualMachinesMetricByName(dummies.CSMachine1.Name).Return(nil, -1, notFoundError) + } + + It("doesn't re-create if one already exists.", func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(vmMetricResp, -1, nil) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + Should(Succeed()) + }) + + It("returns unknown error while fetching VM instance", func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, -1, unknownError) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + Should(MatchError(unknownErrorMessage)) + }) + + It("returns errors occurring while fetching service offering information", func() { + expectVMNotFound() + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name).Return("", -1, unknownError) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("returns errors if more than one service offering found", func() { + expectVMNotFound() + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name).Return("", 2, nil) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("returns errors while fetching template", func() { + expectVMNotFound() + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name). + Return(dummies.CSMachine1.Spec.Offering.ID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID). + Return("", -1, unknownError) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("returns errors when more than one template found", func() { + expectVMNotFound() + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name). + Return(dummies.CSMachine1.Spec.Offering.ID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID).Return("", 2, nil) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("returns errors when more than one diskoffering found", func() { + expectVMNotFound() + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name). + Return(dummies.CSMachine1.Spec.Offering.ID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID).Return(dummies.CSMachine1.Spec.Template.ID, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 2, nil) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("returns errors when fetching diskoffering", func() { + expectVMNotFound() + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name). + Return(dummies.CSMachine1.Spec.Offering.ID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID).Return(dummies.CSMachine1.Spec.Template.ID, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(diskOfferingFakeID).Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, unknownError) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("returns errors when disk size not zero for non-customized disk offering", func() { + expectVMNotFound() + dummies.CSMachine1.Spec.DiskOffering.CustomSize = 1 + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name). + Return(dummies.CSMachine1.Spec.Offering.ID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID).Return(dummies.CSMachine1.Spec.Template.ID, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(diskOfferingFakeID).Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, nil) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("returns errors when disk size zero for customized disk offering", func() { + expectVMNotFound() + dummies.CSMachine1.Spec.DiskOffering.CustomSize = 0 + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name). + Return(dummies.CSMachine1.Spec.Offering.ID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID).Return(dummies.CSMachine1.Spec.Template.ID, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(diskOfferingFakeID).Return(&cloudstack.DiskOffering{Iscustomized: true}, 1, nil) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + ShouldNot(Succeed()) + }) + + It("handles deployment errors", func() { + expectVMNotFound() + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name). + Return(offeringFakeID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID). + Return(templateFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name). + Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(diskOfferingFakeID). + Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, nil) + vms.EXPECT().NewDeployVirtualMachineParams(offeringFakeID, templateFakeID, dummies.Zone1.ID). + Return(&cloudstack.DeployVirtualMachineParams{}) + vms.EXPECT().DeployVirtualMachine(gomock.Any()).Return(nil, unknownError) + vms.EXPECT().NewListVirtualMachinesParams().Return(&cloudstack.ListVirtualMachinesParams{}) + vms.EXPECT().ListVirtualMachines(gomock.Any()).Return(&cloudstack.ListVirtualMachinesResponse{}, nil) + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + Should(MatchError(unknownErrorMessage)) + }) + + Context("when using UUIDs and/or names to locate service offerings and templates", func() { + BeforeEach(func() { + gomock.InOrder( + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID). + Return(nil, -1, notFoundError), + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID). + Return(&cloudstack.VirtualMachinesMetric{}, 1, nil)) + + vms.EXPECT().GetVirtualMachinesMetricByName(dummies.CSMachine1.Name).Return(nil, -1, notFoundError) + }) + + ActionAndAssert := func() { + vms.EXPECT().NewDeployVirtualMachineParams(offeringFakeID, templateFakeID, dummies.Zone1.ID). + Return(&cloudstack.DeployVirtualMachineParams{}) + + deploymentResp := &cloudstack.DeployVirtualMachineResponse{Id: *dummies.CSMachine1.Spec.InstanceID} + vms.EXPECT().DeployVirtualMachine(gomock.Any()).Return(deploymentResp, nil) + + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + Should(Succeed()) + } + + It("works with service offering name and template name", func() { + dummies.CSMachine1.Spec.DiskOffering.ID = diskOfferingFakeID + dummies.CSMachine1.Spec.Offering.ID = "" + dummies.CSMachine1.Spec.Template.ID = "" + dummies.CSMachine1.Spec.Offering.Name = "offering" + dummies.CSMachine1.Spec.Template.Name = "template" + + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name).Return(offeringFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(dummies.CSMachine1.Spec.DiskOffering.ID).Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID). + Return(templateFakeID, 1, nil) + + ActionAndAssert() + }) + + It("works with service offering name and template name without disk offering", func() { + dummies.CSMachine1.Spec.Offering.ID = "" + dummies.CSMachine1.Spec.Template.ID = "" + dummies.CSMachine1.Spec.Offering.Name = "offering" + dummies.CSMachine1.Spec.Template.Name = "template" + dummies.CSMachine1.Spec.DiskOffering = v1beta1.CloudStackResourceDiskOffering{} + + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name).Return(offeringFakeID, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID). + Return(templateFakeID, 1, nil) + + ActionAndAssert() + }) + + It("works with service offering ID and template name", func() { + dummies.CSMachine1.Spec.DiskOffering.ID = diskOfferingFakeID + dummies.CSMachine1.Spec.Offering.ID = offeringFakeID + dummies.CSMachine1.Spec.Template.ID = "" + dummies.CSMachine1.Spec.Offering.Name = "" + dummies.CSMachine1.Spec.Template.Name = "template" + + sos.EXPECT().GetServiceOfferingByID(dummies.CSMachine1.Spec.Offering.ID).Return(&cloudstack.ServiceOffering{Name: ""}, 1, nil) + ts.EXPECT().GetTemplateID(dummies.CSMachine1.Spec.Template.Name, executableFilter, dummies.Zone1.ID). + Return(templateFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(dummies.CSMachine1.Spec.DiskOffering.ID).Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, nil) + + ActionAndAssert() + }) + + It("works with service offering name and template ID", func() { + dummies.CSMachine1.Spec.DiskOffering.ID = diskOfferingFakeID + dummies.CSMachine1.Spec.Offering.ID = "" + dummies.CSMachine1.Spec.Template.ID = templateFakeID + dummies.CSMachine1.Spec.Offering.Name = "offering" + dummies.CSMachine1.Spec.Template.Name = "" + + sos.EXPECT().GetServiceOfferingID(dummies.CSMachine1.Spec.Offering.Name).Return(offeringFakeID, 1, nil) + ts.EXPECT().GetTemplateByID(dummies.CSMachine1.Spec.Template.ID, executableFilter).Return(&cloudstack.Template{Name: ""}, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(dummies.CSMachine1.Spec.DiskOffering.ID).Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, nil) + + ActionAndAssert() + }) + + It("works with service offering ID and template ID", func() { + dummies.CSMachine1.Spec.DiskOffering.ID = diskOfferingFakeID + dummies.CSMachine1.Spec.Offering.ID = offeringFakeID + dummies.CSMachine1.Spec.Template.ID = templateFakeID + dummies.CSMachine1.Spec.Offering.Name = "" + dummies.CSMachine1.Spec.Template.Name = "" + + sos.EXPECT().GetServiceOfferingByID(dummies.CSMachine1.Spec.Offering.ID). + Return(&cloudstack.ServiceOffering{Name: "offering"}, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name). + Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(dummies.CSMachine1.Spec.DiskOffering.ID). + Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, nil) + ts.EXPECT().GetTemplateByID(dummies.CSMachine1.Spec.Template.ID, executableFilter). + Return(&cloudstack.Template{Name: "template"}, 1, nil) + + ActionAndAssert() + }) + + It("works with Id and name both provided", func() { + dummies.CSMachine1.Spec.DiskOffering.ID = diskOfferingFakeID + dummies.CSMachine1.Spec.Offering.ID = offeringFakeID + dummies.CSMachine1.Spec.Template.ID = templateFakeID + dummies.CSMachine1.Spec.Offering.Name = "offering" + dummies.CSMachine1.Spec.Template.Name = "template" + + sos.EXPECT().GetServiceOfferingByID(dummies.CSMachine1.Spec.Offering.ID).Return(&cloudstack.ServiceOffering{Name: "offering"}, 1, nil) + ts.EXPECT().GetTemplateByID(dummies.CSMachine1.Spec.Template.ID, executableFilter).Return(&cloudstack.Template{Name: "template"}, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID, 1, nil) + dos.EXPECT().GetDiskOfferingByID(dummies.CSMachine1.Spec.DiskOffering.ID).Return(&cloudstack.DiskOffering{Iscustomized: false}, 1, nil) + + ActionAndAssert() + }) + }) + + Context("when using both UUIDs and names to locate service offerings and templates", func() { + BeforeEach(func() { + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID). + Return(nil, -1, notFoundError) + vms.EXPECT().GetVirtualMachinesMetricByName(dummies.CSMachine1.Name).Return(nil, -1, notFoundError) + }) + + It("works with Id and name both provided, offering name mismatch", func() { + dummies.CSMachine1.Spec.Offering.ID = offeringFakeID + dummies.CSMachine1.Spec.Template.ID = templateFakeID + dummies.CSMachine1.Spec.Offering.Name = "offering" + dummies.CSMachine1.Spec.Template.Name = "template" + + sos.EXPECT().GetServiceOfferingByID(dummies.CSMachine1.Spec.Offering.ID).Return(&cloudstack.ServiceOffering{Name: "offering-not-match"}, 1, nil) + requiredRegexp := "offering name %s does not match name %s returned using UUID %s" + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + Should(MatchError(MatchRegexp(requiredRegexp, dummies.CSMachine1.Spec.Offering.Name, "offering-not-match", offeringFakeID))) + }) + + It("works with Id and name both provided, template name mismatch", func() { + dummies.CSMachine1.Spec.Offering.ID = offeringFakeID + dummies.CSMachine1.Spec.Template.ID = templateFakeID + dummies.CSMachine1.Spec.Offering.Name = "offering" + dummies.CSMachine1.Spec.Template.Name = "template" + + sos.EXPECT().GetServiceOfferingByID(dummies.CSMachine1.Spec.Offering.ID).Return(&cloudstack.ServiceOffering{Name: "offering"}, 1, nil) + ts.EXPECT().GetTemplateByID(dummies.CSMachine1.Spec.Template.ID, executableFilter).Return(&cloudstack.Template{Name: "template-not-match"}, 1, nil) + requiredRegexp := "template name %s does not match name %s returned using UUID %s" + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + Should(MatchError(MatchRegexp(requiredRegexp, dummies.CSMachine1.Spec.Template.Name, "template-not-match", templateFakeID))) + + }) + + It("works with Id and name both provided, disk offering id/name mismatch", func() { + dummies.CSMachine1.Spec.Offering.ID = offeringFakeID + dummies.CSMachine1.Spec.Template.ID = templateFakeID + dummies.CSMachine1.Spec.DiskOffering.ID = diskOfferingFakeID + dummies.CSMachine1.Spec.Offering.Name = "offering" + dummies.CSMachine1.Spec.Template.Name = "template" + dummies.CSMachine1.Spec.DiskOffering.Name = "diskoffering" + + sos.EXPECT().GetServiceOfferingByID(dummies.CSMachine1.Spec.Offering.ID).Return(&cloudstack.ServiceOffering{Name: "offering"}, 1, nil) + ts.EXPECT().GetTemplateByID(dummies.CSMachine1.Spec.Template.ID, executableFilter).Return(&cloudstack.Template{Name: "template"}, 1, nil) + dos.EXPECT().GetDiskOfferingID(dummies.CSMachine1.Spec.DiskOffering.Name).Return(diskOfferingFakeID+"-not-match", 1, nil) + requiredRegexp := "diskOffering ID %s does not match ID %s returned using name %s" + Ω(client.GetOrCreateVMInstance( + dummies.CSMachine1, dummies.CAPIMachine, dummies.CSCluster, dummies.CSZone1, dummies.CSAffinityGroup, "")). + Should(MatchError(MatchRegexp(requiredRegexp, dummies.CSMachine1.Spec.DiskOffering.ID, diskOfferingFakeID+"-not-match", dummies.CSMachine1.Spec.DiskOffering.Name))) + + }) + }) + }) + + Context("when destroying a VM instance", func() { + expungeDestroyParams := &cloudstack.DestroyVirtualMachineParams{} + expungeDestroyParams.SetExpunge(true) + listVolumesParams := &cloudstack.ListVolumesParams{} + listVolumesResponse := &cloudstack.ListVolumesResponse{ + Volumes: []*cloudstack.Volume{ + { + Id: "123", + }, + { + Id: "456", + }, + }, + } + + It("calls destroy and finds VM doesn't exist, then returns nil", func() { + listVolumesParams.SetVirtualmachineid(*dummies.CSMachine1.Spec.InstanceID) + vms.EXPECT().NewDestroyVirtualMachineParams(*dummies.CSMachine1.Spec.InstanceID). + Return(expungeDestroyParams) + vms.EXPECT().DestroyVirtualMachine(expungeDestroyParams).Return(nil, fmt.Errorf("unable to find uuid for id")) + vs.EXPECT().NewListVolumesParams().Return(listVolumesParams) + vs.EXPECT().ListVolumes(listVolumesParams).Return(listVolumesResponse, nil) + Ω(client.DestroyVMInstance(dummies.CSMachine1)). + Should(Succeed()) + }) + + It("calls destroy and returns unexpected error", func() { + listVolumesParams.SetVirtualmachineid(*dummies.CSMachine1.Spec.InstanceID) + vms.EXPECT().NewDestroyVirtualMachineParams(*dummies.CSMachine1.Spec.InstanceID). + Return(expungeDestroyParams) + vms.EXPECT().DestroyVirtualMachine(expungeDestroyParams).Return(nil, fmt.Errorf("new error")) + vs.EXPECT().NewListVolumesParams().Return(listVolumesParams) + vs.EXPECT().ListVolumes(listVolumesParams).Return(listVolumesResponse, nil) + Ω(client.DestroyVMInstance(dummies.CSMachine1)).Should(MatchError("new error")) + }) + + It("calls destroy without error but cannot resolve VM after", func() { + listVolumesParams.SetVirtualmachineid(*dummies.CSMachine1.Spec.InstanceID) + vms.EXPECT().NewDestroyVirtualMachineParams(*dummies.CSMachine1.Spec.InstanceID). + Return(expungeDestroyParams) + vms.EXPECT().DestroyVirtualMachine(expungeDestroyParams).Return(nil, nil) + vs.EXPECT().NewListVolumesParams().Return(listVolumesParams) + vs.EXPECT().ListVolumes(listVolumesParams).Return(listVolumesResponse, nil) + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID).Return(nil, -1, notFoundError) + vms.EXPECT().GetVirtualMachinesMetricByName(dummies.CSMachine1.Name).Return(nil, -1, notFoundError) + Ω(client.DestroyVMInstance(dummies.CSMachine1)). + Should(Succeed()) + }) + + It("calls destroy without error and identifies it as expunging", func() { + listVolumesParams.SetVirtualmachineid(*dummies.CSMachine1.Spec.InstanceID) + vms.EXPECT().NewDestroyVirtualMachineParams(*dummies.CSMachine1.Spec.InstanceID). + Return(expungeDestroyParams) + vms.EXPECT().DestroyVirtualMachine(expungeDestroyParams).Return(nil, nil) + vs.EXPECT().NewListVolumesParams().Return(listVolumesParams) + vs.EXPECT().ListVolumes(listVolumesParams).Return(listVolumesResponse, nil) + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID). + Return(&cloudstack.VirtualMachinesMetric{ + State: "Expunging", + }, 1, nil) + Ω(client.DestroyVMInstance(dummies.CSMachine1)). + Should(Succeed()) + }) + + It("calls destroy without error and identifies it as expunged", func() { + listVolumesParams.SetVirtualmachineid(*dummies.CSMachine1.Spec.InstanceID) + vms.EXPECT().NewDestroyVirtualMachineParams(*dummies.CSMachine1.Spec.InstanceID). + Return(expungeDestroyParams) + vms.EXPECT().DestroyVirtualMachine(expungeDestroyParams).Return(nil, nil) + vs.EXPECT().NewListVolumesParams().Return(listVolumesParams) + vs.EXPECT().ListVolumes(listVolumesParams).Return(listVolumesResponse, nil) + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID). + Return(&cloudstack.VirtualMachinesMetric{ + State: "Expunged", + }, 1, nil) + Ω(client.DestroyVMInstance(dummies.CSMachine1)). + Should(Succeed()) + }) + + It("calls destroy without error and identifies it as stopping", func() { + listVolumesParams.SetVirtualmachineid(*dummies.CSMachine1.Spec.InstanceID) + vms.EXPECT().NewDestroyVirtualMachineParams(*dummies.CSMachine1.Spec.InstanceID). + Return(expungeDestroyParams) + vms.EXPECT().DestroyVirtualMachine(expungeDestroyParams).Return(nil, nil) + vs.EXPECT().NewListVolumesParams().Return(listVolumesParams) + vs.EXPECT().ListVolumes(listVolumesParams).Return(listVolumesResponse, nil) + vms.EXPECT().GetVirtualMachinesMetricByID(*dummies.CSMachine1.Spec.InstanceID). + Return(&cloudstack.VirtualMachinesMetric{ + State: "Stopping", + }, 1, nil) + Ω(client.DestroyVMInstance(dummies.CSMachine1)).Should(MatchError("VM deletion in progress")) + }) + }) +}) diff --git a/test/unit/cloud/isolated_network_test.go b/test/unit/cloud/isolated_network_test.go new file mode 100644 index 00000000..0db9d51e --- /dev/null +++ b/test/unit/cloud/isolated_network_test.go @@ -0,0 +1,266 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "strconv" + + csapi "github.com/apache/cloudstack-go/v2/cloudstack" + capcv1 "github.com/aws/cluster-api-provider-cloudstack/api/v1beta1" + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/aws/cluster-api-provider-cloudstack/test/dummies" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pkg/errors" +) + +var _ = Describe("Network", func() { + var ( // Declare shared vars. + mockCtrl *gomock.Controller + mockClient *csapi.CloudStackClient + ns *csapi.MockNetworkServiceIface + nos *csapi.MockNetworkOfferingServiceIface + fs *csapi.MockFirewallServiceIface + as *csapi.MockAddressServiceIface + lbs *csapi.MockLoadBalancerServiceIface + rs *csapi.MockResourcetagsServiceIface + client cloud.Client + ) + + BeforeEach(func() { + // Setup new mock services. + mockCtrl = gomock.NewController(GinkgoT()) + mockClient = csapi.NewMockClient(mockCtrl) + ns = mockClient.Network.(*csapi.MockNetworkServiceIface) + nos = mockClient.NetworkOffering.(*csapi.MockNetworkOfferingServiceIface) + fs = mockClient.Firewall.(*csapi.MockFirewallServiceIface) + as = mockClient.Address.(*csapi.MockAddressServiceIface) + lbs = mockClient.LoadBalancer.(*csapi.MockLoadBalancerServiceIface) + rs = mockClient.Resourcetags.(*csapi.MockResourcetagsServiceIface) + client = cloud.NewClientFromCSAPIClient(mockClient) + dummies.SetDummyVars() + dummies.SetDummyClusterStatus() + }) + + AfterEach(func() { + mockCtrl.Finish() + }) + + It("calls to create an isolated network when not found", func() { + dummies.Zone1.Network = dummies.ISONet1 + dummies.Zone1.Network.ID = "" + dummies.CSCluster.Status.Zones = capcv1.ZoneStatusMap{dummies.Zone1.ID: dummies.Zone1} + dummies.CSCluster.Status.PublicIPNetworkID = dummies.ISONet1.ID + + nos.EXPECT().GetNetworkOfferingID(gomock.Any()).Return("someOfferingID", 1, nil) + ns.EXPECT().NewCreateNetworkParams(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&csapi.CreateNetworkParams{}) + ns.EXPECT().GetNetworkByName(dummies.ISONet1.Name).Return(nil, 0, nil) + ns.EXPECT().GetNetworkByID(dummies.ISONet1.ID).Return(nil, 0, nil) + ns.EXPECT().CreateNetwork(gomock.Any()).Return(&csapi.CreateNetworkResponse{Id: dummies.ISONet1.ID}, nil) + as.EXPECT().NewListPublicIpAddressesParams().Return(&csapi.ListPublicIpAddressesParams{}) + as.EXPECT().ListPublicIpAddresses(gomock.Any()). + Return(&csapi.ListPublicIpAddressesResponse{ + Count: 1, + PublicIpAddresses: []*csapi.PublicIpAddress{{Id: dummies.PublicIPID, Ipaddress: "fakeIP"}}}, nil) + as.EXPECT().NewAssociateIpAddressParams().Return(&csapi.AssociateIpAddressParams{}) + as.EXPECT().AssociateIpAddress(gomock.Any()) + fs.EXPECT().NewCreateEgressFirewallRuleParams(dummies.ISONet1.ID, cloud.NetworkProtocolTCP). + Return(&csapi.CreateEgressFirewallRuleParams{}) + fs.EXPECT().CreateEgressFirewallRule(&csapi.CreateEgressFirewallRuleParams{}). + Return(&csapi.CreateEgressFirewallRuleResponse{}, nil) + + // Will add cluster tag once to Network and once to PublicIP. + createdByResponse := &csapi.ListTagsResponse{Tags: []*csapi.Tag{{Key: cloud.CreatedByCAPCTagName, Value: "1"}}} + gomock.InOrder( + rs.EXPECT().NewListTagsParams().Return(&csapi.ListTagsParams{}), + rs.EXPECT().ListTags(gomock.Any()).Return(createdByResponse, nil), + rs.EXPECT().NewListTagsParams().Return(&csapi.ListTagsParams{}), + rs.EXPECT().ListTags(gomock.Any()).Return(createdByResponse, nil)) + + // Will add creation and cluster tags to network and PublicIP. + rs.EXPECT().NewCreateTagsParams(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&csapi.CreateTagsParams{}).Times(4) + rs.EXPECT().CreateTags(gomock.Any()).Return(&csapi.CreateTagsResponse{}, nil).Times(4) + + lbs.EXPECT().NewListLoadBalancerRulesParams().Return(&csapi.ListLoadBalancerRulesParams{}) + lbs.EXPECT().ListLoadBalancerRules(gomock.Any()).Return( + &csapi.ListLoadBalancerRulesResponse{LoadBalancerRules: []*csapi.LoadBalancerRule{ + {Publicport: strconv.Itoa(int(dummies.EndPointPort)), Id: dummies.LBRuleID}}}, nil) + + Ω(client.GetOrCreateIsolatedNetwork(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster)).Should(Succeed()) + }) + + Context("for a closed firewall", func() { + It("OpenFirewallRule asks CloudStack to open the firewall", func() { + dummies.Zone1.Network = dummies.ISONet1 + dummies.CSCluster.Status.Zones = capcv1.ZoneStatusMap{dummies.Zone1.ID: dummies.Zone1} + dummies.CSCluster.Status.PublicIPNetworkID = dummies.ISONet1.ID + fs.EXPECT().NewCreateEgressFirewallRuleParams(dummies.ISONet1.ID, cloud.NetworkProtocolTCP). + Return(&csapi.CreateEgressFirewallRuleParams{}) + fs.EXPECT().CreateEgressFirewallRule(&csapi.CreateEgressFirewallRuleParams{}). + Return(&csapi.CreateEgressFirewallRuleResponse{}, nil) + + Ω(client.OpenFirewallRules(dummies.CSISONet1)).Should(Succeed()) + }) + }) + + Context("for an open firewall", func() { + It("OpenFirewallRule asks CloudStack to open the firewall anyway, but doesn't fail", func() { + dummies.Zone1.Network = dummies.ISONet1 + dummies.CSCluster.Status.Zones = capcv1.ZoneStatusMap{dummies.Zone1.ID: dummies.Zone1} + dummies.CSCluster.Status.PublicIPNetworkID = dummies.ISONet1.ID + + fs.EXPECT().NewCreateEgressFirewallRuleParams(dummies.ISONet1.ID, "tcp"). + Return(&csapi.CreateEgressFirewallRuleParams{}) + fs.EXPECT().CreateEgressFirewallRule(&csapi.CreateEgressFirewallRuleParams{}). + Return(&csapi.CreateEgressFirewallRuleResponse{}, errors.New("there is already a rule like this")) + + Ω(client.OpenFirewallRules(dummies.CSISONet1)).Should(Succeed()) + }) + }) + + Context("in an isolated network with public IPs available", func() { + It("will resolve public IP details given an endpoint spec", func() { + ipAddress := "192.168.1.14" + as.EXPECT().NewListPublicIpAddressesParams().Return(&csapi.ListPublicIpAddressesParams{}) + as.EXPECT().ListPublicIpAddresses(gomock.Any()). + Return(&csapi.ListPublicIpAddressesResponse{ + Count: 1, + PublicIpAddresses: []*csapi.PublicIpAddress{{Id: "PublicIPID", Ipaddress: ipAddress}}, + }, nil) + publicIPAddress, err := client.GetPublicIP(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster) + Ω(err).Should(Succeed()) + Ω(publicIPAddress).ShouldNot(BeNil()) + Ω(publicIPAddress.Ipaddress).Should(Equal(ipAddress)) + }) + }) + + Context("The specific load balancer rule does exist", func() { + It("resolves the rule's ID", func() { + lbs.EXPECT().NewListLoadBalancerRulesParams().Return(&csapi.ListLoadBalancerRulesParams{}) + lbs.EXPECT().ListLoadBalancerRules(gomock.Any()).Return( + &csapi.ListLoadBalancerRulesResponse{LoadBalancerRules: []*csapi.LoadBalancerRule{ + {Publicport: strconv.Itoa(int(dummies.EndPointPort)), Id: dummies.LBRuleID}}}, nil) + + dummies.CSISONet1.Status.LBRuleID = "" + Ω(client.ResolveLoadBalancerRuleDetails(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster)).Should(Succeed()) + Ω(dummies.CSISONet1.Status.LBRuleID).Should(Equal(dummies.LBRuleID)) + }) + + It("doesn't create a new load balancer rule on create", func() { + lbs.EXPECT().NewListLoadBalancerRulesParams().Return(&csapi.ListLoadBalancerRulesParams{}) + lbs.EXPECT().ListLoadBalancerRules(gomock.Any()). + Return(&csapi.ListLoadBalancerRulesResponse{ + LoadBalancerRules: []*csapi.LoadBalancerRule{ + {Publicport: strconv.Itoa(int(dummies.EndPointPort)), Id: dummies.LBRuleID}}}, nil) + + Ω(client.GetOrCreateLoadBalancerRule(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster)).Should(Succeed()) + Ω(dummies.CSISONet1.Status.LBRuleID).Should(Equal(dummies.LBRuleID)) + }) + }) + + Context("load balancer rule does not exist", func() { + It("calls cloudstack to create a new load balancer rule.", func() { + lbs.EXPECT().NewListLoadBalancerRulesParams().Return(&csapi.ListLoadBalancerRulesParams{}) + lbs.EXPECT().ListLoadBalancerRules(gomock.Any()). + Return(&csapi.ListLoadBalancerRulesResponse{ + LoadBalancerRules: []*csapi.LoadBalancerRule{{Publicport: "7443", Id: dummies.LBRuleID}}}, nil) + lbs.EXPECT().NewCreateLoadBalancerRuleParams(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&csapi.CreateLoadBalancerRuleParams{}) + lbs.EXPECT().CreateLoadBalancerRule(gomock.Any()). + Return(&csapi.CreateLoadBalancerRuleResponse{Id: "2ndLBRuleID"}, nil) + + Ω(client.GetOrCreateLoadBalancerRule(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster)).Should(Succeed()) + Ω(dummies.CSISONet1.Status.LBRuleID).Should(Equal("2ndLBRuleID")) + }) + }) + + Context("Networking Integ Tests", func() { + client, connectionErr := cloud.NewClient("../../cloud-config") + + BeforeEach(func() { + if connectionErr != nil { // Only do these tests if an actual ACS instance is available via cloud-config. + Skip("Could not connect to ACS instance.") + } + if err := client.ResolveNetwork(&dummies.Net1); err != nil { + Skip("Could not find network.") + } + + // Delete any existing tags + existingTags, err := client.GetTags(cloud.ResourceTypeNetwork, dummies.Net1.ID) + if err != nil { + Fail("Failed to get existing tags. Error: " + err.Error()) + } + if len(existingTags) != 0 { + err = client.DeleteTags(cloud.ResourceTypeNetwork, dummies.Net1.ID, existingTags) + if err != nil { + Fail("Failed to delete existing tags. Error: " + err.Error()) + } + } + }) + + It("fetches an isolated network", func() { + dummies.SetDummyIsoNetToNameOnly() + dummies.SetClusterSpecToNet(&dummies.ISONet1) + + Ω(client.ResolveNetwork(&dummies.ISONet1)).Should(Succeed()) + Ω(dummies.ISONet1.ID).ShouldNot(BeEmpty()) + Ω(dummies.ISONet1.Type).Should(Equal(cloud.NetworkTypeIsolated)) + }) + + It("fetches a public IP", func() { + dummies.Zone1.ID = "" + dummies.SetDummyIsoNetToNameOnly() + dummies.SetClusterSpecToNet(&dummies.ISONet1) + dummies.CSCluster.Spec.ControlPlaneEndpoint.Host = "" + Ω(client.ResolveNetwork(&dummies.ISONet1)).Should(Succeed()) + }) + }) + + Context("Network Semi-Integ Tests", func() { + client, connectionErr := cloud.NewClient("../../cloud-config") + + BeforeEach(func() { + if connectionErr != nil { // Only do these tests if an actual ACS instance is available via cloud-config. + Skip("Could not connect to ACS instance.") + } + + dummies.SetDummyVars() + + // Setup Isolated Network Dummy Vars. + dummies.CSISONet1.Spec.ID = "" // Make CAPC methods resolve this. + dummies.CSCluster.Spec.ControlPlaneEndpoint.Host = "" // Make CAPC methods resolve this. + dummies.CSZone1.Spec.ID = "" // Make CAPC methods resolve this. + dummies.CSCluster.Status.Zones = capcv1.ZoneStatusMap{} + + // Get Zone info needed for network testing. + Ω(client.ResolveZone(dummies.CSZone1)).Should(Succeed()) + dummies.CSISONet1.Spec.ID = "" + }) + + It("adds an isolated network and doesn't fail when asked to GetOrCreateIsolatedNetwork multiple times", func() { + Ω(client.GetOrCreateIsolatedNetwork(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster)).Should(Succeed()) + Ω(client.GetOrCreateIsolatedNetwork(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster)).Should(Succeed()) + // Network should now exist if it didn't at the start. + Ω(client.ResolveNetwork(&dummies.ISONet1)).Should(Succeed()) + // Do once more. + Ω(client.GetOrCreateIsolatedNetwork(dummies.CSZone1, dummies.CSISONet1, dummies.CSCluster)).Should(Succeed()) + }) + }) +}) diff --git a/test/unit/cloud/network_test.go b/test/unit/cloud/network_test.go new file mode 100644 index 00000000..c0b2c4c2 --- /dev/null +++ b/test/unit/cloud/network_test.go @@ -0,0 +1,64 @@ +/* +Copyright 2022 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 cloud_test + +import ( + csapi "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/aws/cluster-api-provider-cloudstack/test/dummies" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Network", func() { + var ( // Declare shared vars. + mockCtrl *gomock.Controller + mockClient *csapi.CloudStackClient + ns *csapi.MockNetworkServiceIface + client cloud.Client + ) + + BeforeEach(func() { + // Setup new mock services. + mockCtrl = gomock.NewController(GinkgoT()) + mockClient = csapi.NewMockClient(mockCtrl) + ns = mockClient.Network.(*csapi.MockNetworkServiceIface) + client = cloud.NewClientFromCSAPIClient(mockClient) + dummies.SetDummyVars() + dummies.SetDummyClusterStatus() + }) + + AfterEach(func() { + mockCtrl.Finish() + }) + + Context("for an existing network", func() { + It("resolves network by ID", func() { + ns.EXPECT().GetNetworkByName(dummies.ISONet1.Name).Return(nil, 0, nil) + ns.EXPECT().GetNetworkByID(dummies.ISONet1.ID).Return(dummies.CAPCNetToCSAPINet(&dummies.ISONet1), 1, nil) + + Ω(client.ResolveNetwork(&dummies.ISONet1)).Should(Succeed()) + }) + + It("resolves network by Name", func() { + ns.EXPECT().GetNetworkByName(dummies.ISONet1.Name).Return(dummies.CAPCNetToCSAPINet(&dummies.ISONet1), 1, nil) + + Ω(client.ResolveNetwork(&dummies.ISONet1)).Should(Succeed()) + }) + }) +}) diff --git a/test/unit/cloud/tags_test.go b/test/unit/cloud/tags_test.go new file mode 100644 index 00000000..55c70c29 --- /dev/null +++ b/test/unit/cloud/tags_test.go @@ -0,0 +1,141 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/aws/cluster-api-provider-cloudstack/test/dummies" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tag Unit Tests", func() { + BeforeEach(func() { + dummies.SetDummyVars() + }) + + Context("Tag Integ Tests", func() { + client, connectionErr := cloud.NewClient("../../cloud-config") + + BeforeEach(func() { + if connectionErr != nil { // Only do these tests if an actual ACS instance is available via cloud-config. + Skip("Could not connect to ACS instance.") + } + if err := client.ResolveNetwork(&dummies.Net1); err != nil { + Skip("Could not find network.") + } + + // Delete any existing tags + existingTags, err := client.GetTags(cloud.ResourceTypeNetwork, dummies.Net1.ID) + if err != nil { + Fail("Failed to get existing tags. Error: " + err.Error()) + } + if len(existingTags) > 0 { + err = client.DeleteTags(cloud.ResourceTypeNetwork, dummies.Net1.ID, existingTags) + if err != nil { + Fail("Failed to delete existing tags. Error: " + err.Error()) + } + } + }) + + It("adds and gets a resource tag", func() { + Ω(client.AddTags(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.Tags)).Should(Succeed()) + Ω(client.GetTags(cloud.ResourceTypeNetwork, dummies.Net1.ID)).Should(Equal(dummies.Tags)) + }) + + It("deletes a resource tag", func() { + Ω(client.AddTags(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.Tags)).Should(Succeed()) + Ω(client.DeleteTags(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.Tags)).Should(Succeed()) + Ω(client.GetTags(cloud.ResourceTypeNetwork, dummies.Net1.ID)).Should(Equal(map[string]string{})) + }) + + It("returns an error when you delete a tag that doesn't exist", func() { + Ω(client.DeleteTags(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.Tags)).Should(Succeed()) + }) + + It("adds the tags for a cluster (resource created by CAPC)", func() { + Ω(client.AddCreatedByCAPCTag(cloud.ResourceTypeNetwork, dummies.Net1.ID)). + Should(Succeed()) + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)). + Should(Succeed()) + + // Verify tags + tags, err := client.GetTags(cloud.ResourceTypeNetwork, dummies.Net1.ID) + Ω(err).ShouldNot(HaveOccurred()) + + Ω(tags[dummies.CSClusterTagKey]).Should(Equal(dummies.CSClusterTagVal)) + }) + + It("does not fail when the cluster tags are added twice", func() { + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + }) + + It("doesn't adds the tags for a cluster (resource NOT created by CAPC)", func() { + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + + // Verify tags + tags, err := client.GetTags(cloud.ResourceTypeNetwork, dummies.Net1.ID) + Ω(err).Should(BeNil()) + Ω(tags[dummies.CreatedByCapcKey]).Should(Equal("")) + Ω(tags[dummies.CSClusterTagKey]).Should(Equal("")) + }) + + It("deletes a cluster tag", func() { + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + Ω(client.DeleteClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + + Ω(client.GetTags(cloud.ResourceTypeNetwork, dummies.Net1.ID)).ShouldNot(HaveKey(dummies.CSClusterTagKey)) + }) + + It("adds and deletes a created by capc tag", func() { + Ω(client.AddCreatedByCAPCTag(cloud.ResourceTypeNetwork, dummies.Net1.ID)).Should(Succeed()) + Ω(client.DeleteCreatedByCAPCTag(cloud.ResourceTypeNetwork, dummies.Net1.ID)).Should(Succeed()) + }) + + It("does not fail when cluster and CAPC created tags are deleted twice", func() { + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + Ω(client.DeleteClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + Ω(client.DeleteClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + Ω(client.DeleteCreatedByCAPCTag(cloud.ResourceTypeNetwork, dummies.Net1.ID)).Should(Succeed()) + Ω(client.DeleteCreatedByCAPCTag(cloud.ResourceTypeNetwork, dummies.Net1.ID)).Should(Succeed()) + }) + + It("does not allow a resource to be deleted when there are no tags", func() { + tagsAllowDisposal, err := client.DoClusterTagsAllowDisposal(cloud.ResourceTypeNetwork, dummies.Net1.ID) + Ω(err).Should(BeNil()) + Ω(tagsAllowDisposal).Should(BeFalse()) + }) + + It("does not allow a resource to be deleted when there is a cluster tag", func() { + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + tagsAllowDisposal, err := client.DoClusterTagsAllowDisposal(cloud.ResourceTypeNetwork, dummies.Net1.ID) + Ω(err).Should(BeNil()) + Ω(tagsAllowDisposal).Should(BeFalse()) + }) + + It("does allow a resource to be deleted when there are no cluster tags and there is a CAPC created tag", func() { + Ω(client.AddClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + Ω(client.AddCreatedByCAPCTag(cloud.ResourceTypeNetwork, dummies.Net1.ID)).Should(Succeed()) + Ω(client.DeleteClusterTag(cloud.ResourceTypeNetwork, dummies.Net1.ID, dummies.CSCluster)).Should(Succeed()) + + tagsAllowDisposal, err := client.DoClusterTagsAllowDisposal(cloud.ResourceTypeNetwork, dummies.Net1.ID) + Ω(err).Should(BeNil()) + Ω(tagsAllowDisposal).Should(BeTrue()) + }) + }) +}) diff --git a/test/unit/cloud/user_credentials_test.go b/test/unit/cloud/user_credentials_test.go new file mode 100644 index 00000000..4700beca --- /dev/null +++ b/test/unit/cloud/user_credentials_test.go @@ -0,0 +1,97 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/aws/cluster-api-provider-cloudstack/test/dummies" + . "github.com/onsi/ginkgo/v2" + "github.com/pkg/errors" + + . "github.com/onsi/gomega" +) + +var _ = Describe("User Credentials", func() { + + BeforeEach(func() { + dummies.SetDummyVars() + dummies.SetDummyClusterStatus() + dummies.SetDummyCSMachineStatuses() + }) + + AfterEach(func() { + }) + + Context("UserCred Semi-Integ Tests", func() { + client, connectionErr := cloud.NewClient("../../cloud-config") + var domain cloud.Domain + var account cloud.Account + var user cloud.User + + BeforeEach(func() { + if connectionErr != nil { // Only do these tests if an actual ACS instance is available via cloud-config. + Skip(errors.Wrapf(connectionErr, "Could not connect to ACS instance").Error()) + } + + // Settup dummies. + // TODO: move these to the test dummies package. + domain = cloud.Domain{Path: "ROOT/blah/blah/subsub"} + account = cloud.Account{Name: "SuperNested", Domain: domain} + user = cloud.User{Name: "SubSub", Account: account} + }) + + It("can resolve a domain from the path", func() { + Ω(client.ResolveDomain(&domain)).Should(Succeed()) + Ω(domain.ID).ShouldNot(BeEmpty()) + }) + + It("can resolve an account from the domain path and account name", func() { + Ω(client.ResolveAccount(&account)).Should(Succeed()) + Ω(account.ID).ShouldNot(BeEmpty()) + }) + + It("can resolve a user from the domain path, account name, and user name", func() { + Ω(client.ResolveUser(&user)).Should(Succeed()) + Ω(user.ID).ShouldNot(BeEmpty()) + }) + + It("can get sub-domain user's credentials", func() { + Ω(client.ResolveUserKeys(&user)).Should(Succeed()) + + Ω(user.APIKey).ShouldNot(BeEmpty()) + Ω(user.SecretKey).ShouldNot(BeEmpty()) + }) + + It("can get an arbitrary user with keys from domain and account specifications alone", func() { + found, err := client.GetUserWithKeys(&user) + Ω(err).ShouldNot(HaveOccurred()) + Ω(found).Should(BeTrue()) + Ω(user.APIKey).ShouldNot(BeEmpty()) + }) + + It("can get create a new client as another user", func() { + found, err := client.GetUserWithKeys(&user) + Ω(err).ShouldNot(HaveOccurred()) + Ω(found).Should(BeTrue()) + Ω(user.APIKey).ShouldNot(BeEmpty()) + cfg := cloud.Config{APIKey: user.APIKey, SecretKey: user.SecretKey} + newClient, err := client.NewClientFromSpec(cfg) + Ω(err).ShouldNot(HaveOccurred()) + Ω(newClient).ShouldNot(BeNil()) + }) + }) +}) diff --git a/test/unit/cloud/zone_test.go b/test/unit/cloud/zone_test.go new file mode 100644 index 00000000..6e28163f --- /dev/null +++ b/test/unit/cloud/zone_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2022 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 cloud_test + +import ( + "fmt" + + csapi "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/aws/cluster-api-provider-cloudstack/pkg/cloud" + "github.com/aws/cluster-api-provider-cloudstack/test/dummies" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pkg/errors" +) + +var _ = Describe("Cluster", func() { + var ( + client cloud.Client + mockCtrl *gomock.Controller + mockClient *csapi.CloudStackClient + zs *csapi.MockZoneServiceIface + ) + + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + mockClient = csapi.NewMockClient(mockCtrl) + zs = mockClient.Zone.(*csapi.MockZoneServiceIface) + client = cloud.NewClientFromCSAPIClient(mockClient) + dummies.SetDummyVars() + dummies.SetDummyDomainAndAccount() + dummies.SetDummyCSApiResponse() + }) + + AfterEach(func() { + mockCtrl.Finish() + }) + + Context("an existing abstract dummies.CSCluster", func() { + It("handles zone not found.", func() { + expectedErr := fmt.Errorf("Not found") + zs.EXPECT().GetZoneID(dummies.Zone1.Name).Return("", -1, expectedErr) + zs.EXPECT().GetZoneByID(dummies.Zone1.ID).Return(nil, -1, expectedErr) + + err := client.ResolveZone(dummies.CSZone1) + Expect(errors.Cause(err)).To(MatchError(expectedErr)) + }) + + It("handles multiple zone IDs returned", func() { + zs.EXPECT().GetZoneID(dummies.Zone1.Name).Return(dummies.Zone1.ID, 2, nil) + zs.EXPECT().GetZoneByID(dummies.Zone1.ID).Return(nil, -1, fmt.Errorf("Not found")) + + Ω(client.ResolveZone(dummies.CSZone1)).Should(MatchError(And( + ContainSubstring("expected 1 Zone with name "+dummies.Zone1.Name+", but got 2"), + ContainSubstring("could not get Zone by ID "+dummies.Zone1.ID+": Not found")))) + }) + }) +}) diff --git a/test/unit/test/fixtures/cloud-config-files/cloud-config-good b/test/unit/test/fixtures/cloud-config-files/cloud-config-good new file mode 100644 index 00000000..a840ba7a --- /dev/null +++ b/test/unit/test/fixtures/cloud-config-files/cloud-config-good @@ -0,0 +1,5 @@ +[Global] +api-key = api-key1 +secret-key = secret-key1 +api-url = api-url1 + diff --git a/test/unit/test/fixtures/cloud-config-files/cloud-config-no-global b/test/unit/test/fixtures/cloud-config-files/cloud-config-no-global new file mode 100644 index 00000000..cc4ed156 --- /dev/null +++ b/test/unit/test/fixtures/cloud-config-files/cloud-config-no-global @@ -0,0 +1,5 @@ +[NonGlobal] +api-key = api-key1 +secret-key = secret-key1 +api-url = api-url1 +