From 8c4e9ece2727b40a7b70edbbbb5ee764a9411a3d Mon Sep 17 00:00:00 2001 From: Gabriel Saratura Date: Mon, 2 Dec 2024 11:47:33 +0100 Subject: [PATCH] Add billing tests --- apis/vshn/v1/vshn_nextcloud.go | 2 +- .../functions/common/billing.go | 45 +-- .../functions/common/billing_test.go | 291 ++++++++++++++++++ test/functions/common/billing.yaml | 187 +++++++++++ 4 files changed, 494 insertions(+), 31 deletions(-) create mode 100644 pkg/comp-functions/functions/common/billing_test.go create mode 100644 test/functions/common/billing.yaml diff --git a/apis/vshn/v1/vshn_nextcloud.go b/apis/vshn/v1/vshn_nextcloud.go index 4eaecf039e..7989fdc15d 100644 --- a/apis/vshn/v1/vshn_nextcloud.go +++ b/apis/vshn/v1/vshn_nextcloud.go @@ -179,7 +179,7 @@ func (v *VSHNNextcloud) SetInstanceNamespaceStatus() { v.Status.InstanceNamespace = v.GetInstanceNamespace() } -// CollaboraSpec defines the desired state of a Collabora. +// CollaboraSpec defines the desired state of a Collabora instance. type CollaboraSpec struct { // Enabled enables the Collabora integration. It will autoconfigure the Collabora server URL in Your Nextcloud instance. //+kubebuilder:default=false diff --git a/pkg/comp-functions/functions/common/billing.go b/pkg/comp-functions/functions/common/billing.go index 6504cae274..457453b9b9 100644 --- a/pkg/comp-functions/functions/common/billing.go +++ b/pkg/comp-functions/functions/common/billing.go @@ -1,12 +1,9 @@ package common import ( - "bytes" "context" _ "embed" "fmt" - "text/template" - xfnproto "github.com/crossplane/function-sdk-go/proto/v1beta1" v1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" "github.com/vshn/appcat/v4/pkg/comp-functions/runtime" @@ -20,8 +17,6 @@ type ServiceAddOns struct { Instances int } -var rawExpr = "vector({{.}})" - // CreateBillingRecord creates a new prometheus rule per each instance namespace // The rule is skipped for any secondary service such as postgresql instance for nextcloud // The skipping is based on whether label appuio.io/billing-name is set or not on instance namespace @@ -34,10 +29,7 @@ func CreateBillingRecord(ctx context.Context, svc *runtime.ServiceRuntime, comp return nil } - expr, err := getExprFromTemplate(comp.GetInstances()) - if err != nil { - runtime.NewWarningResult(fmt.Sprintf("cannot add billing to service %s", comp.GetName())) - } + expr := getVectorExpression(comp.GetInstances()) org, err := getOrg(comp.GetName(), svc) if err != nil { @@ -74,10 +66,7 @@ func CreateBillingRecord(ctx context.Context, svc *runtime.ServiceRuntime, comp for _, addOn := range addOns { log.Info("Adding billing addOn for service", "service", comp.GetName(), "addOn", addOn.Name) - exprAddOn, err := getExprFromTemplate(addOn.Instances) - if err != nil { - return runtime.NewWarningResult(fmt.Sprintf("cannot add addOn %s billing to service %s", addOn.Name, comp.GetName())) - } + exprAddOn := getVectorExpression(addOn.Instances) rg.Rules = append(rg.Rules, v1.Rule{ Record: "appcat:metering", Expr: intstr.FromString(exprAddOn), @@ -107,11 +96,15 @@ func CreateBillingRecord(ctx context.Context, svc *runtime.ServiceRuntime, comp } func getLabels(svc *runtime.ServiceRuntime, comp InfoGetter, org, addOnName string) map[string]string { + b, err := getBillingNameWithAddOn(comp.GetBillingName(), addOnName) + if err != nil { + panic(fmt.Errorf("set billing name for service %s: %v", comp.GetServiceName(), err)) + } labels := map[string]string{ "label_appcat_vshn_io_claim_name": comp.GetClaimName(), "label_appcat_vshn_io_claim_namespace": comp.GetClaimNamespace(), "label_appcat_vshn_io_sla": comp.GetSLA(), - "label_appuio_io_billing_name": getFinalBillingName(comp.GetBillingName(), addOnName), + "label_appuio_io_billing_name": b, "label_appuio_io_organization": org, } @@ -123,24 +116,16 @@ func getLabels(svc *runtime.ServiceRuntime, comp InfoGetter, org, addOnName stri return labels } -func getFinalBillingName(billingName, addOn string) string { +func getBillingNameWithAddOn(billingName, addOn string) (string, error) { + if billingName == "" { + return "", fmt.Errorf("billing name is empty") + } if addOn == "" { - return billingName + return billingName, nil } - return fmt.Sprintf("%s-%s", billingName, addOn) + return fmt.Sprintf("%s-%s", billingName, addOn), nil } -func getExprFromTemplate(i int) (string, error) { - var buf bytes.Buffer - tmpl, err := template.New("billing").Parse(rawExpr) - if err != nil { - return "", err - } - - err = tmpl.Execute(&buf, i) - if err != nil { - return "", err - } - - return buf.String(), err +func getVectorExpression(i int) string { + return fmt.Sprintf("vector(%d)", i) } diff --git a/pkg/comp-functions/functions/common/billing_test.go b/pkg/comp-functions/functions/common/billing_test.go new file mode 100644 index 0000000000..476fc6e2fe --- /dev/null +++ b/pkg/comp-functions/functions/common/billing_test.go @@ -0,0 +1,291 @@ +package common + +import ( + "context" + _ "embed" + xfnproto "github.com/crossplane/function-sdk-go/proto/v1beta1" + v2 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "github.com/stretchr/testify/assert" + v1 "github.com/vshn/appcat/v4/apis/vshn/v1" + "github.com/vshn/appcat/v4/pkg/comp-functions/functions/commontest" + "github.com/vshn/appcat/v4/pkg/comp-functions/runtime" + api "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "testing" +) + +func TestBilling(t *testing.T) { + + tests := []struct { + name string + comp *v1.VSHNNextcloud + svc *runtime.ServiceRuntime + org string + addOns []ServiceAddOns + expectedRuleGroup v2.RuleGroup + }{ + { + name: "TestCreateBillingRecord_WhenServiceWithAddOn_ThenReturnMultipleRules", + comp: &v1.VSHNNextcloud{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "crossplane.io/claim-namespace": "test-namespace", + "crossplane.io/claim-name": "test-instance", + }, + Name: "nextcloud-gc9x4", + Namespace: "unit-test", + }, + Spec: v1.VSHNNextcloudSpec{ + Parameters: v1.VSHNNextcloudParameters{ + Instances: 1, + Service: v1.VSHNNextcloudServiceSpec{ServiceLevel: v1.BestEffort}, + }, + }, + }, + svc: commontest.LoadRuntimeFromFile(t, "common/billing.yaml"), + org: "APPUiO", + addOns: []ServiceAddOns{ + { + Name: "office", + Instances: 1, + }, + }, + expectedRuleGroup: v2.RuleGroup{ + Name: "appcat-metering-rules", + Rules: []v2.Rule{ + { + Record: "appcat:metering", + Expr: intstr.IntOrString{ + Type: 1, + StrVal: "vector(1)", + }, + Labels: map[string]string{ + "label_appcat_vshn_io_claim_name": "test-instance", + "label_appcat_vshn_io_claim_namespace": "test-namespace", + "label_appcat_vshn_io_sla": "besteffort", + "label_appuio_io_billing_name": "appcat-nextcloud", + "label_appuio_io_organization": "vshn", + }, + }, + { + Record: "appcat:metering", + Expr: intstr.IntOrString{ + Type: 1, + StrVal: "vector(1)", + }, + Labels: map[string]string{ + "label_appcat_vshn_io_claim_name": "test-instance", + "label_appcat_vshn_io_claim_namespace": "test-namespace", + "label_appcat_vshn_io_sla": "besteffort", + "label_appuio_io_billing_name": "appcat-nextcloud-office", + "label_appuio_io_organization": "vshn", + }, + }, + }, + }, + }, + { + name: "TestCreateBillingRecord_WhenServiceWithoutAddOn_ThenReturnSingleRule", + comp: &v1.VSHNNextcloud{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "crossplane.io/claim-namespace": "test-namespace", + "crossplane.io/claim-name": "test-instance", + }, + Name: "nextcloud-gc9x4", + Namespace: "unit-test", + }, + Spec: v1.VSHNNextcloudSpec{ + Parameters: v1.VSHNNextcloudParameters{ + Instances: 1, + Service: v1.VSHNNextcloudServiceSpec{ServiceLevel: v1.BestEffort}, + }, + }, + }, + svc: commontest.LoadRuntimeFromFile(t, "common/billing.yaml"), + org: "APPUiO", + addOns: []ServiceAddOns{}, + expectedRuleGroup: v2.RuleGroup{ + Name: "appcat-metering-rules", + Rules: []v2.Rule{ + { + Record: "appcat:metering", + Expr: intstr.IntOrString{ + Type: 1, + StrVal: "vector(1)", + }, + Labels: map[string]string{ + "label_appcat_vshn_io_claim_name": "test-instance", + "label_appcat_vshn_io_claim_namespace": "test-namespace", + "label_appcat_vshn_io_sla": "besteffort", + "label_appuio_io_billing_name": "appcat-nextcloud", + "label_appuio_io_organization": "vshn", + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := CreateBillingRecord(context.Background(), tt.svc, tt.comp, tt.addOns...) + assert.Equal(t, xfnproto.Severity_SEVERITY_NORMAL, r.Severity) + actual := &v2.PrometheusRule{} + err := tt.svc.GetDesiredKubeObject(actual, "nextcloud-gc9x4-billing") + assert.NoError(t, err) + assert.Equal(t, tt.expectedRuleGroup, actual.Spec.Groups[0]) + + }) + } +} + +func TestGetLabels(t *testing.T) { + tests := []struct { + name string + comp *v1.VSHNNextcloud + svc *runtime.ServiceRuntime + org string + addOnName string + expectedLabels map[string]string + }{ + { + name: "TestGetLabels_WhenManagedAndAddOn_ThenReturnLabels", + comp: &v1.VSHNNextcloud{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "crossplane.io/claim-namespace": "test-namespace", + "crossplane.io/claim-name": "test-instance", + }, + }, + Spec: v1.VSHNNextcloudSpec{ + Parameters: v1.VSHNNextcloudParameters{ + Service: v1.VSHNNextcloudServiceSpec{ServiceLevel: v1.BestEffort}, + }, + }, + }, + svc: &runtime.ServiceRuntime{ + Config: api.ConfigMap{ + Data: map[string]string{ + "salesOrder": "12132ST", + }, + }, + }, + org: "APPUiO", + addOnName: "office", + expectedLabels: map[string]string{ + "label_appcat_vshn_io_claim_name": "test-instance", + "label_appcat_vshn_io_claim_namespace": "test-namespace", + "label_appcat_vshn_io_sla": "besteffort", + "label_appuio_io_billing_name": "appcat-nextcloud-office", + "label_appuio_io_organization": "APPUiO", + "sales_order": "12132ST", + }, + }, + { + name: "TestGetLabels_WhenNonManagedAndAddOn_ThenReturnLabels", + comp: &v1.VSHNNextcloud{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "crossplane.io/claim-namespace": "test-namespace", + "crossplane.io/claim-name": "test-instance", + }, + }, + Spec: v1.VSHNNextcloudSpec{ + Parameters: v1.VSHNNextcloudParameters{ + Service: v1.VSHNNextcloudServiceSpec{ServiceLevel: v1.BestEffort}, + }, + }, + }, + svc: &runtime.ServiceRuntime{}, + org: "APPUiO", + addOnName: "office", + expectedLabels: map[string]string{ + "label_appcat_vshn_io_claim_name": "test-instance", + "label_appcat_vshn_io_claim_namespace": "test-namespace", + "label_appcat_vshn_io_sla": "besteffort", + "label_appuio_io_billing_name": "appcat-nextcloud-office", + "label_appuio_io_organization": "APPUiO", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := getLabels(tt.svc, tt.comp, tt.org, tt.addOnName) + assert.Equal(t, tt.expectedLabels, labels) + }) + } +} + +func TestGetBillingNameWithAddOn(t *testing.T) { + tests := []struct { + name string + billingName string + addOn string + result string + err bool + }{ + { + name: "TestGetBillingNameWithAddOn_WhenAddOn_ThenBillingNameWithAddOn", + billingName: "nextcloud", + addOn: "office", + result: "nextcloud-office", + err: false, + }, + { + name: "TestGetBillingNameWithAddOn_WhenNoAddOn_ThenJustBillingName", + billingName: "nextcloud", + addOn: "", + result: "nextcloud", + err: false, + }, + { + name: "TestGetBillingNameWithAddOn_WhenEmpty_ThenError", + billingName: "", + addOn: "", + result: "", + err: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := getBillingNameWithAddOn(tt.billingName, tt.addOn) + assert.Equal(t, tt.result, actual) + if tt.err { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestGetExprFromTemplate(t *testing.T) { + tests := []struct { + name string + instances int + result string + }{ + { + name: "TestGetExprFromTemplate_WhenInstances1_ThenExprWithInstance", + instances: 1, + result: "vector(1)", + }, + { + name: "TestGetExprFromTemplate_WhenInstances0_ThenExprWithInstance", + instances: 0, + result: "vector(0)", + }, + { + name: "TestGetExprFromTemplate_WhenInstancesHA_ThenExprWithInstance", + instances: 3, + result: "vector(3)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := getVectorExpression(tt.instances) + assert.Equal(t, tt.result, actual) + }) + } +} diff --git a/test/functions/common/billing.yaml b/test/functions/common/billing.yaml new file mode 100644 index 0000000000..2187c95eb0 --- /dev/null +++ b/test/functions/common/billing.yaml @@ -0,0 +1,187 @@ +desired: {} +input: + apiVersion: v1 + kind: ConfigMap + metadata: + annotations: {} + labels: + name: xfn-config + name: xfn-config + data: + defaultPlan: standard-2 + collabora_image: docker.io/collabora/code:24.04.9.2.1 + collaboraCPULimit: "1" + collaboraCPURequests: 250m + collaboraMemoryLimit: 1Gi + collaboraMemoryRequests: 256Mi + imageTag: nextcloud_collabora + controlNamespace: appcat-control + plans: '{"standard-2": {"size": {"cpu": "500m", "disk": "16Gi", "enabled": + true, "memory": "2Gi"}}, "standard-4": {"size": {"cpu": "1", "disk": "16Gi", + "enabled": true, "memory": "4Gi"}}, "standard-8": {"size": {"cpu": "2", + "disk": "16Gi", "enabled": true, "memory": "8Gi"}}}' + isOpenshift: "false" +observed: + composite: + resource: + apiVersion: vshn.appcat.vshn.io/v1 + kind: XVSHNNextcloud + metadata: + annotations: null + creationTimestamp: "2024-07-08T16:30:28Z" + finalizers: + - composite.apiextensions.crossplane.io + generateName: nextcloud- + generation: 1 + labels: + crossplane.io/claim-name: nextcloud + crossplane.io/claim-namespace: unit-test + crossplane.io/composite: nextcloud-gc9x4 + name: nextcloud-gc9x4 + spec: + parameters: + service: + fqdn: + - nextcloud.example.com + useExternalPostgreSQL: true + version: "29" + claimRef: + apiVersion: vshn.appcat.vshn.io/v1 + kind: VSHNNextcloud + name: nextcloud + namespace: unit-test + compositionRef: + name: vshnnextcloud.vshn.appcat.vshn.io + compositionRevisionRef: + name: vshnnextcloud.vshn.appcat.vshn.io-ce52f13 + compositionUpdatePolicy: Automatic + resources: + # necessary for collabora tests + nextcloud-gc9x4-release: + resource: + apiVersion: helm.crossplane.io/v1beta1 + kind: Release + metadata: + name: nextcloud-gc9x4 + spec: {} + status: + atProvider: + conditions: + - lastTransitionTime: "2024-07-08T14:50:19Z" + reason: Available + status: "True" + type: Ready + - lastTransitionTime: "2024-07-08T14:50:18Z" + reason: ReconcileSuccess + status: "True" + type: Synced + nextcloud-gc9x4-claim-ns-observer: + resource: + apiVersion: v1 + kind: Object + metadata: + name: unit-test + spec: + forProvider: + manifest: + apiVersion: v1 + kind: Namespace + metadata: + name: unit-test + status: + atProvider: + manifest: + apiVersion: v1 + kind: Namespace + metadata: + name: unit-test + labels: + appuio.io/organization: vshn + nextcloud-gc9x4-credentials-secret: + connection_details: + admin: czNjcjN0UGFzcwo= + nextcloud-gc9x4-pg: + resource: + apiVersion: v1 + kind: Object + metadata: + name: "nextcloud-gc9x4-pg" + spec: + forProvider: + manifest: + apiVersion: vshn.appcat.vshn.io/v1 + kind: VSHNNextcloud + metadata: + name: "nextcloud-gc9x4-pg" + spec: + parameters: + service: + fqdn: + - nextcloud.example.com + repackEnabled: false + vacuumEnabled: false + backup: + retention: 6 + deletionProtection: true + deletionRetention: 7 + encryption: + enabled: false + instances: 1 + security: + allowAllNamespaces: false + status: + atProvider: + manifest: + apiVersion: vshn.appcat.vshn.io/v1 + kind: VSHNNextcloud + metadata: + annotations: null + creationTimestamp: "2024-07-08T16:30:28Z" + generation: 8 + name: "nextcloud-gc9x4-pg" + namespace: vshn-postgresql-nextcloud-gc9x4 + resourceVersion: "583272583" + uid: 44ead047-98de-4e73-9cc0-d99454090a36 + spec: + parameters: + service: + repackEnabled: false + vacuumEnabled: false + backup: + retention: 6 + deletionProtection: true + deletionRetention: 7 + encryption: + enabled: false + instances: 1 + security: + allowAllNamespaces: false + status: + conditions: + - lastTransitionTime: "2024-07-08T14:50:19Z" + reason: Available + status: "True" + type: Ready + - lastTransitionTime: "2024-07-08T14:50:18Z" + reason: ReconcileSuccess + status: "True" + type: Synced + connection_details: + POSTGRESQL_HOST: bmV4dGNsb3VkLWdjOXg0LnZzaG4tcG9zdGdyZXNxbC1uZXh0Y2xvdWQtZ2N4NC5zdmMuY2x1c3Rlci5sb2NhbAo= + POSTGRESQL_DB: cG9zdGdyZXMK + POSTGRESQL_URL: cG9zdGdyZXM6Ly9wb3N0Z3Jlczo0YTcwLWY2OTItNGE5YS1iMzBAbmV4dGNsb3VkLWdjOXg0LnZzaG4tcG9zdGdyZXNxbC1uZXh0Y2xvdWQtZ2N4NC5zdmMuY2x1c3Rlci5sb2NhbC9wb3N0Z3Jlcwo= + POSTGRESQL_USER: cG9zdGdyZXMK + POSTGRESQL_PORT: NTQzMgo= + POSTGRESQL_PASSWORD: NGE3MC1mNjkyLTRhOWEtYjMwCg== + status: + conditions: + - lastTransitionTime: "2024-07-08T14:50:19Z" + reason: Available + status: "True" + type: Ready + - lastTransitionTime: "2024-07-08T14:50:18Z" + reason: ReconcileSuccess + status: "True" + type: Synced + +