diff --git a/apis/vshn/v1/billing_service.go b/apis/vshn/v1/billing_service.go index e6d2ebdec6..4b1bf815c9 100644 --- a/apis/vshn/v1/billing_service.go +++ b/apis/vshn/v1/billing_service.go @@ -38,31 +38,40 @@ type BillingServiceSpec struct { Odoo OdooSpec `json:"odoo,omitempty"` } +// ItemSpec defines a single billable product/item +type ItemSpec struct { + // ProductID identifies the product in the billing system + ProductID string `json:"productID"` + + // ItemDescription is a human-readable description of the billing item + ItemDescription string `json:"itemDescription,omitempty"` + + // ItemGroupDescription describes the billing item group + ItemGroupDescription string `json:"itemGroupDescription,omitempty"` + + // Unit defines the billing unit type for this product + Unit string `json:"unit,omitempty"` + + // Value represents the billable metric for this product + // Can be: replica count, disk size (e.g., "50Gi"), percentage, etc. + Value string `json:"value"` +} + // OdooSpec defines Odoo-specific billing configuration type OdooSpec struct { // InstanceID uniquely identifies the service instance in Odoo InstanceID string `json:"instanceID"` - // ProductID identifies the product in the billing system - ProductID string `json:"productID"` - // SalesOrderID identifies the sales order in Odoo SalesOrderID string `json:"salesOrderID,omitempty"` // Organization used to identify sales order Organization string `json:"organization,omitempty"` - // UnitID defines the billing unit type in Odoo - UnitID string `json:"unitID"` - - // Size represents the size of the service instance - Size string `json:"size,omitempty"` - - // ItemGroupDescription describes the billing item group - ItemGroupDescription string `json:"itemGroupDescription"` - - // ItemDescription is a human readable description of the billing item - ItemDescription string `json:"itemDescription"` + // Items defines list of billable products for this instance + // Each item represents a product with independent lifecycle and event tracking + // +kubebuilder:validation:MinItems=1 + Items []ItemSpec `json:"items"` } // BillingServiceStatus defines the observed state of a BillingService @@ -76,15 +85,25 @@ type BillingServiceStatus struct { // BillingEventStatus represents the status of a billing event type BillingEventStatus struct { - // Type is the type of billing event (created, deleted, scaled) - // +kubebuilder:validation:Enum="created";"deleted";"scaled" + // Type is the type of billing event (create, delete, scale) + // +kubebuilder:validation:Enum="create";"delete";"scale" Type string `json:"type"` // ProductID identifies the product in the billing system ProductID string `json:"productId"` - // Size represents the size/plan at the time of the event - Size string `json:"size"` + // Value represents the billable metric at the time of the event + // Generic field supporting replica count, disk size, percentages, etc. + Value string `json:"value"` + + // Unit defines the billing unit type for this product + Unit string `json:"unit,omitempty"` + + // ItemDescription is a human-readable description of the billing item + ItemDescription string `json:"itemDescription,omitempty"` + + // ItemGroupDescription describes the billing item group + ItemGroupDescription string `json:"itemGroupDescription,omitempty"` // Timestamp when the event occurred Timestamp metav1.Time `json:"timestamp"` diff --git a/apis/vshn/v1/zz_generated.deepcopy.go b/apis/vshn/v1/zz_generated.deepcopy.go index f9d9f45b8d..9d69bde6fc 100644 --- a/apis/vshn/v1/zz_generated.deepcopy.go +++ b/apis/vshn/v1/zz_generated.deepcopy.go @@ -35,7 +35,7 @@ func (in *BillingService) DeepCopyInto(out *BillingService) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -92,7 +92,7 @@ func (in *BillingServiceList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BillingServiceSpec) DeepCopyInto(out *BillingServiceSpec) { *out = *in - out.Odoo = in.Odoo + in.Odoo.DeepCopyInto(&out.Odoo) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BillingServiceSpec. @@ -190,6 +190,21 @@ func (in *InitialMaintenanceStatus) DeepCopy() *InitialMaintenanceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ItemSpec) DeepCopyInto(out *ItemSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ItemSpec. +func (in *ItemSpec) DeepCopy() *ItemSpec { + if in == nil { + return nil + } + out := new(ItemSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *K8upBackupSpec) DeepCopyInto(out *K8upBackupSpec) { *out = *in @@ -259,6 +274,11 @@ func (in *LocalObjectReference) DeepCopy() *LocalObjectReference { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OdooSpec) DeepCopyInto(out *OdooSpec) { *out = *in + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ItemSpec, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OdooSpec. diff --git a/cmd/controller.go b/cmd/controller.go index 3800b780a6..a11a5b5b4a 100644 --- a/cmd/controller.go +++ b/cmd/controller.go @@ -107,7 +107,12 @@ func (c *controller) executeController(cmd *cobra.Command, _ []string) error { return fmt.Errorf("initialize Odoo client: %w", err) } - b := billing.New(mgr.GetClient(), mgr.GetScheme(), odooClient) + maxEvents := viper.GetInt("BILLING_MAX_EVENTS_PRODUCT") + if maxEvents <= 0 { + maxEvents = 100 // default + } + + b := billing.New(mgr.GetClient(), mgr.GetScheme(), odooClient, maxEvents) if err := b.SetupWithManager(mgr); err != nil { return err } diff --git a/config/controller/cluster-role.yaml b/config/controller/cluster-role.yaml index 43ba98de99..6206be42eb 100644 --- a/config/controller/cluster-role.yaml +++ b/config/controller/cluster-role.yaml @@ -126,18 +126,9 @@ rules: - xvshnpostgresqls/status - xvshnredis - xvshnredis/status - - xobjectbuckets verbs: - get - list - patch - update - watch -- apiGroups: - - apiextensions.crossplane.io - resources: - - compositeresourcedefinitions - verbs: - - get - - list - - watch diff --git a/crds/vshn.appcat.vshn.io_billingservices.yaml b/crds/vshn.appcat.vshn.io_billingservices.yaml index 1f51b01de6..ec0d2059a2 100644 --- a/crds/vshn.appcat.vshn.io_billingservices.yaml +++ b/crds/vshn.appcat.vshn.io_billingservices.yaml @@ -62,34 +62,49 @@ spec: description: InstanceID uniquely identifies the service instance in Odoo type: string - itemDescription: - description: ItemDescription is a human readable description of - the billing item - type: string - itemGroupDescription: - description: ItemGroupDescription describes the billing item group - type: string + items: + description: |- + Items defines list of billable products for this instance + Each item represents a product with independent lifecycle and event tracking + items: + description: ItemSpec defines a single billable product/item + properties: + itemDescription: + description: ItemDescription is a human-readable description + of the billing item + type: string + itemGroupDescription: + description: ItemGroupDescription describes the billing + item group + type: string + productID: + description: ProductID identifies the product in the billing + system + type: string + unit: + description: Unit defines the billing unit type for this + product + type: string + value: + description: |- + Value represents the billable metric for this product + Can be: replica count, disk size (e.g., "50Gi"), percentage, etc. + type: string + required: + - productID + - value + type: object + minItems: 1 + type: array organization: description: Organization used to identify sales order type: string - productID: - description: ProductID identifies the product in the billing system - type: string salesOrderID: description: SalesOrderID identifies the sales order in Odoo type: string - size: - description: Size represents the size of the service instance - type: string - unitID: - description: UnitID defines the billing unit type in Odoo - type: string required: - instanceID - - itemDescription - - itemGroupDescription - - productID - - unitID + - items type: object type: object status: @@ -159,6 +174,14 @@ spec: description: BillingEventStatus represents the status of a billing event properties: + itemDescription: + description: ItemDescription is a human-readable description + of the billing item + type: string + itemGroupDescription: + description: ItemGroupDescription describes the billing item + group + type: string lastAttemptTime: description: LastAttemptTime is when we last tried to send this event @@ -173,10 +196,6 @@ spec: description: RetryCount tracks the number of retry attempts for failed events type: integer - size: - description: Size represents the size/plan at the time of the - event - type: string state: description: State represents the current state of the event (sent, pending, failed, superseded) @@ -191,19 +210,27 @@ spec: format: date-time type: string type: - description: Type is the type of billing event (created, deleted, - scaled) + description: Type is the type of billing event (create, delete, + scale) enum: - - created - - deleted - - scaled + - create + - delete + - scale + type: string + unit: + description: Unit defines the billing unit type for this product + type: string + value: + description: |- + Value represents the billable metric at the time of the event + Generic field supporting replica count, disk size, percentages, etc. type: string required: - productId - - size - state - timestamp - type + - value type: object type: array type: object diff --git a/pkg/comp-functions/functions/common/billing_service.go b/pkg/comp-functions/functions/common/billing_service.go index 1880a11af6..f3bb6bc2cb 100644 --- a/pkg/comp-functions/functions/common/billing_service.go +++ b/pkg/comp-functions/functions/common/billing_service.go @@ -26,10 +26,8 @@ const ( type BillingServiceOptions struct { // ResourceNameSuffix is appended to comp.GetName() to form the resource name (e.g., "-billing-service", "-addon-collabora") ResourceNameSuffix string - // ProductID overrides the auto-generated productID based on service type and sla - ProductID string - // Size overrides the replica count for billing purposes - Size string + // Items defines the list of billable items/products for this instance + Items []vshnv1.ItemSpec // AdditionalLabels are added to the BillingService CR labels AdditionalLabels map[string]string } @@ -64,13 +62,7 @@ func CreateOrUpdateBillingServiceWithOptions(ctx context.Context, svc *runtime.S namespace := comp.GetClaimNamespace() service := comp.GetServiceName() - // Create productID from service and number of replicas (or use override) - productID := opts.ProductID - if productID == "" { - productID = getProductID(comp.GetInstances(), service) - } - - // Get unitID from config + // Get unitID from config (for default item if no items specified) unitID := svc.Config.Data["billingUnitID"] if unitID == "" { log.Error(fmt.Errorf("missing billing unitID"), "UnitID missing in composition") @@ -98,10 +90,23 @@ func CreateOrUpdateBillingServiceWithOptions(ctx context.Context, svc *runtime.S isAPPUiOCloud = true } - // Determine size (use override or instance count) - size := opts.Size - if size == "" { - size = strconv.Itoa(comp.GetInstances()) + // Prepare ItemGroupDescription and ItemDescription for all items + itemGroupDescription := claim + itemDescription := GetItemDescription(isAPPUiOCloud, clusterName, namespace) + + // If no items specified, create default compute item + items := opts.Items + if len(items) == 0 { + productID := getProductID(comp, service) + items = []vshnv1.ItemSpec{ + { + ProductID: productID, + Value: strconv.Itoa(comp.GetInstances()), + Unit: unitID, + ItemDescription: itemDescription, + ItemGroupDescription: itemGroupDescription, + }, + } } // Build labels @@ -124,13 +129,9 @@ func CreateOrUpdateBillingServiceWithOptions(ctx context.Context, svc *runtime.S Spec: vshnv1.BillingServiceSpec{ KeepAfterDeletion: keepAfterDeletion, Odoo: vshnv1.OdooSpec{ - InstanceID: comp.GetName(), - ProductID: productID, - UnitID: unitID, - Size: size, - SalesOrderID: salesOrder, - ItemGroupDescription: claim, - ItemDescription: GetItemDescription(isAPPUiOCloud, clusterName, namespace), + InstanceID: comp.GetName(), + SalesOrderID: salesOrder, + Items: items, }, }, } @@ -180,7 +181,7 @@ func CreateOrUpdateBillingServiceWithOptions(ctx context.Context, svc *runtime.S return runtime.NewWarningResult(fmt.Sprintf("cannot create BillingService for %s: %v", comp.GetName(), err)) } - return runtime.NewNormalResult(fmt.Sprintf("BillingService configured for instance %s", comp.GetName())) + return runtime.NewNormalResult(fmt.Sprintf("BillingService configured for instance %s with %d items", comp.GetName(), len(items))) } // GetItemDescription returns item description with cluster and namespace name @@ -191,9 +192,9 @@ func GetItemDescription(isAPPUiOCloud bool, cluster, namespace string) string { return fmt.Sprintf("APPUiO Managed - Cluster: %s / Namespace: %s", cluster, namespace) } -func getProductID(instances int, service string) string { +func getProductID(comp InfoGetter, service string) string { sla := vshnv1.BestEffort - if instances > 1 { + if comp.GetInstances() > 1 && comp.GetSLA() == string(vshnv1.Guaranteed) { sla = vshnv1.Guaranteed } diff --git a/pkg/comp-functions/functions/vshnkeycloak/billing.go b/pkg/comp-functions/functions/vshnkeycloak/billing.go index e0d11a6171..7e576bab96 100644 --- a/pkg/comp-functions/functions/vshnkeycloak/billing.go +++ b/pkg/comp-functions/functions/vshnkeycloak/billing.go @@ -25,7 +25,9 @@ func AddBilling(ctx context.Context, comp *v1.VSHNKeycloak, svc *runtime.Service } // Add BillingService CR-based billing - billingServiceResult := common.CreateOrUpdateBillingService(ctx, svc, comp) + billingServiceResult := common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ + ResourceNameSuffix: "-billing-service", + }) if billingServiceResult != nil && billingServiceResult.Severity != xfnproto.Severity_SEVERITY_NORMAL { return billingServiceResult diff --git a/pkg/comp-functions/functions/vshnmariadb/billing.go b/pkg/comp-functions/functions/vshnmariadb/billing.go index 0d2f32ce5a..3855c8fa51 100644 --- a/pkg/comp-functions/functions/vshnmariadb/billing.go +++ b/pkg/comp-functions/functions/vshnmariadb/billing.go @@ -25,7 +25,9 @@ func AddBilling(ctx context.Context, comp *v1.VSHNMariaDB, svc *runtime.ServiceR } // Add BillingService CR-based billing - billingServiceResult := common.CreateOrUpdateBillingService(ctx, svc, comp) + billingServiceResult := common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ + ResourceNameSuffix: "-billing-service", + }) if billingServiceResult != nil && billingServiceResult.Severity != xfnproto.Severity_SEVERITY_NORMAL { return billingServiceResult diff --git a/pkg/comp-functions/functions/vshnminio/billing.go b/pkg/comp-functions/functions/vshnminio/billing.go index 14b8f08dc9..9d3c0db359 100644 --- a/pkg/comp-functions/functions/vshnminio/billing.go +++ b/pkg/comp-functions/functions/vshnminio/billing.go @@ -25,7 +25,9 @@ func AddBilling(ctx context.Context, comp *v1.VSHNMinio, svc *runtime.ServiceRun } // Add BillingService CR-based billing - billingServiceResult := common.CreateOrUpdateBillingService(ctx, svc, comp) + billingServiceResult := common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ + ResourceNameSuffix: "-billing-service", + }) if billingServiceResult != nil && billingServiceResult.Severity != xfnproto.Severity_SEVERITY_NORMAL { return billingServiceResult diff --git a/pkg/comp-functions/functions/vshnnextcloud/billing.go b/pkg/comp-functions/functions/vshnnextcloud/billing.go index bf91b053f8..fb142cbfb9 100644 --- a/pkg/comp-functions/functions/vshnnextcloud/billing.go +++ b/pkg/comp-functions/functions/vshnnextcloud/billing.go @@ -35,7 +35,9 @@ func AddBilling(ctx context.Context, comp *v1.VSHNNextcloud, svc *runtime.Servic } // Add BillingService CR-based billing - billingServiceResult := common.CreateOrUpdateBillingService(ctx, svc, comp) + billingServiceResult := common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ + ResourceNameSuffix: "-billing-service", + }) if billingServiceResult != nil && billingServiceResult.Severity != xfnproto.Severity_SEVERITY_NORMAL { return billingServiceResult @@ -56,8 +58,12 @@ func AddBilling(ctx context.Context, comp *v1.VSHNNextcloud, svc *runtime.Servic func createOrUpdateBillingServiceCollabora(ctx context.Context, svc *runtime.ServiceRuntime, comp *v1.VSHNNextcloud) *xfnproto.Result { return common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ ResourceNameSuffix: "-collabora-billing-service", - ProductID: "appcat-vshn-nextcloud-office-besteffort", - Size: "1", + Items: []v1.ItemSpec{ + { + ProductID: "appcat-vshn-nextcloud-office-besteffort", + Value: "1", + }, + }, AdditionalLabels: map[string]string{ "appcat.vshn.io/add-on": "true", }, diff --git a/pkg/comp-functions/functions/vshnpostgres/billing.go b/pkg/comp-functions/functions/vshnpostgres/billing.go index 67b2e2690b..3b7347cdc1 100644 --- a/pkg/comp-functions/functions/vshnpostgres/billing.go +++ b/pkg/comp-functions/functions/vshnpostgres/billing.go @@ -29,7 +29,9 @@ func AddBilling(ctx context.Context, comp *v1.VSHNPostgreSQL, svc *runtime.Servi } // Add BillingService CR-based billing - billingServiceResult := common.CreateOrUpdateBillingService(ctx, svc, comp) + billingServiceResult := common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ + ResourceNameSuffix: "-billing-service", + }) if billingServiceResult != nil && billingServiceResult.Severity != xfnproto.Severity_SEVERITY_NORMAL { return billingServiceResult diff --git a/pkg/comp-functions/functions/vshnpostgrescnpg/billing.go b/pkg/comp-functions/functions/vshnpostgrescnpg/billing.go index 2dc342f1fc..38110a1a31 100644 --- a/pkg/comp-functions/functions/vshnpostgrescnpg/billing.go +++ b/pkg/comp-functions/functions/vshnpostgrescnpg/billing.go @@ -24,7 +24,9 @@ func AddBilling(ctx context.Context, comp *v1.VSHNPostgreSQL, svc *runtime.Servi return prometheusResult } // Add BillingService CR-based billing - billingServiceResult := common.CreateOrUpdateBillingService(ctx, svc, comp) + billingServiceResult := common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ + ResourceNameSuffix: "-billing-service", + }) if billingServiceResult != nil && billingServiceResult.Severity != xfnproto.Severity_SEVERITY_NORMAL { return billingServiceResult diff --git a/pkg/comp-functions/functions/vshnredis/billing.go b/pkg/comp-functions/functions/vshnredis/billing.go index 8a85ed865e..af0f8554ed 100644 --- a/pkg/comp-functions/functions/vshnredis/billing.go +++ b/pkg/comp-functions/functions/vshnredis/billing.go @@ -25,7 +25,9 @@ func AddBilling(ctx context.Context, comp *v1.VSHNRedis, svc *runtime.ServiceRun } // Add BillingService CR-based billing - billingServiceResult := common.CreateOrUpdateBillingService(ctx, svc, comp) + billingServiceResult := common.CreateOrUpdateBillingServiceWithOptions(ctx, svc, comp, common.BillingServiceOptions{ + ResourceNameSuffix: "-billing-service", + }) if billingServiceResult != nil && billingServiceResult.Severity != xfnproto.Severity_SEVERITY_NORMAL { return billingServiceResult diff --git a/pkg/controller/billing/billing_handler.go b/pkg/controller/billing/billing_handler.go index ed206209ea..e19d48c3b0 100644 --- a/pkg/controller/billing/billing_handler.go +++ b/pkg/controller/billing/billing_handler.go @@ -30,14 +30,19 @@ type BillingHandler struct { Scheme *runtime.Scheme odooClient *odoo.Client log logr.Logger + maxEvents int } -func New(c client.Client, scheme *runtime.Scheme, odooClient *odoo.Client) *BillingHandler { +func New(c client.Client, scheme *runtime.Scheme, odooClient *odoo.Client, maxEvents int) *BillingHandler { + if maxEvents <= 0 { + maxEvents = 100 // default + } return &BillingHandler{ Client: c, Scheme: scheme, odooClient: odooClient, log: ctrl.Log.WithName("controller").WithName("billing"), + maxEvents: maxEvents, } } @@ -166,15 +171,19 @@ func (b *BillingHandler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. return ctrl.Result{}, nil } - if err := b.handleSLAChange(ctx, &billingService); err != nil { - return ctrl.Result{}, err - } + // Handle lifecycle for each item/product in the spec + for _, item := range billingService.Spec.Odoo.Items { + if err := b.handleItemCreation(ctx, &billingService, item); err != nil { + return ctrl.Result{}, err + } - if err := b.handleCreation(ctx, &billingService); err != nil { - return ctrl.Result{}, err + if err := b.handleItemScaling(ctx, &billingService, item); err != nil { + return ctrl.Result{}, err + } } - if err := b.handleScaling(ctx, &billingService); err != nil { + // Handle items/products that were removed from spec + if err := b.handleRemovedItems(ctx, &billingService); err != nil { return ctrl.Result{}, err } diff --git a/pkg/controller/billing/create.go b/pkg/controller/billing/create.go index bd7f90f203..1069e916de 100644 --- a/pkg/controller/billing/create.go +++ b/pkg/controller/billing/create.go @@ -6,21 +6,22 @@ import ( vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" ) -func (b *BillingHandler) handleCreation(ctx context.Context, billingService *vshnv1.BillingService) error { - currentProd := billingService.Spec.Odoo.ProductID - currentSize := billingService.Spec.Odoo.Size - - if hasEvent(billingService, BillingEventTypeCreated, currentProd) { +// handleItemCreation checks if each item/product has a created event +func (b *BillingHandler) handleItemCreation(ctx context.Context, billingService *vshnv1.BillingService, item vshnv1.ItemSpec) error { + if hasEvent(billingService, BillingEventTypeCreated, item.ProductID) { return nil } event := vshnv1.BillingEventStatus{ - Type: string(BillingEventTypeCreated), - ProductID: currentProd, - Size: currentSize, - Timestamp: billingService.ObjectMeta.CreationTimestamp, - State: string(BillingEventStatePending), - RetryCount: 0, + Type: string(BillingEventTypeCreated), + ProductID: item.ProductID, + Value: item.Value, + Unit: item.Unit, + ItemDescription: item.ItemDescription, + ItemGroupDescription: item.ItemGroupDescription, + Timestamp: billingService.ObjectMeta.CreationTimestamp, + State: string(BillingEventStatePending), + RetryCount: 0, } return enqueueEvent(ctx, b, billingService, event) diff --git a/pkg/controller/billing/create_test.go b/pkg/controller/billing/create_test.go new file mode 100644 index 0000000000..db71db32b6 --- /dev/null +++ b/pkg/controller/billing/create_test.go @@ -0,0 +1,221 @@ +package billing + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestHandleItemCreation(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, vshnv1.AddToScheme(scheme)) + + tests := []struct { + name string + billingService *vshnv1.BillingService + item vshnv1.ItemSpec + expectEvent bool + expectedType string + }{ + { + name: "creates event for new item", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Test Item", ItemGroupDescription: "Test Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{}, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Test Item", + ItemGroupDescription: "Test Group", + }, + expectEvent: true, + expectedType: string(BillingEventTypeCreated), + }, + { + name: "does not create duplicate event for existing item", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Test Item", ItemGroupDescription: "Test Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStatePending), + }, + }, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Test Item", + ItemGroupDescription: "Test Group", + }, + expectEvent: false, + }, + { + name: "creates event for second item", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Test Item", ItemGroupDescription: "Test Group"}, + {ProductID: "prod-456", Value: "50Gi", Unit: "storage", ItemDescription: "Storage Item", ItemGroupDescription: "Storage Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-456", + Value: "50Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + }, + expectEvent: true, + expectedType: string(BillingEventTypeCreated), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.billingService). + WithStatusSubresource(tt.billingService). + Build() + + handler := &BillingHandler{ + Client: client, + Scheme: scheme, + maxEvents: 100, + } + + initialEventCount := len(tt.billingService.Status.Events) + err := handler.handleItemCreation(context.Background(), tt.billingService, tt.item) + require.NoError(t, err) + + if tt.expectEvent { + assert.Equal(t, initialEventCount+1, len(tt.billingService.Status.Events), + "expected new event to be added") + newEvent := tt.billingService.Status.Events[0] + assert.Equal(t, tt.expectedType, newEvent.Type) + assert.Equal(t, tt.item.ProductID, newEvent.ProductID) + assert.Equal(t, tt.item.Value, newEvent.Value) + assert.Equal(t, tt.item.Unit, newEvent.Unit) + assert.Equal(t, tt.item.ItemDescription, newEvent.ItemDescription) + assert.Equal(t, tt.item.ItemGroupDescription, newEvent.ItemGroupDescription) + assert.Equal(t, string(BillingEventStatePending), newEvent.State) + } else { + assert.Equal(t, initialEventCount, len(tt.billingService.Status.Events), + "expected no new event to be added") + } + }) + } +} + +func TestHandleItemCreation_MultipleItems(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, vshnv1.AddToScheme(scheme)) + + billingService := &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-compute", Value: "2", Unit: "instance", ItemDescription: "Compute Item", ItemGroupDescription: "Compute Group"}, + {ProductID: "prod-storage", Value: "50Gi", Unit: "storage", ItemDescription: "Storage Item", ItemGroupDescription: "Storage Group"}, + {ProductID: "prod-backup", Value: "enabled", Unit: "boolean", ItemDescription: "Backup Item", ItemGroupDescription: "Backup Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{}, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(billingService). + WithStatusSubresource(billingService). + Build() + + handler := &BillingHandler{ + Client: client, + Scheme: scheme, + maxEvents: 100, + } + + // Create events for all three items + for _, item := range billingService.Spec.Odoo.Items { + err := handler.handleItemCreation(context.Background(), billingService, item) + require.NoError(t, err) + } + + // Verify all three items have created events + assert.Equal(t, 3, len(billingService.Status.Events)) + + productIDs := make(map[string]bool) + for _, event := range billingService.Status.Events { + assert.Equal(t, string(BillingEventTypeCreated), event.Type) + assert.Equal(t, string(BillingEventStatePending), event.State) + productIDs[event.ProductID] = true + } + + assert.True(t, productIDs["prod-compute"]) + assert.True(t, productIDs["prod-storage"]) + assert.True(t, productIDs["prod-backup"]) +} diff --git a/pkg/controller/billing/delete.go b/pkg/controller/billing/delete.go index 294294ef9d..0982d180d4 100644 --- a/pkg/controller/billing/delete.go +++ b/pkg/controller/billing/delete.go @@ -13,25 +13,30 @@ func (b *BillingHandler) handleDeletion(ctx context.Context, billingService *vsh return nil } - currentProduct := billingService.Spec.Odoo.ProductID - - if !hasEvent(billingService, BillingEventTypeDeleted, currentProduct) { - delTime := *billingService.DeletionTimestamp - lastSize := lastObservedSizeForProduct(billingService, currentProduct, billingService.Spec.Odoo.Size) - ev := vshnv1.BillingEventStatus{ - Type: string(BillingEventTypeDeleted), - ProductID: currentProduct, - Size: lastSize, - Timestamp: delTime, - State: string(BillingEventStatePending), - RetryCount: 0, - } - if err := enqueueEvent(ctx, b, billingService, ev); err != nil { - return err + delTime := *billingService.DeletionTimestamp + + // Delete all items/products currently in spec + for _, item := range billingService.Spec.Odoo.Items { + if !hasEvent(billingService, BillingEventTypeDeleted, item.ProductID) { + lastValue := lastObservedValueForProduct(billingService, item.ProductID, item.Value) + event := vshnv1.BillingEventStatus{ + Type: string(BillingEventTypeDeleted), + ProductID: item.ProductID, + Value: lastValue, + Unit: item.Unit, + ItemDescription: item.ItemDescription, + ItemGroupDescription: item.ItemGroupDescription, + Timestamp: delTime, + State: string(BillingEventStatePending), + RetryCount: 0, + } + if err := enqueueEvent(ctx, b, billingService, event); err != nil { + return err + } } } - // Finalizer removal is performed in Reconcile once the delete event is sent + // Finalizer removal is performed in Reconcile once the delete events are sent _ = controllerutil.AddFinalizer(billingService, vshnv1.BillingServiceFinalizer) return nil } diff --git a/pkg/controller/billing/delete_test.go b/pkg/controller/billing/delete_test.go index 7bf8baeb66..a0f1b4f452 100644 --- a/pkg/controller/billing/delete_test.go +++ b/pkg/controller/billing/delete_test.go @@ -28,7 +28,9 @@ func TestShouldRemoveFinalizer(t *testing.T) { Spec: vshnv1.BillingServiceSpec{ KeepAfterDeletion: 0, Odoo: vshnv1.OdooSpec{ - ProductID: "prod-123", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "1", Unit: "instance", ItemDescription: "test", ItemGroupDescription: "test-group"}, + }, }, }, }, @@ -44,7 +46,9 @@ func TestShouldRemoveFinalizer(t *testing.T) { Spec: vshnv1.BillingServiceSpec{ KeepAfterDeletion: 7, Odoo: vshnv1.OdooSpec{ - ProductID: "prod-123", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "1", Unit: "instance", ItemDescription: "test", ItemGroupDescription: "test-group"}, + }, }, }, }, @@ -63,7 +67,9 @@ func TestShouldRemoveFinalizer(t *testing.T) { Spec: vshnv1.BillingServiceSpec{ KeepAfterDeletion: 7, Odoo: vshnv1.OdooSpec{ - ProductID: "prod-123", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "1", Unit: "instance", ItemDescription: "test", ItemGroupDescription: "test-group"}, + }, }, }, }, @@ -78,7 +84,9 @@ func TestShouldRemoveFinalizer(t *testing.T) { }, Spec: vshnv1.BillingServiceSpec{ Odoo: vshnv1.OdooSpec{ - ProductID: "prod-123", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "1", Unit: "instance", ItemDescription: "test", ItemGroupDescription: "test-group"}, + }, }, }, }, @@ -94,7 +102,9 @@ func TestShouldRemoveFinalizer(t *testing.T) { Spec: vshnv1.BillingServiceSpec{ KeepAfterDeletion: -1, Odoo: vshnv1.OdooSpec{ - ProductID: "prod-123", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "1", Unit: "instance", ItemDescription: "test", ItemGroupDescription: "test-group"}, + }, }, }, }, diff --git a/pkg/controller/billing/delivery.go b/pkg/controller/billing/delivery.go index ed28855645..72f5316ab8 100644 --- a/pkg/controller/billing/delivery.go +++ b/pkg/controller/billing/delivery.go @@ -66,11 +66,11 @@ func (b *BillingHandler) sendEventToOdoo(ctx context.Context, billingService *vs ProductID: event.ProductID, InstanceID: billingService.Spec.Odoo.InstanceID, SalesOrderID: billingService.Spec.Odoo.SalesOrderID, - ItemDescription: billingService.Spec.Odoo.ItemDescription, - ItemGroupDescription: billingService.Spec.Odoo.ItemGroupDescription, - UnitID: billingService.Spec.Odoo.UnitID, + ItemDescription: event.ItemDescription, + ItemGroupDescription: event.ItemGroupDescription, + UnitID: event.Unit, EventType: event.Type, - Size: event.Size, + Size: event.Value, Timestamp: event.Timestamp.Format(time.RFC3339), }) } diff --git a/pkg/controller/billing/events.go b/pkg/controller/billing/events.go index 0667e6225e..2d5397519b 100644 --- a/pkg/controller/billing/events.go +++ b/pkg/controller/billing/events.go @@ -14,10 +14,85 @@ type findEventOpts struct { // enqueueEvent prepends a billing event to the list and updates the BillingService status. func enqueueEvent(ctx context.Context, b *BillingHandler, billingService *vshnv1.BillingService, event vshnv1.BillingEventStatus) error { + // Prepend event (newest first) billingService.Status.Events = append([]vshnv1.BillingEventStatus{event}, billingService.Status.Events...) + + // Prune events if needed (per-product limits) + pruned := pruneEventsIfNeeded(billingService, b.maxEvents) + if pruned > 0 { + b.log.Info("Pruned old billing events", + "billingService", billingService.Name, + "prunedCount", pruned, + "remainingEvents", len(billingService.Status.Events)) + } + return b.Status().Update(ctx, billingService) } +// pruneEventsIfNeeded removes oldest sent events per product when limit exceeded +// maxEvents applies globally to all products (same limit for each product) +func pruneEventsIfNeeded(billingService *vshnv1.BillingService, maxEvents int) int { + // Count events per product + eventCountPerProduct := make(map[string]int) + for _, event := range billingService.Status.Events { + eventCountPerProduct[event.ProductID]++ + } + + // Build pruning list per product + eventsToRemove := make(map[int]bool) + totalPruned := 0 + + // Prune events for ALL products (including removed ones) + // This prevents unbounded growth of events for products that were removed from spec + for productID := range eventCountPerProduct { + currentCount := eventCountPerProduct[productID] + if currentCount <= maxEvents { + continue // no pruning needed for this product + } + + toPrune := currentCount - maxEvents + + // Find oldest sent events for this product + // Only prune events with status "sent" + prunableIndices := []int{} + for i := len(billingService.Status.Events) - 1; i >= 0; i-- { + event := billingService.Status.Events[i] + if event.ProductID != productID { + continue + } + if event.State == string(BillingEventStateSent) { + prunableIndices = append(prunableIndices, i) + } + } + + // Prune oldest events for this product + pruneCount := toPrune + if pruneCount > len(prunableIndices) { + pruneCount = len(prunableIndices) + } + + for i := 0; i < pruneCount; i++ { + eventsToRemove[prunableIndices[i]] = true + totalPruned++ + } + } + + if totalPruned == 0 { + return 0 + } + + // Build new events list without pruned events + newEvents := make([]vshnv1.BillingEventStatus, 0, len(billingService.Status.Events)-totalPruned) + for i, event := range billingService.Status.Events { + if !eventsToRemove[i] { + newEvents = append(newEvents, event) + } + } + + billingService.Status.Events = newEvents + return totalPruned +} + // findEvent returns the oldest event matching the given findEventOpts. // It returns the event index, the event itself, and true if found. Otherwise it returns -1, empty, and false. func findEvent(billingService *vshnv1.BillingService, opts findEventOpts) (int, vshnv1.BillingEventStatus, bool) { @@ -49,8 +124,8 @@ func findEvent(billingService *vshnv1.BillingService, opts findEventOpts) (int, return -1, vshnv1.BillingEventStatus{}, false } -// hasSentEvent returns true if there is a sent event of the given type optionally filtered by productID and size. -func hasSentEvent(billingService *vshnv1.BillingService, eventType BillingEventType, productID string, size string) bool { +// hasSentEvent returns true if there is a sent event of the given type optionally filtered by productID and value. +func hasSentEvent(billingService *vshnv1.BillingService, eventType BillingEventType, productID string, value string) bool { for _, event := range billingService.Status.Events { if event.Type != string(eventType) || event.State != string(BillingEventStateSent) { continue @@ -58,7 +133,7 @@ func hasSentEvent(billingService *vshnv1.BillingService, eventType BillingEventT if productID != "" && event.ProductID != productID { continue } - if size != "" && event.Size != size { + if value != "" && event.Value != value { continue } return true @@ -76,10 +151,10 @@ func hasEvent(billingService *vshnv1.BillingService, eventType BillingEventType, return false } -// hasEventWithSize returns true if an event of type t exists for (productID,size) (any state). -func hasEventWithSize(billingService *vshnv1.BillingService, eventType BillingEventType, productID, size string) bool { +// hasEventWithValue returns true if an event of type t exists for (productID,value) (any state). +func hasEventWithValue(billingService *vshnv1.BillingService, eventType BillingEventType, productID, value string) bool { for _, event := range billingService.Status.Events { - if event.Type == string(eventType) && event.ProductID == productID && event.Size == size { + if event.Type == string(eventType) && event.ProductID == productID && event.Value == value { return true } } @@ -97,7 +172,7 @@ func hasOpenCreated(billingService *vshnv1.BillingService, productID string) boo } // lastActiveSentProduct returns the most recent sent created event that has not been superseded. -func lastActiveSentProduct(billingService *vshnv1.BillingService) (productID string, size string, ok bool) { +func lastActiveSentProduct(billingService *vshnv1.BillingService) (productID string, value string, ok bool) { deleted := map[string]struct{}{} for _, event := range billingService.Status.Events { @@ -110,7 +185,7 @@ func lastActiveSentProduct(billingService *vshnv1.BillingService) (productID str } if event.Type == string(BillingEventTypeCreated) { if _, seen := deleted[event.ProductID]; !seen { - return event.ProductID, event.Size, true + return event.ProductID, event.Value, true } } } @@ -150,16 +225,16 @@ func supersedeCreatedForSLA(billingService *vshnv1.BillingService, currentProduc return changed } -// lastObservedSizeForProduct returns last sent size for productID or fallback if none found. -func lastObservedSizeForProduct(billingService *vshnv1.BillingService, productID string, fallback string) string { - if size, ok := lastSentSizeForProduct(billingService, productID); ok { - return size +// lastObservedValueForProduct returns last sent value for productID or fallback if none found. +func lastObservedValueForProduct(billingService *vshnv1.BillingService, productID string, fallback string) string { + if value, ok := lastSentValueForProduct(billingService, productID); ok { + return value } return fallback } -// lastSentSizeForProduct returns the size from the most recent SENT created/scaled for productID. -func lastSentSizeForProduct(billingService *vshnv1.BillingService, productID string) (string, bool) { +// lastSentValueForProduct returns the value from the most recent SENT created/scaled for productID. +func lastSentValueForProduct(billingService *vshnv1.BillingService, productID string) (string, bool) { for _, event := range billingService.Status.Events { if event.State != string(BillingEventStateSent) { continue @@ -168,7 +243,7 @@ func lastSentSizeForProduct(billingService *vshnv1.BillingService, productID str continue } if event.Type == string(BillingEventTypeScaled) || event.Type == string(BillingEventTypeCreated) { - return event.Size, true + return event.Value, true } } return "", false diff --git a/pkg/controller/billing/events_test.go b/pkg/controller/billing/events_test.go new file mode 100644 index 0000000000..25365fafc1 --- /dev/null +++ b/pkg/controller/billing/events_test.go @@ -0,0 +1,676 @@ +package billing + +import ( + "testing" + + "github.com/stretchr/testify/assert" + vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHasEvent(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + eventType BillingEventType + productID string + expected bool + }{ + { + name: "returns true when event exists", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + State: string(BillingEventStatePending), + }, + }, + }, + }, + eventType: BillingEventTypeCreated, + productID: "prod-123", + expected: true, + }, + { + name: "returns false when event does not exist", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + State: string(BillingEventStatePending), + }, + }, + }, + }, + eventType: BillingEventTypeDeleted, + productID: "prod-123", + expected: false, + }, + { + name: "returns false for different productID", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + State: string(BillingEventStatePending), + }, + }, + }, + }, + eventType: BillingEventTypeCreated, + productID: "prod-456", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasEvent(tt.billingService, tt.eventType, tt.productID) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasSentEvent(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + eventType BillingEventType + productID string + value string + expected bool + }{ + { + name: "returns true for sent event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + eventType: BillingEventTypeCreated, + productID: "prod-123", + value: "2", + expected: true, + }, + { + name: "returns false for pending event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStatePending), + }, + }, + }, + }, + eventType: BillingEventTypeCreated, + productID: "prod-123", + value: "2", + expected: false, + }, + { + name: "returns false for different value", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + eventType: BillingEventTypeCreated, + productID: "prod-123", + value: "3", + expected: false, + }, + { + name: "works with empty productID filter", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + eventType: BillingEventTypeCreated, + productID: "", + value: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasSentEvent(tt.billingService, tt.eventType, tt.productID, tt.value) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasEventWithValue(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + eventType BillingEventType + productID string + value string + expected bool + }{ + { + name: "returns true for matching event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeScaled), + ProductID: "prod-123", + Value: "5", + State: string(BillingEventStatePending), + }, + }, + }, + }, + eventType: BillingEventTypeScaled, + productID: "prod-123", + value: "5", + expected: true, + }, + { + name: "returns false for different value", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeScaled), + ProductID: "prod-123", + Value: "5", + State: string(BillingEventStatePending), + }, + }, + }, + }, + eventType: BillingEventTypeScaled, + productID: "prod-123", + value: "3", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasEventWithValue(tt.billingService, tt.eventType, tt.productID, tt.value) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestLastSentValueForProduct(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + productID string + expectedValue string + expectedOk bool + }{ + { + name: "returns value from sent created event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + productID: "prod-123", + expectedValue: "2", + expectedOk: true, + }, + { + name: "returns value from most recent sent scaled event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeScaled), + ProductID: "prod-123", + Value: "5", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + productID: "prod-123", + expectedValue: "5", + expectedOk: true, + }, + { + name: "returns false when no sent event exists", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStatePending), + }, + }, + }, + }, + productID: "prod-123", + expectedValue: "", + expectedOk: false, + }, + { + name: "returns false for non-existent product", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + productID: "prod-456", + expectedValue: "", + expectedOk: false, + }, + { + name: "ignores delete events", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeDeleted), + ProductID: "prod-123", + Value: "5", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + State: string(BillingEventStateSent), + }, + }, + }, + }, + productID: "prod-123", + expectedValue: "2", + expectedOk: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value, ok := lastSentValueForProduct(tt.billingService, tt.productID) + assert.Equal(t, tt.expectedOk, ok) + if tt.expectedOk { + assert.Equal(t, tt.expectedValue, value) + } + }) + } +} + +func TestHasOpenCreated(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + productID string + expected bool + }{ + { + name: "returns true for non-superseded created event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + State: string(BillingEventStatePending), + }, + }, + }, + }, + productID: "prod-123", + expected: true, + }, + { + name: "returns false for superseded created event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + State: string(BillingEventStateSuperseded), + }, + }, + }, + }, + productID: "prod-123", + expected: false, + }, + { + name: "returns false when no created event exists", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeScaled), + ProductID: "prod-123", + State: string(BillingEventStatePending), + }, + }, + }, + }, + productID: "prod-123", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasOpenCreated(tt.billingService, tt.productID) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasBacklog(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + expected bool + }{ + { + name: "returns true for pending events", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + State: string(BillingEventStatePending), + }, + }, + }, + }, + expected: true, + }, + { + name: "returns true for failed events", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + State: string(BillingEventStateFailed), + }, + }, + }, + }, + expected: true, + }, + { + name: "returns false when all events are sent", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + State: string(BillingEventStateSent), + }, + }, + }, + }, + expected: false, + }, + { + name: "returns false when all events are sent or superseded", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + State: string(BillingEventStateSuperseded), + }, + }, + }, + }, + expected: false, + }, + { + name: "returns false for empty events", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{}, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasBacklog(tt.billingService) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPruneEventsIfNeeded(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + maxEvents int + expectedPruned int + expectedRemaining int + checkRemainingState bool + }{ + { + name: "prunes oldest sent events when exceeding limit", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", Value: "5", State: string(BillingEventStateSent), Timestamp: metav1.Now()}, + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", Value: "4", State: string(BillingEventStateSent), Timestamp: metav1.Now()}, + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", Value: "3", State: string(BillingEventStateSent), Timestamp: metav1.Now()}, + {Type: string(BillingEventTypeCreated), ProductID: "prod-123", Value: "2", State: string(BillingEventStateSent), Timestamp: metav1.Now()}, + }, + }, + }, + maxEvents: 2, + expectedPruned: 2, + expectedRemaining: 2, + }, + { + name: "does not prune when under limit", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + {Type: string(BillingEventTypeCreated), ProductID: "prod-123", Value: "2", State: string(BillingEventStateSent)}, + {Type: string(BillingEventTypeCreated), ProductID: "prod-456", Value: "50Gi", State: string(BillingEventStateSent)}, + }, + }, + }, + maxEvents: 5, + expectedPruned: 0, + expectedRemaining: 2, + }, + { + name: "does not prune pending events", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", Value: "5", State: string(BillingEventStatePending), Timestamp: metav1.Now()}, + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", Value: "4", State: string(BillingEventStateSent), Timestamp: metav1.Now()}, + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", Value: "3", State: string(BillingEventStateSent), Timestamp: metav1.Now()}, + {Type: string(BillingEventTypeCreated), ProductID: "prod-123", Value: "2", State: string(BillingEventStateSent), Timestamp: metav1.Now()}, + }, + }, + }, + maxEvents: 2, + expectedPruned: 2, + expectedRemaining: 2, + checkRemainingState: true, + }, + { + name: "prunes per-product independently", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", Value: "5", State: string(BillingEventStateSent)}, + {Type: string(BillingEventTypeCreated), ProductID: "prod-123", Value: "2", State: string(BillingEventStateSent)}, + {Type: string(BillingEventTypeScaled), ProductID: "prod-456", Value: "100Gi", State: string(BillingEventStateSent)}, + {Type: string(BillingEventTypeCreated), ProductID: "prod-456", Value: "50Gi", State: string(BillingEventStateSent)}, + }, + }, + }, + maxEvents: 1, + expectedPruned: 2, + expectedRemaining: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pruned := pruneEventsIfNeeded(tt.billingService, tt.maxEvents) + assert.Equal(t, tt.expectedPruned, pruned) + assert.Equal(t, tt.expectedRemaining, len(tt.billingService.Status.Events)) + + if tt.checkRemainingState { + // Verify pending event was not pruned + hasPending := false + for _, event := range tt.billingService.Status.Events { + if event.State == string(BillingEventStatePending) { + hasPending = true + break + } + } + assert.True(t, hasPending, "pending event should not be pruned") + } + }) + } +} + +func TestFindEvent(t *testing.T) { + tests := []struct { + name string + billingService *vshnv1.BillingService + opts findEventOpts + expectedFound bool + expectedIndex int + expectedType string + }{ + { + name: "finds oldest pending event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", State: string(BillingEventStatePending)}, + {Type: string(BillingEventTypeCreated), ProductID: "prod-123", State: string(BillingEventStatePending)}, + }, + }, + }, + opts: findEventOpts{ + States: []BillingEventState{BillingEventStatePending}, + }, + expectedFound: true, + expectedIndex: 1, + expectedType: string(BillingEventTypeCreated), + }, + { + name: "finds event by type", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + {Type: string(BillingEventTypeScaled), ProductID: "prod-123", State: string(BillingEventStatePending)}, + {Type: string(BillingEventTypeDeleted), ProductID: "prod-456", State: string(BillingEventStatePending)}, + }, + }, + }, + opts: findEventOpts{ + States: []BillingEventState{BillingEventStatePending}, + Type: ptrTo(BillingEventTypeDeleted), + }, + expectedFound: true, + expectedIndex: 1, + expectedType: string(BillingEventTypeDeleted), + }, + { + name: "returns false when no matching event", + billingService: &vshnv1.BillingService{ + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + {Type: string(BillingEventTypeCreated), ProductID: "prod-123", State: string(BillingEventStateSent)}, + }, + }, + }, + opts: findEventOpts{ + States: []BillingEventState{BillingEventStatePending}, + }, + expectedFound: false, + expectedIndex: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + idx, event, found := findEvent(tt.billingService, tt.opts) + assert.Equal(t, tt.expectedFound, found) + assert.Equal(t, tt.expectedIndex, idx) + if tt.expectedFound { + assert.Equal(t, tt.expectedType, event.Type) + } + }) + } +} + +// Helper function for tests +func ptrTo(t BillingEventType) *BillingEventType { + return &t +} diff --git a/pkg/controller/billing/remove.go b/pkg/controller/billing/remove.go new file mode 100644 index 0000000000..84c8e7960f --- /dev/null +++ b/pkg/controller/billing/remove.go @@ -0,0 +1,66 @@ +package billing + +import ( + "context" + + vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// handleRemovedItems detects items removed from spec and enqueues delete events +func (b *BillingHandler) handleRemovedItems(ctx context.Context, billingService *vshnv1.BillingService) error { + currentProducts := make(map[string]bool) + for _, item := range billingService.Spec.Odoo.Items { + currentProducts[item.ProductID] = true + } + + // Single pass through events (newest-first order) + type eventInfo struct { + value, unit, itemDesc, itemGroupDesc string + } + createdProducts := make(map[string]bool) + lastSent := make(map[string]eventInfo) + + for _, event := range billingService.Status.Events { + if event.Type == string(BillingEventTypeCreated) && + event.State != string(BillingEventStateSuperseded) { + createdProducts[event.ProductID] = true + } + + // Capture from first (most recent) sent created/scaled event + if _, seen := lastSent[event.ProductID]; !seen && + event.State == string(BillingEventStateSent) && + (event.Type == string(BillingEventTypeCreated) || event.Type == string(BillingEventTypeScaled)) { + lastSent[event.ProductID] = eventInfo{ + value: event.Value, + unit: event.Unit, + itemDesc: event.ItemDescription, + itemGroupDesc: event.ItemGroupDescription, + } + } + } + + for productID := range createdProducts { + if currentProducts[productID] || hasEvent(billingService, BillingEventTypeDeleted, productID) { + continue + } + + info := lastSent[productID] + delEvent := vshnv1.BillingEventStatus{ + Type: string(BillingEventTypeDeleted), + ProductID: productID, + Value: info.value, + Unit: info.unit, + ItemDescription: info.itemDesc, + ItemGroupDescription: info.itemGroupDesc, + Timestamp: metav1.Now(), + State: string(BillingEventStatePending), + RetryCount: 0, + } + if err := enqueueEvent(ctx, b, billingService, delEvent); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/controller/billing/remove_test.go b/pkg/controller/billing/remove_test.go new file mode 100644 index 0000000000..58eeebe496 --- /dev/null +++ b/pkg/controller/billing/remove_test.go @@ -0,0 +1,412 @@ +package billing + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestHandleRemovedItems(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, vshnv1.AddToScheme(scheme)) + + tests := []struct { + name string + billingService *vshnv1.BillingService + expectDeleteEvents []string // productIDs that should have delete events + expectNoDeleteEvents []string // productIDs that should not have delete events + }{ + { + name: "creates delete event for removed item", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Instance Item", ItemGroupDescription: "Instance Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-456", + Value: "50Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + State: string(BillingEventStateSent), + }, + }, + }, + }, + expectDeleteEvents: []string{"prod-456"}, + expectNoDeleteEvents: []string{"prod-123"}, + }, + { + name: "does not create delete event when no items removed", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Instance Item", ItemGroupDescription: "Instance Group"}, + {ProductID: "prod-456", Value: "50Gi", Unit: "storage", ItemDescription: "Storage Item", ItemGroupDescription: "Storage Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-456", + Value: "50Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + State: string(BillingEventStateSent), + }, + }, + }, + }, + expectDeleteEvents: []string{}, + expectNoDeleteEvents: []string{"prod-123", "prod-456"}, + }, + { + name: "creates delete events for multiple removed items", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Instance Item", ItemGroupDescription: "Instance Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-456", + Value: "50Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-789", + Value: "enabled", + Unit: "boolean", + ItemDescription: "Boolean Item", + ItemGroupDescription: "Boolean Group", + State: string(BillingEventStateSent), + }, + }, + }, + }, + expectDeleteEvents: []string{"prod-456", "prod-789"}, + expectNoDeleteEvents: []string{"prod-123"}, + }, + { + name: "does not create duplicate delete event", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Instance Item", ItemGroupDescription: "Instance Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeDeleted), + ProductID: "prod-456", + Value: "50Gi", + State: string(BillingEventStatePending), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-456", + Value: "50Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + State: string(BillingEventStateSent), + }, + }, + }, + }, + expectDeleteEvents: []string{}, + expectNoDeleteEvents: []string{"prod-123"}, + }, + { + name: "ignores superseded created events", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Instance Item", ItemGroupDescription: "Instance Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-456", + Value: "50Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + State: string(BillingEventStateSuperseded), + }, + }, + }, + }, + expectDeleteEvents: []string{}, + expectNoDeleteEvents: []string{"prod-123", "prod-456"}, + }, + { + name: "uses last sent value for delete event", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{}, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeScaled), + ProductID: "prod-123", + Value: "5", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + }, + }, + }, + expectDeleteEvents: []string{"prod-123"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.billingService). + WithStatusSubresource(tt.billingService). + Build() + + handler := &BillingHandler{ + Client: client, + Scheme: scheme, + maxEvents: 100, + } + + initialEventCount := len(tt.billingService.Status.Events) + err := handler.handleRemovedItems(context.Background(), tt.billingService) + require.NoError(t, err) + + // Check for expected delete events + for _, productID := range tt.expectDeleteEvents { + found := false + for _, event := range tt.billingService.Status.Events { + if event.Type == string(BillingEventTypeDeleted) && event.ProductID == productID { + found = true + assert.Equal(t, string(BillingEventStatePending), event.State) + + // Special check for "uses last sent value for delete event" test + if tt.name == "uses last sent value for delete event" { + assert.Equal(t, "5", event.Value, "should use last sent scaled value") + assert.Equal(t, "instance", event.Unit, "should use last sent unit") + assert.Equal(t, "Instance Item", event.ItemDescription, "should use last sent item description") + assert.Equal(t, "Instance Group", event.ItemGroupDescription, "should use last sent item group description") + } + break + } + } + assert.True(t, found, "expected delete event for product %s", productID) + } + + // Check that no delete events exist for products that should not be deleted + for _, productID := range tt.expectNoDeleteEvents { + for _, event := range tt.billingService.Status.Events { + if event.Type == string(BillingEventTypeDeleted) && event.ProductID == productID { + t.Errorf("unexpected delete event for product %s", productID) + } + } + } + + expectedEventCount := initialEventCount + len(tt.expectDeleteEvents) + assert.Equal(t, expectedEventCount, len(tt.billingService.Status.Events)) + }) + } +} + +func TestHandleRemovedItems_EmptySpec(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, vshnv1.AddToScheme(scheme)) + + billingService := &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{}, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Instance Item", + ItemGroupDescription: "Instance Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-456", + Value: "50Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-789", + Value: "enabled", + Unit: "boolean", + ItemDescription: "Boolean Item", + ItemGroupDescription: "Boolean Group", + State: string(BillingEventStateSent), + }, + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(billingService). + WithStatusSubresource(billingService). + Build() + + handler := &BillingHandler{ + Client: client, + Scheme: scheme, + maxEvents: 100, + } + + err := handler.handleRemovedItems(context.Background(), billingService) + require.NoError(t, err) + + // Should create delete events for all three products + deleteEvents := 0 + for _, event := range billingService.Status.Events { + if event.Type == string(BillingEventTypeDeleted) { + deleteEvents++ + } + } + assert.Equal(t, 3, deleteEvents) + assert.Equal(t, 6, len(billingService.Status.Events)) // 3 created + 3 deleted +} diff --git a/pkg/controller/billing/scale.go b/pkg/controller/billing/scale.go index e63c59e164..42265033b9 100644 --- a/pkg/controller/billing/scale.go +++ b/pkg/controller/billing/scale.go @@ -7,26 +7,26 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// handleScaling enqueues a scaled event if the size changed from the last sent value. -func (b *BillingHandler) handleScaling(ctx context.Context, billingService *vshnv1.BillingService) error { - currentProduct := billingService.Spec.Odoo.ProductID - currentSize := billingService.Spec.Odoo.Size - - lastSize, ok := lastSentSizeForProduct(billingService, currentProduct) - if !ok || lastSize == currentSize { +// handleItemScaling enqueues a scaled event if the value changed from the last sent value. +func (b *BillingHandler) handleItemScaling(ctx context.Context, billingService *vshnv1.BillingService, item vshnv1.ItemSpec) error { + lastValue, ok := lastSentValueForProduct(billingService, item.ProductID) + if !ok || lastValue == item.Value { return nil } - if hasEventWithSize(billingService, BillingEventTypeScaled, currentProduct, currentSize) { + if hasEventWithValue(billingService, BillingEventTypeScaled, item.ProductID, item.Value) { return nil // already queued } ev := vshnv1.BillingEventStatus{ - Type: string(BillingEventTypeScaled), - ProductID: currentProduct, - Size: currentSize, - Timestamp: metav1.Now(), - State: string(BillingEventStatePending), - RetryCount: 0, + Type: string(BillingEventTypeScaled), + ProductID: item.ProductID, + Value: item.Value, + Unit: item.Unit, + ItemDescription: item.ItemDescription, + ItemGroupDescription: item.ItemGroupDescription, + Timestamp: metav1.Now(), + State: string(BillingEventStatePending), + RetryCount: 0, } return enqueueEvent(ctx, b, billingService, ev) diff --git a/pkg/controller/billing/scale_test.go b/pkg/controller/billing/scale_test.go new file mode 100644 index 0000000000..4d4ab8210a --- /dev/null +++ b/pkg/controller/billing/scale_test.go @@ -0,0 +1,330 @@ +package billing + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestHandleItemScaling(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, vshnv1.AddToScheme(scheme)) + + tests := []struct { + name string + billingService *vshnv1.BillingService + item vshnv1.ItemSpec + expectEvent bool + expectedValue string + }{ + { + name: "creates scaled event when value changes", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "3", Unit: "instance", ItemDescription: "Test Item", ItemGroupDescription: "Test Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "1", + Unit: "instance", + State: string(BillingEventStateSent), + }, + }, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-123", + Value: "3", + Unit: "instance", + ItemDescription: "Test Item", + ItemGroupDescription: "Test Group", + }, + expectEvent: true, + expectedValue: "3", + }, + { + name: "does not create scaled event when value unchanged", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "2", Unit: "instance", ItemDescription: "Test Item", ItemGroupDescription: "Test Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "2", + Unit: "instance", + State: string(BillingEventStateSent), + }, + }, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-123", + Value: "2", + Unit: "instance", + ItemDescription: "Test Item", + ItemGroupDescription: "Test Group", + }, + expectEvent: false, + }, + { + name: "does not create scaled event when no previous sent event exists", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "3", Unit: "instance", ItemDescription: "Test Item", ItemGroupDescription: "Test Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "1", + State: string(BillingEventStatePending), + }, + }, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-123", + Value: "3", + Unit: "instance", + ItemDescription: "Test Item", + ItemGroupDescription: "Test Group", + }, + expectEvent: false, + }, + { + name: "does not create duplicate scaled event", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-123", Value: "3", Unit: "instance", ItemDescription: "Test Item", ItemGroupDescription: "Test Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeScaled), + ProductID: "prod-123", + Value: "3", + State: string(BillingEventStatePending), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-123", + Value: "1", + State: string(BillingEventStateSent), + }, + }, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-123", + Value: "3", + Unit: "instance", + ItemDescription: "Test Item", + ItemGroupDescription: "Test Group", + }, + expectEvent: false, + }, + { + name: "creates scaled event for storage size change", + billingService: &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-storage", Value: "100Gi", Unit: "storage", ItemDescription: "Storage Item", ItemGroupDescription: "Storage Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-storage", + Value: "50Gi", + Unit: "instance", + State: string(BillingEventStateSent), + }, + }, + }, + }, + item: vshnv1.ItemSpec{ + ProductID: "prod-storage", + Value: "100Gi", + Unit: "storage", + ItemDescription: "Storage Item", + ItemGroupDescription: "Storage Group", + }, + expectEvent: true, + expectedValue: "100Gi", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.billingService). + WithStatusSubresource(tt.billingService). + Build() + + handler := &BillingHandler{ + Client: client, + Scheme: scheme, + maxEvents: 100, + } + + initialEventCount := len(tt.billingService.Status.Events) + err := handler.handleItemScaling(context.Background(), tt.billingService, tt.item) + require.NoError(t, err) + + if tt.expectEvent { + assert.Equal(t, initialEventCount+1, len(tt.billingService.Status.Events), + "expected new scaled event to be added") + newEvent := tt.billingService.Status.Events[0] + assert.Equal(t, string(BillingEventTypeScaled), newEvent.Type) + assert.Equal(t, tt.item.ProductID, newEvent.ProductID) + assert.Equal(t, tt.expectedValue, newEvent.Value) + assert.Equal(t, tt.item.Unit, newEvent.Unit) + assert.Equal(t, tt.item.ItemDescription, newEvent.ItemDescription) + assert.Equal(t, tt.item.ItemGroupDescription, newEvent.ItemGroupDescription) + assert.Equal(t, string(BillingEventStatePending), newEvent.State) + } else { + assert.Equal(t, initialEventCount, len(tt.billingService.Status.Events), + "expected no new event to be added") + } + }) + } +} + +func TestHandleItemScaling_MultipleItems(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, vshnv1.AddToScheme(scheme)) + + billingService := &vshnv1.BillingService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-ns", + }, + Spec: vshnv1.BillingServiceSpec{ + Odoo: vshnv1.OdooSpec{ + InstanceID: "test-instance", + Items: []vshnv1.ItemSpec{ + {ProductID: "prod-compute", Value: "4", Unit: "instance", ItemDescription: "Compute Item", ItemGroupDescription: "Compute Group"}, + {ProductID: "prod-storage", Value: "100Gi", Unit: "storage", ItemDescription: "Storage Item", ItemGroupDescription: "Storage Group"}, + {ProductID: "prod-backup", Value: "enabled", Unit: "boolean", ItemDescription: "Backup Item", ItemGroupDescription: "Backup Group"}, + }, + }, + }, + Status: vshnv1.BillingServiceStatus{ + Events: []vshnv1.BillingEventStatus{ + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-compute", + Value: "2", + Unit: "instance", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-storage", + Value: "50Gi", + State: string(BillingEventStateSent), + }, + { + Type: string(BillingEventTypeCreated), + ProductID: "prod-backup", + Value: "enabled", + State: string(BillingEventStateSent), + }, + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(billingService). + WithStatusSubresource(billingService). + Build() + + handler := &BillingHandler{ + Client: client, + Scheme: scheme, + maxEvents: 100, + } + + // Scale compute and storage, but not backup + for _, item := range billingService.Spec.Odoo.Items { + err := handler.handleItemScaling(context.Background(), billingService, item) + require.NoError(t, err) + } + + // Should have 5 events: 3 original created + 2 new scaled (compute and storage) + assert.Equal(t, 5, len(billingService.Status.Events)) + + // Verify scaled events were created for compute and storage only + scaledEvents := 0 + for _, event := range billingService.Status.Events { + if event.Type == string(BillingEventTypeScaled) { + scaledEvents++ + assert.True(t, event.ProductID == "prod-compute" || event.ProductID == "prod-storage") + if event.ProductID == "prod-compute" { + assert.Equal(t, "4", event.Value) + } else if event.ProductID == "prod-storage" { + assert.Equal(t, "100Gi", event.Value) + } + } + } + assert.Equal(t, 2, scaledEvents) +} diff --git a/pkg/controller/billing/sla.go b/pkg/controller/billing/sla.go deleted file mode 100644 index 288571428d..0000000000 --- a/pkg/controller/billing/sla.go +++ /dev/null @@ -1,57 +0,0 @@ -package billing - -import ( - "context" - - vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// handleSLAChange enqueues delete and create events if product changed. -func (b *BillingHandler) handleSLAChange(ctx context.Context, billingService *vshnv1.BillingService) error { - currentProductID := billingService.Spec.Odoo.ProductID - currentSize := billingService.Spec.Odoo.Size - - activeProd, activeSize, hasActive := lastActiveSentProduct(billingService) - if hasActive && activeProd == currentProductID { - return nil - } - - if supersedeCreatedForSLA(billingService, currentProductID) { - if err := b.Status().Update(ctx, billingService); err != nil { - return err - } - } - - now := metav1.Now() - - if hasActive && activeProd != currentProductID && !hasEvent(billingService, BillingEventTypeDeleted, activeProd) { - delEvent := vshnv1.BillingEventStatus{ - Type: string(BillingEventTypeDeleted), - ProductID: activeProd, - Size: lastObservedSizeForProduct(billingService, activeProd, activeSize), - Timestamp: now, - State: string(BillingEventStatePending), - RetryCount: 0, - } - if err := enqueueEvent(ctx, b, billingService, delEvent); err != nil { - return err - } - } - - if !hasOpenCreated(billingService, currentProductID) { - createEvent := vshnv1.BillingEventStatus{ - Type: string(BillingEventTypeCreated), - ProductID: currentProductID, - Size: currentSize, - Timestamp: now, - State: string(BillingEventStatePending), - RetryCount: 0, - } - if err := enqueueEvent(ctx, b, billingService, createEvent); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/controller/billing/types.go b/pkg/controller/billing/types.go index 2da3a51308..934650c878 100644 --- a/pkg/controller/billing/types.go +++ b/pkg/controller/billing/types.go @@ -29,9 +29,9 @@ const ( type BillingEventType string const ( - BillingEventTypeCreated BillingEventType = "created" - BillingEventTypeDeleted BillingEventType = "deleted" - BillingEventTypeScaled BillingEventType = "scaled" + BillingEventTypeCreated BillingEventType = "create" + BillingEventTypeDeleted BillingEventType = "delete" + BillingEventTypeScaled BillingEventType = "scale" ) const (