From 29e2584f04e2e67b9641787efdfa01aa1d23e75e Mon Sep 17 00:00:00 2001 From: convee Date: Thu, 10 Sep 2026 23:41:25 +0800 Subject: [PATCH] helm: let the chart use an external PostgreSQL `postgresql.embedded.enabled: false` already dropped the bundled StatefulSet, its Service and its ingress NetworkPolicy, but the Control Plane kept a hardcoded in-cluster host and had no port of its own, so the release dialled a Service the same render had declined to create. `postgresql.external.host` and `postgresql.external.port` now supply SANDBOX_DB_HOST and SANDBOX_DB_PORT. The credential contract is unchanged - `postgresql.authSecret` is mounted in either mode, so an external server only needs a Secret carrying its own values, and the database and role must already exist there. An empty host fails `helm template`. The Control Plane's own default is `sandbox-postgres`, so a silent fallback would ship a release that starts, reports ready, and answers every request from a Service that does not exist. --- CHANGELOG.md | 13 ++++++ README.md | 2 +- charts/sandbox/templates/resources.yaml | 4 +- charts/sandbox/values.schema.json | 11 ++++- charts/sandbox/values.yaml | 11 +++++ docs/DEPLOYMENT.md | 34 ++++++++++++++ tests/test_helm_package.py | 60 +++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eef849a..4fc1e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,19 @@ Versioning after its first public release. and once more on the bytes about to reach PyPI. - `twine check --strict` runs on the wheel and sdist before a tag is spent, because PyPI rejects an unrenderable long description only after the version number is consumed. +- The Helm chart can point the Control Plane at a PostgreSQL you operate instead + of the StatefulSet it ships. `postgresql.embedded.enabled: false` already + dropped that StatefulSet, its Service and its ingress NetworkPolicy, but the + Control Plane kept a hardcoded in-cluster host and no port of its own, so the + release dialled a Service the same render had declined to create. + `postgresql.external.host` and `postgresql.external.port` now supply + `SANDBOX_DB_HOST` and `SANDBOX_DB_PORT`; the credential contract is unchanged + - `postgresql.authSecret` is mounted in either mode - so an external server + only needs a Secret carrying its own `database`, `username` and `password`, + and the database and role must already exist there. An empty + `postgresql.external.host` fails `helm template` rather than falling back to + `sandbox-postgres`, which is the process default and would have produced a + release that starts, reports ready, and answers from a Service that is gone. ### Changed diff --git a/README.md b/README.md index 6191257..b83da9b 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ operation fails; it never falls back to running on the host. ```bash make bootstrap # create .venv and install SDK + test dependencies -make test # 878 unit and contract tests, no network, no cluster +make test # 880 unit and contract tests, no network, no cluster make verify # complete Python, Console, manifest, Helm, wheel gate make help # every Make target with its one-line description ``` diff --git a/charts/sandbox/templates/resources.yaml b/charts/sandbox/templates/resources.yaml index 9cd1fa8..6377d7a 100644 --- a/charts/sandbox/templates/resources.yaml +++ b/charts/sandbox/templates/resources.yaml @@ -463,7 +463,9 @@ spec: - name: SANDBOX_STORE_BACKEND value: postgresql - name: SANDBOX_DB_HOST - value: sandbox-postgres + value: {{ if .Values.postgresql.embedded.enabled }}sandbox-postgres{{ else }}{{ required "postgresql.external.host is required when postgresql.embedded.enabled is false" .Values.postgresql.external.host }}{{ end }} + - name: SANDBOX_DB_PORT + value: {{ .Values.postgresql.external.port | default 5432 | quote }} - name: SANDBOX_DB_NAME valueFrom: secretKeyRef: diff --git a/charts/sandbox/values.schema.json b/charts/sandbox/values.schema.json index 73a9300..2ee905f 100644 --- a/charts/sandbox/values.schema.json +++ b/charts/sandbox/values.schema.json @@ -52,9 +52,18 @@ "postgresql": { "type": "object", "additionalProperties": false, - "required": ["embedded", "authSecret", "storageClass", "storageSize"], + "required": ["embedded", "external", "authSecret", "storageClass", "storageSize"], "properties": { "embedded": {"$ref": "#/$defs/feature"}, + "external": { + "type": "object", + "additionalProperties": false, + "required": ["host", "port"], + "properties": { + "host": {"type": "string"}, + "port": {"type": "integer", "minimum": 1, "maximum": 65535} + } + }, "authSecret": {"type": "string", "minLength": 1}, "storageClass": {"type": "string"}, "storageSize": {"type": "string", "minLength": 1} diff --git a/charts/sandbox/values.yaml b/charts/sandbox/values.yaml index 8c0ccac..bd81de9 100644 --- a/charts/sandbox/values.yaml +++ b/charts/sandbox/values.yaml @@ -54,6 +54,17 @@ workspace: postgresql: embedded: enabled: true + # Read only when embedded.enabled is false: the Control Plane dials this + # server instead of the Service the chart creates for it. The credential + # contract does not change with the mode - `authSecret` is mounted either way, + # so an external server needs a Secret carrying its own `database`, + # `username` and `password`, and the database and role must already exist + # there. The chart never carries credentials in values. + external: + # Required when embedded.enabled is false. The render fails on an empty + # value rather than falling back to the in-cluster Service name. + host: "" + port: 5432 authSecret: sandbox-postgres-auth storageClass: "" storageSize: 4Gi diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index a184070..54b5c4c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -56,6 +56,40 @@ replace these values. Release `package-metadata.json` follows the shared schema and carries the OCI Chart digest plus all four runtime-image digests and their Helm value paths. +### Embedded or external PostgreSQL + +`postgresql.embedded.enabled` decides whether the release carries its own +database. The default is the single-instance StatefulSet, Service and ingress +NetworkPolicy this chart ships; setting it to `false` drops all three and points +the Control Plane at a server you operate: + +```yaml +postgresql: + embedded: + enabled: false + external: + host: postgres.example.net + port: 5432 +``` + +| Values path | Rendered variable | Read by | +| --- | --- | --- | +| `postgresql.embedded.enabled` | `SANDBOX_DB_HOST` (the in-cluster Service, or `external.host`) | `control_plane/core.py` | +| `postgresql.external.port` | `SANDBOX_DB_PORT` | the same reader | + +The credential contract does not change with the mode: `postgresql.authSecret` +is mounted either way and supplies `database`, `username` and `password`, so an +external server only needs a Secret carrying its own values. The database and +role must already exist there - the bundled StatefulSet is what creates the two +it is handed. An empty `postgresql.external.host` fails `helm template` instead +of falling back to the in-cluster Service name, because the Control Plane's own +default is `sandbox-postgres`: the release would start, report ready, and answer +every request from a Service the same render declined to create. + +The Kustomize base keeps its embedded server and the example +`overlays/external-deps/control-plane-external.yaml` remains the source-deployment +path for an out-of-cluster database. + ## Local integration Lima provides the Linux VM in the standalone integration environment; kubeadm diff --git a/tests/test_helm_package.py b/tests/test_helm_package.py index f002242..0f0b369 100644 --- a/tests/test_helm_package.py +++ b/tests/test_helm_package.py @@ -153,6 +153,66 @@ def test_postgres_secret_override_reaches_every_consumer(self) -> None: self.assertEqual(rendered.count(f"name: {secret}"), 5) self.assertIn(f"secretName: {secret}", rendered) + def test_external_postgres_replaces_the_embedded_database(self) -> None: + """Switching off the embedded server must move the address too. + + The chart already omitted the Service and the StatefulSet, but the + Control Plane kept a hardcoded in-cluster host and no port of its own: + a release built for an external server dialled a Service the same + render had declined to create. + """ + if not shutil.which("helm"): + self.skipTest("helm is not installed") + host = "postgres.example.net" + rendered = subprocess.run( + [ + "helm", "template", "sandbox", str(CHART), + "--set", "postgresql.embedded.enabled=false", + "--set-string", f"postgresql.external.host={host}", + "--set", "postgresql.external.port=6432", + ], + check=True, + capture_output=True, + text=True, + ).stdout + documents = [item for item in yaml.safe_load_all(rendered) if item] + named = {(item.get("kind"), item["metadata"]["name"]) for item in documents} + for absent in ("Service", "StatefulSet", "NetworkPolicy"): + self.assertNotIn((absent, "sandbox-postgres"), named) + control_plane = next( + item for item in documents + if item.get("kind") == "Deployment" + and item["metadata"]["name"] == "sandbox-control-plane" + ) + environment = { + item["name"]: item.get("value") + for item in control_plane["spec"]["template"]["spec"]["containers"][0]["env"] + } + self.assertEqual(host, environment["SANDBOX_DB_HOST"]) + # A string, not an integer: the API server refuses a non-string env value. + self.assertEqual("6432", environment["SANDBOX_DB_PORT"]) + + def test_external_postgres_without_a_host_is_refused_at_render(self) -> None: + """The Control Plane's own default is the reason this cannot be silent. + + `SANDBOX_DB_HOST` falls back to `sandbox-postgres` in the process, so an + empty external host would ship a release that starts, reports ready, and + answers every request from a Service that does not exist. + """ + if not shutil.which("helm"): + self.skipTest("helm is not installed") + result = subprocess.run( + [ + "helm", "template", "sandbox", str(CHART), + "--set", "postgresql.embedded.enabled=false", + ], + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("postgresql.external.host", result.stderr) + def test_otlp_endpoint_is_opt_in_and_reaches_both_traced_roles(self) -> None: if not shutil.which("helm"): self.skipTest("helm is not installed")