prole/tests/installer/test_cnpg_barman_webhook.py
chrisfu b1755f4e7b Stabilize prod deployment flow and harden Garage/CNPG ops
Switch production config to k8s/GKE contexts and align service naming. Add immutable StatefulSet update fallback for Garage across k3d/k3s/k8s. Harden CNPG deploy and backup bootstrap paths, and update installer coverage for CNPG webhook and Garage common ops.

Co-authored-by: Junie <junie@jetbrains.com>
2026-04-11 20:19:36 -07:00

907 lines
35 KiB
Python

from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from knoe.core.ops import cloudnative_pg
# ---------------------------------------------------------------------------
# _wait_for_webhook_ca_bundle
# ---------------------------------------------------------------------------
def test_wait_for_webhook_ca_bundle_returns_true_when_ca_bundle_present(monkeypatch):
"""Returns True immediately when caBundle is non-empty on the first poll."""
calls = []
def _fake_run(args, **kwargs):
calls.append(args)
return SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
result = cloudnative_pg._wait_for_webhook_ca_bundle(env=None, log=None, timeout=30)
assert result is True
assert len(calls) == 1
assert "cert-manager-webhook" in calls[0]
assert "jsonpath={.webhooks[0].clientConfig.caBundle}" in calls[0]
def test_wait_for_webhook_ca_bundle_returns_false_on_timeout(monkeypatch):
"""Returns False when caBundle never appears within the timeout."""
monkeypatch.setattr(
cloudnative_pg.subprocess, "run",
lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="", stderr=""),
)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
# Patch time.time to advance past the deadline quickly
_times = iter([0.0, 1.0, 2.0, 200.0]) # deadline=5; last value exceeds it
monkeypatch.setattr(cloudnative_pg.time, "time", lambda: next(_times))
result = cloudnative_pg._wait_for_webhook_ca_bundle(env=None, log=None, timeout=5)
assert result is False
def test_wait_for_webhook_ca_bundle_returns_true_after_retries(monkeypatch):
"""Returns True once caBundle becomes non-empty after initial empty responses."""
responses = [
SimpleNamespace(returncode=0, stdout="", stderr=""),
SimpleNamespace(returncode=1, stdout="", stderr="not found"),
SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr=""),
]
_iter = iter(responses)
monkeypatch.setattr(cloudnative_pg.subprocess, "run", lambda *_a, **_kw: next(_iter))
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
result = cloudnative_pg._wait_for_webhook_ca_bundle(env=None, log=None, timeout=60)
assert result is True
# ---------------------------------------------------------------------------
# _try_inject_ca_bundle_manually
# ---------------------------------------------------------------------------
def test_try_inject_ca_bundle_manually_succeeds(monkeypatch):
"""Patches the webhook caBundle from the cert-manager-webhook-ca secret."""
logs: list[str] = []
run_calls: list[list] = []
def _fake_run(args, **kwargs):
run_calls.append(list(args))
# Secret fetch returns a valid CA bundle
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr="")
# Patch command succeeds
if "patch" in args and "validatingwebhookconfiguration" in args:
return SimpleNamespace(returncode=0, stdout="patched", stderr="")
# Final poll: caBundle is now present
if "jsonpath={.webhooks[0].clientConfig.caBundle}" in args:
return SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
result = cloudnative_pg._try_inject_ca_bundle_manually(env=None, log=logs.append)
assert result is True
assert any("Patching cert-manager-webhook" in m for m in logs)
def test_try_inject_ca_bundle_manually_returns_false_when_secret_missing(monkeypatch):
"""Returns False and logs a warning when neither CA secret is found."""
logs: list[str] = []
monkeypatch.setattr(
cloudnative_pg.subprocess, "run",
lambda *_a, **_kw: SimpleNamespace(returncode=1, stdout="", stderr="not found"),
)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
result = cloudnative_pg._try_inject_ca_bundle_manually(env=None, log=logs.append)
assert result is False
assert any("CA secret not found" in m for m in logs)
def test_try_inject_ca_bundle_manually_returns_false_when_patch_fails(monkeypatch):
"""Returns False when the kubectl patch command fails."""
logs: list[str] = []
def _fake_run(args, **kwargs):
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr="")
if "patch" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="patch failed")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
result = cloudnative_pg._try_inject_ca_bundle_manually(env=None, log=logs.append)
assert result is False
assert any("Failed to patch caBundle" in m for m in logs)
# ---------------------------------------------------------------------------
# install_barman_plugin — cert-manager recovery path
# ---------------------------------------------------------------------------
def test_install_barman_plugin_succeeds_after_cert_manager_recovery(monkeypatch):
"""Plugin apply succeeds on the second attempt after cert-manager recovery."""
logs: list[str] = []
_apply_calls = [0]
_cert_mgr_called = [False]
def _fake_kubectl(args, **_kwargs):
if args[:2] == ["apply", "-f"]:
_apply_calls[0] += 1
if _apply_calls[0] == 1:
return SimpleNamespace(
returncode=1,
stdout="",
stderr="webhook.cert-manager.io: failed to call webhook: tls: failed to verify certificate: x509: certificate signed by unknown authority",
)
return SimpleNamespace(returncode=0, stdout="configured", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
def _fake_ensure(**_kw):
_cert_mgr_called[0] = True
return True # CA bundle confirmed ready
monkeypatch.setattr(cloudnative_pg, "_ensure_cert_manager_for_barman", _fake_ensure)
monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True)
env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"}
cloudnative_pg.install_barman_plugin(env=env, log=logs.append)
assert _cert_mgr_called[0], "cert-manager recovery must be triggered"
assert any("cert-manager recovered; webhook CA bundle ready" in m for m in logs)
assert _apply_calls[0] == 2
def test_install_barman_plugin_logs_warn_when_ca_not_confirmed(monkeypatch):
"""Logs a warning (not 'ready') when _ensure_cert_manager_for_barman returns False."""
logs: list[str] = []
_apply_calls = [0]
def _fake_kubectl(args, **_kwargs):
if args[:2] == ["apply", "-f"]:
_apply_calls[0] += 1
if _apply_calls[0] == 1:
return SimpleNamespace(
returncode=1, stdout="", stderr="x509: certificate signed by unknown authority",
)
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
monkeypatch.setattr(cloudnative_pg, "_ensure_cert_manager_for_barman", lambda **_kw: False)
monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True)
env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"}
cloudnative_pg.install_barman_plugin(env=env, log=logs.append)
assert any("CA bundle not confirmed" in m for m in logs)
assert not any("webhook CA bundle ready" in m for m in logs)
def test_install_barman_plugin_raises_after_all_retries_exhausted(monkeypatch):
"""RuntimeError is raised if all 6 attempts fail with webhook CA errors."""
monkeypatch.setattr(
cloudnative_pg,
"_kubectl",
lambda args, **_kw: SimpleNamespace(
returncode=1,
stdout="",
stderr="x509: certificate signed by unknown authority",
) if args[:2] == ["apply", "-f"] else SimpleNamespace(returncode=0, stdout="", stderr=""),
)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
monkeypatch.setattr(
cloudnative_pg, "_ensure_cert_manager_for_barman",
lambda **_kw: False,
)
monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True)
env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"}
with pytest.raises(RuntimeError, match="Failed to apply Barman Cloud plugin after cert-manager restart"):
cloudnative_pg.install_barman_plugin(env=env, log=None)
# ---------------------------------------------------------------------------
# _wait_for_barman_tls_secrets
# ---------------------------------------------------------------------------
def test_wait_for_barman_tls_secrets_returns_true_when_secrets_present(monkeypatch):
"""Returns True immediately when both TLS secrets have all fields populated."""
logs: list[str] = []
monkeypatch.setattr(
cloudnative_pg.subprocess, "run",
lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr=""),
)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
result = cloudnative_pg._wait_for_barman_tls_secrets(env=None, log=logs.append, timeout=30)
assert result is True
assert any("available" in m for m in logs)
def test_wait_for_barman_tls_secrets_returns_false_on_timeout(monkeypatch):
"""Returns False when secrets never appear within the timeout."""
logs: list[str] = []
monkeypatch.setattr(
cloudnative_pg.subprocess, "run",
lambda *_a, **_kw: SimpleNamespace(returncode=1, stdout="", stderr="not found"),
)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
_times = iter([0.0, 1.0, 2.0, 200.0])
monkeypatch.setattr(cloudnative_pg.time, "time", lambda: next(_times))
result = cloudnative_pg._wait_for_barman_tls_secrets(env=None, log=logs.append, timeout=5)
assert result is False
assert any("not ready" in m for m in logs)
def test_wait_for_barman_tls_secrets_returns_true_after_retry(monkeypatch):
"""Returns True once secrets become available after an initial miss."""
logs: list[str] = []
_call_count = [0]
def _fake_run(args, **kwargs):
_call_count[0] += 1
# First 4 calls (one full secret loop) return empty; after that return populated
if _call_count[0] <= 4:
return SimpleNamespace(returncode=0, stdout="", stderr="")
return SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
result = cloudnative_pg._wait_for_barman_tls_secrets(env=None, log=logs.append, timeout=60)
assert result is True
def test_install_barman_plugin_bootstraps_tls_fallback_when_secrets_not_ready(monkeypatch):
"""Calls _bootstrap_barman_tls_secrets when _wait_for_barman_tls_secrets returns False."""
logs: list[str] = []
_bootstrap_called = [False]
monkeypatch.setattr(
cloudnative_pg.subprocess, "run",
lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="configured", stderr=""),
)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
monkeypatch.setattr(cloudnative_pg, "_kubectl", lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="", stderr=""))
monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: False)
def _fake_bootstrap(**_kw):
_bootstrap_called[0] = True
monkeypatch.setattr(cloudnative_pg, "_bootstrap_barman_tls_secrets", _fake_bootstrap)
env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"}
cloudnative_pg.install_barman_plugin(env=env, log=logs.append)
assert _bootstrap_called[0], "_bootstrap_barman_tls_secrets must be called as fallback"
assert any("bootstrapping self-signed fallback" in m for m in logs)
# ---------------------------------------------------------------------------
# _bootstrap_barman_tls_secrets
# ---------------------------------------------------------------------------
def test_bootstrap_barman_tls_secrets_skips_when_already_present(monkeypatch):
"""Skips generation when both secrets already have tls.crt and tls.key."""
logs: list[str] = []
monkeypatch.setattr(
cloudnative_pg.subprocess, "run",
lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr=""),
)
cloudnative_pg._bootstrap_barman_tls_secrets(env=None, log=logs.append)
assert any("already present" in m for m in logs)
def test_bootstrap_barman_tls_secrets_applies_both_secrets(monkeypatch):
"""Generates and applies server and client TLS secrets when not present."""
logs: list[str] = []
applied: list[str] = []
def _fake_run(args, **kwargs):
# Secret existence checks return empty (not present)
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
# Apply calls succeed
if "apply" in args:
# Extract secret name from stdin YAML via kwargs
stdin = kwargs.get("input", "")
for line in stdin.splitlines():
if line.strip().startswith("name:"):
applied.append(line.strip().split(":", 1)[1].strip())
break
return SimpleNamespace(returncode=0, stdout="created", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
cloudnative_pg._bootstrap_barman_tls_secrets(env=None, log=logs.append)
assert "barman-cloud-server-tls" in applied
assert "barman-cloud-client-tls" in applied
assert any("Bootstrapping Barman Cloud TLS secrets" in m for m in logs)
def test_bootstrap_barman_tls_secrets_raises_on_apply_failure(monkeypatch):
"""Raises RuntimeError when kubectl apply fails for a TLS secret."""
def _fake_run(args, **kwargs):
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "apply" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="forbidden")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
with pytest.raises(RuntimeError, match="Failed to apply Barman TLS secret"):
cloudnative_pg._bootstrap_barman_tls_secrets(env=None, log=None)
# ---------------------------------------------------------------------------
# _bootstrap_db_user_secrets
# ---------------------------------------------------------------------------
def test_bootstrap_db_user_secrets_honors_kubecontext(monkeypatch):
commands: list[list[str]] = []
def _fake_run(args, **_kwargs):
commands.append(list(args))
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "--dry-run=client" in args:
return SimpleNamespace(returncode=0, stdout="apiVersion: v1\nkind: Secret\n", stderr="")
if "apply" in args:
return SimpleNamespace(returncode=0, stdout="created", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
cloudnative_pg._bootstrap_db_user_secrets(
namespace="knoe-db-0",
env={"KUBECONTEXT": "gke-db-context", "DB_PASSWORD": "pw"},
log=None,
)
assert commands
assert all(cmd[:3] == ["kubectl", "--context", "gke-db-context"] for cmd in commands)
# ---------------------------------------------------------------------------
# _bootstrap_cnpg_admin_key
# ---------------------------------------------------------------------------
def test_bootstrap_cnpg_admin_key_skips_when_already_present(monkeypatch):
"""Does nothing when cnpg-admin-key already exists in the namespace."""
logs: list[str] = []
monkeypatch.setattr(
cloudnative_pg.subprocess, "run",
lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="", stderr=""),
)
cloudnative_pg._bootstrap_cnpg_admin_key(namespace="knoe-db-0", env=None, log=logs.append)
assert not any("Creating" in m for m in logs)
def test_bootstrap_cnpg_admin_key_creates_secret_when_missing(monkeypatch):
"""Generates ED25519 keypair and applies cnpg-admin-key when not present."""
logs: list[str] = []
applied: list[str] = []
def _fake_run(args, **kwargs):
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "--dry-run=client" in args:
return SimpleNamespace(returncode=0, stdout="apiVersion: v1\nkind: Secret\nmetadata:\n name: cnpg-admin-key\n", stderr="")
if "apply" in args:
applied.append("cnpg-admin-key")
return SimpleNamespace(returncode=0, stdout="created", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
cloudnative_pg._bootstrap_cnpg_admin_key(namespace="knoe-db-0", env=None, log=logs.append)
assert "cnpg-admin-key" in applied
assert any("Created secret 'cnpg-admin-key'" in m for m in logs)
def test_bootstrap_cnpg_admin_key_honors_kubecontext(monkeypatch):
commands: list[list[str]] = []
def _fake_run(args, **_kwargs):
commands.append(list(args))
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "--dry-run=client" in args:
return SimpleNamespace(returncode=0, stdout="apiVersion: v1\nkind: Secret\n", stderr="")
if "apply" in args:
return SimpleNamespace(returncode=0, stdout="created", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
cloudnative_pg._bootstrap_cnpg_admin_key(
namespace="knoe-db-0",
env={"KUBECONTEXT": "gke-db-context"},
log=None,
)
assert commands
assert all(cmd[:3] == ["kubectl", "--context", "gke-db-context"] for cmd in commands)
def test_bootstrap_cnpg_admin_key_raises_on_apply_failure(monkeypatch):
"""Raises RuntimeError when kubectl apply fails."""
def _fake_run(args, **kwargs):
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "--dry-run=client" in args:
return SimpleNamespace(returncode=0, stdout="apiVersion: v1\nkind: Secret\n", stderr="")
if "apply" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="forbidden")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
with pytest.raises(RuntimeError, match="Failed to apply secret 'cnpg-admin-key'"):
cloudnative_pg._bootstrap_cnpg_admin_key(namespace="knoe-db-0", env=None, log=None)
def test_bootstrap_cnpg_admin_key_raises_on_render_failure(monkeypatch):
"""Raises RuntimeError when kubectl dry-run rendering fails."""
def _fake_run(args, **kwargs):
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "--dry-run=client" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="render error")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
with pytest.raises(RuntimeError, match="Failed to render secret 'cnpg-admin-key'"):
cloudnative_pg._bootstrap_cnpg_admin_key(namespace="knoe-db-0", env=None, log=None)
def test_apply_manifest_renders_artifact_registry_and_tag_placeholders(monkeypatch, tmp_path):
manifest = tmp_path / "knoe-db.yaml"
manifest.write_text(
"""
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
spec:
imageName: "${ARTIFACT_REGISTRY}/knoe-db:${KNOE_DB_IMAGE_TAG}"
""".strip(),
encoding="utf-8",
)
seen: dict[str, str] = {}
def _fake_kubectl(args, env=None, timeout=30, check=False):
assert check is False
rendered_path = Path(args[-1])
seen["path"] = str(rendered_path)
seen["manifest"] = rendered_path.read_text(encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="applied", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
cloudnative_pg._apply_manifest(
namespace="knoe-db-0",
manifest=manifest,
env={
"ARTIFACT_REGISTRY": "us-west3-docker.pkg.dev/example/prole",
"KNOE_DB_IMAGE_TAG": "18-030",
},
log=None,
attempts=1,
)
assert "${ARTIFACT_REGISTRY}" not in seen["manifest"]
assert "${KNOE_DB_IMAGE_TAG}" not in seen["manifest"]
assert "us-west3-docker.pkg.dev/example/prole/knoe-db:18-030" in seen["manifest"]
assert seen["path"] != str(manifest)
def test_apply_manifest_raises_when_artifact_registry_is_missing(monkeypatch, tmp_path):
manifest = tmp_path / "knoe-db.yaml"
manifest.write_text(
"""
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
spec:
imageName: "${ARTIFACT_REGISTRY}/knoe-db:${KNOE_DB_IMAGE_TAG}"
""".strip(),
encoding="utf-8",
)
calls: list[list[str]] = []
def _fake_kubectl(args, env=None, timeout=30, check=False):
calls.append(args)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
with pytest.raises(RuntimeError, match="ARTIFACT_REGISTRY is not set"):
cloudnative_pg._apply_manifest(
namespace="knoe-db-0",
manifest=manifest,
env={},
log=None,
attempts=1,
)
assert calls == []
def test_apply_manifest_strips_synology_blocks_in_k3d_mode(monkeypatch, tmp_path):
manifest = tmp_path / "knoe-db.yaml"
manifest.write_text(
"""
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: knoe-db
spec:
storage:
size: 20Gi
storageClassName: synology-iscsi
pvcTemplate:
spec:
selector:
matchLabels:
synology.storage/role: data
walStorage:
size: 20Gi
pvcTemplate:
spec:
selector:
matchLabels:
app.kubernetes.io/name: keep-this
synology.storage/role: wal
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: ["node-a"]
nodeSelector:
kubernetes.io/os: linux
""".strip(),
encoding="utf-8",
)
seen: dict[str, str] = {}
def _fake_kubectl(args, env=None, timeout=30, check=False):
rendered_path = Path(args[-1])
seen["manifest"] = rendered_path.read_text(encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="applied", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
cloudnative_pg._apply_manifest(
namespace="knoe-db-0",
manifest=manifest,
env={"PROLE_MODE": "k3d"},
log=None,
attempts=1,
)
rendered = seen["manifest"]
assert "storageClassName: synology-iscsi" not in rendered
assert "synology.storage/role" not in rendered
assert "kubernetes.io/hostname" not in rendered
assert "nodeSelector:" not in rendered
def test_apply_manifest_uses_k3d_registry_fallback(monkeypatch, tmp_path):
manifest = tmp_path / "knoe-db.yaml"
manifest.write_text(
"""
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
spec:
imageName: ${KNOE_IMAGE_REGISTRY}/knoe-db:${KNOE_DB_IMAGE_TAG}
""".strip(),
encoding="utf-8",
)
seen: dict[str, str] = {}
def _fake_kubectl(args, env=None, timeout=30, check=False):
rendered_path = Path(args[-1])
seen["manifest"] = rendered_path.read_text(encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="applied", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
cloudnative_pg._apply_manifest(
namespace="knoe-db-0",
manifest=manifest,
env={
"PROLE_MODE": "k3d",
"KNOE_DB_IMAGE_TAG": "18-014",
},
log=None,
attempts=1,
)
assert "k3d-prole-registry:5000/knoe-db:18-014" in seen["manifest"]
def test_apply_manifest_resolves_knoe_db_image_tag_from_version_files(monkeypatch, tmp_path):
manifest = tmp_path / "knoe-db.yaml"
manifest.write_text(
"""
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
spec:
imageName: "${ARTIFACT_REGISTRY}/knoe-db:${KNOE_DB_IMAGE_TAG}"
""".strip(),
encoding="utf-8",
)
(tmp_path / "modes" / "k8s" / "conf" / "postgresql").mkdir(parents=True, exist_ok=True)
(tmp_path / "modes" / "k8s" / "knoe-db").mkdir(parents=True, exist_ok=True)
(tmp_path / "modes" / "k8s" / "conf" / "postgresql" / ".version").write_text(
"18\n", encoding="utf-8"
)
(tmp_path / "modes" / "k8s" / "knoe-db" / ".version").write_text("30\n", encoding="utf-8")
seen: dict[str, str] = {}
def _fake_kubectl(args, env=None, timeout=30, check=False):
assert check is False
rendered_path = Path(args[-1])
seen["manifest"] = rendered_path.read_text(encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="applied", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
cloudnative_pg._apply_manifest(
namespace="knoe-db-0",
manifest=manifest,
env={"ARTIFACT_REGISTRY": "us-west3-docker.pkg.dev/example/prole", "PROLE_MODE": "k8s"},
log=None,
attempts=1,
)
assert "us-west3-docker.pkg.dev/example/prole/knoe-db:18-030" in seen["manifest"]
def test_apply_manifest_raises_on_unresolved_image_placeholder(monkeypatch, tmp_path):
manifest = tmp_path / "knoe-db.yaml"
manifest.write_text(
"""
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
spec:
imageName: "${IMAGE_REF}"
""".strip(),
encoding="utf-8",
)
monkeypatch.setattr(
cloudnative_pg,
"_kubectl",
lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="", stderr=""),
)
with pytest.raises(RuntimeError, match="unresolved image placeholder"):
cloudnative_pg._apply_manifest(
namespace="knoe-db-0",
manifest=manifest,
env={},
log=None,
attempts=1,
)
def test_remove_legacy_barman_object_store_when_plugin_present(monkeypatch):
calls: list[list[str]] = []
def _fake_kubectl(args, env=None, timeout=30, check=False):
calls.append(args)
if args[-1] == "name":
return SimpleNamespace(returncode=0, stdout="cluster.postgresql.cnpg.io/knoe-db", stderr="")
if "jsonpath={range .spec.plugins[*]}{.name}{\n}{end}" in args:
return SimpleNamespace(returncode=0, stdout="audit.plugin\nbarman-cloud.cloudnative-pg.io\n", stderr="")
if "jsonpath={.spec.backup.barmanObjectStore.destinationPath}" in args:
return SimpleNamespace(returncode=0, stdout="gs://knoe-0-backups", stderr="")
if "patch" in args:
return SimpleNamespace(returncode=0, stdout="patched", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
cloudnative_pg._remove_legacy_barman_object_store_when_plugin_is_enabled(
namespace="knoe-db-0",
cluster_name="knoe-db",
env=None,
log=None,
)
patch_calls = [args for args in calls if "patch" in args]
assert len(patch_calls) == 1
assert "--type" in patch_calls[0] and "merge" in patch_calls[0]
assert '"barmanObjectStore": null' in patch_calls[0][-1]
def test_remove_legacy_barman_object_store_skips_when_plugin_not_present(monkeypatch):
calls: list[list[str]] = []
def _fake_kubectl(args, env=None, timeout=30, check=False):
calls.append(args)
if args[-1] == "name":
return SimpleNamespace(returncode=0, stdout="cluster.postgresql.cnpg.io/knoe-db", stderr="")
if "jsonpath={range .spec.plugins[*]}{.name}{\n}{end}" in args:
return SimpleNamespace(returncode=0, stdout="audit.plugin\n", stderr="")
if "patch" in args:
return SimpleNamespace(returncode=0, stdout="patched", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
cloudnative_pg._remove_legacy_barman_object_store_when_plugin_is_enabled(
namespace="knoe-db-0",
cluster_name="knoe-db",
env=None,
log=None,
)
assert not any("jsonpath={.spec.backup.barmanObjectStore.destinationPath}" in args for args in calls)
assert not any("patch" in args for args in calls)
def test_wait_cnpg_pods_relaxes_workload_selector_when_no_matching_nodes(monkeypatch):
calls: list[list[str]] = []
readiness_polls = 0
def _fake_kubectl(args, env=None, timeout=30, check=False):
nonlocal readiness_polls
calls.append(list(args))
if "jsonpath={.spec.instances}" in args:
return SimpleNamespace(returncode=0, stdout="3", stderr="")
if "jsonpath={range .items[*]}{.metadata.name}={.status.conditions[?(@.type==\"Ready\")].status}\\n{end}" in args:
readiness_polls += 1
if readiness_polls == 1:
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1=False\\nknoe-db-2=False\\nknoe-db-3=False\\n",
stderr="",
)
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1=True\\nknoe-db-2=True\\nknoe-db-3=True\\n",
stderr="",
)
if "jsonpath={range .items[*]}{.metadata.name}|phase=" in args:
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1|phase=Pending|node=|ready=False|reason=Unschedulable\\n",
stderr="",
)
if "get" in args and "events" in args:
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1|0/3 nodes are available: 3 node(s) didn't match Pod's node affinity/selector.\\n",
stderr="",
)
if "jsonpath={.spec.affinity.nodeSelector.workload}" in args:
return SimpleNamespace(returncode=0, stdout="db", stderr="")
if args[:2] == ["get", "nodes"]:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "patch" in args and "cluster" in args:
return SimpleNamespace(returncode=0, stdout="patched", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monotonic_values = iter([0.0, 1.1, 1.2, 1.3, 1.4])
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
monkeypatch.setattr(cloudnative_pg.time, "monotonic", lambda: next(monotonic_values))
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
cloudnative_pg._wait_cnpg_pods(
namespace="knoe-db-0",
cluster_name="knoe-db",
env={"CNPG_SELECTOR_RELAX_GRACE_TIMEOUT": "60"},
timeout=1,
log=None,
)
patch_calls = [c for c in calls if "patch" in c and "cluster" in c]
assert len(patch_calls) == 1
assert '"nodeSelector": null' in patch_calls[0][-1]
def test_init_cnpg_backup_cleans_legacy_backup_fields_before_plugin_enable():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "wait_for_objectstore_ready\n ensure_cluster_backup_config\n ensure_barman_plugin_config" in script
def test_init_cnpg_backup_requires_wal_archiver_flags_when_plugin_is_present():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "plugin_enabled=$(kubectl -n \"$NAMESPACE\" get cluster \"$CNPG_CLUSTER_NAME\"" in script
assert "plugin_wal_archiver=$(kubectl -n \"$NAMESPACE\" get cluster \"$CNPG_CLUSTER_NAME\"" in script
assert "${plugin_enabled,,}" in script
assert "${plugin_wal_archiver,,}" in script
assert "isWALArchiver=true" in script
def test_init_cnpg_backup_retries_on_wal_archive_plugin_unavailable_errors():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "wal archive plugin is not available" in script
def test_init_cnpg_backup_waits_for_continuous_archiving_before_triggering_backups():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "wait_for_plugin_ready\n wait_for_continuous_archiving_ready\n ensure_scheduled_backup" in script
assert "wait_for_plugin_ready\n wait_for_continuous_archiving_ready\n wait_for_cnpg_webhook" in script
def test_init_cnpg_backup_reconciles_when_continuous_archiving_reports_plugin_unavailable():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "ContinuousArchiving reports plugin unavailable" in script
assert "ContinuousArchiving is failing due to plugin availability" in script
assert "ca_line=$(continuous_archiving_condition_line)" in script
def test_init_cnpg_backup_reconciles_objectstore_before_readiness_wait():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "ensure_barman_object_store_config\n wait_for_objectstore_ready" in script
assert "ObjectStore '$BARMAN_OBJECT_NAME' spec does not match expected Garage configuration" in script
def test_init_cnpg_backup_prefers_knoe_system_garage_namespace_when_discoverable():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "resolve_garage_namespace_and_endpoint" in script
assert "garage_pod_exists_in_namespace \"knoe-system\"" in script
def test_init_cnpg_backup_statusless_objectstore_fallback_requires_controller_and_spec_match():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")
assert "barman-cloud is available and ObjectStore spec matches expected Garage configuration" in script
assert "status.availableReplicas" in script