diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index f7cf161..29ddb2f 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -459,6 +459,7 @@ class ComputePlanAssignmentInline(admin.TabularInline): "odoo_product_id", "odoo_unit_id", "price", + "unit", "minimum_service_size", "sort_order", "is_active", @@ -509,6 +510,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin): "control_plane_crd", "sla", "price", + "unit", "sort_order", "is_active", ) @@ -545,6 +547,7 @@ class ComputePlanAssignmentAdmin(admin.ModelAdmin): { "fields": ( "price", + "unit", "minimum_service_size", ) }, diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index 659684e..b88f2db 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -69,13 +69,17 @@ class CrdModelFormMixin(FormGeneratorMixin): "spec.parameters.network.serviceType", "spec.parameters.scheduling", "spec.parameters.security", + "spec.publishConnectionDetailsTo", + "spec.resourceRef", + "spec.writeConnectionSecretToRef", + ] + + # Fields populated from compute plan + READONLY_FIELDS = [ "spec.parameters.size.cpu", "spec.parameters.size.memory", "spec.parameters.size.requests.cpu", "spec.parameters.size.requests.memory", - "spec.publishConnectionDetailsTo", - "spec.resourceRef", - "spec.writeConnectionSecretToRef", ] def __init__(self, *args, **kwargs): @@ -88,6 +92,15 @@ class CrdModelFormMixin(FormGeneratorMixin): ): field.widget = forms.HiddenInput() field.required = False + elif name in self.READONLY_FIELDS or any( + name.startswith(f) for f in self.READONLY_FIELDS + ): + field.disabled = True + field.required = False + field.widget.attrs["readonly"] = "readonly" + field.widget.attrs["class"] = ( + field.widget.attrs.get("class", "") + " form-control-plaintext" + ) def strip_title(self, field_name, label): field = self.fields[field_name] diff --git a/src/servala/core/migrations/0016_computeplan_and_more.py b/src/servala/core/migrations/0016_computeplan_and_more.py new file mode 100644 index 0000000..a64bf50 --- /dev/null +++ b/src/servala/core/migrations/0016_computeplan_and_more.py @@ -0,0 +1,309 @@ +# Generated by Django 5.2.8 on 2025-12-02 09:51 + +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +import rules.contrib.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0015_add_hide_expert_mode_to_service_definition"), + ] + + operations = [ + migrations.CreateModel( + name="ComputePlan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ("name", models.CharField(max_length=100, verbose_name="Name")), + ( + "description", + models.TextField(blank=True, verbose_name="Description"), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Whether this plan is available for selection", + verbose_name="Is active", + ), + ), + ( + "memory_requests", + models.CharField( + max_length=20, + verbose_name="Memory requests", + ), + ), + ( + "memory_limits", + models.CharField( + max_length=20, + verbose_name="Memory limits", + ), + ), + ( + "cpu_requests", + models.CharField( + max_length=20, + verbose_name="CPU requests", + ), + ), + ( + "cpu_limits", + models.CharField( + max_length=20, + verbose_name="CPU limits", + ), + ), + ], + options={ + "verbose_name": "Compute Plan", + "verbose_name_plural": "Compute Plans", + "ordering": ["name"], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddField( + model_name="controlplane", + name="storage_plan_odoo_product_id", + field=models.IntegerField( + blank=True, + help_text="ID of the storage product in Odoo", + null=True, + verbose_name="Storage plan Odoo product ID", + ), + ), + migrations.AddField( + model_name="controlplane", + name="storage_plan_odoo_unit_id", + field=models.IntegerField( + blank=True, + help_text="ID of the unit of measure in Odoo (uom.uom)", + null=True, + verbose_name="Storage plan Odoo unit ID", + ), + ), + migrations.AddField( + model_name="controlplane", + name="storage_plan_price_per_gib", + field=models.DecimalField( + blank=True, + decimal_places=2, + help_text="Price per GiB of storage", + max_digits=10, + null=True, + verbose_name="Storage plan price per GiB", + ), + ), + migrations.CreateModel( + name="ComputePlanAssignment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ( + "sla", + models.CharField( + choices=[ + ("besteffort", "Best Effort"), + ("guaranteed", "Guaranteed Availability"), + ], + help_text="Service Level Agreement", + max_length=20, + verbose_name="SLA", + ), + ), + ( + "odoo_product_id", + models.IntegerField( + help_text="ID of the product in Odoo (product.product or product.template)", + verbose_name="Odoo product ID", + ), + ), + ( + "odoo_unit_id", + models.IntegerField( + help_text="ID of the unit of measure in Odoo (uom.uom)", + verbose_name="Odoo unit ID", + ), + ), + ( + "price", + models.DecimalField( + decimal_places=2, + help_text="Price per unit", + max_digits=10, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.00")) + ], + verbose_name="Price", + ), + ), + ( + "minimum_service_size", + models.PositiveIntegerField( + default=1, + help_text="Minimum value for spec.parameters.instances (Guaranteed Availability may require multiple instances)", + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="Minimum service size", + ), + ), + ( + "sort_order", + models.PositiveIntegerField( + default=0, + help_text="Order in which plans are displayed to users", + verbose_name="Sort order", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Whether this plan is available for this CRD", + verbose_name="Is active", + ), + ), + ( + "compute_plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assignments", + to="core.computeplan", + verbose_name="Compute plan", + ), + ), + ( + "control_plane_crd", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="compute_plan_assignments", + to="core.controlplanecrd", + verbose_name="Control plane CRD", + ), + ), + ], + options={ + "verbose_name": "Compute Plan Assignment", + "verbose_name_plural": "Compute Plan Assignments", + "ordering": ["sort_order", "compute_plan__name", "sla"], + "unique_together": {("compute_plan", "control_plane_crd", "sla")}, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddField( + model_name="serviceinstance", + name="compute_plan_assignment", + field=models.ForeignKey( + blank=True, + help_text="Compute plan with SLA for this instance", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="instances", + to="core.computeplanassignment", + verbose_name="Compute plan assignment", + ), + ), + migrations.CreateModel( + name="OdooObjectCache", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ( + "odoo_model", + models.CharField( + help_text="Odoo model name: 'product.product', 'product.template', 'uom.uom', etc.", + max_length=100, + verbose_name="Odoo model", + ), + ), + ( + "odoo_id", + models.PositiveIntegerField( + help_text="ID in the Odoo model", verbose_name="Odoo ID" + ), + ), + ( + "data", + models.JSONField( + help_text="Cached Odoo data including price, reporting_product_id, etc.", + verbose_name="Cached data", + ), + ), + ( + "expires_at", + models.DateTimeField( + blank=True, + help_text="When cache should be refreshed (null = never expires)", + null=True, + verbose_name="Expires at", + ), + ), + ], + options={ + "verbose_name": "Odoo Object Cache", + "verbose_name_plural": "Odoo Object Caches", + "indexes": [ + models.Index( + fields=["odoo_model", "odoo_id"], + name="core_odooob_odoo_mo_51e258_idx", + ), + models.Index( + fields=["expires_at"], name="core_odooob_expires_8fc00b_idx" + ), + ], + "unique_together": {("odoo_model", "odoo_id")}, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py b/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py new file mode 100644 index 0000000..38bfd46 --- /dev/null +++ b/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.8 on 2025-12-02 10:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0016_computeplan_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="computeplanassignment", + name="unit", + field=models.CharField( + choices=[ + ("hour", "Hour"), + ("day", "Day"), + ("month", "Month (30 days)"), + ("year", "Year"), + ], + default="hour", + help_text="Unit for the price (e.g., price per hour)", + max_length=10, + verbose_name="Billing unit", + ), + ), + migrations.AlterField( + model_name="computeplanassignment", + name="odoo_product_id", + field=models.CharField( + help_text="Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')", + max_length=255, + verbose_name="Odoo product ID", + ), + ), + migrations.AlterField( + model_name="computeplanassignment", + name="odoo_unit_id", + field=models.CharField( + max_length=255, + verbose_name="Odoo unit ID", + ), + ), + migrations.AlterField( + model_name="controlplane", + name="storage_plan_odoo_product_id", + field=models.CharField( + blank=True, + help_text="Storage product ID in Odoo", + max_length=255, + null=True, + verbose_name="Storage plan Odoo product ID", + ), + ), + migrations.AlterField( + model_name="controlplane", + name="storage_plan_odoo_unit_id", + field=models.CharField( + blank=True, + help_text="Unit of measure ID in Odoo", + max_length=255, + null=True, + verbose_name="Storage plan Odoo unit ID", + ), + ), + ] diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py index 6fc50e4..0e493af 100644 --- a/src/servala/core/models/plan.py +++ b/src/servala/core/models/plan.py @@ -1,5 +1,6 @@ from decimal import Decimal +from auditlog.registry import auditlog from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ @@ -29,26 +30,21 @@ class ComputePlan(ServalaModelMixin): help_text=_("Whether this plan is available for selection"), ) - # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m") memory_requests = models.CharField( max_length=20, verbose_name=_("Memory requests"), - help_text=_("e.g., '2Gi', '512Mi'"), ) memory_limits = models.CharField( max_length=20, verbose_name=_("Memory limits"), - help_text=_("e.g., '4Gi', '1Gi'"), ) cpu_requests = models.CharField( max_length=20, verbose_name=_("CPU requests"), - help_text=_("e.g., '500m', '1', '2'"), ) cpu_limits = models.CharField( max_length=20, verbose_name=_("CPU limits"), - help_text=_("e.g., '2000m', '2', '4'"), ) class Meta: @@ -60,12 +56,6 @@ class ComputePlan(ServalaModelMixin): return self.name def get_resource_summary(self): - """ - Get a human-readable summary of resources. - - Returns: - String like "2 vCPU, 4Gi RAM" - """ return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" @@ -95,26 +85,23 @@ class ComputePlanAssignment(ServalaModelMixin): related_name="compute_plan_assignments", verbose_name=_("Control plane CRD"), ) - - # Service Level Agreement sla = models.CharField( max_length=20, choices=SLA_CHOICES, verbose_name=_("SLA"), help_text=_("Service Level Agreement"), ) - - # Odoo product reference - odoo_product_id = models.IntegerField( + odoo_product_id = models.CharField( + max_length=255, verbose_name=_("Odoo product ID"), - help_text=_("ID of the product in Odoo (product.product or product.template)"), + help_text=_( + "Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')" + ), ) - odoo_unit_id = models.IntegerField( + odoo_unit_id = models.CharField( + max_length=255, verbose_name=_("Odoo unit ID"), - help_text=_("ID of the unit of measure in Odoo (uom.uom)"), ) - - # Pricing price = models.DecimalField( max_digits=10, decimal_places=2, @@ -123,7 +110,20 @@ class ComputePlanAssignment(ServalaModelMixin): help_text=_("Price per unit"), ) - # Service constraints + BILLING_UNIT_CHOICES = [ + ("hour", _("Hour")), + ("day", _("Day")), + ("month", _("Month (30 days / 720 hours)")), + ("year", _("Year")), + ] + unit = models.CharField( + max_length=10, + choices=BILLING_UNIT_CHOICES, + default="hour", + verbose_name=_("Billing unit"), + help_text=_("Unit for the price (e.g., price per hour)"), + ) + minimum_service_size = models.PositiveIntegerField( default=1, validators=[MinValueValidator(1)], @@ -133,15 +133,11 @@ class ComputePlanAssignment(ServalaModelMixin): "(Guaranteed Availability may require multiple instances)" ), ) - - # Display ordering in UI sort_order = models.PositiveIntegerField( default=0, verbose_name=_("Sort order"), help_text=_("Order in which plans are displayed to users"), ) - - # Allow per-assignment activation is_active = models.BooleanField( default=True, verbose_name=_("Is active"), @@ -158,15 +154,12 @@ class ComputePlanAssignment(ServalaModelMixin): return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" def get_odoo_reporting_product_id(self): - """ - Get the reporting product ID for this plan. - - In the future, this will query Odoo based on invoicing policy. - For now, returns the product ID directly. - - Returns: - The Odoo product ID to use for billing - """ # TODO: Implement Odoo cache lookup when OdooObjectCache is integrated # For now, just return the product ID return self.odoo_product_id + + +auditlog.register(ComputePlan, exclude_fields=["updated_at"], serialize_data=True) +auditlog.register( + ComputePlanAssignment, exclude_fields=["updated_at"], serialize_data=True +) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index d4612c7..3465d54 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,18 +170,19 @@ class ControlPlane(ServalaModelMixin, models.Model): ), ) - # Storage plan configuration (hardcoded per control plane) - storage_plan_odoo_product_id = models.IntegerField( + storage_plan_odoo_product_id = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_("Storage plan Odoo product ID"), - help_text=_("ID of the storage product in Odoo"), + help_text=_("Storage product ID in Odoo"), ) - storage_plan_odoo_unit_id = models.IntegerField( + storage_plan_odoo_unit_id = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_("Storage plan Odoo unit ID"), - help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + help_text=_("Unit of measure ID in Odoo"), ) storage_plan_price_per_gib = models.DecimalField( max_digits=10, @@ -685,6 +686,60 @@ class ServiceInstance(ServalaModelMixin, models.Model): spec_data = prune_empty_data(spec_data) return spec_data + @staticmethod + def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment): + """ + Apply compute plan resource allocations and SLA to spec. + """ + if not compute_plan_assignment: + return spec_data + + compute_plan = compute_plan_assignment.compute_plan + + if "parameters" not in spec_data: + spec_data["parameters"] = {} + if "size" not in spec_data["parameters"]: + spec_data["parameters"]["size"] = {} + if "requests" not in spec_data["parameters"]["size"]: + spec_data["parameters"]["size"]["requests"] = {} + if "service" not in spec_data["parameters"]: + spec_data["parameters"]["service"] = {} + + spec_data["parameters"]["size"]["memory"] = compute_plan.memory_limits + spec_data["parameters"]["size"]["cpu"] = compute_plan.cpu_limits + spec_data["parameters"]["size"]["requests"][ + "memory" + ] = compute_plan.memory_requests + spec_data["parameters"]["size"]["requests"]["cpu"] = compute_plan.cpu_requests + spec_data["parameters"]["service"]["serviceLevel"] = compute_plan_assignment.sla + return spec_data + + @staticmethod + def _build_billing_annotations(compute_plan_assignment, control_plane): + """ + Build Kubernetes annotations for billing integration. + """ + annotations = {} + + if compute_plan_assignment: + annotations["servala.com/erp_product_id_resource"] = str( + compute_plan_assignment.odoo_product_id + ) + annotations["servala.com/erp_unit_id_resource"] = str( + compute_plan_assignment.odoo_unit_id + ) + + if control_plane.storage_plan_odoo_product_id: + annotations["servala.com/erp_product_id_storage"] = str( + control_plane.storage_plan_odoo_product_id + ) + if control_plane.storage_plan_odoo_unit_id: + annotations["servala.com/erp_unit_id_storage"] = str( + control_plane.storage_plan_odoo_unit_id + ) + + return annotations + @classmethod def _format_kubernetes_error(cls, error_message): if not error_message: @@ -739,7 +794,15 @@ class ServiceInstance(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_instance(cls, name, organization, context, created_by, spec_data): + def create_instance( + cls, + name, + organization, + context, + created_by, + spec_data, + compute_plan_assignment=None, + ): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) try: @@ -748,6 +811,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): organization=organization, created_by=created_by, context=context, + compute_plan_assignment=compute_plan_assignment, ) except IntegrityError: message = _( @@ -758,6 +822,11 @@ class ServiceInstance(ServalaModelMixin, models.Model): try: spec_data = cls._prepare_spec_data(spec_data) + if compute_plan_assignment: + spec_data = cls._apply_compute_plan_to_spec( + spec_data, compute_plan_assignment + ) + if "writeConnectionSecretToRef" not in spec_data: spec_data["writeConnectionSecretToRef"] = {} @@ -775,6 +844,13 @@ class ServiceInstance(ServalaModelMixin, models.Model): }, "spec": spec_data, } + + annotations = cls._build_billing_annotations( + compute_plan_assignment, context.control_plane + ) + if annotations: + create_data["metadata"]["annotations"] = annotations + if label := context.control_plane.required_label: create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label} api_instance = context.control_plane.custom_objects_api @@ -812,12 +888,23 @@ class ServiceInstance(ServalaModelMixin, models.Model): raise ValidationError(organization.add_support_message(message)) return instance - def update_spec(self, spec_data, updated_by): + def update_spec(self, spec_data, updated_by, compute_plan_assignment=None): try: spec_data = self._prepare_spec_data(spec_data) + + plan_to_use = compute_plan_assignment or self.compute_plan_assignment + if plan_to_use: + spec_data = self._apply_compute_plan_to_spec(spec_data, plan_to_use) + api_instance = self.context.control_plane.custom_objects_api patch_body = {"spec": spec_data} + annotations = self._build_billing_annotations( + plan_to_use, self.context.control_plane + ) + if annotations: + patch_body["metadata"] = {"annotations": annotations} + api_instance.patch_namespaced_custom_object( group=self.context.group, version=self.context.version, @@ -827,7 +914,14 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=patch_body, ) self._clear_kubernetes_caches() - self.save() # Updates updated_at timestamp + + if ( + compute_plan_assignment + and compute_plan_assignment != self.compute_plan_assignment + ): + self.compute_plan_assignment = compute_plan_assignment + # Saving to update updated_at timestamp even if nothing was visibly changed + self.save() except ApiException as e: if e.status == 404: message = _( diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 23325f3..169d6ea 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from servala.core.models import ( CloudProvider, + ComputePlanAssignment, ControlPlane, Service, ServiceCategory, @@ -56,6 +57,34 @@ class ControlPlaneSelectForm(forms.Form): self.fields["control_plane"].initial = planes.first() +class ComputePlanSelectionForm(forms.Form): + compute_plan_assignment = forms.ModelChoiceField( + queryset=ComputePlanAssignment.objects.none(), + widget=forms.RadioSelect, + required=True, + label=_("Compute Plan"), + empty_label=None, + ) + + def __init__(self, *args, control_plane_crd=None, **kwargs): + super().__init__(*args, **kwargs) + if control_plane_crd: + self.fields["compute_plan_assignment"].queryset = ( + ComputePlanAssignment.objects.filter( + control_plane_crd=control_plane_crd, is_active=True + ) + .select_related("compute_plan") + .order_by("sort_order", "compute_plan__name", "sla") + ) + if ( + not self.is_bound + and self.fields["compute_plan_assignment"].queryset.exists() + ): + self.fields["compute_plan_assignment"].initial = self.fields[ + "compute_plan_assignment" + ].queryset.first() + + class ServiceInstanceFilterForm(forms.Form): name = forms.CharField(required=False, label=_("Name")) service = forms.ModelChoiceField( diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index 948a2df..5c72de6 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -51,6 +51,29 @@