diff --git a/Makefile b/Makefile index 4a3de9b6..b730afd3 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,10 @@ cache/bin/kustomize: cache/bin curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash [[ "$$(which kustomize)" =~ cache/bin/kustomize ]] +.PHONY: test-fast +test-fast: generate manifests kubebuilder + go test -short -coverprofile cover.out ./... + .PHONY: test test: generate manifests kubebuilder go test -race -coverprofile cover.out ./... diff --git a/controllers/service_controller.go b/controllers/service_controller.go index de43f944..0a878915 100644 --- a/controllers/service_controller.go +++ b/controllers/service_controller.go @@ -33,7 +33,6 @@ import ( ibmcloudv1beta1 "github.com/ibm/cloud-operators/api/v1beta1" "github.com/ibm/cloud-operators/internal/config" - "github.com/ibm/cloud-operators/internal/ibmcloud" "github.com/ibm/cloud-operators/internal/ibmcloud/cfservice" "github.com/ibm/cloud-operators/internal/ibmcloud/resource" ) @@ -64,6 +63,7 @@ type ServiceReconciler struct { DeleteCFServiceInstance cfservice.InstanceDeleter DeleteResourceServiceInstance resource.ServiceInstanceDeleter GetCFServiceInstance cfservice.InstanceGetter + GetIBMCloudInfo IBMCloudInfoGetter GetResourceServiceAliasInstance resource.ServiceAliasInstanceGetter GetResourceServiceInstanceState resource.ServiceInstanceStatusGetter UpdateResourceServiceInstance resource.ServiceInstanceUpdater @@ -121,7 +121,7 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) targetCRN string ) { - ibmCloudInfo, err := ibmcloud.GetInfo(logt, r.Client, instance) + ibmCloudInfo, err := r.GetIBMCloudInfo(logt, r.Client, instance) if err != nil { // If secrets have already been deleted and we are in a deletion flow, just delete the finalizers // to not prevent object from finalizing. This would cause orphaned service in IBM Cloud. @@ -130,11 +130,13 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) logt.Info("Cannot get IBMCloud related secrets and configmaps, just remove finalizers", "in deletion", err.Error()) instance.ObjectMeta.Finalizers = deleteServiceFinalizer(instance) if err := r.Update(ctx, instance); err != nil { - logt.Info("Error removing finalizers", "in deletion", err.Error()) + logt.Error(err, "Error removing finalizers in deletion") + // TODO(johnstarich): Shouldn't this be a failure so it can be requeued? + // Also, should the status be updated to include this failure message? } return ctrl.Result{}, nil } - logt.Info(err.Error()) + logt.Error(err, "Failed to get IBM Cloud info for service") return r.updateStatusError(instance, serviceStateFailed, err) } resourceContext = ibmCloudInfo.Context @@ -170,7 +172,8 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) if !containsServiceFinalizer(instance) { instance.ObjectMeta.Finalizers = append(instance.ObjectMeta.Finalizers, serviceFinalizer) if err := r.Update(ctx, instance); err != nil { - logt.Info("Error adding finalizer", instance.ObjectMeta.Name, err.Error()) + logt.Error(err, "Error adding finalizer", "service", instance.ObjectMeta.Name) + // TODO(johnstarich): Shouldn't this update the status with the failure message? return ctrl.Result{}, err } } @@ -179,14 +182,16 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) if containsServiceFinalizer(instance) { err := r.deleteService(session, logt, instance, serviceClassType) if err != nil { - logt.Info("Error deleting resource", instance.ObjectMeta.Name, err.Error()) + logt.Error(err, "Error deleting resource", "service", instance.ObjectMeta.Name) + // TODO(johnstarich): Shouldn't this return the error so it will be logged? return ctrl.Result{Requeue: true, RequeueAfter: time.Second * 10}, nil } // remove our finalizer from the list and update it. instance.ObjectMeta.Finalizers = deleteServiceFinalizer(instance) - if err := r.Update(ctx, instance); err != nil { - logt.Info("Error removing finalizers", "in deletion", err.Error()) + err = r.Update(ctx, instance) + if err != nil { + logt.Error(err, "Error removing finalizers") } return ctrl.Result{}, err } @@ -222,14 +227,14 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) externalName := getExternalName(instance) params, err := r.getParams(ctx, instance) if err != nil { - logt.Error(err, "Instance ", instance.ObjectMeta.Name, " has problems with its parameters") + logt.Error(err, "Instance has problems with its parameters", "service", instance.ObjectMeta.Name) return r.updateStatusError(instance, serviceStateFailed, err) } tags := getTags(instance) logt.Info("ServiceInstance ", "name", externalName, "tags", tags) if serviceClassType == "CF" { - logt.Info("ServiceInstance ", "is CF", instance.ObjectMeta.Name) + logt.Info("ServiceInstance is CF", "instance", instance.ObjectMeta.Name) if instance.Status.InstanceID == "" { // ServiceInstance has not been created on Bluemix // check if using the alias plan, in that case we need to use the existing instance if isAlias(instance) { @@ -243,7 +248,7 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) return r.updateStatus(session, logt, instance, resourceContext, instanceID, serviceStateOnline, serviceClassType) } // Service is not Alias - logt.Info("Creating ", instance.ObjectMeta.Name, instance.Spec.ServiceClass) + logt.Info("Creating", "instance", instance.ObjectMeta.Name, "service class", instance.Spec.ServiceClass) guid, state, err := r.CreateCFServiceInstance(session, externalName, servicePlanID, spaceID, params, tags) if err != nil { return r.updateStatusError(instance, serviceStateFailed, err) @@ -285,13 +290,13 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) if instance.Status.InstanceID == "" { // ServiceInstance has not been created on Bluemix // check if using the alias plan, in that case we need to use the existing instance if isAlias(instance) { + logt := logt.WithValues("Name", instance.ObjectMeta.Name) logt.Info("Using `Alias` plan, checking if instance exists") // check if there is an annotation for service ID instanceID := instance.ObjectMeta.GetAnnotations()[instanceIDKey] - logger := logt.WithValues("Name", instance.ObjectMeta.Name) - id, state, err := r.GetResourceServiceAliasInstance(session, instanceID, resourceGroupID, servicePlanID, externalName, logger) + id, state, err := r.GetResourceServiceAliasInstance(session, instanceID, resourceGroupID, servicePlanID, externalName, logt) if _, notFound := err.(resource.NotFoundError); notFound { return r.updateStatusError(instance, serviceStateFailed, errors.Wrapf(err, "no service instances with name %s found for alias plan", instance.ObjectMeta.Name)) } @@ -323,7 +328,7 @@ func (r *ServiceReconciler) Reconcile(request ctrl.Request) (ctrl.Result, error) if _, ok := err.(resource.NotFoundError); ok { // Need to recreate it! if !isAlias(instance) { logt.Info("Recreating ", instance.ObjectMeta.Name, instance.Spec.ServiceClass) - instance.Status.InstanceID = "IN PROGRESS" + instance.Status.InstanceID = inProgress if err := r.Status().Update(ctx, instance); err != nil { logt.Info("Error updating instanceID to be in progress", "Error", err.Error()) return ctrl.Result{}, err @@ -476,7 +481,7 @@ func (r *ServiceReconciler) getParams(ctx context.Context, instance *ibmcloudv1b // paramToJSON converts variable value to JSON value func (r *ServiceReconciler) paramToJSON(ctx context.Context, p ibmcloudv1beta1.Param, namespace string) (interface{}, error) { if p.Value != nil && p.ValueFrom != nil { - return nil, fmt.Errorf("value and ValueFrom properties are mutually exclusive (for %s variable)", p.Name) + return nil, fmt.Errorf("Value and ValueFrom properties are mutually exclusive (for %s variable)", p.Name) } valueFrom := p.ValueFrom @@ -496,18 +501,18 @@ func (r *ServiceReconciler) paramValueToJSON(ctx context.Context, valueFrom ibmc data, err := getKubeSecretValue(ctx, r, r.Log, valueFrom.SecretKeyRef.Name, valueFrom.SecretKeyRef.Key, true, namespace) if err != nil { // Recoverable - return nil, fmt.Errorf("missing secret %s", valueFrom.SecretKeyRef.Name) + return nil, fmt.Errorf("Missing secret %s", valueFrom.SecretKeyRef.Name) } return paramToJSONFromString(string(data)) } else if valueFrom.ConfigMapKeyRef != nil { data, err := getConfigMapValue(ctx, r, r.Log, valueFrom.ConfigMapKeyRef.Name, valueFrom.ConfigMapKeyRef.Key, true, namespace) if err != nil { // Recoverable - return nil, fmt.Errorf("missing configmap %s", valueFrom.ConfigMapKeyRef.Name) + return nil, fmt.Errorf("Missing configmap %s", valueFrom.ConfigMapKeyRef.Name) } return paramToJSONFromString(data) } - return nil, fmt.Errorf("missing secretKeyRef or configMapKeyRef") + return nil, fmt.Errorf("Missing secretKeyRef or configMapKeyRef") } func getTags(instance *ibmcloudv1beta1.Service) []string { diff --git a/controllers/service_controller_test.go b/controllers/service_controller_test.go index b84859e0..351b90df 100644 --- a/controllers/service_controller_test.go +++ b/controllers/service_controller_test.go @@ -2,21 +2,35 @@ package controllers import ( "context" + "encoding/json" "fmt" "path/filepath" "testing" + "time" "github.com/IBM-Cloud/bluemix-go/api/mccp/mccpv2" bxcontroller "github.com/IBM-Cloud/bluemix-go/api/resource/resourcev1/controller" "github.com/IBM-Cloud/bluemix-go/models" + "github.com/IBM-Cloud/bluemix-go/session" "github.com/go-logr/logr" "github.com/go-logr/zapr" ibmcloudv1beta1 "github.com/ibm/cloud-operators/api/v1beta1" + "github.com/ibm/cloud-operators/internal/config" "github.com/ibm/cloud-operators/internal/ibmcloud" + "github.com/ibm/cloud-operators/internal/ibmcloud/cfservice" + "github.com/ibm/cloud-operators/internal/ibmcloud/resource" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func TestService(t *testing.T) { @@ -201,3 +215,2007 @@ func TestServiceV1Alpha1Compat(t *testing.T) { _, err = getServiceInstanceFromObj(logger, serviceCopy) assert.True(t, ibmcloud.IsNotFound(err), "Expect service to be deleted") } + +func TestServiceLoadServiceFailed(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + t.Run("not found error", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{} + r := &ServiceReconciler{ + Client: fake.NewFakeClientWithScheme(scheme, objects...), + Log: testLogger(t), + Scheme: scheme, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.NoError(t, err) + }) + + t.Run("other error", func(t *testing.T) { + scheme := runtime.NewScheme() + objects := []runtime.Object{} + r := &ServiceReconciler{ + Client: fake.NewFakeClientWithScheme(scheme, objects...), + Log: testLogger(t), + Scheme: scheme, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.Error(t, err) + assert.False(t, k8sErrors.IsNotFound(err)) + }) +} + +func TestServiceSpecChangedAndUpdateFailed(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + }, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{UpdateErr: fmt.Errorf("failed")}, + ), + Log: testLogger(t), + Scheme: scheme, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.EqualError(t, err, "failed") +} + +func TestServiceGetIBMCloudInfoFailed(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + now := metav1Now(t) + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + DeletionTimestamp: now, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{Plan: "Lite"}, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, + } + + t.Run("not found error", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{UpdateErr: fmt.Errorf("failed")}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return nil, errors.NewNotFound(ctrl.GroupResource{Group: "ibmcloud.ibm.com", Resource: "secret"}, "secret-ibm-cloud-operator") + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + DeletionTimestamp: now, + Finalizers: nil, // attempt to remove finalizers + }, + Status: ibmcloudv1beta1.ServiceStatus{ + //State: serviceStateFailed, // TODO this state should be set! + Plan: "Lite", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, r.Client.(MockClient).LastUpdate()) + assert.Equal(t, nil, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("other error", func(t *testing.T) { + fakeClient := newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ) + r := &ServiceReconciler{ + Client: fakeClient, + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, r client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return nil, fmt.Errorf("failed") + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + DeletionTimestamp: now, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: "Lite", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, fakeClient.LastStatusUpdate()) + }) +} + +func TestServiceFirstStatusFailed(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{}, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{StatusUpdateErr: fmt.Errorf("failed")}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, r client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.EqualError(t, err, "failed") +} + +func TestServiceEnsureFinalizerFailed(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + DeletionTimestamp: nil, // not deleting + Finalizers: nil, // AND missing finalizer + }, + Status: ibmcloudv1beta1.ServiceStatus{Plan: "Lite"}, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, + } + var r *ServiceReconciler + r = &ServiceReconciler{ + Client: fake.NewFakeClientWithScheme(scheme, objects...), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + r.Client = newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{UpdateErr: fmt.Errorf("failed")}, + ) + return &ibmcloud.Info{}, nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.EqualError(t, err, "failed") + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, r.Client.(MockClient).LastUpdate()) +} + +func TestServiceDeletingFailed(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + t.Run("service delete failed", func(t *testing.T) { + scheme := schemas(t) + now := metav1Now(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + DeletionTimestamp: now, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{Plan: "Lite", InstanceID: "myinstanceid"}, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, + } + + var r *ServiceReconciler + r = &ServiceReconciler{ + Client: fake.NewFakeClientWithScheme(scheme, objects...), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + r.Client = newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ) + return &ibmcloud.Info{}, nil + }, + DeleteResourceServiceInstance: func(session *session.Session, instanceID string, logt logr.Logger) error { + return fmt.Errorf("failed") + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: 10 * time.Second, + }, result) + assert.NoError(t, err) + }) + + t.Run("update failed", func(t *testing.T) { + scheme := schemas(t) + now := metav1Now(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + DeletionTimestamp: now, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{Plan: "Lite"}, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, + } + + var r *ServiceReconciler + r = &ServiceReconciler{ + Client: fake.NewFakeClientWithScheme(scheme, objects...), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + r.Client = newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{UpdateErr: fmt.Errorf("failed")}, + ) + return &ibmcloud.Info{}, nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.EqualError(t, err, "failed") + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + DeletionTimestamp: now, + Finalizers: nil, // attempt to remove finalizers + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite"}, + }, r.Client.(MockClient).LastUpdate()) + }) +} + +func TestServiceGetParamsFailed(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + Parameters: []ibmcloudv1beta1.Param{ + { + Name: "hello", + Value: &ibmcloudv1beta1.ParamValue{RawMessage: json.RawMessage(`"world"`)}, + ValueFrom: &ibmcloudv1beta1.ParamSource{}, + }, + }, + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + Parameters: []ibmcloudv1beta1.Param{ + { + Name: "hello", + Value: &ibmcloudv1beta1.ParamValue{RawMessage: json.RawMessage(`"world"`)}, + ValueFrom: &ibmcloudv1beta1.ParamSource{}, + }, + }, + }, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "Value and ValueFrom properties are mutually exclusive (for hello variable)", + Plan: "Lite", + Parameters: []ibmcloudv1beta1.Param{ + { + Name: "hello", + Value: &ibmcloudv1beta1.ParamValue{RawMessage: json.RawMessage(`"world"`)}, + ValueFrom: &ibmcloudv1beta1.ParamSource{}, + }, + }, + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + Parameters: []ibmcloudv1beta1.Param{ + { + Name: "hello", + Value: &ibmcloudv1beta1.ParamValue{RawMessage: json.RawMessage(`"world"`)}, + ValueFrom: &ibmcloudv1beta1.ParamSource{}, + }, + }, + }, + }, r.Client.(MockClient).LastStatusUpdate()) +} + +func TestServiceEnsureCFServiceExists(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + t.Run("create - empty service ID", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{Plan: "Lite", ServiceClass: "service-name"}, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite", ServiceClass: "service-name"}, + }, + } + var createErr error + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{ + ServiceClassType: "CF", + }, nil + }, + CreateCFServiceInstance: func(session *session.Session, externalName, planID, spaceID string, params map[string]interface{}, tags []string) (guid string, state string, err error) { + return "guid", "state", createErr + }, + } + + t.Run("success", func(t *testing.T) { + createErr = nil + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", + Message: "state", + Plan: "Lite", + InstanceID: "guid", + DashboardURL: "https://cloud.ibm.com/services/service-name/guid", + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite", ServiceClass: "service-name"}, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("failed", func(t *testing.T) { + createErr = fmt.Errorf("failed") + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: "Lite", + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite", ServiceClass: "service-name"}, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + }) + + t.Run("create alias success", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{ + ServiceClassType: "CF", + }, nil + }, + CreateCFServiceInstance: func(session *session.Session, externalName, planID, spaceID string, params map[string]interface{}, tags []string) (guid string, state string, err error) { + return "", "", fmt.Errorf("failed") + }, + GetCFServiceInstance: func(session *session.Session, name string) (guid string, state string, err error) { + return "guid", "state", nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", // TODO(johnstarich) This isn't a known state, right? We should have predictable states here. + Message: "state", + DashboardURL: "https://cloud.ibm.com/services/service-name/guid", + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("ensure alias - empty instance ID", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "", // no instance ID set + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, + } + var getInstanceErr error + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{ + ServiceClassType: "CF", + }, nil + }, + GetCFServiceInstance: func(session *session.Session, name string) (guid string, state string, err error) { + return "guid", "state", getInstanceErr + }, + } + + t.Run("success", func(t *testing.T) { + getInstanceErr = nil + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateOnline, + Message: serviceStateOnline, + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "guid", + DashboardURL: "https://cloud.ibm.com/services/service-name/guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("failed", func(t *testing.T) { + getInstanceErr = fmt.Errorf("failed") + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + }) + + t.Run("get instance failed - not found", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{ + ServiceClassType: "CF", + }, nil + }, + CreateCFServiceInstance: func(session *session.Session, externalName, planID, spaceID string, params map[string]interface{}, tags []string) (guid string, state string, err error) { + return "guid", "state", nil + }, + GetCFServiceInstance: func(session *session.Session, name string) (guid string, state string, err error) { + return "", "", cfservice.NotFoundError{Err: fmt.Errorf("failed")} + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", + Message: "state", + Plan: "Lite", + InstanceID: "guid", + ServiceClass: "service-name", + DashboardURL: "https://cloud.ibm.com/services/service-name/guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite", ServiceClass: "service-name"}, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("get instance failed - not found, create failed", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{ + ServiceClassType: "CF", + }, nil + }, + CreateCFServiceInstance: func(session *session.Session, externalName, planID, spaceID string, params map[string]interface{}, tags []string) (guid string, state string, err error) { + return "", "", fmt.Errorf("failed") + }, + GetCFServiceInstance: func(session *session.Session, name string) (guid string, state string, err error) { + return "", "", cfservice.NotFoundError{Err: fmt.Errorf("failed")} + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: "Lite", + InstanceID: "guid", + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite", ServiceClass: "service-name"}, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("get instance failed - other error", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{ + ServiceClassType: "CF", + }, nil + }, + GetCFServiceInstance: func(session *session.Session, name string) (guid string, state string, err error) { + return "", "", fmt.Errorf("failed") + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: "Lite", + InstanceID: "guid", + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{Plan: "Lite", ServiceClass: "service-name"}, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("ensure alias - instance does not exist", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "some-instance-id", // instance ID set + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, + } + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{ + ServiceClassType: "CF", + }, nil + }, + GetCFServiceInstance: func(session *session.Session, name string) (guid string, state string, err error) { + return "", "", cfservice.NotFoundError{Err: fmt.Errorf("failed")} + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStatePending, + Message: "failed", + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "", // instance ID should be deleted + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) +} + +func TestServiceEnsureResourceServiceInstance(t *testing.T) { + t.Parallel() + const ( + serviceName = "myservice" + namespace = "mynamespace" + ) + + t.Run("alias", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, + } + + t.Run("success", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceAliasInstance: func(session *session.Session, instanceID, resourceGroupID, servicePlanID, externalName string, logt logr.Logger) (id string, state string, err error) { + return "guid", "state", nil + }, + } + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", + Message: "state", + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "guid", + DashboardURL: "https://cloud.ibm.com/services/service-name/guid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("not found", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceAliasInstance: func(session *session.Session, instanceID, resourceGroupID, servicePlanID, externalName string, logt logr.Logger) (id string, state string, err error) { + return "", "", resource.NotFoundError{Err: fmt.Errorf("failed")} + }, + } + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "no service instances with name myservice found for alias plan: failed", + Plan: aliasPlan, + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("other error", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceAliasInstance: func(session *session.Session, instanceID, resourceGroupID, servicePlanID, externalName string, logt logr.Logger) (id string, state string, err error) { + return "", "", fmt.Errorf("failed") + }, + } + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed to resolve Alias plan instance myservice: failed", + Plan: aliasPlan, + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + }) + + t.Run("non-alias", func(t *testing.T) { + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + ServiceClass: "service-name", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, + } + + t.Run("success", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + CreateResourceServiceInstance: func(session *session.Session, externalName, servicePlanID, resourceGroupID, targetCrn string, params map[string]interface{}, tags []string) (id string, state string, err error) { + return "id", "state", nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", + Message: "state", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "id", + DashboardURL: "https://cloud.ibm.com/services/service-name/id", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("update status failed", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{StatusUpdateErr: fmt.Errorf("failed")}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{}, result) + assert.EqualError(t, err, "failed") + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "", + Message: "", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: inProgress, + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("create failed", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + CreateResourceServiceInstance: func(session *session.Session, externalName, servicePlanID, resourceGroupID, targetCrn string, params map[string]interface{}, tags []string) (id string, state string, err error) { + return "", "", fmt.Errorf("failed") + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: inProgress, + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + }) +} + +func TestServiceVerifyExists(t *testing.T) { + t.Parallel() + const ( + namespace = "mynamespace" + serviceName = "myservice" + ) + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, + } + aliasObjects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, + } + + t.Run("success", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceInstanceState: func(session *session.Session, resourceGroupID, servicePlanID, externalName, instanceID string) (state string, err error) { + return "state", nil + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", + Message: "state", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "myinstanceid", + DashboardURL: "https://cloud.ibm.com/services/service-name/myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("not found non-alias - recreate service", func(t *testing.T) { + var createErr error + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceInstanceState: func(session *session.Session, resourceGroupID, servicePlanID, externalName, instanceID string) (state string, err error) { + return "", resource.NotFoundError{Err: fmt.Errorf("failed")} + }, + CreateResourceServiceInstance: func(session *session.Session, externalName, servicePlanID, resourceGroupID, targetCrn string, params map[string]interface{}, tags []string) (id string, state string, err error) { + return "id", "state", createErr + }, + } + + t.Run("success", func(t *testing.T) { + createErr = nil + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", + Message: "state", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "id", + DashboardURL: "https://cloud.ibm.com/services/service-name/id", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("create error", func(t *testing.T) { + createErr = fmt.Errorf("failed") + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: inProgress, + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + }) + + t.Run("not found alias", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, aliasObjects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceInstanceState: func(session *session.Session, resourceGroupID, servicePlanID, externalName, instanceID string) (state string, err error) { + return "", resource.NotFoundError{Err: fmt.Errorf("failed")} + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStatePending, + Message: "aliased service instance no longer exists", + Plan: aliasPlan, + ServiceClass: "service-name", + InstanceID: "", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: aliasPlan, + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) + + t.Run("other error", func(t *testing.T) { + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceInstanceState: func(session *session.Session, resourceGroupID, servicePlanID, externalName, instanceID string) (state string, err error) { + return "", fmt.Errorf("failed") + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStatePending, + Message: "failed", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) + }) +} + +func TestServiceUpdateTagsOrParamsFailed(t *testing.T) { + t.Parallel() + const ( + namespace = "mynamespace" + serviceName = "myservice" + ) + scheme := schemas(t) + objects := []runtime.Object{ + &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + Tags: []string{"somethingNew"}, + }, + }, + } + + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, objects...), + MockConfig{}, + ), + Log: testLogger(t), + Scheme: scheme, + + GetIBMCloudInfo: func(logt logr.Logger, _ client.Client, instance *ibmcloudv1beta1.Service) (*ibmcloud.Info, error) { + return &ibmcloud.Info{}, nil + }, + GetResourceServiceInstanceState: func(session *session.Session, resourceGroupID, servicePlanID, externalName, instanceID string) (state string, err error) { + return "state", nil + }, + UpdateResourceServiceInstance: func(session *session.Session, serviceInstanceID, externalName, servicePlanID string, params map[string]interface{}, tags []string) (state string, err error) { + return "", fmt.Errorf("failed") + }, + } + + result, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{Name: serviceName, Namespace: namespace}, + }) + assert.Equal(t, ctrl.Result{ + Requeue: true, + RequeueAfter: config.Get().SyncPeriod, + }, result) + assert.NoError(t, err) + assert.Equal(t, &ibmcloudv1beta1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "ibmcloud.ibm.com/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + Finalizers: []string{serviceFinalizer}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: serviceStateFailed, + Message: "failed", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + Tags: []string{"somethingNew"}, + }, + }, r.Client.(MockClient).LastStatusUpdate()) +} + +func TestSpecChanged(t *testing.T) { + t.Parallel() + const ( + something = "something" + somethingElse = "something else" + ) + for _, tc := range []struct { + description string + instance ibmcloudv1beta1.Service + expectChanged bool + }{ + { + description: "empty object", + instance: ibmcloudv1beta1.Service{}, + expectChanged: false, + }, + { + description: "missing status plan", + instance: ibmcloudv1beta1.Service{ + Spec: ibmcloudv1beta1.ServiceSpec{ExternalName: something}, + Status: ibmcloudv1beta1.ServiceStatus{ExternalName: something}, + }, + expectChanged: false, + }, + { + description: "mismatched external name", + instance: ibmcloudv1beta1.Service{ + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: something, + ExternalName: something, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: something, + ExternalName: somethingElse, + }, + }, + expectChanged: true, + }, + { + description: "mismatched plan", + instance: ibmcloudv1beta1.Service{ + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: something, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: somethingElse, + }, + }, + expectChanged: true, + }, + { + description: "mismatched service class", + instance: ibmcloudv1beta1.Service{ + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: something, + ServiceClass: something, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: something, + ServiceClass: somethingElse, + }, + }, + expectChanged: true, + }, + { + description: "mismatched service class type", + instance: ibmcloudv1beta1.Service{ + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: something, + ServiceClassType: something, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: something, + ServiceClassType: somethingElse, + }, + }, + expectChanged: true, + }, + { + description: "mismatched context", + instance: ibmcloudv1beta1.Service{ + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: something, + Context: ibmcloudv1beta1.ResourceContext{User: something}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: something, + Context: ibmcloudv1beta1.ResourceContext{User: somethingElse}, + }, + }, + expectChanged: true, + }, + { + description: "matching contexts", + instance: ibmcloudv1beta1.Service{ + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: something, + Context: ibmcloudv1beta1.ResourceContext{User: somethingElse}, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: something, + Context: ibmcloudv1beta1.ResourceContext{User: somethingElse}, + }, + }, + expectChanged: false, + }, + } { + t.Run(tc.description, func(t *testing.T) { + assert.Equal(t, tc.expectChanged, specChanged(&tc.instance)) + }) + } +} + +func TestDeleteServiceFinalizer(t *testing.T) { + t.Parallel() + t.Run("no finalizer found", func(t *testing.T) { + finalizers := []string(nil) + assert.Equal(t, finalizers, deleteServiceFinalizer(&ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Finalizers: finalizers}, + })) + }) + + t.Run("one other finalizer found", func(t *testing.T) { + finalizers := []string{"not-service-finalizer"} + assert.Equal(t, finalizers, deleteServiceFinalizer(&ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Finalizers: finalizers}, + })) + }) + + t.Run("one finalizer found", func(t *testing.T) { + finalizers := []string{serviceFinalizer} + assert.Equal(t, []string(nil), deleteServiceFinalizer(&ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Finalizers: finalizers}, + })) + }) + + t.Run("multiple finalizers found", func(t *testing.T) { + finalizers := []string{serviceFinalizer, serviceFinalizer} + assert.Equal(t, []string(nil), deleteServiceFinalizer(&ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Finalizers: finalizers}, + })) + }) +} + +func TestServiceParamToJSON(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + description string + param ibmcloudv1beta1.Param + expectJSON map[string]interface{} + expectErr string + }{ + { + description: "error: value and valueFrom both set", + param: ibmcloudv1beta1.Param{ + Name: "myvalue", + Value: &ibmcloudv1beta1.ParamValue{}, + ValueFrom: &ibmcloudv1beta1.ParamSource{}, + }, + expectErr: "Value and ValueFrom properties are mutually exclusive (for myvalue variable)", + }, + { + description: "empty valueFrom error", + param: ibmcloudv1beta1.Param{ + Name: "myvalue", + ValueFrom: &ibmcloudv1beta1.ParamSource{}, + }, + expectErr: "Missing secretKeyRef or configMapKeyRef", + }, + { + description: "empty value error", + param: ibmcloudv1beta1.Param{ + Name: "myvalue", + Value: &ibmcloudv1beta1.ParamValue{}, + }, + expectErr: "unexpected end of JSON input", + }, + { + description: "value happy path", + param: ibmcloudv1beta1.Param{ + Name: "myvalue", + Value: &ibmcloudv1beta1.ParamValue{ + RawMessage: json.RawMessage(`{"hello": true, "world": {"!": 1}}`), + }, + }, + expectJSON: map[string]interface{}{ + "hello": true, + "world": map[string]interface{}{ + "!": 1.0, + }, + }, + }, + { + description: "neither value nor valueFrom set", + param: ibmcloudv1beta1.Param{Name: "myvalue"}, + expectJSON: nil, + expectErr: "", + }, + } { + t.Run(tc.description, func(t *testing.T) { + r := &ServiceReconciler{} + j, err := r.paramToJSON(context.TODO(), tc.param, "someNamespace") + if tc.expectErr != "" { + assert.EqualError(t, err, tc.expectErr) + return + } + require.NoError(t, err) + if tc.expectJSON == nil { + assert.Nil(t, j) + } else { + assert.Equal(t, tc.expectJSON, j) + } + }) + } +} + +func TestServiceParamValueToJSON(t *testing.T) { + t.Parallel() + const ( + secretName = "secretName" + secretKey = "mykey" + secretValue = "myvalue" + configMapName = "configMapName" + configMapKey = "mykey" + configMapValue = "myvalue" + namespace = "mynamespace" + ) + + objects := []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, + Data: map[string][]byte{ + secretKey: []byte(secretValue), + }, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: namespace}, + Data: map[string]string{ + configMapKey: configMapValue, + }, + }, + } + + for _, tc := range []struct { + description string + valueFrom ibmcloudv1beta1.ParamSource + expectJSON interface{} + expectErr string + }{ + { + description: "no value error", + expectErr: "Missing secretKeyRef or configMapKeyRef", + }, + { + description: "secret ref success", + valueFrom: ibmcloudv1beta1.ParamSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretName, + }, + Key: secretKey, + }, + }, + expectJSON: secretValue, + }, + { + description: "secret ref name failure", + valueFrom: ibmcloudv1beta1.ParamSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "wrong-secret-name", + }, + Key: secretKey, + }, + }, + expectErr: "Missing secret wrong-secret-name", + }, + { + description: "secret ref key failure", + valueFrom: ibmcloudv1beta1.ParamSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretName, + }, + Key: "wrong-key-name", + }, + }, + expectJSON: "", + }, + { + description: "configmap ref success", + valueFrom: ibmcloudv1beta1.ParamSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + Key: configMapKey, + }, + }, + expectJSON: configMapValue, + }, + { + description: "configmap ref name failure", + valueFrom: ibmcloudv1beta1.ParamSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "wrong-configmap-name", + }, + Key: configMapKey, + }, + }, + expectErr: "Missing configmap wrong-configmap-name", + }, + { + description: "configmap ref key failure", + valueFrom: ibmcloudv1beta1.ParamSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + Key: "wrong-key-name", + }, + }, + expectJSON: "", + }, + } { + t.Run(tc.description, func(t *testing.T) { + scheme := schemas(t) + r := &ServiceReconciler{ + Client: fake.NewFakeClientWithScheme(scheme, objects...), + Log: testLogger(t), + Scheme: scheme, + } + + j, err := r.paramValueToJSON(context.TODO(), tc.valueFrom, namespace) + if tc.expectErr != "" { + assert.EqualError(t, err, tc.expectErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectJSON, j) + }) + } +} + +func TestServiceUpdateStatusFailed(t *testing.T) { + t.Parallel() + const ( + namespace = "mynamespace" + serviceName = "myservice" + ) + scheme := schemas(t) + instance := &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace}, + Status: ibmcloudv1beta1.ServiceStatus{ + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + } + + r := &ServiceReconciler{ + Client: newMockClient( + fake.NewFakeClientWithScheme(scheme, instance), + MockConfig{StatusUpdateErr: fmt.Errorf("failed")}, + ), + Log: testLogger(t), + Scheme: scheme, + + DeleteResourceServiceInstance: func(session *session.Session, instanceID string, logt logr.Logger) error { + return fmt.Errorf("failed to delete") // only gets logged, no error handling + }, + } + + result, err := r.updateStatus(nil, r.Log, instance, ibmcloudv1beta1.ResourceContext{}, "myinstanceid", "state", "") + assert.Equal(t, ctrl.Result{}, result) + assert.EqualError(t, err, "failed") + assert.Equal(t, &ibmcloudv1beta1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: namespace, + }, + Status: ibmcloudv1beta1.ServiceStatus{ + State: "state", + Message: "state", + Plan: "Lite", + ServiceClass: "service-name", + InstanceID: "myinstanceid", + DashboardURL: "https://cloud.ibm.com/services/service-name/myinstanceid", + }, + Spec: ibmcloudv1beta1.ServiceSpec{ + Plan: "Lite", + ServiceClass: "service-name", + }, + }, r.Client.(MockClient).LastStatusUpdate()) +} diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 829a5958..ffb1dfd0 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -168,6 +168,7 @@ func mainSetup(ctx context.Context) error { CreateResourceServiceInstance: resource.CreateServiceInstance, DeleteResourceServiceInstance: resource.DeleteServiceInstance, GetCFServiceInstance: cfservice.GetInstance, + GetIBMCloudInfo: ibmcloud.GetInfo, GetResourceServiceAliasInstance: resource.GetServiceAliasInstance, GetResourceServiceInstanceState: resource.GetServiceInstanceState, UpdateResourceServiceInstance: resource.UpdateServiceInstance, diff --git a/internal/ibmcloud/cfservice/service_instance.go b/internal/ibmcloud/cfservice/service_instance.go index 12505a6b..e4710c7b 100644 --- a/internal/ibmcloud/cfservice/service_instance.go +++ b/internal/ibmcloud/cfservice/service_instance.go @@ -9,7 +9,11 @@ import ( ) type NotFoundError struct { - error + Err error +} + +func (e NotFoundError) Error() string { + return e.Err.Error() } type InstanceGetter func(session *session.Session, name string) (guid, state string, err error) @@ -24,7 +28,7 @@ func GetInstance(session *session.Session, name string) (guid, state string, err serviceInstance, err := bxClient.ServiceInstances().FindByName(name) if err != nil { if strings.Contains(err.Error(), "doesn't exist") { - err = NotFoundError{err} + err = NotFoundError{Err: err} } return "", "", err } diff --git a/internal/ibmcloud/resource/service_instance.go b/internal/ibmcloud/resource/service_instance.go index a98e822a..41bde3d3 100644 --- a/internal/ibmcloud/resource/service_instance.go +++ b/internal/ibmcloud/resource/service_instance.go @@ -13,7 +13,11 @@ import ( ) type NotFoundError struct { - error + Err error +} + +func (n NotFoundError) Error() string { + return n.Err.Error() } type ServiceInstanceCRNGetter func(session *session.Session, instanceID string) (instanceCRN crn.CRN, serviceID string, err error) diff --git a/main.go b/main.go index 9b3fab3a..3d4d714f 100644 --- a/main.go +++ b/main.go @@ -103,6 +103,7 @@ func main() { CreateResourceServiceInstance: resource.CreateServiceInstance, DeleteResourceServiceInstance: resource.DeleteServiceInstance, GetCFServiceInstance: cfservice.GetInstance, + GetIBMCloudInfo: ibmcloud.GetInfo, GetResourceServiceAliasInstance: resource.GetServiceAliasInstance, GetResourceServiceInstanceState: resource.GetServiceInstanceState, UpdateResourceServiceInstance: resource.UpdateServiceInstance,