diff --git a/.env.example b/.env.example
index 9e31fbd..63df700 100644
--- a/.env.example
+++ b/.env.example
@@ -77,7 +77,3 @@ 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 f435a90..eaa1cdd 100644
--- a/README.md
+++ b/README.md
@@ -97,32 +97,6 @@ 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 1b33aae..03deb98 100644
--- a/deployment/kustomize/base/portal/deployment.yaml
+++ b/deployment/kustomize/base/portal/deployment.yaml
@@ -21,13 +21,6 @@ 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 bc6050f..47574f4 100644
--- a/deployment/kustomize/overlays/production/portal-deployment.yaml
+++ b/deployment/kustomize/overlays/production/portal-deployment.yaml
@@ -32,3 +32,10 @@ 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 e978ccd..0c13f2d 100644
--- a/deployment/kustomize/overlays/staging/portal-deployment.yaml
+++ b/deployment/kustomize/overlays/staging/portal-deployment.yaml
@@ -7,13 +7,6 @@ spec:
spec:
containers:
- name: servala
- resources:
- requests:
- cpu: 250m
- memory: 1Gi
- limits:
- cpu: 1
- memory: 2Gi
env:
- name: SERVALA_ENVIRONMENT
value: staging
@@ -39,15 +32,8 @@ spec:
secretKeyRef:
name: portal-storage-creds
key: AWS_SECRET_ACCESS_KEY
- - name: ssh-tunnel-dev
+ - name: ssh-tunnel
image: servala.app.codey.ch/servala/servala-portal:latest
- resources:
- requests:
- cpu: 50m
- memory: 204Mi
- limits:
- cpu: 100m
- memory: 256Mi
command:
- "/bin/bash"
- "-c"
@@ -55,40 +41,13 @@ 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 6443:127.0.0.1:6443 -N -i /app/.ssh/id -v
+ ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 8443:127.0.0.1:8443 -N -i /app/.ssh/id -v
env:
- name: 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
+ valueFrom:
+ secretKeyRef:
+ name: servala-sshclient
+ key: ssh-host
- name: SSH_USER
valueFrom:
secretKeyRef:
diff --git a/pyproject.toml b/pyproject.toml
index bb254a9..0e895cc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,15 +28,15 @@ dependencies = [
[dependency-groups]
dev = [
- "black>=25.12.0",
+ "black>=25.11.0",
"bumpver>=2025.1131",
- "coverage>=7.13.0",
+ "coverage>=7.12.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.2",
+ "pytest>=9.0.1",
"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 0bca7a7..9b0082d 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.display_name} ({service_instance.name}) - {instance_url}"
+ f"Instance: {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 6bde2c9..18df8bc 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 = ["display_name"]
+MANDATORY_FIELDS = ["name"]
# Default field configurations - fields that can be included with just a mapping
# to avoid administrators having to duplicate common information
DEFAULT_FIELD_CONFIGS = {
- "display_name": {
+ "name": {
"type": "text",
"label": "Instance Name",
- "help_text": "",
+ "help_text": "Unique name for the new instance",
"required": True,
- "max_length": 100,
+ "max_length": 63,
},
"spec.parameters.service.fqdn": {
"type": "array",
@@ -51,6 +51,11 @@ 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:
@@ -302,6 +307,15 @@ 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", []):
@@ -458,7 +472,7 @@ def generate_custom_form_class(form_config, model):
"""
Generate a custom (user-friendly) form class from form_config JSON.
"""
- field_list = ["context", "display_name"]
+ field_list = ["context", "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 a4fcc28..86df97f 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 fields to know our display name and our full namespace
+ # We always need these three fields to know our own name and our full namespace
model_fields = {"__module__": "crd_models"}
- for field_name in ("display_name", "context"):
+ for field_name in ("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 3bad850..090abba 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") | {"display_name"}
+ valid_paths = self._extract_field_paths(spec_schema, "spec") | {"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 ad1d74b..2093948 100644
--- a/src/servala/core/management/commands/sync_billing_metadata.py
+++ b/src/servala/core/management/commands/sync_billing_metadata.py
@@ -195,7 +195,6 @@ 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
deleted file mode 100644
index 4ff3b92..0000000
--- a/src/servala/core/migrations/0019_add_display_name.py
+++ /dev/null
@@ -1,46 +0,0 @@
-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 31527c5..e973235 100644
--- a/src/servala/core/models/service.py
+++ b/src/servala/core/models/service.py
@@ -1,5 +1,4 @@
import copy
-import hashlib
import html
import json
import re
@@ -616,16 +615,8 @@ 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=_("Instance ID"),
- validators=[kubernetes_name_validator],
- )
- display_name = models.CharField(
- max_length=100,
- verbose_name=_("Name"),
+ max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator]
)
organization = models.ForeignKey(
to="core.Organization",
@@ -695,24 +686,6 @@ 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):
"""
@@ -746,20 +719,16 @@ 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 and display name.
+ Build Kubernetes annotations for billing integration.
"""
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
@@ -861,35 +830,18 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@transaction.atomic
def create_instance(
cls,
- display_name,
+ 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,
@@ -931,9 +883,8 @@ 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=service,
+ service=context.service_offering.service,
)
if annotations:
create_data["metadata"]["annotations"] = annotations
@@ -990,7 +941,6 @@ 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,
)
@@ -1115,7 +1065,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
if not self.context.django_model:
return
return self.context.django_model(
- display_name=self.display_name,
+ name=self.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 26c9c70..169d6ea 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 ID"),
+ label=_("Instance Name"),
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 ID: {instance_name}"
+ "To confirm deletion, please type the instance name: {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 ID. Deletion not confirmed."
+ "The entered name does not match the instance name. 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 1d31737..964687d 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 "Please review and correct the errors highlighted in the form below." %}
+ {% translate "We could not save your changes." %}
{% endif %}
diff --git a/src/servala/frontend/templates/frontend/organizations/dashboard.html b/src/servala/frontend/templates/frontend/organizations/dashboard.html
index 9a309f1..441d0be 100644
--- a/src/servala/frontend/templates/frontend/organizations/dashboard.html
+++ b/src/servala/frontend/templates/frontend/organizations/dashboard.html
@@ -96,7 +96,7 @@
|
{{ instance.display_name }}
+ class="fw-semibold text-decoration-none">{{ instance.name }}
|
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 3129814..5296c56 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 20c337e..c443e6e 100644
--- a/src/servala/frontend/templates/frontend/organizations/service_instances.html
+++ b/src/servala/frontend/templates/frontend/organizations/service_instances.html
@@ -23,7 +23,6 @@
| {% translate "Name" %} |
- {% translate "Instance ID" %} |
{% translate "Service" %} |
{% translate "Service Provider" %} |
{% translate "Service Provider Zone" %} |
@@ -34,10 +33,7 @@
{% for instance in instances %}
|
- {{ instance.display_name }}
- |
-
- {{ instance.name }}
+ {{ instance.name }}
|
{{ instance.context.service_definition.service.name }} |
{{ instance.context.service_offering.provider.name }} |
@@ -46,7 +42,7 @@
{% empty %}
- | {% translate "No service instances found." %} |
+ {% translate "No service instances found." %} |
{% endfor %}
diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_create.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html
similarity index 98%
rename from src/servala/frontend/templates/frontend/organizations/service_instance_create.html
rename to src/servala/frontend/templates/frontend/organizations/service_offering_detail.html
index 3c41ddf..39b69a8 100644
--- a/src/servala/frontend/templates/frontend/organizations/service_instance_create.html
+++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html
@@ -26,8 +26,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %}
- {% 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 %}
+ {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %}
{% endif %}
diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html
index c72976a..f54b1ae 100644
--- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html
+++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html
@@ -1,9 +1,7 @@
{% load i18n %}
{% load get_field %}
{% load static %}
-{% if not hide_form_errors %}
- {% include "frontend/forms/errors.html" %}
-{% endif %}
+{% include "frontend/forms/errors.html" %}
{% if form and expert_form and not hide_expert_mode %}
/offering//",
- views.ServiceInstanceCreateView.as_view(),
- name="organization.instance.create",
+ views.ServiceOfferingDetailView.as_view(),
+ name="organization.offering",
),
path(
"",
diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py
index 3307754..33b0560 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 2a858dc..fe439c6 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.instance.create",
+ "frontend:organization.offering",
organization=self.request.organization.slug,
slug=self.object.slug,
pk=offering.pk,
@@ -97,8 +97,8 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
return context
-class ServiceInstanceCreateView(OrganizationViewMixin, HtmxViewMixin, DetailView):
- template_name = "frontend/organizations/service_instance_create.html"
+class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView):
+ template_name = "frontend/organizations/service_offering_detail.html"
context_object_name = "offering"
model = ServiceOffering
permission_type = "view"
@@ -276,7 +276,7 @@ class ServiceInstanceCreateView(OrganizationViewMixin, HtmxViewMixin, DetailView
try:
service_instance = ServiceInstance.create_instance(
organization=self.request.organization,
- display_name=form.cleaned_data["display_name"],
+ name=form.cleaned_data["name"],
context=self.context_object,
created_by=request.user,
spec_data=form.get_nested_data().get("spec"),
@@ -762,9 +762,6 @@ 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(
@@ -779,7 +776,7 @@ class ServiceInstanceUpdateView(
messages.success(
self.request,
_("Service instance '{name}' updated successfully.").format(
- name=self.object.display_name
+ name=self.object.name
),
)
return redirect(self.object.urls.base)
@@ -840,7 +837,7 @@ class ServiceInstanceDeleteView(
messages.success(
self.request,
_("Service instance '{name}' has been scheduled for deletion.").format(
- name=self.object.display_name
+ name=self.object.name
),
)
response = HttpResponse()
@@ -851,7 +848,7 @@ class ServiceInstanceDeleteView(
self.request,
self.organization.add_support_message(
_(
- f"An error occurred while trying to delete instance '{self.object.display_name}': {str(e)}."
+ f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}."
)
),
)
diff --git a/src/servala/settings.py b/src/servala/settings.py
index 1319394..f57ac0d 100644
--- a/src/servala/settings.py
+++ b/src/servala/settings.py
@@ -270,9 +270,6 @@ 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 86964c4..e775ebc 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}-display_name`);
+ const nameField = document.querySelector(`input#id_${prefix}-name`);
if (!nameField) return
// Try to find array input first (DynamicArrayWidget), then fallback to regular text input
@@ -23,19 +23,9 @@ 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 '';
- const sanitized = sanitizeForFqdn(instanceName);
- if (!sanitized) return ''
- return `${sanitized}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
+ return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
}
nameField.addEventListener('input', function() {
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index 298eb42..859b4ca 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -18,7 +18,6 @@ from servala.core.models.service import (
Service,
ServiceCategory,
ServiceDefinition,
- ServiceInstance,
ServiceOffering,
)
@@ -45,38 +44,15 @@ def other_organization(origin):
@pytest.fixture
def org_owner(organization):
- owner = User.objects.create(email="owner@example.org", password="example")
+ user = User.objects.create(email="user@example.org", password="example")
OrganizationMembership.objects.create(
- organization=organization, user=owner, role="owner"
+ organization=organization, user=user, role="owner"
)
- return owner
+ return user
@pytest.fixture
-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():
+def test_service_category():
return ServiceCategory.objects.create(
name="Databases",
description="Database services",
@@ -84,18 +60,18 @@ def service_category():
@pytest.fixture
-def service(service_category):
+def test_service(test_service_category):
return Service.objects.create(
name="Redis",
slug="redis",
- category=service_category,
+ category=test_service_category,
description="Redis database service",
osb_service_id="test-service-123",
)
@pytest.fixture
-def cloud_provider():
+def test_cloud_provider():
return CloudProvider.objects.create(
name="Exoscale",
description="Exoscale cloud provider",
@@ -103,10 +79,10 @@ def cloud_provider():
@pytest.fixture
-def service_offering(service, cloud_provider):
+def test_service_offering(test_service, test_cloud_provider):
return ServiceOffering.objects.create(
- service=service,
- provider=cloud_provider,
+ service=test_service,
+ provider=test_cloud_provider,
description="Redis on Exoscale",
osb_plan_id="test-plan-123",
)
@@ -149,11 +125,11 @@ def mock_odoo_failure(mocker):
@pytest.fixture
-def control_plane(cloud_provider):
+def test_control_plane(test_cloud_provider):
return ControlPlane.objects.create(
name="Geneva (CH-GVA-2)",
description="Geneva control plane",
- cloud_provider=cloud_provider,
+ cloud_provider=test_cloud_provider,
api_credentials={
"server": "https://k8s.example.com",
"token": "test-token",
@@ -163,10 +139,10 @@ def control_plane(cloud_provider):
@pytest.fixture
-def service_definition(service):
+def test_service_definition(test_service):
return ServiceDefinition.objects.create(
name="Redis Standard",
- service=service,
+ service=test_service,
api_definition={
"group": "vshn.appcat.vshn.io",
"version": "v1",
@@ -176,11 +152,13 @@ def service_definition(service):
@pytest.fixture
-def control_plane_crd(service_offering, control_plane, service_definition):
+def test_control_plane_crd(
+ test_service_offering, test_control_plane, test_service_definition
+):
return ControlPlaneCRD.objects.create(
- service_offering=service_offering,
- control_plane=control_plane,
- service_definition=service_definition,
+ service_offering=test_service_offering,
+ control_plane=test_control_plane,
+ service_definition=test_service_definition,
)
@@ -198,10 +176,10 @@ def compute_plan():
@pytest.fixture
-def compute_plan_assignment(compute_plan, control_plane_crd):
+def compute_plan_assignment(compute_plan, test_control_plane_crd):
return ComputePlanAssignment.objects.create(
compute_plan=compute_plan,
- control_plane_crd=control_plane_crd,
+ control_plane_crd=test_control_plane_crd,
sla="besteffort",
odoo_product_id="test-product-id",
odoo_unit_id="test-unit-id",
@@ -209,30 +187,3 @@ def compute_plan_assignment(compute_plan, 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 4362748..37542e5 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,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_id
+ valid_osb_payload["service_id"] = test_service.osb_service_id
+ valid_osb_payload["plan_id"] = test_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,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
billing_entity,
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_id
+ valid_osb_payload["service_id"] = test_service.osb_service_id
+ valid_osb_payload["plan_id"] = test_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,
- service,
- service_offering,
+ test_service,
+ test_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(service)
+ org.limit_osb_services.add(test_service)
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_id
+ valid_osb_payload["service_id"] = test_service.osb_service_id
+ valid_osb_payload["plan_id"] = test_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,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
valid_osb_payload,
instance_id,
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_id
+ valid_osb_payload["service_id"] = test_service.osb_service_id
+ valid_osb_payload["plan_id"] = test_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,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
valid_osb_payload,
field_to_remove,
expected_error,
instance_id,
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_id
+ valid_osb_payload["service_id"] = test_service.osb_service_id
+ valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
if isinstance(field_to_remove, tuple):
if field_to_remove[0] == "context":
@@ -251,8 +251,10 @@ 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, service, valid_osb_payload, instance_id):
- valid_osb_payload["service_id"] = service.osb_service_id
+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
valid_osb_payload["plan_id"] = 99999
response = osb_client.put(
@@ -264,17 +266,17 @@ def test_invalid_plan_id_error(osb_client, service, valid_osb_payload, instance_
assert response.status_code == 400
response_data = json.loads(response.content)
assert (
- f"Unknown plan_id: 99999 for service_id: {service.osb_service_id}"
+ f"Unknown plan_id: 99999 for service_id: {test_service.osb_service_id}"
in response_data["error"]
)
@pytest.mark.django_db
def test_empty_users_array_error(
- osb_client, service, service_offering, valid_osb_payload, instance_id
+ osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_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["parameters"]["users"] = []
response = osb_client.put(
@@ -290,10 +292,10 @@ def test_empty_users_array_error(
@pytest.mark.django_db
def test_multiple_users_error(
- osb_client, service, service_offering, valid_osb_payload, instance_id
+ osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_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["parameters"]["users"] = [
{"email": "user1@example.com", "full_name": "User One"},
{"email": "user2@example.com", "full_name": "User Two"},
@@ -312,10 +314,10 @@ def test_multiple_users_error(
@pytest.mark.django_db
def test_empty_email_address_error(
- osb_client, service, service_offering, valid_osb_payload, instance_id
+ osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_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["parameters"]["users"] = [
{"email": "", "full_name": "User With No Email"},
]
@@ -348,14 +350,14 @@ def test_invalid_json_error(osb_client, instance_id):
def test_user_creation_with_name_parsing(
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_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["parameters"]["users"][0]["full_name"] = "John Doe Smith"
response = osb_client.put(
@@ -374,14 +376,14 @@ def test_user_creation_with_name_parsing(
def test_email_normalization(
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_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["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
response = osb_client.put(
@@ -399,14 +401,14 @@ def test_email_normalization(
def test_odoo_integration_failure_handling(
mock_odoo_failure,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
- valid_osb_payload["service_id"] = service.osb_service_id
- valid_osb_payload["plan_id"] = service_offering.osb_plan_id
+ valid_osb_payload["service_id"] = test_service.osb_service_id
+ valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
@@ -423,14 +425,14 @@ def test_odoo_integration_failure_handling(
def test_organization_creation_with_context_only(
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
exoscale_origin,
instance_id,
):
payload = {
- "service_id": service.osb_service_id,
- "plan_id": service_offering.osb_plan_id,
+ "service_id": test_service.osb_service_id,
+ "plan_id": test_service_offering.osb_plan_id,
"context": {
"organization_guid": "fallback-org-guid",
"organization_name": "Fallback Organization",
@@ -460,13 +462,13 @@ def test_organization_creation_with_context_only(
def test_delete_offboarding_success(
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
instance_id,
):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
- f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
+ f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
@@ -474,9 +476,9 @@ def test_delete_offboarding_success(
@pytest.mark.django_db
-def test_delete_missing_service_id(osb_client, service_offering, instance_id):
+def test_delete_missing_service_id(osb_client, test_service_offering, instance_id):
response = osb_client.delete(
- f"/api/osb/v2/service_instances/{instance_id}?plan_id={service_offering.osb_plan_id}"
+ f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 400
@@ -485,9 +487,9 @@ def test_delete_missing_service_id(osb_client, service_offering, instance_id):
@pytest.mark.django_db
-def test_delete_missing_plan_id(osb_client, service, instance_id):
+def test_delete_missing_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
- f"/api/osb/v2/service_instances/{instance_id}?service_id={service.osb_service_id}"
+ f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}"
)
assert response.status_code == 400
@@ -507,16 +509,16 @@ def test_delete_invalid_service_id(osb_client, instance_id):
@pytest.mark.django_db
-def test_delete_invalid_plan_id(osb_client, service, instance_id):
+def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
- f"?service_id={service.osb_service_id}&plan_id=invalid"
+ f"?service_id={test_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: {service.osb_service_id}"
+ f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}"
in response_data["error"]
)
@@ -525,13 +527,13 @@ def test_delete_invalid_plan_id(osb_client, service, instance_id):
def test_patch_suspension_success(
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
instance_id,
):
payload = {
- "service_id": service.osb_service_id,
- "plan_id": service_offering.osb_plan_id,
+ "service_id": test_service.osb_service_id,
+ "plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
@@ -554,9 +556,9 @@ def test_patch_suspension_success(
@pytest.mark.django_db
-def test_patch_missing_service_id(osb_client, service_offering, instance_id):
+def test_patch_missing_service_id(osb_client, test_service_offering, instance_id):
payload = {
- "plan_id": service_offering.osb_plan_id,
+ "plan_id": test_service_offering.osb_plan_id,
"parameters": {"users": []},
}
@@ -572,9 +574,9 @@ def test_patch_missing_service_id(osb_client, service_offering, instance_id):
@pytest.mark.django_db
-def test_patch_missing_plan_id(osb_client, service, instance_id):
+def test_patch_missing_plan_id(osb_client, test_service, instance_id):
payload = {
- "service_id": service.osb_service_id,
+ "service_id": test_service.osb_service_id,
"parameters": {"users": []},
}
@@ -607,8 +609,8 @@ def test_delete_creates_ticket_with_admin_links(
mocker,
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
instance_id,
):
# Mock the create_helpdesk_ticket function
@@ -616,7 +618,7 @@ def test_delete_creates_ticket_with_admin_links(
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
- f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
+ f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
@@ -627,10 +629,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"/{service_offering.pk}/" in call_kwargs["description"]
+ assert f"/{test_service_offering.pk}/" in call_kwargs["description"]
assert (
call_kwargs["title"]
- == f"Exoscale OSB Offboard - {service.name} - {instance_id}"
+ == f"Exoscale OSB Offboard - {test_service.name} - {instance_id}"
)
@@ -639,8 +641,8 @@ def test_patch_creates_ticket_with_user_admin_links(
mocker,
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
instance_id,
org_owner,
):
@@ -648,8 +650,8 @@ def test_patch_creates_ticket_with_user_admin_links(
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
payload = {
- "service_id": service.osb_service_id,
- "plan_id": service_offering.osb_plan_id,
+ "service_id": test_service.osb_service_id,
+ "plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
@@ -678,7 +680,8 @@ 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 - {service.name} - {instance_id}"
+ call_kwargs["title"]
+ == f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
)
@@ -687,8 +690,8 @@ def test_ticket_includes_organization_and_instance_when_found(
mocker,
mock_odoo_success,
osb_client,
- service,
- service_offering,
+ test_service,
+ test_service_offering,
organization,
):
# Mock the create_helpdesk_ticket function
@@ -696,12 +699,12 @@ def test_ticket_includes_organization_and_instance_when_found(
service_definition = ServiceDefinition.objects.create(
name="Test Definition",
- service=service,
+ service=test_service,
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
)
control_plane = ControlPlane.objects.create(
name="Test Control Plane",
- cloud_provider=service_offering.provider,
+ cloud_provider=test_service_offering.provider,
api_credentials={
"certificate-authority-data": "test",
"server": "https://test",
@@ -709,22 +712,20 @@ def test_ticket_includes_organization_and_instance_when_found(
},
)
crd = ControlPlaneCRD.objects.create(
- service_offering=service_offering,
+ service_offering=test_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={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
+ f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
@@ -738,10 +739,7 @@ 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 (format: "display_name (resource_name)")
- assert (
- f"Instance: {service_instance.display_name} ({service_instance.name})"
- in call_kwargs["description"]
- )
+ # Check instance is included
+ assert f"Instance: {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 2a010ed..014f1f2 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": "display_name",
+ "controlplane_field_mapping": "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):
- display_name = models.CharField(max_length=100)
+ 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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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={"display_name": "test-service", "environment": "dev"})
+ form = form_class(data={"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={"display_name": "test-service", "environment": "prod"})
+ form = form_class(data={"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={"display_name": "test-service", "environment": "invalid"})
+ form = form_class(data={"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": "display_name",
+ "controlplane_field_mapping": "name",
},
{
"type": "choice",
@@ -399,7 +399,7 @@ def test_admin_form_validates_choice_values_against_schema():
{
"type": "text",
"label": "Name",
- "controlplane_field_mapping": "display_name",
+ "controlplane_field_mapping": "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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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["display_name"].initial == "default-name"
+ assert form.fields["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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "name",
"default_value": "default-name",
},
{
@@ -597,11 +597,11 @@ def test_default_value_not_override_existing_instance():
]
}
- instance = TestModel(display_name="existing-name", port=3000)
+ instance = TestModel(name="existing-name", port=3000)
form_class = generate_custom_form_class(form_config, TestModel)
form = form_class(instance=instance)
- assert form.initial["display_name"] == "existing-name"
+ assert form.initial["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": "display_name",
+ "controlplane_field_mapping": "name",
"max_length": "", # Empty string
},
]
@@ -744,7 +744,7 @@ def test_single_element_choices_are_normalized():
{
"type": "text",
"label": "Name",
- "controlplane_field_mapping": "display_name",
+ "controlplane_field_mapping": "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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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["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"]
+ 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"]
def test_field_with_default_config_can_override_defaults():
class TestModel(models.Model):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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["display_name"]
+ name_field = form.fields["name"]
assert name_field.label == "Custom Name Label"
assert name_field.required is False
- assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
+ assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"]
def test_empty_values_dont_override_default_configs():
class TestModel(models.Model):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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["display_name"]
+ name_field = form.fields["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.max_length == DEFAULT_FIELD_CONFIGS["display_name"]["max_length"]
+ 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.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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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={"display_name": "test-service", "port": 0})
+ form = form_class(data={"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={"display_name": "test-service", "port": 65536})
+ form = form_class(data={"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={"display_name": "test-service", "port": 8080})
+ form = form_class(data={"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):
- display_name = models.CharField(max_length=100)
+ 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": "display_name",
+ "controlplane_field_mapping": "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={"display_name": "test-instance", "disk_size": "25"})
+ form = form_class(data={"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 f0f0277..714d29a 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, control_plane):
+ def test_reencrypt_fields_with_control_plane(self, test_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, control_plane):
+ def test_sync_billing_metadata_dry_run_with_control_plane(self, test_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 control_plane.name in output
+ assert test_control_plane.name in output
diff --git a/src/tests/test_rules.py b/src/tests/test_rules.py
deleted file mode 100644
index 9a83163..0000000
--- a/src/tests/test_rules.py
+++ /dev/null
@@ -1,151 +0,0 @@
-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
deleted file mode 100644
index 794fad2..0000000
--- a/src/tests/test_service_models.py
+++ /dev/null
@@ -1,349 +0,0 @@
-"""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 " |