From 973988c91e55afb7cb874009ed7cca5694f5ec11 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 9 Apr 2025 16:05:31 +0200 Subject: [PATCH 1/2] Generate organization namespaces --- src/servala/core/admin.py | 3 +-- .../0003_alter_organization_namespace.py | 24 +++++++++++++++++++ src/servala/core/models/organization.py | 19 ++++++++++----- src/servala/frontend/forms/organization.py | 19 +-------------- .../frontend/organizations/update.html | 2 +- 5 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 src/servala/core/migrations/0003_alter_organization_namespace.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 7d110d1..746d5da 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -65,8 +65,7 @@ class OrganizationAdmin(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): readonly_fields = list(super().get_readonly_fields(request, obj) or []) - if obj: # If this is an edit (not a new organization) - readonly_fields.append("namespace") + readonly_fields.append("namespace") # Always read-only return readonly_fields diff --git a/src/servala/core/migrations/0003_alter_organization_namespace.py b/src/servala/core/migrations/0003_alter_organization_namespace.py new file mode 100644 index 0000000..3a01990 --- /dev/null +++ b/src/servala/core/migrations/0003_alter_organization_namespace.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2b1 on 2025-04-09 14:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_alter_controlplanecrd_options_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="organization", + name="namespace", + field=models.CharField( + help_text="This namespace will be used for all Kubernetes resources.", + max_length=20, + null=True, + unique=True, + verbose_name="Kubernetes Namespace", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 62894d5..7ebda11 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -9,19 +9,19 @@ from django_scopes import ScopedManager, scopes_disabled from servala.core import rules as perms from servala.core.models.mixins import ServalaModelMixin -from servala.core.validators import kubernetes_name_validator class Organization(ServalaModelMixin, models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) + # The namespace is generated as "org-{id}" in accordance with RFC 1035 Label Names. + # It is nullable as we need to write to the database in order to read the ID, but should + # not be null in practical use. namespace = models.CharField( - max_length=63, + max_length=20, verbose_name=_("Kubernetes Namespace"), + null=True, unique=True, - help_text=_( - "This namespace will be used for all Kubernetes resources. Cannot be changed after creation." - ), - validators=[kubernetes_name_validator], + help_text=_("This namespace will be used for all Kubernetes resources."), ) billing_entity = models.ForeignKey( @@ -90,6 +90,13 @@ class Organization(ServalaModelMixin, models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if not self.namespace: + # Set namespace after initial save to ensure we have an ID + self.namespace = f"org-{self.pk}" + self.save(update_fields=["namespace"]) + class BillingEntity(ServalaModelMixin, models.Model): """ diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index d582ce9..41cc26c 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,6 +1,4 @@ -from django import forms from django.forms import ModelForm -from django.utils.translation import gettext_lazy as _ from servala.core.models import Organization from servala.frontend.forms.mixins import HtmxMixin @@ -9,19 +7,4 @@ from servala.frontend.forms.mixins import HtmxMixin class OrganizationForm(HtmxMixin, ModelForm): class Meta: model = Organization - fields = ("name", "namespace") - widgets = { - "namespace": forms.TextInput( - attrs={ - "pattern": "[a-z0-9]([-a-z0-9]*[a-z0-9])?", - "title": _( - 'Lowercase alphanumeric characters or "-", must start and end with alphanumeric' - ), - } - ) - } - help_texts = { - "namespace": _( - "This namespace will be used for all resources and cannot be changed later." - ) - } + fields = ("name",) diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 993b76a..0d55f22 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -52,7 +52,7 @@
{{ form.instance.namespace }}
- {% translate "The namespace cannot be changed after creation." %} + {% translate "System-generated namespace for Kubernetes resources." %} From e90a60e88212f6593b97899b6439e51457a7a857 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 9 Apr 2025 16:06:45 +0200 Subject: [PATCH 2/2] Test namespace generation --- src/tests/conftest.py | 8 ++------ src/tests/test_views.py | 1 + 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index a69e50c..d88870e 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -15,16 +15,12 @@ def origin(): @pytest.fixture def organization(origin): - return Organization.objects.create( - name="Test Org", namespace="test-org", origin=origin - ) + return Organization.objects.create(name="Test Org", origin=origin) @pytest.fixture def other_organization(origin): - return Organization.objects.create( - name="Test Org Alternate", namespace="test-org-alternate", origin=origin - ) + return Organization.objects.create(name="Test Org Alternate", origin=origin) @pytest.fixture diff --git a/src/tests/test_views.py b/src/tests/test_views.py index 23475fc..5ec429e 100644 --- a/src/tests/test_views.py +++ b/src/tests/test_views.py @@ -23,6 +23,7 @@ def test_root_view_invalid_urls_404(client): def test_owner_can_access_dashboard(client, org_owner, organization): client.force_login(org_owner) response = client.get(organization.urls.base) + assert organization.namespace == f"org-{organization.pk}" assert response.status_code == 200 assert organization.name in response.content.decode()