diff --git a/pyproject.toml b/pyproject.toml index 9f7812f..51ae0c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ known_first_party = "servala" [tool.flake8] max-line-length = 160 exclude = ".venv" -ignore = "E203" +ignore = "E203,W503" [tool.djlint] extend_exclude = "src/servala/static/mazer" diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 746d5da..740b7bf 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -126,6 +126,7 @@ class ControlPlaneAdmin(admin.ModelAdmin): search_fields = ("name", "description") autocomplete_fields = ("cloud_provider",) actions = ["test_kubernetes_connection"] + ordering = ("name",) fieldsets = ( ( diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 82d00da..baca840 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -355,11 +355,27 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): def __str__(self): return f"{self.service_offering} on {self.control_plane} with {self.service_definition}" + @cached_property + def group(self): + return self.service_definition.api_definition["group"] + + @cached_property + def version(self): + return self.service_definition.api_definition["version"] + + @cached_property + def kind(self): + return self.service_definition.api_definition["kind"] + + @cached_property + def kind_plural(self): + plural = self.kind.lower() + if not plural.endswith("s"): + plural = f"{plural}s" + return plural + @cached_property def resource_definition(self): - kind = self.service_definition.api_definition["kind"] - group = self.service_definition.api_definition["group"] - version = self.service_definition.api_definition["version"] client = self.control_plane.get_kubernetes_client() extensions_api = kubernetes.client.ApiextensionsV1Api(client) @@ -368,10 +384,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): for crd in crds.items: if matching_crd: break - if crd.spec.group == group: + if crd.spec.group == self.group: for served_version in crd.spec.versions: - if served_version.name == version and served_version.served: - if crd.spec.names.kind == kind: + if served_version.name == self.version and served_version.served: + if crd.spec.names.kind == self.kind: matching_crd = crd break return matching_crd @@ -382,9 +398,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): if result := cache.get(cache_key): return result - version = self.service_definition.api_definition["version"] for v in self.resource_definition.spec.versions: - if v.name == version: + if v.name == self.version: result = v.schema.open_apiv3_schema.to_dict() timeout_seconds = 60 * 60 * 24 cache.set(cache_key, result, timeout=timeout_seconds) @@ -395,9 +410,9 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): from servala.core.crd import generate_django_model kwargs = { - key: value - for key, value in self.service_definition.api_definition.items() - if key in ("group", "version", "kind") + "group": self.group, + "version": self.version, + "kind": self.kind, } return generate_django_model(self.resource_schema, **kwargs) @@ -517,12 +532,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): ) try: - group = context.service_definition.api_definition["group"] - version = context.service_definition.api_definition["version"] - kind = context.service_definition.api_definition["kind"] create_data = { - "apiVersion": f"{group}/{version}", - "kind": kind, + "apiVersion": f"{context.group}/{context.version}", + "kind": context.kind, "metadata": { "name": name, "namespace": organization.namespace, @@ -534,15 +546,11 @@ class ServiceInstance(ServalaModelMixin, models.Model): api_instance = client.CustomObjectsApi( context.control_plane.get_kubernetes_client() ) - plural = kind.lower() - if not plural.endswith("s"): - plural = f"{plural}s" - api_instance.create_namespaced_custom_object( - group=group, - version=version, + group=context.group, + version=context.version, namespace=organization.namespace, - plural=plural, + plural=context.kind_plural, body=create_data, ) except Exception as e: @@ -556,3 +564,95 @@ class ServiceInstance(ServalaModelMixin, models.Model): raise ValidationError(_("Kubernetes API error: {}").format(str(e))) raise ValidationError(_("Error creating instance: {}").format(str(e))) return instance + + @cached_property + def kubernetes_object(self): + """Fetch the Kubernetes custom resource object""" + try: + api_instance = client.CustomObjectsApi( + self.context.control_plane.get_kubernetes_client() + ) + + return api_instance.get_namespaced_custom_object( + group=self.context.group, + version=self.context.version, + namespace=self.organization.namespace, + plural=self.context.kind_plural, + name=self.name, + ) + except ApiException as e: + if e.status == 404: + return None + raise + + @cached_property + def spec(self): + if not self.kubernetes_object: + return {} + if not (spec := self.kubernetes_object.get("spec")): + return {} + + # Remove fields that shouldn't be displayed + spec = spec.copy() + spec.pop("resourceRef", None) + spec.pop("writeConnectionSecretToRef", None) + return spec + + @cached_property + def status_conditions(self): + if not self.kubernetes_object: + return [] + if not (status := self.kubernetes_object.get("status")): + return [] + return status.get("conditions") or [] + + @cached_property + def connection_credentials(self): + """ + Get connection credentials directly from the resource's writeConnectionSecretToRef + after checking that secret conditions are available. + """ + if not self.kubernetes_object: + return {} + + # Check if secrets are available based on conditions + secrets_available = any( + [ + condition.get("type") == "Ready" and condition.get("status") == "True" + for condition in self.status_conditions + ] + ) + if not secrets_available: + return {} + + spec = self.kubernetes_object.get("spec") + if not (secret_ref := spec.get("writeConnectionSecretToRef")): + return {} + if not (secret_name := secret_ref.get("name")): + return {} + + try: + # Get the secret data + v1 = kubernetes.client.CoreV1Api( + self.context.control_plane.get_kubernetes_client() + ) + secret = v1.read_namespaced_secret( + name=secret_name, namespace=self.organization.namespace + ) + + # Secret data is base64 encoded + credentials = {} + if hasattr(secret, "data") and secret.data: + import base64 + + for key, value in secret.data.items(): + try: + credentials[key] = base64.b64decode(value).decode("utf-8") + except Exception: + credentials[key] = f"" + + return credentials + except ApiException as e: + return {"error": str(e)} + except Exception as e: + return {"error": str(e)} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index 7a949f1..b9cf4dc 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -7,11 +7,13 @@ {% endblock html_title %} {% block content %}
-
-
-
-
-
{% translate "Details" %}
+
+
+
+
+

{% translate "Details" %}

+
+
{% translate "Service" %}
@@ -49,6 +51,148 @@
+ {% if instance.status_conditions %} +
+
+
+

{% translate "Status" %}

+
+
+
+
+ + + + + + + + + + + + {% for condition in instance.status_conditions %} + + + + + + + + {% endfor %} + +
{% translate "Type" %}{% translate "Status" %}{% translate "Last Transition Time" %}{% translate "Reason" %}{% translate "Message" %}
{{ condition.type }} + {% if condition.status == "True" %} + True + {% elif condition.status == "False" %} + False + {% else %} + {{ condition.status }} + {% endif %} + {{ condition.lastTransitionTime }}{{ condition.reason }}{{ condition.message }}
+
+
+
+
+
+ {% endif %} + {% if instance.spec %} +
+
+
+

{% translate "Specification" %}

+
+
+
+
+ + + +
+ {% for fieldset in spec_fieldsets %} +
+ +
+ {% for field in fieldset.fields %} +
{{ field.label }}
+
+ {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} +
{{ field.value|pprint }}
+ {% else %} + {{ field.value }} + {% endif %} +
+ {% endfor %} +
+ + {% for sub_key, sub_fieldset in fieldset.fieldsets.items %} +
{{ sub_fieldset.title }}
+
+ {% for field in sub_fieldset.fields %} +
{{ field.label }}
+
+ {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} +
{{ field.value|pprint }}
+ {% else %} + {{ field.value }} + {% endif %} +
+ {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+
+
+
+
+
+ {% endif %} + {% if instance.connection_credentials %} +
+
+

{% translate "Connection Credentials" %}

+
+
+
+ + + + + + + + + {% for key, value in instance.connection_credentials.items %} + + + + + {% endfor %} + +
{% translate "Name" %}{% translate "Value" %}
{{ key }} + {% if key == "error" %} + {{ value }} + {% else %} + {{ value }} + {% endif %} +
+
+
+
+ {% endif %}
{% endblock content %} diff --git a/src/servala/frontend/templatetags/pprint_filters.py b/src/servala/frontend/templatetags/pprint_filters.py new file mode 100644 index 0000000..e20772e --- /dev/null +++ b/src/servala/frontend/templatetags/pprint_filters.py @@ -0,0 +1,12 @@ +import json + +from django import template + +register = template.Library() + + +@register.filter +def pprint(value): + if isinstance(value, (dict, list)): + return json.dumps(value, indent=2) + return value diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 6ee6b1e..b6ecc42 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -4,6 +4,7 @@ from django.shortcuts import redirect from django.utils.functional import cached_property from django.views.generic import DetailView, ListView +from servala.core.crd import deslugify from servala.core.models import ( ControlPlaneCRD, Service, @@ -168,6 +169,116 @@ class ServiceInstanceDetailView(OrganizationViewMixin, DetailView): "context__service_definition__service", ) + def get_nested_spec(self): + """ + Organize spec data into fieldsets similar to how the form does it. + """ + spec = self.object.spec or {} + + # Process spec fields + others = [] + nested_fieldsets = {} + + # First pass: organize fields into nested structures + for key, value in spec.items(): + if isinstance(value, dict): + # This is a nested structure + if key not in nested_fieldsets: + nested_fieldsets[key] = { + "title": deslugify(key), + "fields": [], + "fieldsets": {}, + } + + # Process fields in the nested structure + for sub_key, sub_value in value.items(): + if isinstance(sub_value, dict): + # Even deeper nesting + if sub_key not in nested_fieldsets[key]["fieldsets"]: + nested_fieldsets[key]["fieldsets"][sub_key] = { + "title": deslugify(sub_key), + "fields": [], + } + + # Add fields from the deeper level + for leaf_key, leaf_value in sub_value.items(): + nested_fieldsets[key]["fieldsets"][sub_key][ + "fields" + ].append( + { + "key": leaf_key, + "label": deslugify(leaf_key), + "value": leaf_value, + } + ) + else: + # Add field to parent level + nested_fieldsets[key]["fields"].append( + { + "key": sub_key, + "label": deslugify(sub_key), + "value": sub_value, + } + ) + else: + # This is a top-level field + others.append( + { + "key": key, + "label": deslugify(key), + "value": value, + } + ) + + # Second pass: Promote fields based on count + for group_key, group in list(nested_fieldsets.items()): + # Promote single sub-fieldsets to parent + for sub_key, sub_fieldset in list(group["fieldsets"].items()): + if len(sub_fieldset["fields"]) == 1: + field = sub_fieldset["fields"][0] + field["label"] = f"{sub_fieldset['title']}: {field['label']}" + group["fields"].append(field) + del group["fieldsets"][sub_key] + + # Move single-field groups to others + total_fields = len(group["fields"]) + for sub_fieldset in group["fieldsets"].values(): + total_fields += len(sub_fieldset["fields"]) + + if ( + total_fields == 1 + and len(group["fields"]) == 1 + and not group["fieldsets"] + ): + field = group["fields"][0] + field["label"] = f"{group['title']}: {field['label']}" + others.append(field) + del nested_fieldsets[group_key] + elif total_fields == 0: + del nested_fieldsets[group_key] + + fieldsets = [] + if others: + fieldsets.append( + { + "title": "General", + "fields": others, + "fieldsets": {}, + } + ) + # Create fieldsets from the organized data + for group in nested_fieldsets.values(): + fieldsets.append(group) + + return fieldsets + + def get_context_data(self, **kwargs): + """Return service instance for the current organization.""" + context = super().get_context_data(**kwargs) + if self.object.spec: + context["spec_fieldsets"] = self.get_nested_spec() + return context + class ServiceInstanceListView(OrganizationViewMixin, ListView): template_name = "frontend/organizations/service_instances.html"