mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 18:14:33 +00:00
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>
907 lines
35 KiB
Python
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
|