From eaa06148396e3ef7223e3720b6b2f1ecdf29c13c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 26 Mar 2025 15:25:47 +0100 Subject: [PATCH 1/4] Fix fallback values in model generation --- src/servala/core/crd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 1c2bcef..6fc4219 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -26,8 +26,8 @@ def generate_django_model(schema, group, version, kind): def build_object_fields(schema, name, verbose_name_prefix=None): - required_fields = schema.get("required", []) - properties = schema.get("properties", {}) + required_fields = schema.get("required") or [] + properties = schema.get("properties") or {} fields = {} for field_name, field_schema in properties.items(): From ebf88527fe866a93a3be7060d23ecf7694c1412a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 26 Mar 2025 15:26:14 +0100 Subject: [PATCH 2/4] Simplify CRD and schema retrieval --- src/servala/core/models/service.py | 85 ++++++------------------------ 1 file changed, 15 insertions(+), 70 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 0b28a7a..c8c1867 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -318,81 +318,26 @@ class ServiceOfferingControlPlane(models.Model): version = self.service_definition.api_definition["version"] client = self.control_plane.get_kubernetes_client() - # We have to determine the ``name`` first, via the discovery API - # The official kubernetes client APIs only include the regular discovery API, - # but not the APIGroupDiscoveryList that we actually need, and passing it as - # response_type leads to the client receiving the data, but refusing to return - # it since the model is not defined. Instead, we have to manually - # construct the API call: - content_type = "application/json" - response_type = "APIGroupDiscoveryList" - accept_header = ",".join( - [ - f"{content_type};g=apidiscovery.k8s.io;v={api_version};as={response_type}" - for api_version in ("v2", "v2beta1") - ] - ) - accept_header = f"{accept_header},{content_type}" - groups = client.call_api( - "/apis", - "GET", - header_params={"Accept": accept_header}, - auth_settings=["BearerToken"], - _return_http_data_only=True, - _preload_content=False, - ).json() - api_group = [ - g for g in groups.get("items", []) if g["metadata"]["name"] == group - ][0] - api_version = [v for v in api_group["versions"] if v["version"] == version][0] - resource = [ - r for r in api_version["resources"] if r["responseKind"]["kind"] == kind - ][0] - # This is the thing we went to all that effort for! - name = f"{resource['resource']}.{group}" - extensions_api = kubernetes.client.ApiextensionsV1Api(client) - crd = extensions_api.read_custom_resource_definition(name) - return crd + crds = extensions_api.list_custom_resource_definition() + matching_crd = None + for crd in crds.items: + if matching_crd: + break + if crd.spec.group == group: + for served_version in crd.spec.versions: + if served_version.name == version and served_version.served: + if crd.spec.names.kind == kind: + matching_crd = crd + break + return matching_crd @cached_property def resource_schema(self): - # We extract the schema directly from the API. As an alternative, you can - # also get it using self.resource_definition, though that requires some - # extra serialization to remove the API client types: - # for version in self.resource_definition.spec.versions: - # if self.service_definition.api_definition["version"] != version.name: - # continue - # if version.schema and version.schema.open_apiv3_schema: - # schema_dict = kubernetes.client.ApiClient().sanitize_for_serialization( - # version.schema.open_apiv3_schema - # ) - # return schema_dict - 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() - response = client.call_api( - f"/openapi/v3/apis/{group}/{version}", - "GET", - header_params={"Accept": "application/json"}, - auth_settings=["BearerToken"], - _return_http_data_only=True, - _preload_content=False, - ).json() - for schema in response["components"]["schemas"].values(): - gkvs = schema.get("x-kubernetes-group-version-kind") - if not gkvs or not isinstance(gkvs, list): - continue - if any( - [ - gkv["group"] == group - and gkv["version"] == version - and gkv["kind"] == kind - for gkv in gkvs - ] - ): - return schema + for v in self.resource_definition.spec.versions: + if v.name == version: + return v.schema.open_apiv3_schema.to_dict() @cached_property def django_model(self): From 70acf2c381abc054c3d02c458bccf6bc74512558 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 26 Mar 2025 15:38:25 +0100 Subject: [PATCH 3/4] Add and document database cache --- README.md | 1 + docker/run.sh | 1 + src/servala/settings.py | 7 +++++++ 3 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 07e1764..ba6fdcd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Then use ``uv`` to install the project and run its commands while you’re devel ```bash uv sync --dev uv run --env-file=.env src/manage.py migrate +uv run --env-file=.env src/manage.py createcachetable uv run --env-file=.env src/manage.py runserver ``` diff --git a/docker/run.sh b/docker/run.sh index f3c7f9a..bab8a08 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -8,6 +8,7 @@ export XDG_CONFIG_HOME="/app/config" echo "Applying database migrations" uv run src/manage.py migrate +uv run src/manage.py createcachetable echo "Starting Caddy" exec caddy run --config /app/config/caddy/Caddyfile --adapter caddyfile 2>&1 & diff --git a/src/servala/settings.py b/src/servala/settings.py index 5d670ae..3e27e35 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -132,6 +132,13 @@ STATIC_URL = "static/" # CSS, JavaScript, etc. STATIC_ROOT = BASE_DIR / "static.dist" MEDIA_URL = "media/" # User uploads, e.g. images +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "servala_cache", + } +} + # Additional locations of static files STATICFILES_FINDERS = ( "django.contrib.staticfiles.finders.FileSystemFinder", From 83533129bd438f6369df5d4a14195671568a10af Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 26 Mar 2025 15:39:34 +0100 Subject: [PATCH 4/4] Cache resource schemas for a full day --- src/servala/core/models/service.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index c8c1867..c4d2b79 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,4 +1,5 @@ import kubernetes +from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models from django.utils.functional import cached_property @@ -334,10 +335,17 @@ class ServiceOfferingControlPlane(models.Model): @cached_property def resource_schema(self): + cache_key = f"servala:crd:schema:{self.pk}" + 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: - return v.schema.open_apiv3_schema.to_dict() + result = v.schema.open_apiv3_schema.to_dict() + timeout_seconds = 60 * 60 * 24 + cache.set(cache_key, result, timeout=timeout_seconds) + return result @cached_property def django_model(self):