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/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 c6552c4..c2fedad 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/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 @@