From 4111e931780d1f52068dbba478dc415001dab211 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Fri, 16 Oct 2026 10:41:04 -0300 Subject: [PATCH 1/2] feat(pricing): Convert usage into the pricing unit Meters count raw units (bytes, byte-seconds, millicore-seconds) while ServicePricings price per GiB, GiB-second or vCPU-second, and nothing said how to get from one to the other. Providers billed the per-GiB rate per byte. ServicePricing.spec.unitConversion states how many meter units make one pricing unit, with a required description of why. Offers snapshot it with the rest of the spec. Co-Authored-By: Claude Opus 5.5 --- api/v1alpha1/servicepricing_types.go | 35 ++++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 20 ++++++++ .../api/v1alpha1/servicepricingspec.go | 13 ++++++ .../api/v1alpha1/unitconversion.go | 46 +++++++++++++++++++ applyconfiguration/utils.go | 2 + .../bases/billing.miloapis.com_offers.yaml | 34 ++++++++++++++ .../billing.miloapis.com_servicepricings.yaml | 33 +++++++++++++ docs/emitting-usage.md | 4 +- 8 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 applyconfiguration/api/v1alpha1/unitconversion.go diff --git a/api/v1alpha1/servicepricing_types.go b/api/v1alpha1/servicepricing_types.go index 3f3dc32..9c6477b 100644 --- a/api/v1alpha1/servicepricing_types.go +++ b/api/v1alpha1/servicepricing_types.go @@ -14,6 +14,7 @@ import ( // rather than parsing ServiceConfiguration. // // +kubebuilder:validation:XValidation:rule="self.chargeType != 'Usage' || (has(self.metric) && has(self.pricingUnit) && has(self.rates))",message="Usage ServicePricing requires metric, pricingUnit, and rates" +// +kubebuilder:validation:XValidation:rule="self.chargeType == 'Usage' || !has(self.unitConversion)",message="unitConversion is only allowed for Usage ServicePricing" // +kubebuilder:validation:XValidation:rule="self.chargeType != 'OneTime' || (has(self.amount) && has(self.trigger))",message="OneTime ServicePricing requires amount and trigger" // +kubebuilder:validation:XValidation:rule="self.chargeType != 'Recurring' || (has(self.amount) && has(self.interval))",message="Recurring ServicePricing requires amount and interval" type ServicePricingSpec struct { @@ -59,6 +60,14 @@ type ServicePricingSpec struct { // +kubebuilder:validation:MaxLength=64 PricingUnit string `json:"pricingUnit,omitempty"` + // UnitConversion converts the meter's usage into pricingUnit before the + // rates apply, for a metric measured in a different unit than it is + // priced in (e.g. bytes priced per GiB). Omitted when the metric is + // already measured in pricingUnit. Usage charges only. + // + // +kubebuilder:validation:Optional + UnitConversion *UnitConversion `json:"unitConversion,omitempty"` + // Rates is the ordered list of rate entries for Usage charges. // Required when chargeType is Usage. // @@ -85,6 +94,32 @@ type ServicePricingSpec struct { Interval ChargeInterval `json:"interval,omitempty"` } +// UnitConversion converts usage measured in the meter's unit into the +// ServicePricing's pricingUnit: usage in pricingUnit = metered usage / +// meterUnitsPerPricingUnit. Rates and tier bounds stay in pricingUnit, and +// invoices show the converted quantity. +type UnitConversion struct { + // MeterUnitsPerPricingUnit is how many of the meter's units make one + // pricingUnit, as a positive decimal string (e.g. "1073741824" for a + // meter in bytes priced per GiB, "1000" for millicore-seconds priced + // per vCPU-second). + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=64 + // +kubebuilder:validation:Pattern=`^(0|[1-9]\d*)(\.\d+)?$` + // +kubebuilder:validation:XValidation:rule="!self.matches('^0(\\\\.0+)?$')",message="meterUnitsPerPricingUnit must be greater than zero" + MeterUnitsPerPricingUnit string `json:"meterUnitsPerPricingUnit"` + + // Description explains the conversion to the people who maintain the + // pricing (e.g. "Memory is metered in byte-seconds and priced per + // GiB-second; 1 GiB = 2^30 bytes"). + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=512 + Description string `json:"description"` +} + // ServicePricingStatus defines the observed state of a ServicePricing. type ServicePricingStatus struct { // Conditions represent the latest available observations of the diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 60020eb..275db39 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1633,6 +1633,11 @@ func (in *ServicePricingSnapshot) DeepCopy() *ServicePricingSnapshot { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServicePricingSpec) DeepCopyInto(out *ServicePricingSpec) { *out = *in + if in.UnitConversion != nil { + in, out := &in.UnitConversion, &out.UnitConversion + *out = new(UnitConversion) + **out = **in + } if in.Rates != nil { in, out := &in.Rates, &out.Rates *out = make([]PricingRate, len(*in)) @@ -1688,3 +1693,18 @@ func (in *TaxID) DeepCopy() *TaxID { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UnitConversion) DeepCopyInto(out *UnitConversion) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UnitConversion. +func (in *UnitConversion) DeepCopy() *UnitConversion { + if in == nil { + return nil + } + out := new(UnitConversion) + in.DeepCopyInto(out) + return out +} diff --git a/applyconfiguration/api/v1alpha1/servicepricingspec.go b/applyconfiguration/api/v1alpha1/servicepricingspec.go index ce96b81..eba7b90 100644 --- a/applyconfiguration/api/v1alpha1/servicepricingspec.go +++ b/applyconfiguration/api/v1alpha1/servicepricingspec.go @@ -35,6 +35,11 @@ type ServicePricingSpecApplyConfiguration struct { // "gib"). Required when chargeType is Usage. Does not need to be the // literal UCUM unit string of the meter. PricingUnit *string `json:"pricingUnit,omitempty"` + // UnitConversion converts the meter's usage into pricingUnit before the + // rates apply, for a metric measured in a different unit than it is + // priced in (e.g. bytes priced per GiB). Omitted when the metric is + // already measured in pricingUnit. Usage charges only. + UnitConversion *UnitConversionApplyConfiguration `json:"unitConversion,omitempty"` // Rates is the ordered list of rate entries for Usage charges. // Required when chargeType is Usage. Rates []PricingRateApplyConfiguration `json:"rates,omitempty"` @@ -101,6 +106,14 @@ func (b *ServicePricingSpecApplyConfiguration) WithPricingUnit(value string) *Se return b } +// WithUnitConversion sets the UnitConversion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UnitConversion field is set to the value of the last call. +func (b *ServicePricingSpecApplyConfiguration) WithUnitConversion(value *UnitConversionApplyConfiguration) *ServicePricingSpecApplyConfiguration { + b.UnitConversion = value + return b +} + // WithRates adds the given value to the Rates field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the Rates field. diff --git a/applyconfiguration/api/v1alpha1/unitconversion.go b/applyconfiguration/api/v1alpha1/unitconversion.go new file mode 100644 index 0000000..e4f5262 --- /dev/null +++ b/applyconfiguration/api/v1alpha1/unitconversion.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// UnitConversionApplyConfiguration represents a declarative configuration of the UnitConversion type for use +// with apply. +// +// UnitConversion converts usage measured in the meter's unit into the +// ServicePricing's pricingUnit: usage in pricingUnit = metered usage / +// meterUnitsPerPricingUnit. Rates and tier bounds stay in pricingUnit, and +// invoices show the converted quantity. +type UnitConversionApplyConfiguration struct { + // MeterUnitsPerPricingUnit is how many of the meter's units make one + // pricingUnit, as a positive decimal string (e.g. "1073741824" for a + // meter in bytes priced per GiB, "1000" for millicore-seconds priced + // per vCPU-second). + MeterUnitsPerPricingUnit *string `json:"meterUnitsPerPricingUnit,omitempty"` + // Description explains the conversion to the people who maintain the + // pricing (e.g. "Memory is metered in byte-seconds and priced per + // GiB-second; 1 GiB = 2^30 bytes"). + Description *string `json:"description,omitempty"` +} + +// UnitConversionApplyConfiguration constructs a declarative configuration of the UnitConversion type for use with +// apply. +func UnitConversion() *UnitConversionApplyConfiguration { + return &UnitConversionApplyConfiguration{} +} + +// WithMeterUnitsPerPricingUnit sets the MeterUnitsPerPricingUnit field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MeterUnitsPerPricingUnit field is set to the value of the last call. +func (b *UnitConversionApplyConfiguration) WithMeterUnitsPerPricingUnit(value string) *UnitConversionApplyConfiguration { + b.MeterUnitsPerPricingUnit = &value + return b +} + +// WithDescription sets the Description field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Description field is set to the value of the last call. +func (b *UnitConversionApplyConfiguration) WithDescription(value string) *UnitConversionApplyConfiguration { + b.Description = &value + return b +} diff --git a/applyconfiguration/utils.go b/applyconfiguration/utils.go index 6834fbd..398c1cf 100644 --- a/applyconfiguration/utils.go +++ b/applyconfiguration/utils.go @@ -142,6 +142,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apiv1alpha1.ServicePricingStatusApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("TaxID"): return &apiv1alpha1.TaxIDApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("UnitConversion"): + return &apiv1alpha1.UnitConversionApplyConfiguration{} } return nil diff --git a/config/base/crd/bases/billing.miloapis.com_offers.yaml b/config/base/crd/bases/billing.miloapis.com_offers.yaml index 8b35003..8eb3a29 100644 --- a/config/base/crd/bases/billing.miloapis.com_offers.yaml +++ b/config/base/crd/bases/billing.miloapis.com_offers.yaml @@ -262,6 +262,38 @@ spec: enum: - BillingAccountActivation type: string + unitConversion: + description: |- + UnitConversion converts the meter's usage into pricingUnit before the + rates apply, for a metric measured in a different unit than it is + priced in (e.g. bytes priced per GiB). Omitted when the metric is + already measured in pricingUnit. Usage charges only. + properties: + description: + description: |- + Description explains the conversion to the people who maintain the + pricing (e.g. "Memory is metered in byte-seconds and priced per + GiB-second; 1 GiB = 2^30 bytes"). + maxLength: 512 + minLength: 1 + type: string + meterUnitsPerPricingUnit: + description: |- + MeterUnitsPerPricingUnit is how many of the meter's units make one + pricingUnit, as a positive decimal string (e.g. "1073741824" for a + meter in bytes priced per GiB, "1000" for millicore-seconds priced + per vCPU-second). + maxLength: 64 + pattern: ^(0|[1-9]\d*)(\.\d+)?$ + type: string + x-kubernetes-validations: + - message: meterUnitsPerPricingUnit must be greater + than zero + rule: '!self.matches(''^0(\\.0+)?$'')' + required: + - description + - meterUnitsPerPricingUnit + type: object required: - chargeType - currency @@ -272,6 +304,8 @@ spec: and rates rule: self.chargeType != 'Usage' || (has(self.metric) && has(self.pricingUnit) && has(self.rates)) + - message: unitConversion is only allowed for Usage ServicePricing + rule: self.chargeType == 'Usage' || !has(self.unitConversion) - message: OneTime ServicePricing requires amount and trigger rule: self.chargeType != 'OneTime' || (has(self.amount) && has(self.trigger)) diff --git a/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml b/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml index 6bbc8f0..78a91a8 100644 --- a/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml +++ b/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml @@ -187,6 +187,37 @@ spec: enum: - BillingAccountActivation type: string + unitConversion: + description: |- + UnitConversion converts the meter's usage into pricingUnit before the + rates apply, for a metric measured in a different unit than it is + priced in (e.g. bytes priced per GiB). Omitted when the metric is + already measured in pricingUnit. Usage charges only. + properties: + description: + description: |- + Description explains the conversion to the people who maintain the + pricing (e.g. "Memory is metered in byte-seconds and priced per + GiB-second; 1 GiB = 2^30 bytes"). + maxLength: 512 + minLength: 1 + type: string + meterUnitsPerPricingUnit: + description: |- + MeterUnitsPerPricingUnit is how many of the meter's units make one + pricingUnit, as a positive decimal string (e.g. "1073741824" for a + meter in bytes priced per GiB, "1000" for millicore-seconds priced + per vCPU-second). + maxLength: 64 + pattern: ^(0|[1-9]\d*)(\.\d+)?$ + type: string + x-kubernetes-validations: + - message: meterUnitsPerPricingUnit must be greater than zero + rule: '!self.matches(''^0(\\.0+)?$'')' + required: + - description + - meterUnitsPerPricingUnit + type: object required: - chargeType - currency @@ -196,6 +227,8 @@ spec: - message: Usage ServicePricing requires metric, pricingUnit, and rates rule: self.chargeType != 'Usage' || (has(self.metric) && has(self.pricingUnit) && has(self.rates)) + - message: unitConversion is only allowed for Usage ServicePricing + rule: self.chargeType == 'Usage' || !has(self.unitConversion) - message: OneTime ServicePricing requires amount and trigger rule: self.chargeType != 'OneTime' || (has(self.amount) && has(self.trigger)) - message: Recurring ServicePricing requires amount and interval diff --git a/docs/emitting-usage.md b/docs/emitting-usage.md index d4e6078..2c0c262 100644 --- a/docs/emitting-usage.md +++ b/docs/emitting-usage.md @@ -184,8 +184,8 @@ if err != nil { billing account. The billing controllers attribute project → account using the `BillingAccountBinding` graph at processing time. - **Do not use floating point.** `Quantity` is `int64`. Measure - cpu-*seconds*, not cpu-hours; bytes, not gibibytes. The meter's - `consumedUnit`/`pricingUnit` handle scale. + cpu-*seconds*, not cpu-hours; bytes, not gibibytes. A ServicePricing + priced in a larger unit converts with `unitConversion`. ## Emission contract From 8eaf0a249731c819b36e39e6afbe56eda30c910f Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Fri, 16 Oct 2026 10:44:44 -0300 Subject: [PATCH 2/2] fix(pricing): Check the conversion factor with a pattern, not CEL The Offer CRD repeats the ServicePricing spec in its unbounded servicePricings list, so the regex rule's estimated cost exceeded the CEL budget 8.4x and the API server refused the CRD. A pattern that excludes zero checks the same thing at no CEL cost. Co-Authored-By: Claude Opus 5.5 --- api/v1alpha1/servicepricing_types.go | 3 +-- config/base/crd/bases/billing.miloapis.com_offers.yaml | 6 +----- .../crd/bases/billing.miloapis.com_servicepricings.yaml | 5 +---- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/api/v1alpha1/servicepricing_types.go b/api/v1alpha1/servicepricing_types.go index 9c6477b..e9ee9b5 100644 --- a/api/v1alpha1/servicepricing_types.go +++ b/api/v1alpha1/servicepricing_types.go @@ -106,8 +106,7 @@ type UnitConversion struct { // // +kubebuilder:validation:Required // +kubebuilder:validation:MaxLength=64 - // +kubebuilder:validation:Pattern=`^(0|[1-9]\d*)(\.\d+)?$` - // +kubebuilder:validation:XValidation:rule="!self.matches('^0(\\\\.0+)?$')",message="meterUnitsPerPricingUnit must be greater than zero" + // +kubebuilder:validation:Pattern=`^([1-9]\d*(\.\d+)?|0\.\d*[1-9]\d*)$` MeterUnitsPerPricingUnit string `json:"meterUnitsPerPricingUnit"` // Description explains the conversion to the people who maintain the diff --git a/config/base/crd/bases/billing.miloapis.com_offers.yaml b/config/base/crd/bases/billing.miloapis.com_offers.yaml index 8eb3a29..6fa96e4 100644 --- a/config/base/crd/bases/billing.miloapis.com_offers.yaml +++ b/config/base/crd/bases/billing.miloapis.com_offers.yaml @@ -284,12 +284,8 @@ spec: meter in bytes priced per GiB, "1000" for millicore-seconds priced per vCPU-second). maxLength: 64 - pattern: ^(0|[1-9]\d*)(\.\d+)?$ + pattern: ^([1-9]\d*(\.\d+)?|0\.\d*[1-9]\d*)$ type: string - x-kubernetes-validations: - - message: meterUnitsPerPricingUnit must be greater - than zero - rule: '!self.matches(''^0(\\.0+)?$'')' required: - description - meterUnitsPerPricingUnit diff --git a/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml b/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml index 78a91a8..175c133 100644 --- a/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml +++ b/config/base/crd/bases/billing.miloapis.com_servicepricings.yaml @@ -209,11 +209,8 @@ spec: meter in bytes priced per GiB, "1000" for millicore-seconds priced per vCPU-second). maxLength: 64 - pattern: ^(0|[1-9]\d*)(\.\d+)?$ + pattern: ^([1-9]\d*(\.\d+)?|0\.\d*[1-9]\d*)$ type: string - x-kubernetes-validations: - - message: meterUnitsPerPricingUnit must be greater than zero - rule: '!self.matches(''^0(\\.0+)?$'')' required: - description - meterUnitsPerPricingUnit