| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you 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 cloudstack | |
| import ( | |
| "context" | |
| "errors" | |
| "os" | |
| "strconv" | |
| "strings" | |
| "testing" | |
| "github.com/apache/cloudstack-go/v2/cloudstack" | |
| "github.com/blang/semver/v4" | |
| "go.uber.org/mock/gomock" | |
| corev1 "k8s.io/api/core/v1" | |
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | |
| ) | |
| const testClusterName = "testCluster" | |
| func TestReadConfig(t *testing.T) { | |
| _, err := readConfig(nil) | |
| if err != nil { | |
| t.Fatalf("Should not return an error when no config is provided: %v", err) | |
| } | |
| cfg, err := readConfig(strings.NewReader(` | |
| [Global] | |
| api-url = https://cloudstack.url | |
| api-key = a-valid-api-key | |
| secret-key = a-valid-secret-key | |
| ssl-no-verify = true | |
| project-id = a-valid-project-id | |
| `)) | |
| if err != nil { | |
| t.Fatalf("Should succeed when a valid config is provided: %v", err) | |
| } | |
| if cfg.Global.APIURL != "https://cloudstack.url" { | |
| t.Errorf("incorrect api-url: %s", cfg.Global.APIURL) | |
| } | |
| if cfg.Global.APIKey != "a-valid-api-key" { | |
| t.Errorf("incorrect api-key: %s", cfg.Global.APIKey) | |
| } | |
| if cfg.Global.SecretKey != "a-valid-secret-key" { | |
| t.Errorf("incorrect secret-key: %s", cfg.Global.SecretKey) | |
| } | |
| if !cfg.Global.SSLNoVerify { | |
| t.Errorf("incorrect ssl-no-verify: %t", cfg.Global.SSLNoVerify) | |
| } | |
| if cfg.Global.Version != "" { | |
| t.Errorf("version should be empty when not configured: %s", cfg.Global.Version) | |
| } | |
| cfg, err = readConfig(strings.NewReader(` | |
| [Global] | |
| api-url = https://cloudstack.url | |
| version = 4.21.0.0 | |
| `)) | |
| if err != nil { | |
| t.Fatalf("Should succeed when a valid config is provided: %v", err) | |
| } | |
| if cfg.Global.Version != "4.21.0.0" { | |
| t.Errorf("incorrect version: %s", cfg.Global.Version) | |
| } | |
| } | |
| func TestNewCSCloudWithVersionFromConfig(t *testing.T) { | |
| cfg := &CSConfig{} | |
| cfg.Global.APIURL = "https://cloudstack.url/client/api" | |
| cfg.Global.APIKey = "a-valid-api-key" | |
| cfg.Global.SecretKey = "a-valid-secret-key" | |
| cfg.Global.Version = "4.21.0.0" | |
| // The version from the config is used as-is, so no API call is made. | |
| cs, err := newCSCloud(cfg) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| expected := semver.MustParse("4.21.0") | |
| if !cs.version.Equals(expected) { | |
| t.Fatalf("version = %v, want %v", cs.version, expected) | |
| } | |
| cfg.Global.Version = "not-a-version" | |
| if _, err := newCSCloud(cfg); err == nil { | |
| t.Fatalf("expected error for an invalid version in the config") | |
| } | |
| } | |
| // This allows acceptance testing against an existing CloudStack environment. | |
| func configFromEnv() (*CSConfig, bool) { | |
| cfg := &CSConfig{} | |
| cfg.Global.APIURL = os.Getenv("CS_API_URL") | |
| cfg.Global.APIKey = os.Getenv("CS_API_KEY") | |
| cfg.Global.SecretKey = os.Getenv("CS_SECRET_KEY") | |
| cfg.Global.ProjectID = os.Getenv("CS_PROJECT_ID") | |
| // It is save to ignore the error here. If the input cannot be parsed SSLNoVerify | |
| // will still be a bool with its zero value (false) which is the expected default. | |
| cfg.Global.SSLNoVerify, _ = strconv.ParseBool(os.Getenv("CS_SSL_NO_VERIFY")) | |
| // Check if we have the minimum required info to be able to connect to CloudStack. | |
| ok := cfg.Global.APIURL != "" && cfg.Global.APIKey != "" && cfg.Global.SecretKey != "" | |
| return cfg, ok | |
| } | |
| func TestNewCSCloud(t *testing.T) { | |
| cfg, ok := configFromEnv() | |
| if !ok { | |
| t.Skipf("No config found in environment") | |
| } | |
| _, err := newCSCloud(cfg) | |
| if err != nil { | |
| t.Fatalf("Failed to construct/authenticate CloudStack: %v", err) | |
| } | |
| } | |
| func TestLoadBalancer(t *testing.T) { | |
| cfg, ok := configFromEnv() | |
| if !ok { | |
| t.Skipf("No config found in environment") | |
| } | |
| cs, err := newCSCloud(cfg) | |
| if err != nil { | |
| t.Fatalf("Failed to construct/authenticate CloudStack: %v", err) | |
| } | |
| lb, ok := cs.LoadBalancer() | |
| if !ok { | |
| t.Fatalf("LoadBalancer() returned false") | |
| } | |
| _, exists, err := lb.GetLoadBalancer(context.TODO(), testClusterName, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "noexist"}}) | |
| if err != nil { | |
| t.Fatalf("GetLoadBalancer(\"noexist\") returned error: %s", err) | |
| } | |
| if exists { | |
| t.Fatalf("GetLoadBalancer(\"noexist\") returned exists") | |
| } | |
| } | |
| // newCSCloudWithCapabilities returns a CSCloud whose Configuration service is mocked to | |
| // answer a single listCapabilities call with the given response and error. | |
| func newCSCloudWithCapabilities(t *testing.T, resp *cloudstack.ListCapabilitiesResponse, err error) *CSCloud { | |
| t.Helper() | |
| ctrl := gomock.NewController(t) | |
| t.Cleanup(ctrl.Finish) | |
| mockConfig := cloudstack.NewMockConfigurationServiceIface(ctrl) | |
| params := &cloudstack.ListCapabilitiesParams{} | |
| gomock.InOrder( | |
| mockConfig.EXPECT().NewListCapabilitiesParams().Return(params), | |
| mockConfig.EXPECT().ListCapabilities(params).Return(resp, err), | |
| ) | |
| return &CSCloud{ | |
| client: &cloudstack.CloudStackClient{ | |
| Configuration: mockConfig, | |
| }, | |
| } | |
| } | |
| func TestGetCloudStackVersion(t *testing.T) { | |
| t.Run("returns parsed version", func(t *testing.T) { | |
| cs := newCSCloudWithCapabilities(t, &cloudstack.ListCapabilitiesResponse{ | |
| Capabilities: &cloudstack.Capability{Cloudstackversion: "4.17.1.0"}, | |
| }, nil) | |
| version, err := cs.getCloudStackVersion() | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| expected := semver.MustParse("4.17.1") | |
| if !version.Equals(expected) { | |
| t.Fatalf("version = %v, want %v", version, expected) | |
| } | |
| }) | |
| t.Run("returns correct parsed version with development server", func(t *testing.T) { | |
| cs := newCSCloudWithCapabilities(t, &cloudstack.ListCapabilitiesResponse{ | |
| Capabilities: &cloudstack.Capability{Cloudstackversion: "4.17.1.0-SNAPSHOT"}, | |
| }, nil) | |
| version, err := cs.getCloudStackVersion() | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| expected := semver.MustParse("4.17.1") | |
| if !version.Equals(expected) { | |
| t.Fatalf("version = %v, want %v", version, expected) | |
| } | |
| }) | |
| // A version string with fewer than three components used to panic while | |
| // being trimmed to major.minor.patch, crashing the controller at startup. | |
| t.Run("handles short version strings without panicking", func(t *testing.T) { | |
| for _, tc := range []struct { | |
| version string | |
| want semver.Version | |
| }{ | |
| {"4.22", semver.MustParse("4.22.0")}, | |
| {"4", semver.MustParse("4.0.0")}, | |
| {"24.0.0.0", semver.MustParse("24.0.0")}, | |
| {"24.0.0.0-SNAPSHOT", semver.MustParse("24.0.0")}, | |
| } { | |
| t.Run(tc.version, func(t *testing.T) { | |
| cs := newCSCloudWithCapabilities(t, &cloudstack.ListCapabilitiesResponse{ | |
| Capabilities: &cloudstack.Capability{Cloudstackversion: tc.version}, | |
| }, nil) | |
| version, err := cs.getCloudStackVersion() | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| if !version.Equals(tc.want) { | |
| t.Errorf("version = %v, want %v", version, tc.want) | |
| } | |
| }) | |
| } | |
| }) | |
| t.Run("returns error when api call fails", func(t *testing.T) { | |
| cs := newCSCloudWithCapabilities(t, nil, errors.New("api failure")) | |
| if _, err := cs.getCloudStackVersion(); err == nil { | |
| t.Fatalf("expected error, got nil") | |
| } | |
| }) | |
| t.Run("returns error when no capabilities returned", func(t *testing.T) { | |
| cs := newCSCloudWithCapabilities(t, &cloudstack.ListCapabilitiesResponse{}, nil) | |
| if _, err := cs.getCloudStackVersion(); err == nil { | |
| t.Fatalf("expected error for missing capabilities") | |
| } | |
| }) | |
| t.Run("returns error when version is empty", func(t *testing.T) { | |
| cs := newCSCloudWithCapabilities(t, &cloudstack.ListCapabilitiesResponse{ | |
| Capabilities: &cloudstack.Capability{}, | |
| }, nil) | |
| if _, err := cs.getCloudStackVersion(); err == nil { | |
| t.Fatalf("expected error for empty version") | |
| } | |
| }) | |
| t.Run("returns error when version cannot be parsed", func(t *testing.T) { | |
| cs := newCSCloudWithCapabilities(t, &cloudstack.ListCapabilitiesResponse{ | |
| Capabilities: &cloudstack.Capability{Cloudstackversion: "invalid.version.string"}, | |
| }, nil) | |
| if _, err := cs.getCloudStackVersion(); err == nil { | |
| t.Fatalf("expected parse error") | |
| } | |
| }) | |
| } | |
| func TestParseCloudStackVersion(t *testing.T) { | |
| tests := []struct { | |
| version string | |
| want string | |
| wantErr bool | |
| }{ | |
| {version: "4.17.1.0", want: "4.17.1"}, | |
| {version: "4.17.1.0-SNAPSHOT", want: "4.17.1"}, | |
| {version: "4.21.0", want: "4.21.0"}, | |
| {version: "4.21", want: "4.21.0"}, | |
| {version: "4", want: "4.0.0"}, | |
| {version: "4.23.0.0", want: "4.23.0"}, | |
| {version: "24.0.0.0", want: "24.0.0"}, | |
| {version: "24.0.0.0-SNAPSHOT", want: "24.0.0"}, | |
| {version: "24.0", want: "24.0.0"}, | |
| {version: "invalid.version.string", wantErr: true}, | |
| {version: "", wantErr: true}, | |
| } | |
| for _, tt := range tests { | |
| t.Run(tt.version, func(t *testing.T) { | |
| got, err := parseCloudStackVersion(tt.version) | |
| if tt.wantErr { | |
| if err == nil { | |
| t.Fatalf("expected error for %q, got %v", tt.version, got) | |
| } | |
| return | |
| } | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| if !got.Equals(semver.MustParse(tt.want)) { | |
| t.Fatalf("version = %v, want %v", got, tt.want) | |
| } | |
| }) | |
| } | |
| } | |
| func TestGetRegionFromZone(t *testing.T) { | |
| tests := []struct { | |
| name string | |
| region string | |
| zone string | |
| want string | |
| }{ | |
| { | |
| name: "region configured in cloud config", | |
| region: "us-east-1", | |
| zone: "zone-1", | |
| want: "us-east-1", | |
| }, | |
| { | |
| name: "region not configured, returns zone", | |
| region: "", | |
| zone: "zone-1", | |
| want: "zone-1", | |
| }, | |
| { | |
| name: "region configured with empty zone", | |
| region: "eu-central-1", | |
| zone: "", | |
| want: "eu-central-1", | |
| }, | |
| } | |
| for _, tt := range tests { | |
| t.Run(tt.name, func(t *testing.T) { | |
| cs := &CSCloud{ | |
| region: tt.region, | |
| } | |
| got := cs.getRegionFromZone(tt.zone) | |
| if got != tt.want { | |
| t.Errorf("getRegionFromZone(%q) with region=%q = %q, want %q", tt.zone, tt.region, got, tt.want) | |
| } | |
| }) | |
| } | |
| } |