diff --git a/.env.example b/.env.example index 63df700..9e31fbd 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,7 @@ SERVALA_ODOO_HELPDESK_TEAM_ID='5' # OSB API authentication settings SERVALA_OSB_USERNAME='' SERVALA_OSB_PASSWORD='' + +# Prefix for auto-generated Kubernetes resource names for service instances. +# Format: {prefix}-{hash}. Defaults to 'si' (service instance). +SERVALA_INSTANCE_NAME_PREFIX='si' diff --git a/README.md b/README.md index eaa1cdd..f435a90 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,32 @@ Then access it with http://localhost:8080/ and the Django admin with http://loca Deployment files are in the `deployment/kustomize` folder and makes use of [Kustomize](https://kustomize.io/) to account for differences between the deployment stages. Stages are configured with overlays in `deployment/kustomize/overlays/$environment`. +### Resource Requests and Limits + +Resources are configured to comply with APPUiO Cloud's memory-to-CPU ratio of 4096 MiB/core. +See [APPUiO Cloud documentation](https://docs.appuio.cloud/user/how-to/check-cpu-requests.html) for details. + +**Production:** + +| Container | CPU Request | Memory Request | CPU Limit | Memory Limit | +|-----------|-------------|----------------|-----------|--------------| +| servala | 500m | 2Gi | 2 | 4Gi | + +**Staging:** + +| Container | CPU Request | Memory Request | CPU Limit | Memory Limit | +|------------------|-------------|----------------|-----------|--------------| +| servala | 250m | 1Gi | 1 | 2Gi | +| ssh-tunnel-dev | 50m | 204Mi | 100m | 256Mi | +| ssh-tunnel-talos | 50m | 204Mi | 100m | 256Mi | + +**Ratio Calculation:** + +The ratio is calculated as: `Sum of Memory Requests / Sum of CPU Requests` + +- Production: 2048 MiB / 0.5 cores = **4096 MiB/core** ✓ +- Staging: (1024 + 204 + 204) MiB / (0.25 + 0.05 + 0.05) cores = 1432 MiB / 0.35 cores = **4091 MiB/core** ✓ + ### Staging The code is automatically built and deployed on a push to the main branch. diff --git a/deployment/kustomize/base/portal/deployment.yaml b/deployment/kustomize/base/portal/deployment.yaml index 03deb98..1b33aae 100644 --- a/deployment/kustomize/base/portal/deployment.yaml +++ b/deployment/kustomize/base/portal/deployment.yaml @@ -21,6 +21,13 @@ spec: - name: servala image: servala.app.codey.ch/servala/servala-portal:latest imagePullPolicy: Always + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + cpu: 2 + memory: 4Gi ports: - name: http containerPort: 8080 diff --git a/deployment/kustomize/overlays/production/portal-deployment.yaml b/deployment/kustomize/overlays/production/portal-deployment.yaml index 47574f4..bc6050f 100644 --- a/deployment/kustomize/overlays/production/portal-deployment.yaml +++ b/deployment/kustomize/overlays/production/portal-deployment.yaml @@ -32,10 +32,3 @@ spec: secretKeyRef: name: portal-storage-creds key: AWS_SECRET_ACCESS_KEY - resources: - limits: - cpu: 2 - memory: 2Gi - requests: - cpu: 500m - memory: 512Mi diff --git a/deployment/kustomize/overlays/staging/portal-deployment.yaml b/deployment/kustomize/overlays/staging/portal-deployment.yaml index 0c13f2d..e978ccd 100644 --- a/deployment/kustomize/overlays/staging/portal-deployment.yaml +++ b/deployment/kustomize/overlays/staging/portal-deployment.yaml @@ -7,6 +7,13 @@ spec: spec: containers: - name: servala + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + cpu: 1 + memory: 2Gi env: - name: SERVALA_ENVIRONMENT value: staging @@ -32,8 +39,15 @@ spec: secretKeyRef: name: portal-storage-creds key: AWS_SECRET_ACCESS_KEY - - name: ssh-tunnel + - name: ssh-tunnel-dev image: servala.app.codey.ch/servala/servala-portal:latest + resources: + requests: + cpu: 50m + memory: 204Mi + limits: + cpu: 100m + memory: 256Mi command: - "/bin/bash" - "-c" @@ -41,13 +55,40 @@ spec: mkdir -p /app/.ssh && chmod 700 /app/.ssh echo "$SSH_PRIVATE_KEY" > /app/.ssh/id chmod 600 /app/.ssh/id - ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 8443:127.0.0.1:8443 -N -i /app/.ssh/id -v + ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 6443:127.0.0.1:6443 -N -i /app/.ssh/id -v env: - name: SSH_HOST - valueFrom: - secretKeyRef: - name: servala-sshclient - key: ssh-host + value: "78.47.176.209" + - name: SSH_USER + valueFrom: + secretKeyRef: + name: servala-sshclient + key: ssh-user + - name: SSH_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: servala-sshclient + key: ssh-private-key + - name: ssh-tunnel-talos + image: servala.app.codey.ch/servala/servala-portal:latest + resources: + requests: + cpu: 50m + memory: 204Mi + limits: + cpu: 100m + memory: 256Mi + command: + - "/bin/bash" + - "-c" + - | + mkdir -p /app/.ssh && chmod 700 /app/.ssh + echo "$SSH_PRIVATE_KEY" > /app/.ssh/id + chmod 600 /app/.ssh/id + ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 6444:172.18.200.10:6443 -N -i /app/.ssh/id -v + env: + - name: SSH_HOST + value: mgmt.cls-rma1-9c02.servala.com - name: SSH_USER valueFrom: secretKeyRef: diff --git a/pyproject.toml b/pyproject.toml index 0e895cc..bb254a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,15 +28,15 @@ dependencies = [ [dependency-groups] dev = [ - "black>=25.11.0", + "black>=25.12.0", "bumpver>=2025.1131", - "coverage>=7.12.0", + "coverage>=7.13.0", "djlint>=1.36.4", "flake8>=7.3.0", "flake8-bugbear>=25.11.29", "flake8-pyproject>=1.2.4", "isort>=7.0.0", - "pytest>=9.0.1", + "pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-django>=4.11.1", "pytest-mock>=3.15.1", diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 9b0082d..0bca7a7 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -301,7 +301,7 @@ The Servala Team""" "core_serviceinstance_change", service_instance.pk ) description_parts.append( - f"Instance: {service_instance.name} - {instance_url}" + f"Instance: {service_instance.display_name} ({service_instance.name}) - {instance_url}" ) else: description_parts.append(f"Instance: {instance_id}") diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index 18df8bc..6bde2c9 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -9,17 +9,17 @@ from servala.core.models import ControlPlaneCRD from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon # Fields that must be present in every form -MANDATORY_FIELDS = ["name"] +MANDATORY_FIELDS = ["display_name"] # Default field configurations - fields that can be included with just a mapping # to avoid administrators having to duplicate common information DEFAULT_FIELD_CONFIGS = { - "name": { + "display_name": { "type": "text", "label": "Instance Name", - "help_text": "Unique name for the new instance", + "help_text": "", "required": True, - "max_length": 63, + "max_length": 100, }, "spec.parameters.service.fqdn": { "type": "array", @@ -51,11 +51,6 @@ class FormGeneratorMixin: crd = getattr(crd, "pk", crd) # can be int or object self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd) - if self.instance and hasattr(self.instance, "name") and self.instance.name: - if "name" in self.fields: - self.fields["name"].disabled = True - self.fields["name"].widget = forms.HiddenInput() - def has_mandatory_fields(self, field_list): for field_name in field_list: if field_name in self.fields and self.fields[field_name].required: @@ -307,15 +302,6 @@ class CustomFormMixin(FormGeneratorMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._apply_field_config() - if ( - self.instance - and hasattr(self.instance, "name") - and self.instance.name - and "name" in self.fields - ): - self.fields["name"].widget = forms.HiddenInput() - self.fields["name"].disabled = True - self.fields.pop("context", None) def _apply_field_config(self): for fieldset in self.form_config.get("fieldsets", []): @@ -472,7 +458,7 @@ def generate_custom_form_class(form_config, model): """ Generate a custom (user-friendly) form class from form_config JSON. """ - field_list = ["context", "name"] + field_list = ["context", "display_name"] for fieldset in form_config.get("fieldsets", []): for field_config in fieldset.get("fields", []): diff --git a/src/servala/core/crd/models.py b/src/servala/core/crd/models.py index 86df97f..a4fcc28 100644 --- a/src/servala/core/crd/models.py +++ b/src/servala/core/crd/models.py @@ -23,9 +23,9 @@ def generate_django_model(schema, group, version, kind): """ Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. """ - # We always need these three fields to know our own name and our full namespace + # We always need these fields to know our display name and our full namespace model_fields = {"__module__": "crd_models"} - for field_name in ("name", "context"): + for field_name in ("display_name", "context"): model_fields[field_name] = duplicate_field(field_name, ServiceInstance) # All other fields are generated from the schema, except for the diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py index 090abba..3bad850 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -254,7 +254,7 @@ class ServiceDefinitionAdminForm(forms.ModelForm): if not schema or not (spec_schema := schema.get("properties", {}).get("spec")): return - valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"} + valid_paths = self._extract_field_paths(spec_schema, "spec") | {"display_name"} included_mappings = set() errors = [] for fieldset in form_config.get("fieldsets", []): diff --git a/src/servala/core/management/commands/sync_billing_metadata.py b/src/servala/core/management/commands/sync_billing_metadata.py index 2093948..ad1d74b 100644 --- a/src/servala/core/management/commands/sync_billing_metadata.py +++ b/src/servala/core/management/commands/sync_billing_metadata.py @@ -195,6 +195,7 @@ class Command(BaseCommand): compute_plan_assignment=instance.compute_plan_assignment, control_plane=instance.context.control_plane, instance_name=instance.name, + display_name=instance.display_name, organization=instance.organization, service=instance.context.service_offering.service, ) diff --git a/src/servala/core/migrations/0019_add_display_name.py b/src/servala/core/migrations/0019_add_display_name.py new file mode 100644 index 0000000..4ff3b92 --- /dev/null +++ b/src/servala/core/migrations/0019_add_display_name.py @@ -0,0 +1,46 @@ +from django.db import migrations, models + +import servala.core.validators + + +def populate_display_name(apps, schema_editor): + """ + For existing instances, copy name to display_name. + Existing instances already have their name matching the k8s resource, + so we cannot add a prefix. + """ + ServiceInstance = apps.get_model("core", "ServiceInstance") + for instance in ServiceInstance.objects.all(): + instance.display_name = instance.name + instance.save(update_fields=["display_name"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0018_add_invoice_grouping_to_organization_origin"), + ] + + operations = [ + migrations.AlterField( + model_name="serviceinstance", + name="name", + field=models.CharField( + max_length=63, + validators=[servala.core.validators.kubernetes_name_validator], + verbose_name="Instance ID", + ), + ), + # Add display_name field with a temporary default + migrations.AddField( + model_name="serviceinstance", + name="display_name", + field=models.CharField( + default="", + help_text="Display name for this instance", + max_length=100, + verbose_name="Name", + ), + preserve_default=False, + ), + migrations.RunPython(populate_display_name, migrations.RunPython.noop), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index e973235..31527c5 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,4 +1,5 @@ import copy +import hashlib import html import json import re @@ -615,8 +616,16 @@ class ServiceInstance(ServalaModelMixin, models.Model): on the fly. """ + # The Kubernetes resource name (metadata.name). This field is immutable after + # creation and is auto-generated for new instances. Do not modify directly! name = models.CharField( - max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] + max_length=63, + verbose_name=_("Instance ID"), + validators=[kubernetes_name_validator], + ) + display_name = models.CharField( + max_length=100, + verbose_name=_("Name"), ) organization = models.ForeignKey( to="core.Organization", @@ -686,6 +695,24 @@ class ServiceInstance(ServalaModelMixin, models.Model): spec_data = prune_empty_data(spec_data) return spec_data + @staticmethod + def generate_resource_name(organization, display_name, service, attempt=0): + """ + Generate a unique Kubernetes-compatible resource name. + + Format: {prefix}-{sha256[:8]} + + The hash input is: org_slug:display_name:service_slug[:attempt if > 0] + On collision, we retry with an incremented attempt number included in hash. + """ + hash_input = ( + f"{organization.slug}:{display_name.lower().strip()}:{service.slug}" + ) + if attempt > 0: + hash_input += f":{attempt}" + hash_value = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:8] + return f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-{hash_value}" + @staticmethod def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment): """ @@ -719,16 +746,20 @@ class ServiceInstance(ServalaModelMixin, models.Model): compute_plan_assignment, control_plane, instance_name=None, + display_name=None, organization=None, service=None, ): """ - Build Kubernetes annotations for billing integration. + Build Kubernetes annotations for billing integration and display name. """ from servala.core.models.organization import InvoiceGroupingChoice annotations = {} + if display_name: + annotations["servala.com/displayName"] = display_name + if compute_plan_assignment: annotations["servala.com/erp_product_id_resource"] = str( compute_plan_assignment.odoo_product_id @@ -830,18 +861,35 @@ class ServiceInstance(ServalaModelMixin, models.Model): @transaction.atomic def create_instance( cls, - name, + display_name, organization, context, created_by, spec_data, compute_plan_assignment=None, ): + service = context.service_offering.service + name = None + for attempt in range(10): + name = cls.generate_resource_name( + organization, display_name, service, attempt + ) + if not cls.objects.filter( + name=name, organization=organization, context=context + ).exists(): + break + else: + message = _( + "Could not generate a unique resource name. Please try a different display name." + ) + raise ValidationError(organization.add_support_message(message)) + # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) try: instance = cls.objects.create( name=name, + display_name=display_name, organization=organization, created_by=created_by, context=context, @@ -883,8 +931,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): compute_plan_assignment=compute_plan_assignment, control_plane=context.control_plane, instance_name=name, + display_name=display_name, organization=organization, - service=context.service_offering.service, + service=service, ) if annotations: create_data["metadata"]["annotations"] = annotations @@ -941,6 +990,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): compute_plan_assignment=plan_to_use, control_plane=self.context.control_plane, instance_name=self.name, + display_name=self.display_name, organization=self.organization, service=self.context.service_offering.service, ) @@ -1065,7 +1115,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): if not self.context.django_model: return return self.context.django_model( - name=self.name, + display_name=self.display_name, context=self.context, spec=self.spec, # We pass -1 as ID in order to make it clear that a) this object exists (remotely), diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 169d6ea..26c9c70 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -123,7 +123,7 @@ class ServiceInstanceFilterForm(forms.Form): class ServiceInstanceDeleteForm(forms.ModelForm): name = forms.CharField( - label=_("Instance Name"), + label=_("Instance ID"), max_length=63, widget=forms.TextInput(attrs={"class": "form-control"}), ) @@ -132,7 +132,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm): kwargs["initial"] = {"name": ""} super().__init__(*args, **kwargs) self.fields["name"].help_text = _( - "To confirm deletion, please type the instance name: {instance_name}" + "To confirm deletion, please type the instance ID: {instance_name}" ).format(instance_name=self.instance.name) def clean_name(self): @@ -140,7 +140,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm): if entered_name != self.instance.name: raise forms.ValidationError( _( - "The entered name does not match the instance name. Deletion not confirmed." + "The entered name does not match the instance ID. Deletion not confirmed." ) ) return entered_name diff --git a/src/servala/frontend/templates/frontend/forms/errors.html b/src/servala/frontend/templates/frontend/forms/errors.html index 964687d..1d31737 100644 --- a/src/servala/frontend/templates/frontend/forms/errors.html +++ b/src/servala/frontend/templates/frontend/forms/errors.html @@ -11,7 +11,7 @@ {{ form.non_field_errors.0 }} {% endif %} {% else %} - {% translate "We could not save your changes." %} + {% translate "Please review and correct the errors highlighted in the form below." %} {% endif %} diff --git a/src/servala/frontend/templates/frontend/organizations/dashboard.html b/src/servala/frontend/templates/frontend/organizations/dashboard.html index 441d0be..9a309f1 100644 --- a/src/servala/frontend/templates/frontend/organizations/dashboard.html +++ b/src/servala/frontend/templates/frontend/organizations/dashboard.html @@ -96,7 +96,7 @@ {{ instance.name }} + class="fw-semibold text-decoration-none">{{ instance.display_name }}
diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_create.html similarity index 98% rename from src/servala/frontend/templates/frontend/organizations/service_offering_detail.html rename to src/servala/frontend/templates/frontend/organizations/service_instance_create.html index 39b69a8..3c41ddf 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_create.html @@ -26,7 +26,8 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %} + {% translate "Create" as create_label %} + {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form form_submit_label=create_label hide_form_errors=True %} {% endif %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html b/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html index 5296c56..3129814 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html @@ -7,7 +7,7 @@ {% csrf_token %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instances.html b/src/servala/frontend/templates/frontend/organizations/service_instances.html index c443e6e..20c337e 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instances.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instances.html @@ -23,6 +23,7 @@ {% translate "Name" %} + {% translate "Instance ID" %} {% translate "Service" %} {% translate "Service Provider" %} {% translate "Service Provider Zone" %} @@ -33,7 +34,10 @@ {% for instance in instances %} - {{ instance.name }} + {{ instance.display_name }} + + + {{ instance.name }} {{ instance.context.service_definition.service.name }} {{ instance.context.service_offering.provider.name }} @@ -42,7 +46,7 @@ {% empty %} - {% translate "No service instances found." %} + {% translate "No service instances found." %} {% endfor %} diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index f54b1ae..c72976a 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,7 +1,9 @@ {% load i18n %} {% load get_field %} {% load static %} -{% include "frontend/forms/errors.html" %} +{% if not hide_form_errors %} + {% include "frontend/forms/errors.html" %} +{% endif %} {% if form and expert_form and not hide_expert_mode %}
/offering//", - views.ServiceOfferingDetailView.as_view(), - name="organization.offering", + views.ServiceInstanceCreateView.as_view(), + name="organization.instance.create", ), path( "", diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 33b0560..3307754 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -16,12 +16,12 @@ from .organization import ( ) from .service import ( ServiceDetailView, + ServiceInstanceCreateView, ServiceInstanceDeleteView, ServiceInstanceDetailView, ServiceInstanceListView, ServiceInstanceUpdateView, ServiceListView, - ServiceOfferingDetailView, ) from .support import SupportView @@ -35,12 +35,12 @@ __all__ = [ "OrganizationSelectionView", "OrganizationUpdateView", "ServiceDetailView", + "ServiceInstanceCreateView", "ServiceInstanceDeleteView", "ServiceInstanceDetailView", "ServiceInstanceListView", "ServiceInstanceUpdateView", "ServiceListView", - "ServiceOfferingDetailView", "ProfileView", "SupportView", "custom_404", diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index fe439c6..2a858dc 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -83,7 +83,7 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): if self.visible_offerings.count() == 1: offering = self.visible_offerings.first() return redirect( - "frontend:organization.offering", + "frontend:organization.instance.create", organization=self.request.organization.slug, slug=self.object.slug, pk=offering.pk, @@ -97,8 +97,8 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): return context -class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView): - template_name = "frontend/organizations/service_offering_detail.html" +class ServiceInstanceCreateView(OrganizationViewMixin, HtmxViewMixin, DetailView): + template_name = "frontend/organizations/service_instance_create.html" context_object_name = "offering" model = ServiceOffering permission_type = "view" @@ -276,7 +276,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView try: service_instance = ServiceInstance.create_instance( organization=self.request.organization, - name=form.cleaned_data["name"], + display_name=form.cleaned_data["display_name"], context=self.context_object, created_by=request.user, spec_data=form.get_nested_data().get("spec"), @@ -762,6 +762,9 @@ class ServiceInstanceUpdateView( current_spec = dict(self.object.spec) if self.object.spec else {} spec_data = self._deep_merge(current_spec, spec_data) + if display_name := form_data.get("display_name"): + self.object.display_name = display_name + compute_plan_assignment = None if self.plan_form.is_valid(): compute_plan_assignment = self.plan_form.cleaned_data.get( @@ -776,7 +779,7 @@ class ServiceInstanceUpdateView( messages.success( self.request, _("Service instance '{name}' updated successfully.").format( - name=self.object.name + name=self.object.display_name ), ) return redirect(self.object.urls.base) @@ -837,7 +840,7 @@ class ServiceInstanceDeleteView( messages.success( self.request, _("Service instance '{name}' has been scheduled for deletion.").format( - name=self.object.name + name=self.object.display_name ), ) response = HttpResponse() @@ -848,7 +851,7 @@ class ServiceInstanceDeleteView( self.request, self.organization.add_support_message( _( - f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}." + f"An error occurred while trying to delete instance '{self.object.display_name}': {str(e)}." ) ), ) diff --git a/src/servala/settings.py b/src/servala/settings.py index f57ac0d..1319394 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -270,6 +270,9 @@ SESSION_COOKIE_SECURE = not DEBUG DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Prefix for auto-generated Kubernetes resource names for service instances +SERVALA_INSTANCE_NAME_PREFIX = os.environ.get("SERVALA_INSTANCE_NAME_PREFIX", "si") + # TODO TIME_ZONE = "UTC" diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index e775ebc..86964c4 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -1,6 +1,6 @@ const initializeFqdnGeneration = (prefix) => { - const nameField = document.querySelector(`input#id_${prefix}-name`); + const nameField = document.querySelector(`input#id_${prefix}-display_name`); if (!nameField) return // Try to find array input first (DynamicArrayWidget), then fallback to regular text input @@ -23,9 +23,19 @@ const initializeFqdnGeneration = (prefix) => { if (!fqdnField) return if (nameField && fqdnField) { + const sanitizeForFqdn = (name) => { + return name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens + .replace(/-+/g, '-') // Collapse multiple hyphens + .replace(/^-|-$/g, ''); // Trim hyphens from start/end + } + const generateFqdn = (instanceName) => { if (!instanceName) return ''; - return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; + const sanitized = sanitizeForFqdn(instanceName); + if (!sanitized) return '' + return `${sanitized}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; } nameField.addEventListener('input', function() { diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 859b4ca..298eb42 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -18,6 +18,7 @@ from servala.core.models.service import ( Service, ServiceCategory, ServiceDefinition, + ServiceInstance, ServiceOffering, ) @@ -44,15 +45,38 @@ def other_organization(origin): @pytest.fixture def org_owner(organization): - user = User.objects.create(email="user@example.org", password="example") + owner = User.objects.create(email="owner@example.org", password="example") OrganizationMembership.objects.create( - organization=organization, user=user, role="owner" + organization=organization, user=owner, role="owner" ) - return user + return owner @pytest.fixture -def test_service_category(): +def org_admin(organization): + admin = User.objects.create(email="admin@example.org", password="example") + OrganizationMembership.objects.create( + organization=organization, user=admin, role="admin" + ) + return admin + + +@pytest.fixture +def org_member(organization): + member = User.objects.create(email="member@example.org", password="example") + OrganizationMembership.objects.create( + organization=organization, user=member, role="member" + ) + return member + + +@pytest.fixture +def user(): + return User.objects.create(email="generic-user@example.org", password="example") + + +@pytest.fixture +def service_category(): return ServiceCategory.objects.create( name="Databases", description="Database services", @@ -60,18 +84,18 @@ def test_service_category(): @pytest.fixture -def test_service(test_service_category): +def service(service_category): return Service.objects.create( name="Redis", slug="redis", - category=test_service_category, + category=service_category, description="Redis database service", osb_service_id="test-service-123", ) @pytest.fixture -def test_cloud_provider(): +def cloud_provider(): return CloudProvider.objects.create( name="Exoscale", description="Exoscale cloud provider", @@ -79,10 +103,10 @@ def test_cloud_provider(): @pytest.fixture -def test_service_offering(test_service, test_cloud_provider): +def service_offering(service, cloud_provider): return ServiceOffering.objects.create( - service=test_service, - provider=test_cloud_provider, + service=service, + provider=cloud_provider, description="Redis on Exoscale", osb_plan_id="test-plan-123", ) @@ -125,11 +149,11 @@ def mock_odoo_failure(mocker): @pytest.fixture -def test_control_plane(test_cloud_provider): +def control_plane(cloud_provider): return ControlPlane.objects.create( name="Geneva (CH-GVA-2)", description="Geneva control plane", - cloud_provider=test_cloud_provider, + cloud_provider=cloud_provider, api_credentials={ "server": "https://k8s.example.com", "token": "test-token", @@ -139,10 +163,10 @@ def test_control_plane(test_cloud_provider): @pytest.fixture -def test_service_definition(test_service): +def service_definition(service): return ServiceDefinition.objects.create( name="Redis Standard", - service=test_service, + service=service, api_definition={ "group": "vshn.appcat.vshn.io", "version": "v1", @@ -152,13 +176,11 @@ def test_service_definition(test_service): @pytest.fixture -def test_control_plane_crd( - test_service_offering, test_control_plane, test_service_definition -): +def control_plane_crd(service_offering, control_plane, service_definition): return ControlPlaneCRD.objects.create( - service_offering=test_service_offering, - control_plane=test_control_plane, - service_definition=test_service_definition, + service_offering=service_offering, + control_plane=control_plane, + service_definition=service_definition, ) @@ -176,10 +198,10 @@ def compute_plan(): @pytest.fixture -def compute_plan_assignment(compute_plan, test_control_plane_crd): +def compute_plan_assignment(compute_plan, control_plane_crd): return ComputePlanAssignment.objects.create( compute_plan=compute_plan, - control_plane_crd=test_control_plane_crd, + control_plane_crd=control_plane_crd, sla="besteffort", odoo_product_id="test-product-id", odoo_unit_id="test-unit-id", @@ -187,3 +209,30 @@ def compute_plan_assignment(compute_plan, test_control_plane_crd): unit="hour", is_active=True, ) + + +@pytest.fixture +def service_instance(organization, control_plane_crd): + return ServiceInstance.objects.create( + name="test-abc12345", + display_name="My Test Instance", + organization=organization, + context=control_plane_crd, + ) + + +@pytest.fixture +def control_plane_with_storage(cloud_provider): + return ControlPlane.objects.create( + name="Storage Zone", + description="Zone with storage billing", + cloud_provider=cloud_provider, + api_credentials={ + "server": "https://k8s.example.com", + "token": "test-token", + "certificate-authority-data": "test-ca-data", + }, + storage_plan_odoo_product_id="storage-product-123", + storage_plan_odoo_unit_id="storage-unit-456", + storage_plan_price_per_gib="0.10", + ) diff --git a/src/tests/test_api_exoscale.py b/src/tests/test_api_exoscale.py index 37542e5..4362748 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -55,14 +55,14 @@ def valid_osb_payload(): def test_successful_onboarding_new_organization( mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, exoscale_origin, instance_id, ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id response = osb_client.put( f"/api/osb/v2/service_instances/{instance_id}", @@ -107,15 +107,15 @@ def test_successful_onboarding_new_organization( @pytest.mark.django_db def test_new_organization_inherits_origin( osb_client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, exoscale_origin, instance_id, billing_entity, ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id exoscale_origin.billing_entity = billing_entity exoscale_origin.save() @@ -137,8 +137,8 @@ def test_new_organization_inherits_origin( @pytest.mark.django_db def test_duplicate_organization_returns_existing( osb_client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, exoscale_origin, instance_id, @@ -148,10 +148,10 @@ def test_duplicate_organization_returns_existing( osb_guid="test-org-guid-123", origin=exoscale_origin, ) - org.limit_osb_services.add(test_service) + org.limit_osb_services.add(service) - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id response = osb_client.put( f"/api/osb/v2/service_instances/{instance_id}", @@ -169,13 +169,13 @@ def test_duplicate_organization_returns_existing( @pytest.mark.django_db def test_unauthenticated_osb_api_request_fails( client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, instance_id, ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id response = client.put( f"/api/osb/v2/service_instances/{instance_id}", @@ -205,15 +205,15 @@ def test_unauthenticated_osb_api_request_fails( ) def test_missing_required_fields_error( osb_client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, field_to_remove, expected_error, instance_id, ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id if isinstance(field_to_remove, tuple): if field_to_remove[0] == "context": @@ -251,10 +251,8 @@ def test_invalid_service_id_error(osb_client, valid_osb_payload, instance_id): @pytest.mark.django_db -def test_invalid_plan_id_error( - osb_client, test_service, valid_osb_payload, instance_id -): - valid_osb_payload["service_id"] = test_service.osb_service_id +def test_invalid_plan_id_error(osb_client, service, valid_osb_payload, instance_id): + valid_osb_payload["service_id"] = service.osb_service_id valid_osb_payload["plan_id"] = 99999 response = osb_client.put( @@ -266,17 +264,17 @@ def test_invalid_plan_id_error( assert response.status_code == 400 response_data = json.loads(response.content) assert ( - f"Unknown plan_id: 99999 for service_id: {test_service.osb_service_id}" + f"Unknown plan_id: 99999 for service_id: {service.osb_service_id}" in response_data["error"] ) @pytest.mark.django_db def test_empty_users_array_error( - osb_client, test_service, test_service_offering, valid_osb_payload, instance_id + osb_client, service, service_offering, valid_osb_payload, instance_id ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id valid_osb_payload["parameters"]["users"] = [] response = osb_client.put( @@ -292,10 +290,10 @@ def test_empty_users_array_error( @pytest.mark.django_db def test_multiple_users_error( - osb_client, test_service, test_service_offering, valid_osb_payload, instance_id + osb_client, service, service_offering, valid_osb_payload, instance_id ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id valid_osb_payload["parameters"]["users"] = [ {"email": "user1@example.com", "full_name": "User One"}, {"email": "user2@example.com", "full_name": "User Two"}, @@ -314,10 +312,10 @@ def test_multiple_users_error( @pytest.mark.django_db def test_empty_email_address_error( - osb_client, test_service, test_service_offering, valid_osb_payload, instance_id + osb_client, service, service_offering, valid_osb_payload, instance_id ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id valid_osb_payload["parameters"]["users"] = [ {"email": "", "full_name": "User With No Email"}, ] @@ -350,14 +348,14 @@ def test_invalid_json_error(osb_client, instance_id): def test_user_creation_with_name_parsing( mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, exoscale_origin, instance_id, ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith" response = osb_client.put( @@ -376,14 +374,14 @@ def test_user_creation_with_name_parsing( def test_email_normalization( mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, exoscale_origin, instance_id, ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM " response = osb_client.put( @@ -401,14 +399,14 @@ def test_email_normalization( def test_odoo_integration_failure_handling( mock_odoo_failure, osb_client, - test_service, - test_service_offering, + service, + service_offering, valid_osb_payload, exoscale_origin, instance_id, ): - valid_osb_payload["service_id"] = test_service.osb_service_id - valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + valid_osb_payload["service_id"] = service.osb_service_id + valid_osb_payload["plan_id"] = service_offering.osb_plan_id response = osb_client.put( f"/api/osb/v2/service_instances/{instance_id}", @@ -425,14 +423,14 @@ def test_odoo_integration_failure_handling( def test_organization_creation_with_context_only( mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, exoscale_origin, instance_id, ): payload = { - "service_id": test_service.osb_service_id, - "plan_id": test_service_offering.osb_plan_id, + "service_id": service.osb_service_id, + "plan_id": service_offering.osb_plan_id, "context": { "organization_guid": "fallback-org-guid", "organization_name": "Fallback Organization", @@ -462,13 +460,13 @@ def test_organization_creation_with_context_only( def test_delete_offboarding_success( mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, instance_id, ): response = osb_client.delete( f"/api/osb/v2/service_instances/{instance_id}" - f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" + f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}" ) assert response.status_code == 200 @@ -476,9 +474,9 @@ def test_delete_offboarding_success( @pytest.mark.django_db -def test_delete_missing_service_id(osb_client, test_service_offering, instance_id): +def test_delete_missing_service_id(osb_client, service_offering, instance_id): response = osb_client.delete( - f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}" + f"/api/osb/v2/service_instances/{instance_id}?plan_id={service_offering.osb_plan_id}" ) assert response.status_code == 400 @@ -487,9 +485,9 @@ def test_delete_missing_service_id(osb_client, test_service_offering, instance_i @pytest.mark.django_db -def test_delete_missing_plan_id(osb_client, test_service, instance_id): +def test_delete_missing_plan_id(osb_client, service, instance_id): response = osb_client.delete( - f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}" + f"/api/osb/v2/service_instances/{instance_id}?service_id={service.osb_service_id}" ) assert response.status_code == 400 @@ -509,16 +507,16 @@ def test_delete_invalid_service_id(osb_client, instance_id): @pytest.mark.django_db -def test_delete_invalid_plan_id(osb_client, test_service, instance_id): +def test_delete_invalid_plan_id(osb_client, service, instance_id): response = osb_client.delete( f"/api/osb/v2/service_instances/{instance_id}" - f"?service_id={test_service.osb_service_id}&plan_id=invalid" + f"?service_id={service.osb_service_id}&plan_id=invalid" ) assert response.status_code == 400 response_data = json.loads(response.content) assert ( - f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}" + f"Unknown plan_id: invalid for service_id: {service.osb_service_id}" in response_data["error"] ) @@ -527,13 +525,13 @@ def test_delete_invalid_plan_id(osb_client, test_service, instance_id): def test_patch_suspension_success( mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, instance_id, ): payload = { - "service_id": test_service.osb_service_id, - "plan_id": test_service_offering.osb_plan_id, + "service_id": service.osb_service_id, + "plan_id": service_offering.osb_plan_id, "parameters": { "users": [ { @@ -556,9 +554,9 @@ def test_patch_suspension_success( @pytest.mark.django_db -def test_patch_missing_service_id(osb_client, test_service_offering, instance_id): +def test_patch_missing_service_id(osb_client, service_offering, instance_id): payload = { - "plan_id": test_service_offering.osb_plan_id, + "plan_id": service_offering.osb_plan_id, "parameters": {"users": []}, } @@ -574,9 +572,9 @@ def test_patch_missing_service_id(osb_client, test_service_offering, instance_id @pytest.mark.django_db -def test_patch_missing_plan_id(osb_client, test_service, instance_id): +def test_patch_missing_plan_id(osb_client, service, instance_id): payload = { - "service_id": test_service.osb_service_id, + "service_id": service.osb_service_id, "parameters": {"users": []}, } @@ -609,8 +607,8 @@ def test_delete_creates_ticket_with_admin_links( mocker, mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, instance_id, ): # Mock the create_helpdesk_ticket function @@ -618,7 +616,7 @@ def test_delete_creates_ticket_with_admin_links( response = osb_client.delete( f"/api/osb/v2/service_instances/{instance_id}" - f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" + f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}" ) assert response.status_code == 200 @@ -629,10 +627,10 @@ def test_delete_creates_ticket_with_admin_links( # Check that the description contains an admin URL assert "admin/core/serviceoffering" in call_kwargs["description"] - assert f"/{test_service_offering.pk}/" in call_kwargs["description"] + assert f"/{service_offering.pk}/" in call_kwargs["description"] assert ( call_kwargs["title"] - == f"Exoscale OSB Offboard - {test_service.name} - {instance_id}" + == f"Exoscale OSB Offboard - {service.name} - {instance_id}" ) @@ -641,8 +639,8 @@ def test_patch_creates_ticket_with_user_admin_links( mocker, mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, instance_id, org_owner, ): @@ -650,8 +648,8 @@ def test_patch_creates_ticket_with_user_admin_links( mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket") payload = { - "service_id": test_service.osb_service_id, - "plan_id": test_service_offering.osb_plan_id, + "service_id": service.osb_service_id, + "plan_id": service_offering.osb_plan_id, "parameters": { "users": [ { @@ -680,8 +678,7 @@ def test_patch_creates_ticket_with_user_admin_links( assert "admin/core/user" in call_kwargs["description"] assert f"/{org_owner.pk}/" in call_kwargs["description"] assert ( - call_kwargs["title"] - == f"Exoscale OSB Suspend - {test_service.name} - {instance_id}" + call_kwargs["title"] == f"Exoscale OSB Suspend - {service.name} - {instance_id}" ) @@ -690,8 +687,8 @@ def test_ticket_includes_organization_and_instance_when_found( mocker, mock_odoo_success, osb_client, - test_service, - test_service_offering, + service, + service_offering, organization, ): # Mock the create_helpdesk_ticket function @@ -699,12 +696,12 @@ def test_ticket_includes_organization_and_instance_when_found( service_definition = ServiceDefinition.objects.create( name="Test Definition", - service=test_service, + service=service, api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"}, ) control_plane = ControlPlane.objects.create( name="Test Control Plane", - cloud_provider=test_service_offering.provider, + cloud_provider=service_offering.provider, api_credentials={ "certificate-authority-data": "test", "server": "https://test", @@ -712,20 +709,22 @@ def test_ticket_includes_organization_and_instance_when_found( }, ) crd = ControlPlaneCRD.objects.create( - service_offering=test_service_offering, + service_offering=service_offering, control_plane=control_plane, service_definition=service_definition, ) instance_name = "test-instance-123" + instance_display_name = "Test Instance 123" service_instance = ServiceInstance.objects.create( name=instance_name, + display_name=instance_display_name, organization=organization, context=crd, ) response = osb_client.delete( f"/api/osb/v2/service_instances/{instance_name}" - f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" + f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}" ) assert response.status_code == 200 @@ -739,7 +738,10 @@ def test_ticket_includes_organization_and_instance_when_found( assert "admin/core/organization" in call_kwargs["description"] assert f"/{organization.pk}/" in call_kwargs["description"] - # Check instance is included - assert f"Instance: {service_instance.name}" in call_kwargs["description"] + # Check instance is included (format: "display_name (resource_name)") + assert ( + f"Instance: {service_instance.display_name} ({service_instance.name})" + in call_kwargs["description"] + ) assert "admin/core/serviceinstance" in call_kwargs["description"] assert f"/{service_instance.pk}/" in call_kwargs["description"] diff --git a/src/tests/test_form_config.py b/src/tests/test_form_config.py index 014f1f2..2a010ed 100644 --- a/src/tests/test_form_config.py +++ b/src/tests/test_form_config.py @@ -22,7 +22,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "required": True, } ], @@ -32,7 +32,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists(): crd.service_definition = service_def class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) class Meta: app_label = "test" @@ -193,7 +193,7 @@ def test_choice_field_uses_custom_choices_from_form_config(): """Test that choice fields use custom choices when provided in form_config""" class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) environment = models.CharField( max_length=20, choices=[ @@ -215,7 +215,7 @@ def test_choice_field_uses_custom_choices_from_form_config(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "required": True, }, { @@ -246,7 +246,7 @@ def test_choice_field_uses_custom_choices_from_form_config(): def test_choice_field_uses_control_plane_choices_when_no_custom_choices(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) environment = models.CharField( max_length=20, choices=[ @@ -267,7 +267,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "required": True, }, { @@ -292,7 +292,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices(): def test_choice_field_validates_against_control_plane_choices(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) environment = models.CharField( max_length=20, choices=[ @@ -313,7 +313,7 @@ def test_choice_field_validates_against_control_plane_choices(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "required": True, }, { @@ -330,15 +330,15 @@ def test_choice_field_validates_against_control_plane_choices(): form_class = generate_custom_form_class(form_config, TestModel) - form = form_class(data={"name": "test-service", "environment": "dev"}) + form = form_class(data={"display_name": "test-service", "environment": "dev"}) form.fields["context"].required = False # Skip context validation assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" - form = form_class(data={"name": "test-service", "environment": "prod"}) + form = form_class(data={"display_name": "test-service", "environment": "prod"}) form.fields["context"].required = False # Skip context validation assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" - form = form_class(data={"name": "test-service", "environment": "invalid"}) + form = form_class(data={"display_name": "test-service", "environment": "invalid"}) form.fields["context"].required = False # Skip context validation assert not form.is_valid() assert "environment" in form.errors @@ -368,7 +368,7 @@ def test_admin_form_validates_choice_values_against_schema(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", }, { "type": "choice", @@ -399,7 +399,7 @@ def test_admin_form_validates_choice_values_against_schema(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", }, { "type": "choice", @@ -431,7 +431,7 @@ def test_admin_form_validates_choice_values_against_schema(): def test_number_field_min_max_sets_widget_attributes(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) port = models.IntegerField() replica_count = models.IntegerField() @@ -446,7 +446,7 @@ def test_number_field_min_max_sets_widget_attributes(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "required": True, }, { @@ -494,7 +494,7 @@ def test_number_field_min_max_sets_widget_attributes(): def test_default_value_for_all_field_types(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) description = models.TextField() port = models.IntegerField() environment = models.CharField( @@ -518,7 +518,7 @@ def test_default_value_for_all_field_types(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "default_value": "default-name", }, { @@ -559,7 +559,7 @@ def test_default_value_for_all_field_types(): form_class = generate_custom_form_class(form_config, TestModel) form = form_class() - assert form.fields["name"].initial == "default-name" + assert form.fields["display_name"].initial == "default-name" assert form.fields["description"].initial == "Default description text" assert form.fields["port"].initial == "8080" assert form.fields["environment"].initial == "dev" @@ -570,7 +570,7 @@ def test_default_value_for_all_field_types(): def test_default_value_not_override_existing_instance(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) port = models.IntegerField() class Meta: @@ -583,7 +583,7 @@ def test_default_value_not_override_existing_instance(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "default_value": "default-name", }, { @@ -597,11 +597,11 @@ def test_default_value_not_override_existing_instance(): ] } - instance = TestModel(name="existing-name", port=3000) + instance = TestModel(display_name="existing-name", port=3000) form_class = generate_custom_form_class(form_config, TestModel) form = form_class(instance=instance) - assert form.initial["name"] == "existing-name" + assert form.initial["display_name"] == "existing-name" assert form.initial["port"] == 3000 @@ -708,7 +708,7 @@ def test_form_config_handles_empty_string_as_none(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "max_length": "", # Empty string }, ] @@ -744,7 +744,7 @@ def test_single_element_choices_are_normalized(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", }, { "type": "choice", @@ -879,7 +879,7 @@ def test_three_plus_element_choices_fail_validation(): def test_field_with_default_config_only_needs_mapping(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) class Meta: app_label = "test" @@ -889,7 +889,7 @@ def test_field_with_default_config_only_needs_mapping(): { "fields": [ { - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", }, ] } @@ -899,16 +899,16 @@ def test_field_with_default_config_only_needs_mapping(): form_class = generate_custom_form_class(minimal_config, TestModel) form = form_class() - name_field = form.fields["name"] - assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] - assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] - assert name_field.required == DEFAULT_FIELD_CONFIGS["name"]["required"] + name_field = form.fields["display_name"] + assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"] + assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"] + assert name_field.required == DEFAULT_FIELD_CONFIGS["display_name"]["required"] def test_field_with_default_config_can_override_defaults(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) class Meta: app_label = "test" @@ -918,7 +918,7 @@ def test_field_with_default_config_can_override_defaults(): { "fields": [ { - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "label": "Custom Name Label", "required": False, }, @@ -930,16 +930,16 @@ def test_field_with_default_config_can_override_defaults(): form_class = generate_custom_form_class(override_config, TestModel) form = form_class() - name_field = form.fields["name"] + name_field = form.fields["display_name"] assert name_field.label == "Custom Name Label" assert name_field.required is False - assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] + assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"] def test_empty_values_dont_override_default_configs(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) class Meta: app_label = "test" @@ -949,7 +949,7 @@ def test_empty_values_dont_override_default_configs(): { "fields": [ { - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "type": "", "label": "", "help_text": None, @@ -964,11 +964,11 @@ def test_empty_values_dont_override_default_configs(): form_class = generate_custom_form_class(admin_form_config, TestModel) form = form_class() - name_field = form.fields["name"] + name_field = form.fields["display_name"] - assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] - assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] - assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"] + assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"] + assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"] + assert name_field.max_length == DEFAULT_FIELD_CONFIGS["display_name"]["max_length"] assert name_field.required is False # Was overridden by explicit False @@ -976,7 +976,7 @@ def test_empty_values_dont_override_default_configs(): def test_number_field_validates_min_max_values(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) port = models.IntegerField() class Meta: @@ -990,7 +990,7 @@ def test_number_field_validates_min_max_values(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "required": True, }, { @@ -1009,26 +1009,26 @@ def test_number_field_validates_min_max_values(): form_class = generate_custom_form_class(form_config, TestModel) # Test value below minimum fails validation - form = form_class(data={"name": "test-service", "port": 0}) + form = form_class(data={"display_name": "test-service", "port": 0}) form.fields["context"].required = False assert not form.is_valid() assert "port" in form.errors # Test value above maximum fails validation - form = form_class(data={"name": "test-service", "port": 65536}) + form = form_class(data={"display_name": "test-service", "port": 65536}) form.fields["context"].required = False assert not form.is_valid() assert "port" in form.errors # Test valid value passes validation - form = form_class(data={"name": "test-service", "port": 8080}) + form = form_class(data={"display_name": "test-service", "port": 8080}) form.fields["context"].required = False assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" def test_number_field_with_addon_text_roundtrip(): class TestModel(models.Model): - name = models.CharField(max_length=100) + display_name = models.CharField(max_length=100) disk_size = models.IntegerField() class Meta: @@ -1041,7 +1041,7 @@ def test_number_field_with_addon_text_roundtrip(): { "type": "text", "label": "Name", - "controlplane_field_mapping": "name", + "controlplane_field_mapping": "display_name", "required": True, }, { @@ -1059,7 +1059,7 @@ def test_number_field_with_addon_text_roundtrip(): form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"}) assert form.initial["disk_size"] == 25 - form = form_class(data={"name": "test-instance", "disk_size": "25"}) + form = form_class(data={"display_name": "test-instance", "disk_size": "25"}) form.fields["context"].required = False assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" nested_data = form.get_nested_data() diff --git a/src/tests/test_management_commands.py b/src/tests/test_management_commands.py index 714d29a..f0f0277 100644 --- a/src/tests/test_management_commands.py +++ b/src/tests/test_management_commands.py @@ -109,7 +109,7 @@ class TestReencryptFieldsCommand: assert "Starting re-encryption" in output assert "Re-encrypted 0 ControlPlane objects" in output - def test_reencrypt_fields_with_control_plane(self, test_control_plane): + def test_reencrypt_fields_with_control_plane(self, control_plane): out = StringIO() call_command("reencrypt_fields", stdout=out) @@ -147,11 +147,11 @@ class TestSyncBillingMetadataCommand: assert "No control planes found with the specified IDs" in out.getvalue() - def test_sync_billing_metadata_dry_run_with_control_plane(self, test_control_plane): + def test_sync_billing_metadata_dry_run_with_control_plane(self, control_plane): out = StringIO() call_command("sync_billing_metadata", "--dry-run", stdout=out) output = out.getvalue() assert "DRY RUN" in output assert "Syncing billing metadata on 1 control plane(s)" in output - assert test_control_plane.name in output + assert control_plane.name in output diff --git a/src/tests/test_rules.py b/src/tests/test_rules.py new file mode 100644 index 0000000..9a83163 --- /dev/null +++ b/src/tests/test_rules.py @@ -0,0 +1,151 @@ +import pytest +from django_scopes import scope + +from servala.core.models.organization import OrganizationRole +from servala.core.rules import ( + has_organization_role, + is_organization_admin, + is_organization_member, + is_organization_owner, +) + +pytestmark = pytest.mark.django_db + + +def test_has_organization_role_returns_false_for_non_organization_object(user): + assert has_organization_role(user, "not an organization", None) is False + + +def test_has_organization_role_returns_false_for_non_member(user, organization): + with scope(organization=organization): + assert has_organization_role(user, organization, None) is False + + +@pytest.mark.parametrize("user_fixture", ["org_owner", "org_admin", "org_member"]) +def test_has_organization_role_returns_true_for_any_member( + user_fixture, organization, request +): + user = request.getfixturevalue(user_fixture) + with scope(organization=organization): + assert has_organization_role(user, organization, None) is True + + +@pytest.mark.parametrize( + "user_fixture,roles,expected", + [ + ("org_owner", [OrganizationRole.OWNER], True), + ("org_owner", [OrganizationRole.ADMIN], False), + ("org_owner", [OrganizationRole.MEMBER], False), + ("org_owner", [OrganizationRole.OWNER, OrganizationRole.ADMIN], True), + ("org_admin", [OrganizationRole.ADMIN], True), + ("org_admin", [OrganizationRole.OWNER], False), + ("org_admin", [OrganizationRole.OWNER, OrganizationRole.ADMIN], True), + ("org_member", [OrganizationRole.MEMBER], True), + ("org_member", [OrganizationRole.OWNER], False), + ("org_member", [OrganizationRole.ADMIN], False), + ], +) +def test_has_organization_role_filters_by_roles( + user_fixture, roles, expected, organization, request +): + user = request.getfixturevalue(user_fixture) + with scope(organization=organization): + assert has_organization_role(user, organization, roles) is expected + + +@pytest.mark.parametrize( + "user_fixture,expected", + [ + ("org_owner", True), + ("org_admin", False), + ("org_member", False), + ], +) +def test_is_organization_owner(user_fixture, expected, organization, request): + user = request.getfixturevalue(user_fixture) + with scope(organization=organization): + assert is_organization_owner(user, organization) is expected + + +@pytest.mark.parametrize( + "user_fixture,expected", + [ + ("org_owner", True), + ("org_admin", False), + ("org_member", False), + ], +) +def test_is_organization_owner_with_related_object( + user_fixture, expected, organization, mocker, request +): + user = request.getfixturevalue(user_fixture) + obj = mocker.MagicMock() + obj.organization = organization + with scope(organization=organization): + assert is_organization_owner(user, obj) is expected + + +@pytest.mark.parametrize( + "user_fixture,expected", + [ + ("org_owner", True), + ("org_admin", True), + ("org_member", False), + ], +) +def test_is_organization_admin(user_fixture, expected, organization, request): + user = request.getfixturevalue(user_fixture) + with scope(organization=organization): + assert is_organization_admin(user, organization) is expected + + +@pytest.mark.parametrize( + "user_fixture,expected", + [ + ("org_owner", True), + ("org_admin", True), + ("org_member", False), + ], +) +def test_is_organization_admin_with_related_object( + user_fixture, expected, organization, mocker, request +): + user = request.getfixturevalue(user_fixture) + obj = mocker.MagicMock() + obj.organization = organization + with scope(organization=organization): + assert is_organization_admin(user, obj) is expected + + +@pytest.mark.parametrize("user_fixture", ["org_owner", "org_admin", "org_member"]) +def test_is_organization_member_returns_true_for_members( + user_fixture, organization, request +): + user = request.getfixturevalue(user_fixture) + with scope(organization=organization): + assert is_organization_member(user, organization) is True + + +def test_is_organization_member_returns_false_for_non_member(user, organization): + with scope(organization=organization): + assert is_organization_member(user, organization) is False + + +@pytest.mark.parametrize("user_fixture", ["org_owner", "org_admin", "org_member"]) +def test_is_organization_member_with_related_object( + user_fixture, organization, mocker, request +): + user = request.getfixturevalue(user_fixture) + obj = mocker.MagicMock() + obj.organization = organization + with scope(organization=organization): + assert is_organization_member(user, obj) is True + + +def test_is_organization_member_with_related_object_returns_false_for_non_member( + user, organization, mocker +): + obj = mocker.MagicMock() + obj.organization = organization + with scope(organization=organization): + assert is_organization_member(user, obj) is False diff --git a/src/tests/test_service_models.py b/src/tests/test_service_models.py new file mode 100644 index 0000000..794fad2 --- /dev/null +++ b/src/tests/test_service_models.py @@ -0,0 +1,349 @@ +"""Tests for servala.core.models.service module.""" + +from unittest.mock import MagicMock + +import pytest +from django.core.exceptions import ValidationError + +from servala.core.models.service import ( + ControlPlane, + Service, + ServiceInstance, + prune_empty_data, + validate_api_credentials, + validate_dict, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def service_with_external_links(service_category): + return Service.objects.create( + name="PostgreSQL", + slug="postgresql", + category=service_category, + description="PostgreSQL database", + external_links=[ + {"url": "https://docs.example.com", "title": "Docs", "featured": True}, + {"url": "https://github.com/example", "title": "GitHub", "featured": False}, + {"url": "https://api.example.com", "title": "API", "featured": True}, + ], + ) + + +@pytest.mark.parametrize("data", [None, {}]) +def test_validate_dict_allows_empty_by_default(data): + validate_dict(data) + + +@pytest.mark.parametrize("data", [None, {}]) +def test_validate_dict_raises_when_empty_not_allowed(data): + with pytest.raises(ValidationError, match="Data may not be empty"): + validate_dict(data, allow_empty=False) + + +def test_validate_dict_missing_required_fields_raises(): + with pytest.raises(ValidationError, match="Missing required fields"): + validate_dict({"field1": "v"}, required_fields={"field1", "field2", "field3"}) + + +def test_validate_dict_all_required_fields_present_passes(): + validate_dict({"a": 1, "b": 2, "extra": 3}, required_fields={"a", "b"}) + + +@pytest.mark.parametrize("data", [None, {}]) +def test_validate_api_credentials_allows_empty(data): + validate_api_credentials(data) + + +@pytest.mark.parametrize( + "input_data,expected", + [ + ({"a": 1, "b": None, "c": 3}, {"a": 1, "c": 3}), + ({"a": "hello", "b": "", "c": "world"}, {"a": "hello", "c": "world"}), + ({"a": [1, 2], "b": [], "c": [3]}, {"a": [1, 2], "c": [3]}), + ( + {"a": {"nested": 1}, "b": {}, "c": {"x": 2}}, + {"a": {"nested": 1}, "c": {"x": 2}}, + ), + ({"outer": {"inner": {"empty": None}}}, {}), + ( + {"false_val": False, "zero": 0, "none": None}, + {"false_val": False, "zero": 0}, + ), + ("string", "string"), + (42, 42), + ], +) +def test_prune_empty_data(input_data, expected): + assert prune_empty_data(input_data) == expected + + +def test_prune_empty_data_nested_dicts(): + data = {"level1": {"level2": {"keep": "value", "remove": None, "empty": ""}}} + assert prune_empty_data(data) == {"level1": {"level2": {"keep": "value"}}} + + +def test_prune_empty_data_in_lists(): + data = {"items": [{"keep": 1}, {"remove": None}, {"also_keep": 2}]} + assert prune_empty_data(data) == {"items": [{"keep": 1}, {"also_keep": 2}]} + + +def test_service_featured_links_filters_correctly(service_with_external_links): + featured = service_with_external_links.featured_links + assert len(featured) == 2 + assert all(link["featured"] for link in featured) + assert {link["title"] for link in featured} == {"Docs", "API"} + + +@pytest.mark.parametrize( + "external_links,expected_count", + [ + (None, 0), + ([], 0), + ([{"url": "https://x.com", "title": "X", "featured": False}], 0), + ], +) +def test_service_featured_links_empty_cases( + service_category, external_links, expected_count +): + svc = Service.objects.create( + name="Test", + slug="test-svc", + category=service_category, + external_links=external_links, + ) + assert len(svc.featured_links) == expected_count + + +def test_service_str(service): + assert str(service) == "Redis" + + +def test_service_category_str(service_category): + assert str(service_category) == "Databases" + + +def test_cloud_provider_str(cloud_provider): + assert str(cloud_provider) == "Exoscale" + + +def test_control_plane_str(control_plane): + assert str(control_plane) == "Geneva (CH-GVA-2)" + + +def test_control_plane_test_connection_no_credentials(cloud_provider): + plane = ControlPlane.objects.create( + name="No Creds", cloud_provider=cloud_provider, api_credentials={} + ) + success, message = plane.test_connection() + assert success is False + assert "No API credentials" in str(message) + + +def test_service_definition_str(service_definition): + assert str(service_definition) == "Redis Standard" + + +def test_service_definition_control_planes(service_definition, control_plane_crd): + assert control_plane_crd.control_plane in service_definition.control_planes + + +def test_control_plane_crd_str(control_plane_crd): + result = str(control_plane_crd) + assert "Redis" in result and "Exoscale" in result and "Geneva" in result + + +@pytest.mark.parametrize( + "prop,expected", + [("group", "vshn.appcat.vshn.io"), ("version", "v1"), ("kind", "VSHNRedis")], +) +def test_control_plane_crd_api_properties(control_plane_crd, prop, expected): + assert getattr(control_plane_crd, prop) == expected + + +def test_service_offering_str(service_offering): + result = str(service_offering) + assert "Redis" in result and "Exoscale" in result + + +def test_service_offering_control_planes(service_offering, control_plane_crd): + assert control_plane_crd.control_plane in service_offering.control_planes + + +def test_generate_resource_name_consistent(organization, service): + name1 = ServiceInstance.generate_resource_name(organization, "My Instance", service) + name2 = ServiceInstance.generate_resource_name(organization, "My Instance", service) + assert name1 == name2 + + +def test_generate_resource_name_different_inputs(organization, service): + name_a = ServiceInstance.generate_resource_name(organization, "Instance A", service) + name_b = ServiceInstance.generate_resource_name(organization, "Instance B", service) + assert name_a != name_b + + +def test_generate_resource_name_attempt_changes_hash(organization, service): + name0 = ServiceInstance.generate_resource_name( + organization, "X", service, attempt=0 + ) + name1 = ServiceInstance.generate_resource_name( + organization, "X", service, attempt=1 + ) + assert name0 != name1 + + +def test_generate_resource_name_format(organization, service, settings): + name = ServiceInstance.generate_resource_name(organization, "Test", service) + assert name.startswith(f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-") + assert len(name.split("-")[-1]) == 8 + + +@pytest.mark.parametrize( + "display_name", ["My Instance", "MY INSTANCE", " My Instance "] +) +def test_generate_resource_name_normalizes_display_name( + organization, service, display_name +): + canonical = ServiceInstance.generate_resource_name( + organization, "my instance", service + ) + assert ( + ServiceInstance.generate_resource_name(organization, display_name, service) + == canonical + ) + + +@pytest.mark.parametrize("spec_data", [None, {}]) +def test_prepare_spec_data_handles_empty(spec_data): + assert ServiceInstance._prepare_spec_data(spec_data) == {} + + +def test_prepare_spec_data_prunes_empty_values(): + assert ServiceInstance._prepare_spec_data({"keep": "v", "rm": None}) == { + "keep": "v" + } + + +def test_apply_compute_plan_to_spec(compute_plan_assignment): + result = ServiceInstance._apply_compute_plan_to_spec({}, compute_plan_assignment) + assert result["parameters"]["size"]["memory"] == "2Gi" + assert result["parameters"]["size"]["cpu"] == "1000m" + assert result["parameters"]["size"]["requests"]["memory"] == "1Gi" + assert result["parameters"]["size"]["requests"]["cpu"] == "500m" + assert result["parameters"]["service"]["serviceLevel"] == "besteffort" + + +def test_apply_compute_plan_to_spec_none_assignment(): + spec = {"existing": "value"} + assert ServiceInstance._apply_compute_plan_to_spec(spec, None) == { + "existing": "value" + } + + +def test_apply_compute_plan_preserves_existing(compute_plan_assignment): + spec = {"parameters": {"custom": "setting", "service": {"other": "config"}}} + result = ServiceInstance._apply_compute_plan_to_spec(spec, compute_plan_assignment) + assert result["parameters"]["custom"] == "setting" + assert result["parameters"]["service"]["other"] == "config" + + +def test_build_billing_annotations_display_name(): + cp = MagicMock(storage_plan_odoo_product_id=None, storage_plan_odoo_unit_id=None) + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, control_plane=cp, display_name="My Service" + ) + assert annotations["servala.com/displayName"] == "My Service" + + +def test_build_billing_annotations_no_display_name(): + cp = MagicMock(storage_plan_odoo_product_id=None, storage_plan_odoo_unit_id=None) + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, control_plane=cp, display_name=None + ) + assert "servala.com/displayName" not in annotations + + +def test_build_billing_annotations_compute_plan(compute_plan_assignment): + cp = MagicMock(storage_plan_odoo_product_id=None, storage_plan_odoo_unit_id=None) + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=compute_plan_assignment, control_plane=cp + ) + assert annotations["servala.com/erp_product_id_resource"] == "test-product-id" + assert annotations["servala.com/erp_unit_id_resource"] == "test-unit-id" + + +def test_build_billing_annotations_storage_plan(control_plane_with_storage): + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, control_plane=control_plane_with_storage + ) + assert annotations["servala.com/erp_product_id_storage"] == "storage-product-123" + assert annotations["servala.com/erp_unit_id_storage"] == "storage-unit-456" + + +@pytest.mark.parametrize( + "error_msg,expected_has_list,expected_errors", + [ + ("", False, None), + (None, False, None), + ("Something went wrong", False, None), + ("Error: [single error]", False, None), + ("Validation failed: [e1, e2, e3]", True, ["e1", "e2", "e3"]), + ], +) +def test_format_kubernetes_error(error_msg, expected_has_list, expected_errors): + result = ServiceInstance._format_kubernetes_error(error_msg) + assert result["has_list"] == expected_has_list + assert result["errors"] == expected_errors + + +def test_format_kubernetes_error_strips_quotes(): + result = ServiceInstance._format_kubernetes_error("Errors: [\"quoted\", 'single']") + assert "quoted" in result["errors"] + assert "single" in result["errors"] + + +def test_safe_format_error_non_dict(): + assert ServiceInstance._safe_format_error("plain string") == "plain string" + + +def test_safe_format_error_escapes_html(): + error_data = {"message": "", "has_list": False} + result = ServiceInstance._safe_format_error(error_data) + assert "