From 34df5b9e0e146cba7a39dce16dc56ea14c2f70c7 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Mon, 8 Jun 2026 08:08:38 +0100 Subject: [PATCH] feat(5): derive image from required imageTag and add SVO overrides The Data Manager now provides only an image tag (`spec.imDataManager.imageTag`) rather than a full image. The operator keeps the image repository as configuration and combines it with the tag. - `imageTag` is required; a missing tag raises `kopf.PermanentError` (an unrecoverable error, so the operator does not retry). - `_DEFAULT_IMAGE` is now the repository (no tag) and can be overridden by the `SVO_IMAGE` environment variable. - `_DEFAULT_INGRESS_CLASS` can be overridden by the `SVO_INGRESS_CLASS` environment variable. - `_DEFAULT_INGRESS_PROXY_BODY_SIZE` reduced from `500m` to `1m`. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 19 ++++++++++--- operator/handlers.py | 52 ++++++++++++++++++++++++++++++---- tests/test_handlers.py | 63 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1600b7e..1a0f14c 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,17 @@ creates a custom resource and this operator responds by creating a Kubernetes **Deployment**, **Service** and **Ingress** that run the [squonk2-viz-app] container image. -By default, the operator creates instances using the image: - +The Data Manager supplies only the image **tag** (via +`spec.imDataManager.imageTag`); the operator combines it with the image +repository it is configured with: - -- `ghcr.io/informaticsmatters/squonk2-viz-app:0.1.4` (see `operator/handlers.py`) +- `ghcr.io/informaticsmatters/squonk2-viz-app` (see `operator/handlers.py`, + overridable with the `SVO_IMAGE` environment variable) + +So, given an `imageTag` of `0.1.4`, the operator runs +`ghcr.io/informaticsmatters/squonk2-viz-app:0.1.4`. The `imageTag` is +**required** — if it is missing the operator treats the resource as an +unrecoverable error and does not retry. ## The Custom Resource @@ -35,8 +43,9 @@ The operator watches for the following Custom Resource: - - **Kind**: `DataVisualisation` (plural `datavisualisations`) Data-Manager-provided material is namespaced under the `imDataManager` property -of the resource `spec`. Recognised properties (all optional, with operator -defaults) include `image`, `serviceAccountName`, `resources`, +of the resource `spec`. The only **required** property is `imageTag` (the +container image tag, e.g. `0.1.4`). The remaining properties are optional, with +operator defaults, and include `serviceAccountName`, `resources`, `securityContext` (`runAsUser`, `runAsGroup`), `project` (`claimName`, `id`), `ingressClass`, `ingressDomain`, `ingressTlsSecret`, `ingressProxyBodySize`, `imagePullSecrets` (a list of Secret names) and `labels` (a list of @@ -70,6 +79,8 @@ variables are prefixed `SVO_` (Squonk2 Viz Operator): - | `INGRESS_DOMAIN` | _(required)_ | Default ingress host for instances | | `INGRESS_TLS_SECRET` | _(unset)_ | Default TLS secret; if unset, cert-manager is used | | `INGRESS_CERT_ISSUER` | _(unset)_ | cert-manager cluster issuer (when no TLS secret) | +| `SVO_IMAGE` | `ghcr.io/informaticsmatters/squonk2-viz-app` | Image repository (the tag comes from `imageTag`) | +| `SVO_INGRESS_CLASS` | `nginx` | Default ingress class for instances | | `SVO_IMAGE_PULL_SECRET` | _(unset)_ | Name of a `dockerconfigjson` Secret for the (private) image registry | | `SVO_POD_NODE_SELECTOR_KEY` | `informaticsmatters.com/purpose-application` | Pod node-selector key | | `SVO_POD_NODE_SELECTOR_VALUE` | `yes` | Pod node-selector value | diff --git a/operator/handlers.py b/operator/handlers.py index b986070..3ec0c10 100644 --- a/operator/handlers.py +++ b/operator/handlers.py @@ -44,7 +44,12 @@ _PROJECT_MOUNT_PATH: str = "/project" # Some (key) default deployment variables... -_DEFAULT_IMAGE: str = "ghcr.io/informaticsmatters/squonk2-viz-app:0.1.4" +# +# The container image *repository* (without a tag). The tag is supplied +# per-instance by the Data Manager via 'spec.imDataManager.imageTag'; this +# default can be overridden by the SVO_IMAGE environment variable (see +# _get_default_image). +_DEFAULT_IMAGE: str = "ghcr.io/informaticsmatters/squonk2-viz-app" _DEFAULT_SA: str = "default" _DEFAULT_CPU_LIMIT: str = "1" _DEFAULT_CPU_REQUEST: str = "10m" @@ -52,11 +57,31 @@ _DEFAULT_MEM_REQUEST: str = "256Mi" _DEFAULT_USER_ID: int = 1000 _DEFAULT_GROUP_ID: int = 100 -_DEFAULT_INGRESS_PROXY_BODY_SIZE: str = "500m" -# The ingress class +_DEFAULT_INGRESS_PROXY_BODY_SIZE: str = "1m" +# The ingress class (overridable by SVO_INGRESS_CLASS; see +# _get_default_ingress_class). _DEFAULT_INGRESS_CLASS: str = "nginx" +def _get_default_image() -> str: + """The default container image repository (without a tag). + + Defaults to the public viz-app repository but can be replaced by the + SVO_IMAGE environment variable (e.g. to use a private registry mirror). + """ + return os.environ.get("SVO_IMAGE", _DEFAULT_IMAGE) + + +def _get_default_ingress_class() -> str: + """The default ingress class. + + Defaults to 'nginx' but can be replaced by the SVO_INGRESS_CLASS + environment variable. The user can still override it per-instance via the + custom resource. + """ + return os.environ.get("SVO_INGRESS_CLASS", _DEFAULT_INGRESS_CLASS) + + def _get_default_ingress_domain() -> str: """The default ingress domain. @@ -117,6 +142,21 @@ def _get_image_pull_secrets() -> List[str]: return [name] if name else [] +def build_image(*, image: str, image_tag: Optional[str]) -> str: + """Combine the image repository and tag into a full image reference. + + The Data Manager supplies the tag via 'spec.imDataManager.imageTag'. It is + mandatory: its absence is an unrecoverable error, so we raise a kopf + PermanentError (which stops the operator retrying) rather than guessing a + tag. + """ + if not image_tag: + raise kopf.PermanentError( + "spec.imDataManager.imageTag is required but was not provided" + ) + return f"{image}:{image_tag}" + + def image_pull_policy_for(image: str) -> str: """Return the imagePullPolicy appropriate for the given image reference. @@ -388,7 +428,9 @@ def create(spec: Dict[str, Any], name: str, namespace: str, **_: Any) -> Dict[st # All Data-Manager provided material is namespaced under 'imDataManager'. material: Dict[str, Any] = spec.get("imDataManager", {}) - image = material.get("image", _DEFAULT_IMAGE) + # The Data Manager provides only the image *tag* (e.g. '0.1.4'); the + # repository is operator configuration. A missing tag is unrecoverable. + image = build_image(image=_get_default_image(), image_tag=material.get("imageTag")) image_pull_policy = image_pull_policy_for(image) service_account = material.get("serviceAccountName", _DEFAULT_SA) @@ -419,7 +461,7 @@ def create(spec: Dict[str, Any], name: str, namespace: str, **_: Any) -> Dict[st ingress_proxy_body_size = material.get( "ingressProxyBodySize", _DEFAULT_INGRESS_PROXY_BODY_SIZE ) - ingress_class = material.get("ingressClass", _DEFAULT_INGRESS_CLASS) + ingress_class = material.get("ingressClass", _get_default_ingress_class()) ingress_domain = material.get("ingressDomain", _get_default_ingress_domain()) ingress_tls_secret = material.get( "ingressTlsSecret", _get_default_ingress_tls_secret() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 93f2948..2cd5feb 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -11,6 +11,7 @@ import sys from typing import Any, Dict, List +import kopf import pytest # Make 'operator/handlers.py' importable. @@ -53,6 +54,47 @@ def _example_deployment(**overrides: Any) -> Dict[str, Any]: return handlers.build_deployment_body(**kwargs) +# --- image ------------------------------------------------------------------ + + +def test_build_image_combines_repository_and_tag() -> None: + assert ( + handlers.build_image( + image="ghcr.io/informaticsmatters/squonk2-viz-app", image_tag="0.1.4" + ) + == "ghcr.io/informaticsmatters/squonk2-viz-app:0.1.4" + ) + + +def test_build_image_without_tag_is_a_permanent_error() -> None: + # A missing imageTag is unrecoverable - the operator must not retry. + with pytest.raises(kopf.PermanentError): + handlers.build_image( + image="ghcr.io/informaticsmatters/squonk2-viz-app", image_tag=None + ) + + +def test_build_image_with_empty_tag_is_a_permanent_error() -> None: + with pytest.raises(kopf.PermanentError): + handlers.build_image( + image="ghcr.io/informaticsmatters/squonk2-viz-app", image_tag="" + ) + + +def test_default_image_falls_back_to_the_constant( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SVO_IMAGE", raising=False) + assert handlers._get_default_image() == handlers._DEFAULT_IMAGE + + +def test_default_image_is_overridden_by_svo_image( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SVO_IMAGE", "registry.example.com/viz-app") + assert handlers._get_default_image() == "registry.example.com/viz-app" + + # --- image pull policy ------------------------------------------------------ @@ -257,3 +299,24 @@ def test_build_ingress_body_uses_cert_manager_without_tls_secret() -> None: ) annotations = body["metadata"]["annotations"] assert annotations["cert-manager.io/cluster-issuer"] == "letsencrypt" + + +# --- ingress config --------------------------------------------------------- + + +def test_default_ingress_proxy_body_size_is_one_megabyte() -> None: + assert handlers._DEFAULT_INGRESS_PROXY_BODY_SIZE == "1m" + + +def test_default_ingress_class_falls_back_to_the_constant( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SVO_INGRESS_CLASS", raising=False) + assert handlers._get_default_ingress_class() == handlers._DEFAULT_INGRESS_CLASS + + +def test_default_ingress_class_is_overridden_by_svo_ingress_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SVO_INGRESS_CLASS", "traefik") + assert handlers._get_default_ingress_class() == "traefik"