From cc84926693623d1f3dd3d204d6275dfe7e812130 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 9 Dec 2025 09:17:12 +0100 Subject: [PATCH 01/18] Add missing test fixture --- src/tests/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index d80aed8..b9fe003 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -74,6 +74,11 @@ def org_member(organization): return member +@pytest.fixture +def user(): + return User.objects.create(email="generic-user@example.org", password="example") + + @pytest.fixture def test_service_category(): return ServiceCategory.objects.create( From 9cff1e85ac3ee195a1232ed6574f1beeb83f802f Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 9 Dec 2025 09:17:50 +0100 Subject: [PATCH 02/18] Add instance name prefix setting --- .env.example | 4 ++++ src/servala/settings.py | 3 +++ 2 files changed, 7 insertions(+) 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/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" From 582c4ed564a67fe2e57f35d882074ddacb32c6c0 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 9 Dec 2025 09:26:48 +0100 Subject: [PATCH 03/18] Add model field, migrations, and backend methods --- .../core/migrations/0019_add_display_name.py | 46 ++++++++++++++ src/servala/core/models/service.py | 61 +++++++++++++++++-- 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/servala/core/migrations/0019_add_display_name.py 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..996fa67 --- /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="Resource Name", + ), + ), + # 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 c6552c4..8872096 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -18,6 +18,10 @@ from encrypted_fields.fields import EncryptedJSONField from kubernetes import client, config from kubernetes.client.rest import ApiException +import hashlib + +from django.conf import settings + from servala.core import rules as perms from servala.core.models.mixins import ServalaModelMixin from servala.core.validators import kubernetes_name_validator @@ -615,8 +619,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=_("Resource Name"), + validators=[kubernetes_name_validator], + ) + display_name = models.CharField( + max_length=100, + verbose_name=_("Name"), ) organization = models.ForeignKey( to="core.Organization", @@ -686,6 +698,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 +749,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 +864,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 +934,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 +993,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, ) From 97b53ec0722a94f2bf88a4c45ffdab147a79409c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 9 Dec 2025 09:33:06 +0100 Subject: [PATCH 04/18] Use display name where appropriate --- src/servala/api/views.py | 2 +- src/servala/core/crd/forms.py | 9 +- src/servala/core/crd/models.py | 4 +- .../commands/sync_billing_metadata.py | 1 + .../service_instance_delete_form.html | 2 +- src/servala/frontend/views/service.py | 8 +- src/tests/test_api_exoscale.py | 9 +- src/tests/test_form_config.py | 96 +++++++++---------- 8 files changed, 68 insertions(+), 63 deletions(-) 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..e7e4d05 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -9,17 +9,16 @@ 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", "required": True, - "max_length": 63, + "max_length": 100, }, "spec.parameters.service.fqdn": { "type": "array", @@ -472,7 +471,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/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/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 %}