blob: f6aff4f1577b1afd877247431cc10ca15988a48c [file]
/*
* 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"
"reflect"
"strings"
"testing"
"github.com/apache/cloudstack-go/v2/cloudstack"
"go.uber.org/mock/gomock"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
cloudprovider "k8s.io/cloud-provider"
)
const testProjectID = "6a3c1b9e-4f3d-4b43-9a6a-0c3d1f2e7b11"
type projectRecorder struct{ projectID string }
func (r *projectRecorder) SetProjectid(id string) { r.projectID = id }
// appliedProjectID returns the project id the lookup options would put on a request.
func appliedProjectID(t *testing.T, opts []cloudstack.OptionFunc) string {
t.Helper()
recorder := &projectRecorder{}
for _, opt := range opts {
if err := opt(nil, recorder); err != nil {
t.Fatalf("applying lookup option: %v", err)
}
}
return recorder.projectID
}
type vmLookupEnv struct {
cs *CSCloud
vm *cloudstack.MockVirtualMachineServiceIface
}
func newVMLookupEnv(t *testing.T, projectID string) *vmLookupEnv {
t.Helper()
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
vm := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
return &vmLookupEnv{
cs: &CSCloud{
client: &cloudstack.CloudStackClient{VirtualMachine: vm},
projectID: projectID,
},
vm: vm,
}
}
// expectByName expects one lookup of the instance by name, scoped to the CSCloud's project.
func (e *vmLookupEnv) expectByName(t *testing.T, name string, result *cloudstack.VirtualMachine, count int, err error) *gomock.Call {
t.Helper()
return e.vm.EXPECT().GetVirtualMachineByName(gomock.Any(), gomock.Any()).
DoAndReturn(func(got string, opts ...cloudstack.OptionFunc) (*cloudstack.VirtualMachine, int, error) {
if got != name {
t.Errorf("GetVirtualMachineByName name = %q, want %q", got, name)
}
if gotProject := appliedProjectID(t, opts); gotProject != e.cs.projectID {
t.Errorf("GetVirtualMachineByName project = %q, want %q", gotProject, e.cs.projectID)
}
return result, count, err
})
}
// expectByID expects one lookup of the instance by ID, scoped to the CSCloud's project.
func (e *vmLookupEnv) expectByID(t *testing.T, id string, result *cloudstack.VirtualMachine, count int, err error) *gomock.Call {
t.Helper()
return e.vm.EXPECT().GetVirtualMachineByID(gomock.Any(), gomock.Any()).
DoAndReturn(func(got string, opts ...cloudstack.OptionFunc) (*cloudstack.VirtualMachine, int, error) {
if got != id {
t.Errorf("GetVirtualMachineByID id = %q, want %q", got, id)
}
if gotProject := appliedProjectID(t, opts); gotProject != e.cs.projectID {
t.Errorf("GetVirtualMachineByID project = %q, want %q", gotProject, e.cs.projectID)
}
return result, count, err
})
}
func testInstance() *cloudstack.VirtualMachine {
return &cloudstack.VirtualMachine{
Id: "vm-1",
Name: "node-1",
Zonename: "zone-a",
Serviceofferingname: "Small Instance",
Nic: []cloudstack.Nic{{Ipaddress: "10.0.0.1"}},
}
}
type instanceLookupOp struct {
name string
call func(cs *CSCloud, key string) (any, error)
want any
notFound any
notFoundErr error
errPrefix string
}
var instanceLookupOps = []instanceLookupOp{
{
name: "NodeAddresses",
call: func(cs *CSCloud, key string) (any, error) {
return cs.NodeAddresses(context.TODO(), types.NodeName(key))
},
want: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.0.0.1"}},
notFound: []corev1.NodeAddress(nil),
notFoundErr: cloudprovider.InstanceNotFound,
errPrefix: "error retrieving node addresses",
},
{
name: "InstanceID",
call: func(cs *CSCloud, key string) (any, error) {
return cs.InstanceID(context.TODO(), types.NodeName(key))
},
want: "vm-1",
notFound: "",
notFoundErr: cloudprovider.InstanceNotFound,
errPrefix: "error retrieving instance ID",
},
{
name: "InstanceType",
call: func(cs *CSCloud, key string) (any, error) {
return cs.InstanceType(context.TODO(), types.NodeName(key))
},
want: "SmallInstance",
notFound: "",
notFoundErr: cloudprovider.InstanceNotFound,
errPrefix: "error retrieving instance type",
},
}
var providerIDLookupOps = []instanceLookupOp{
{
name: "NodeAddressesByProviderID",
call: func(cs *CSCloud, key string) (any, error) {
return cs.NodeAddressesByProviderID(context.TODO(), key)
},
want: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.0.0.1"}},
notFound: []corev1.NodeAddress(nil),
notFoundErr: cloudprovider.InstanceNotFound,
errPrefix: "error retrieving node addresses",
},
{
name: "InstanceTypeByProviderID",
call: func(cs *CSCloud, key string) (any, error) {
return cs.InstanceTypeByProviderID(context.TODO(), key)
},
want: "SmallInstance",
notFound: "",
notFoundErr: cloudprovider.InstanceNotFound,
errPrefix: "error retrieving instance type",
},
{
name: "InstanceExistsByProviderID",
call: func(cs *CSCloud, key string) (any, error) {
return cs.InstanceExistsByProviderID(context.TODO(), key)
},
want: true,
notFound: false,
errPrefix: "error retrieving instance",
},
}
type lookupScenario struct {
name string
projectID string
result *cloudstack.VirtualMachine
count int
err error
}
func lookupScenarios() []lookupScenario {
return []lookupScenario{
{name: "found", result: testInstance(), count: 1},
{name: "found in a project", projectID: testProjectID, result: testInstance(), count: 1},
{name: "not found", count: 0, err: errors.New("no match")},
{name: "api error", count: 1, err: errors.New("boom")},
}
}
func assertLookupOutcome(t *testing.T, op instanceLookupOp, sc lookupScenario, got any, err error) {
t.Helper()
switch {
case sc.err == nil:
if err != nil {
t.Fatalf("%s() unexpected error: %v", op.name, err)
}
if !reflect.DeepEqual(got, op.want) {
t.Errorf("%s() = %v, want %v", op.name, got, op.want)
}
case sc.count == 0:
if err != op.notFoundErr {
t.Errorf("%s() error = %v, want %v", op.name, err, op.notFoundErr)
}
if !reflect.DeepEqual(got, op.notFound) {
t.Errorf("%s() = %v, want %v", op.name, got, op.notFound)
}
default:
if err == nil || !strings.HasPrefix(err.Error(), op.errPrefix) || !strings.Contains(err.Error(), sc.err.Error()) {
t.Errorf("%s() error = %v, want prefix %q wrapping %q", op.name, err, op.errPrefix, sc.err)
}
}
}
// TestInstanceLookupsByName checks that every node-name lookup asks CloudStack for that name in
// the configured project and maps a miss and an API failure to the errors the node controller expects.
func TestInstanceLookupsByName(t *testing.T) {
for _, op := range instanceLookupOps {
for _, sc := range lookupScenarios() {
t.Run(op.name+"/"+sc.name, func(t *testing.T) {
env := newVMLookupEnv(t, sc.projectID)
env.expectByName(t, "node-1", sc.result, sc.count, sc.err)
got, err := op.call(env.cs, "node-1")
assertLookupOutcome(t, op, sc, got, err)
})
}
}
}
// TestInstanceLookupsByProviderID checks that every provider ID lookup strips the scheme before
// asking CloudStack for the instance and accepts a bare instance ID as well.
func TestInstanceLookupsByProviderID(t *testing.T) {
providerIDs := map[string]string{
"with scheme": "external-cloudstack://vm-1",
"bare id": "vm-1",
}
for _, op := range providerIDLookupOps {
for _, sc := range lookupScenarios() {
for idName, providerID := range providerIDs {
t.Run(op.name+"/"+sc.name+"/"+idName, func(t *testing.T) {
env := newVMLookupEnv(t, sc.projectID)
env.expectByID(t, "vm-1", sc.result, sc.count, sc.err)
got, err := op.call(env.cs, providerID)
assertLookupOutcome(t, op, sc, got, err)
})
}
}
}
}
// TestInstanceTypeStripsInvalidLabelCharacters checks that the service offering name is turned
// into a value that is valid in a node label.
func TestInstanceTypeStripsInvalidLabelCharacters(t *testing.T) {
tests := map[string]string{
"Small Instance": "SmallInstance",
"Medium": "Medium",
"Big (x2)": "Bigx2",
}
for offering, want := range tests {
t.Run(offering, func(t *testing.T) {
env := newVMLookupEnv(t, "")
env.expectByName(t, "node-1", &cloudstack.VirtualMachine{Serviceofferingname: offering}, 1, nil)
got, err := env.cs.InstanceType(context.TODO(), "node-1")
if err != nil || got != want {
t.Errorf("InstanceType() = %q, %v, want %q", got, err, want)
}
})
}
}
// TestInstanceExists checks that the node is resolved to its ID by name before the existence check.
func TestInstanceExists(t *testing.T) {
tests := []struct {
name string
byName func(e *vmLookupEnv, t *testing.T)
byID func(e *vmLookupEnv, t *testing.T)
want bool
wantErr error
errContains string
}{
{
name: "instance exists",
byName: func(e *vmLookupEnv, t *testing.T) { e.expectByName(t, "node-1", testInstance(), 1, nil) },
byID: func(e *vmLookupEnv, t *testing.T) { e.expectByID(t, "vm-1", testInstance(), 1, nil) },
want: true,
},
{
name: "node unknown to CloudStack",
byName: func(e *vmLookupEnv, t *testing.T) { e.expectByName(t, "node-1", nil, 0, errors.New("no match")) },
wantErr: cloudprovider.InstanceNotFound,
},
{
name: "instance deleted after the name lookup",
byName: func(e *vmLookupEnv, t *testing.T) { e.expectByName(t, "node-1", testInstance(), 1, nil) },
byID: func(e *vmLookupEnv, t *testing.T) { e.expectByID(t, "vm-1", nil, 0, errors.New("no match")) },
want: false,
},
{
name: "api error on the ID lookup",
byName: func(e *vmLookupEnv, t *testing.T) { e.expectByName(t, "node-1", testInstance(), 1, nil) },
byID: func(e *vmLookupEnv, t *testing.T) { e.expectByID(t, "vm-1", nil, 1, errors.New("boom")) },
errContains: "error retrieving instance: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := newVMLookupEnv(t, testProjectID)
tt.byName(env, t)
if tt.byID != nil {
tt.byID(env, t)
}
got, err := env.cs.InstanceExists(context.TODO(), &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}})
if got != tt.want {
t.Errorf("InstanceExists() = %v, want %v", got, tt.want)
}
if tt.wantErr != nil && err != tt.wantErr {
t.Errorf("InstanceExists() error = %v, want %v", err, tt.wantErr)
}
if tt.errContains != "" && (err == nil || !strings.Contains(err.Error(), tt.errContains)) {
t.Errorf("InstanceExists() error = %v, want it to contain %q", err, tt.errContains)
}
})
}
}
// TestInstanceMetadata checks the metadata the node controller sets on a new node and that a
// failure of any of the four lookups behind it is returned instead of a half-filled result.
func TestInstanceMetadata(t *testing.T) {
node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}}
lookups := []string{"InstanceID", "InstanceType", "NodeAddresses", "GetZoneByNodeName"}
t.Run("all lookups succeed", func(t *testing.T) {
env := newVMLookupEnv(t, testProjectID)
env.cs.region = "region-1"
env.vm.EXPECT().GetVirtualMachineByName(gomock.Any(), gomock.Any()).Times(len(lookups)).
DoAndReturn(func(name string, opts ...cloudstack.OptionFunc) (*cloudstack.VirtualMachine, int, error) {
if name != "node-1" || appliedProjectID(t, opts) != testProjectID {
t.Errorf("lookup of %q in project %q, want node-1 in %q", name, appliedProjectID(t, opts), testProjectID)
}
return testInstance(), 1, nil
})
got, err := env.cs.InstanceMetadata(context.TODO(), node)
if err != nil {
t.Fatalf("InstanceMetadata() unexpected error: %v", err)
}
want := &cloudprovider.InstanceMetadata{
ProviderID: "external-cloudstack://vm-1",
InstanceType: "SmallInstance",
NodeAddresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.0.0.1"}},
Zone: "zone-a",
Region: "region-1",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("InstanceMetadata() = %+v, want %+v", got, want)
}
})
for failAt, lookup := range lookups {
t.Run("failure in "+lookup, func(t *testing.T) {
env := newVMLookupEnv(t, "")
calls := 0
env.vm.EXPECT().GetVirtualMachineByName(gomock.Any(), gomock.Any()).Times(failAt + 1).
DoAndReturn(func(string, ...cloudstack.OptionFunc) (*cloudstack.VirtualMachine, int, error) {
calls++
if calls == failAt+1 {
return nil, 1, errors.New("boom")
}
return testInstance(), 1, nil
})
got, err := env.cs.InstanceMetadata(context.TODO(), node)
if got != nil || err == nil || !strings.Contains(err.Error(), "boom") {
t.Errorf("InstanceMetadata() = %+v, %v, want nil and an error containing boom", got, err)
}
})
}
}
// TestUnsupportedInstanceOperations checks the operations the provider leaves to Kubernetes.
func TestUnsupportedInstanceOperations(t *testing.T) {
cs := &CSCloud{}
node := &corev1.Node{}
if shutdown, err := cs.InstanceShutdownByProviderID(context.TODO(), "vm-1"); shutdown || err != cloudprovider.NotImplemented {
t.Errorf("InstanceShutdownByProviderID() = %v, %v, want false, NotImplemented", shutdown, err)
}
if shutdown, err := cs.InstanceShutdown(context.TODO(), node); shutdown || err != cloudprovider.NotImplemented {
t.Errorf("InstanceShutdown() = %v, %v, want false, NotImplemented", shutdown, err)
}
if err := cs.AddSSHKeyToAllInstances(context.TODO(), "user", nil); err != cloudprovider.NotImplemented {
t.Errorf("AddSSHKeyToAllInstances() = %v, want NotImplemented", err)
}
if name, err := cs.CurrentNodeName(context.TODO(), "host-1"); err != nil || name != "host-1" {
t.Errorf("CurrentNodeName() = %q, %v, want host-1", name, err)
}
}