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, )