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/knoe", "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/knoe/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={"KNOE_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={ "KNOE_MODE": "k3d", "KNOE_DB_IMAGE_TAG": "18-014", }, log=None, attempts=1, ) assert "k3d-knoe-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/knoe", "KNOE_MODE": "k8s"}, log=None, attempts=1, ) assert "us-west3-docker.pkg.dev/example/knoe/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