Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
52 changes: 47 additions & 5 deletions operator/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,44 @@
_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"
_DEFAULT_MEM_LIMIT: str = "1Gi"
_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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
63 changes: 63 additions & 0 deletions tests/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import sys
from typing import Any, Dict, List

import kopf
import pytest

# Make 'operator/handlers.py' importable.
Expand Down Expand Up @@ -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 ------------------------------------------------------


Expand Down Expand Up @@ -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"
Loading