mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
- improve installer/action/controller flow and shell-variable expansion handling across screens\n- adjust Supabase Helm rendering and storage deployment templates\n- align monitoring, cloudnative-pg and repair pipeline behavior with updated config paths\n- refresh and expand installer/core regression tests around milestones, navigation and repair logic Co-authored-by: Junie <junie@jetbrains.com>
1217 lines
44 KiB
Python
1217 lines
44 KiB
Python
"""
|
|
knoe/core/ops/cloudnative_pg.py
|
|
|
|
Canonical Python owner for CloudNativePG lifecycle behavior.
|
|
|
|
Replaces etc/init_cloudnative_pg.sh runtime dispatch for the four active
|
|
call paths:
|
|
|
|
initialize -- install operator, apply cluster manifests, wait for readiness
|
|
deploy -- image-aware manifest apply + instance reconcile
|
|
rollout -- rolling restart via ordered pod deletion / promotion
|
|
install_barman_plugin -- install the Barman Cloud operator plugin
|
|
|
|
Public API
|
|
----------
|
|
ensure_operator(env, log, mode) -> None
|
|
pin_controller(env, log, mode) -> None
|
|
install_barman_plugin(env, log) -> None
|
|
initialize(namespace, cluster_name, env, project_root, log, mode) -> None
|
|
deploy(namespace, cluster_name, env, project_root, log, mode) -> None
|
|
rollout(namespace, cluster_name, env, log) -> None
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import datetime
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import json
|
|
import logging
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
SECP256R1,
|
|
generate_private_key,
|
|
)
|
|
from cryptography.x509.oid import NameOID
|
|
from typing import Callable
|
|
|
|
_LOG = logging.getLogger(__name__)
|
|
_LogFn = Callable[[str], None]
|
|
|
|
_CNPG_OPERATOR_FALLBACK_VERSION = "1.28.1"
|
|
_BARMAN_PLUGIN_FALLBACK_VERSION = "0.6.0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _log(log: _LogFn | None, msg: str) -> None:
|
|
if log is not None:
|
|
log(msg)
|
|
else:
|
|
_LOG.info(msg)
|
|
|
|
|
|
def _kubectl(
|
|
args: list[str],
|
|
env: dict | None = None,
|
|
timeout: int = 30,
|
|
check: bool = False,
|
|
) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
["kubectl"] + args,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=check,
|
|
)
|
|
|
|
|
|
def _kubectl_ok(args: list[str], env: dict | None = None, timeout: int = 15) -> bool:
|
|
return _kubectl(args, env=env, timeout=timeout).returncode == 0
|
|
|
|
|
|
def _release_stale_pv_claims(
|
|
storage_class: str = "synology-iscsi",
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
"""Clear claimRef on Released PVs so they become Available for new PVCs.
|
|
|
|
Only touches PVs whose status.phase is 'Released' for the given storageClass.
|
|
Idempotent — Available/Bound PVs are left untouched.
|
|
"""
|
|
r = subprocess.run(
|
|
["kubectl", "get", "pv", "-o", "json"],
|
|
text=True, capture_output=True, env=env, timeout=20,
|
|
)
|
|
if r.returncode != 0:
|
|
_log(log, f"WARN: could not list PVs: {r.stderr.strip()}")
|
|
return
|
|
try:
|
|
pv_list = json.loads(r.stdout or "{}")
|
|
except Exception:
|
|
return
|
|
released = [
|
|
item["metadata"]["name"]
|
|
for item in (pv_list.get("items") or [])
|
|
if (item.get("spec", {}).get("storageClassName") == storage_class
|
|
and item.get("status", {}).get("phase") == "Released")
|
|
]
|
|
if not released:
|
|
return
|
|
_log(log, f"Releasing claimRef on {len(released)} Released '{storage_class}' PV(s): {', '.join(released)}")
|
|
for pv_name in released:
|
|
pr = subprocess.run(
|
|
["kubectl", "patch", "pv", pv_name, "-p", '{"spec":{"claimRef":null}}'],
|
|
text=True, capture_output=True, env=env, timeout=15,
|
|
)
|
|
if pr.returncode != 0:
|
|
_log(log, f"WARN: could not release claimRef on PV '{pv_name}': {pr.stderr.strip()}")
|
|
else:
|
|
_log(log, f"Released PV '{pv_name}'.")
|
|
|
|
|
|
def _reconcile_unbound_synology_data_wal_pvs(
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
"""Recreate unbound static Synology data/wal PVs from manifest.
|
|
|
|
This allows nodeAffinity/layout changes in `iscsi-pvs.yaml` to take effect,
|
|
while preserving currently bound volumes.
|
|
"""
|
|
pv_names = [
|
|
"synology-iscsi-d001-data",
|
|
"synology-iscsi-d001-wal",
|
|
"synology-iscsi-d002-data",
|
|
"synology-iscsi-d002-wal",
|
|
"synology-iscsi-d003-data",
|
|
"synology-iscsi-d003-wal",
|
|
]
|
|
|
|
unbound: list[str] = []
|
|
for name in pv_names:
|
|
r = _kubectl(["get", "pv", name, "-o", "json"], env=env, timeout=20)
|
|
if r.returncode != 0:
|
|
continue
|
|
try:
|
|
payload = json.loads(r.stdout or "{}")
|
|
except Exception:
|
|
continue
|
|
phase = str((payload.get("status") or {}).get("phase") or "").strip()
|
|
if phase != "Bound":
|
|
unbound.append(name)
|
|
|
|
if not unbound:
|
|
return
|
|
|
|
_log(log, f"Reconciling unbound Synology data/wal PVs: {', '.join(unbound)}")
|
|
for name in unbound:
|
|
_kubectl(["delete", "pv", name, "--ignore-not-found"], env=env, timeout=45)
|
|
|
|
root = Path(
|
|
(env or {}).get("PROLE_HOME")
|
|
or (env or {}).get("PROLE_SERVICE")
|
|
or Path(__file__).resolve().parents[4]
|
|
)
|
|
manifest = root / "k8s" / "prole" / "iscsi-pvs.yaml"
|
|
if not manifest.exists():
|
|
_log(log, f"WARN: Synology PV manifest not found: {manifest}")
|
|
return
|
|
|
|
r_apply = _kubectl(["apply", "-f", str(manifest)], env=env, timeout=180)
|
|
if r_apply.returncode != 0:
|
|
stderr = ((r_apply.stderr or "") + "\n" + (r_apply.stdout or "")).strip().lower()
|
|
if "field is immutable" not in stderr:
|
|
raise RuntimeError(
|
|
f"Failed to re-apply Synology PV manifest {manifest}: "
|
|
f"{(r_apply.stderr or r_apply.stdout or '').strip()}"
|
|
)
|
|
_log(log, "WARN: Immutable PV fields prevented full re-apply; continuing with recreated unbound PVs.")
|
|
|
|
|
|
def _ensure_namespace(namespace: str, env: dict | None = None) -> None:
|
|
"""Create the namespace if it does not already exist (idempotent)."""
|
|
manifest = (
|
|
"apiVersion: v1\n"
|
|
"kind: Namespace\n"
|
|
"metadata:\n"
|
|
f" name: {namespace}\n"
|
|
)
|
|
res = subprocess.run(
|
|
["kubectl", "apply", "-f", "-"],
|
|
input=manifest,
|
|
text=True,
|
|
capture_output=True,
|
|
env=env,
|
|
)
|
|
if res.returncode != 0:
|
|
raise RuntimeError(f"Failed to ensure namespace '{namespace}':\n{res.stderr}")
|
|
|
|
|
|
def _resolve_cnpg_manifest(project_root: str | Path, env: dict | None) -> Path:
|
|
"""Resolve the CNPG cluster manifest path, mirroring the shell K8S_PROLE_DIR logic."""
|
|
if env:
|
|
override = (env.get("CNPG_MANIFEST_OVERRIDE") or "").strip()
|
|
if override:
|
|
p = Path(override)
|
|
if p.exists():
|
|
return p
|
|
root = Path(project_root)
|
|
candidates = [
|
|
root / "deploy" / "opentofu" / "k3s" / "manifests" / "prole" / "knoe-db.yaml",
|
|
root / "k8s" / "prole" / "knoe-db.yaml",
|
|
]
|
|
if env:
|
|
prole_home = (env.get("PROLE_HOME") or "").strip()
|
|
if prole_home:
|
|
candidates.insert(0, Path(prole_home) / "k8s" / "prole" / "knoe-db.yaml")
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate
|
|
return root / "k8s" / "prole" / "knoe-db.yaml"
|
|
|
|
|
|
def _apply_manifest(
|
|
namespace: str,
|
|
manifest: Path,
|
|
env: dict | None,
|
|
log: _LogFn | None,
|
|
attempts: int = 8,
|
|
) -> None:
|
|
"""Apply a CNPG cluster manifest with retry on transient API errors."""
|
|
if not manifest.exists():
|
|
raise FileNotFoundError(f"CNPG manifest not found: {manifest}")
|
|
_log(log, f"Applying CNPG manifest {manifest} in namespace {namespace}...")
|
|
last_err = ""
|
|
for i in range(1, attempts + 1):
|
|
r = _kubectl(["-n", namespace, "apply", "-f", str(manifest)], env=env, timeout=60)
|
|
if r.returncode == 0:
|
|
if r.stdout.strip():
|
|
_log(log, r.stdout.strip())
|
|
return
|
|
last_err = (r.stderr or r.stdout or "").strip()
|
|
transient = any(
|
|
kw in last_err.lower()
|
|
for kw in ("etcdserver", "connection refused", "timeout", "tls handshake", "webhook")
|
|
)
|
|
if transient and i < attempts:
|
|
_log(log, f"WARN: manifest apply failed (attempt {i}/{attempts}); retrying in 5s...")
|
|
time.sleep(5)
|
|
continue
|
|
break
|
|
raise RuntimeError(f"CNPG manifest apply failed after {attempts} attempts: {last_err}")
|
|
|
|
|
|
def _wait_pod_ready(
|
|
namespace: str, pod: str, env: dict | None, timeout: int = 300, log: _LogFn | None = None
|
|
) -> None:
|
|
r = _kubectl(
|
|
["-n", namespace, "wait", "--for=condition=Ready", f"pod/{pod}", f"--timeout={timeout}s"],
|
|
env=env,
|
|
timeout=timeout + 10,
|
|
)
|
|
if r.returncode != 0:
|
|
_log(log, f"WARN: pod {pod} did not become Ready within {timeout}s")
|
|
|
|
|
|
def _wait_cnpg_pods(
|
|
namespace: str,
|
|
cluster_name: str,
|
|
env: dict | None,
|
|
timeout: int = 900,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
"""Wait for all CNPG cluster pods to reach Ready state."""
|
|
# Determine expected pod count from cluster spec
|
|
r = _kubectl(
|
|
["-n", namespace, "get", "cluster", cluster_name, "-o", "jsonpath={.spec.instances}"],
|
|
env=env,
|
|
timeout=15,
|
|
)
|
|
target = 1
|
|
if r.returncode == 0:
|
|
try:
|
|
target = max(1, int((r.stdout or "1").strip() or "1"))
|
|
except ValueError:
|
|
target = 1
|
|
|
|
_log(log, f"Waiting for {target} CNPG pod(s) for cluster '{cluster_name}' in '{namespace}' (timeout={timeout}s)...")
|
|
|
|
deadline = time.monotonic() + timeout
|
|
last_feedback = 0.0
|
|
while True:
|
|
now = time.monotonic()
|
|
if now >= deadline:
|
|
raise RuntimeError(
|
|
f"Timed out ({timeout}s) waiting for {target} CNPG pods to be Ready "
|
|
f"in namespace '{namespace}'"
|
|
)
|
|
|
|
r2 = _kubectl(
|
|
[
|
|
"-n", namespace, "get", "pods",
|
|
"-l", f"cnpg.io/cluster={cluster_name}",
|
|
"-o",
|
|
"jsonpath={range .items[*]}{.metadata.name}={.status.conditions[?(@.type==\"Ready\")].status}\\n{end}",
|
|
],
|
|
env=env,
|
|
timeout=15,
|
|
)
|
|
if r2.returncode == 0:
|
|
# kubectl jsonpath emits literal \n (backslash-n) not real newlines
|
|
raw = (r2.stdout or "").replace("\\n", "\n")
|
|
lines = [l for l in raw.splitlines() if "=" in l]
|
|
ready = sum(1 for l in lines if l.split("=", 1)[1].strip() == "True")
|
|
if ready >= target:
|
|
_log(log, f"All {ready}/{target} CNPG pods are Ready.")
|
|
return
|
|
|
|
elapsed = timeout - (deadline - now)
|
|
if elapsed - last_feedback >= 30:
|
|
last_feedback = elapsed
|
|
_log(log, f" [{int(elapsed)}s/{timeout}s] {ready}/{target} CNPG pods ready...")
|
|
|
|
time.sleep(5)
|
|
|
|
|
|
def _reconcile_instances(
|
|
namespace: str,
|
|
cluster_name: str,
|
|
env: dict | None,
|
|
log: _LogFn | None,
|
|
) -> None:
|
|
"""
|
|
Cap CNPG cluster instances to the number of eligible schedulable nodes,
|
|
then patch the cluster spec if the count has changed.
|
|
|
|
Mirrors reconcile_cnpg_instances() from the shell script.
|
|
"""
|
|
# Current spec instances
|
|
r = _kubectl(
|
|
["-n", namespace, "get", "cluster", cluster_name, "-o", "jsonpath={.spec.instances}"],
|
|
env=env, timeout=15,
|
|
)
|
|
if r.returncode != 0 or not (r.stdout or "").strip():
|
|
return # cluster may not exist yet
|
|
try:
|
|
current = max(0, int((r.stdout or "0").strip()))
|
|
except ValueError:
|
|
return
|
|
|
|
desired_raw = int((env or {}).get("CNPG_INSTANCES", "3") or "3")
|
|
desired = max(1, desired_raw)
|
|
|
|
# Count eligible nodes (nodes with cnpg.io/node-role=db or all schedulable nodes)
|
|
node_selector = (env or {}).get("CNPG_NODE_SELECTOR", "")
|
|
if node_selector:
|
|
r2 = _kubectl(["get", "nodes", "-l", node_selector, "--no-headers"], env=env, timeout=10)
|
|
else:
|
|
r2 = _kubectl(["get", "nodes", "--no-headers"], env=env, timeout=10)
|
|
eligible = len([l for l in (r2.stdout or "").splitlines() if l.strip()]) if r2.returncode == 0 else 1
|
|
eligible = max(1, eligible)
|
|
|
|
if desired > eligible:
|
|
desired = eligible
|
|
|
|
if current == desired:
|
|
return
|
|
|
|
# Clamp maxSyncReplicas so the spec stays valid (maxSyncReplicas < instances)
|
|
base_max_sync = int((env or {}).get("CNPG_MAX_SYNC_REPLICAS", "1") or "1")
|
|
if desired <= 1:
|
|
max_sync = 0
|
|
else:
|
|
max_sync = min(base_max_sync, desired - 1)
|
|
|
|
_log(log, f"Reconciling CNPG instances: {current} → {desired} (eligible_nodes={eligible})")
|
|
patch = json.dumps({"spec": {"instances": desired, "maxSyncReplicas": max_sync}})
|
|
for attempt in range(1, 9):
|
|
r3 = _kubectl(
|
|
["-n", namespace, "patch", "cluster", cluster_name, "--type", "merge", "-p", patch],
|
|
env=env, timeout=30,
|
|
)
|
|
if r3.returncode == 0:
|
|
_log(log, f"Cluster instances patched to {desired}.")
|
|
return
|
|
err = (r3.stderr or r3.stdout or "").strip().lower()
|
|
transient = any(kw in err for kw in ("etcd", "connection refused", "timeout", "webhook"))
|
|
if transient and attempt < 8:
|
|
_log(log, f"WARN: patch failed (attempt {attempt}/8); retrying in 5s...")
|
|
time.sleep(5)
|
|
else:
|
|
_log(log, f"WARN: could not patch CNPG instances: {err}")
|
|
return
|
|
|
|
|
|
def _controller_manifest_url(version: str) -> str:
|
|
minor = ".".join(version.split(".")[:2])
|
|
return (
|
|
f"https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg"
|
|
f"/release-{minor}/releases/cnpg-{version}.yaml"
|
|
)
|
|
|
|
|
|
def _controller_deployment_json(env: dict | None = None) -> dict | None:
|
|
r = _kubectl(
|
|
["-n", "cnpg-system", "get", "deploy", "cnpg-controller-manager", "-o", "json"],
|
|
env=env,
|
|
timeout=30,
|
|
)
|
|
if r.returncode != 0 or not (r.stdout or "").strip():
|
|
return None
|
|
try:
|
|
return json.loads(r.stdout)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _controller_is_healthy(env: dict | None = None) -> bool:
|
|
dep = _controller_deployment_json(env)
|
|
if not dep:
|
|
return False
|
|
spec = dep.get("spec") or {}
|
|
status = dep.get("status") or {}
|
|
desired = int(spec.get("replicas") or 1)
|
|
available = int(status.get("availableReplicas") or 0)
|
|
return desired > 0 and available >= desired
|
|
|
|
|
|
def _reconcile_controller_replicasets(
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
aggressive_cleanup: bool = False,
|
|
) -> None:
|
|
dep = _controller_deployment_json(env)
|
|
if not dep:
|
|
return
|
|
|
|
dep_meta = dep.get("metadata") or {}
|
|
dep_uid = str(dep_meta.get("uid") or "").strip()
|
|
dep_annotations = dep_meta.get("annotations") or {}
|
|
try:
|
|
dep_revision = int(dep_annotations.get("deployment.kubernetes.io/revision") or "0")
|
|
except Exception:
|
|
dep_revision = 0
|
|
|
|
r = _kubectl(
|
|
[
|
|
"-n",
|
|
"cnpg-system",
|
|
"get",
|
|
"rs",
|
|
"-l",
|
|
"app.kubernetes.io/name=cloudnative-pg",
|
|
"-o",
|
|
"json",
|
|
],
|
|
env=env,
|
|
timeout=45,
|
|
)
|
|
if r.returncode != 0 or not (r.stdout or "").strip():
|
|
return
|
|
try:
|
|
payload = json.loads(r.stdout)
|
|
except Exception:
|
|
return
|
|
|
|
def _rs_revision(item: dict) -> int:
|
|
ann = (item.get("metadata") or {}).get("annotations") or {}
|
|
try:
|
|
return int(ann.get("deployment.kubernetes.io/revision") or "0")
|
|
except Exception:
|
|
return 0
|
|
|
|
owned: list[dict] = []
|
|
for item in payload.get("items") or []:
|
|
meta = item.get("metadata") or {}
|
|
owners = meta.get("ownerReferences") or []
|
|
for owner in owners:
|
|
if str(owner.get("kind") or "") != "Deployment":
|
|
continue
|
|
if str(owner.get("name") or "") != "cnpg-controller-manager":
|
|
continue
|
|
owner_uid = str(owner.get("uid") or "").strip()
|
|
if dep_uid and owner_uid and owner_uid != dep_uid:
|
|
continue
|
|
owned.append(item)
|
|
break
|
|
|
|
active = [i for i in owned if int((i.get("spec") or {}).get("replicas") or 0) > 0]
|
|
if len(active) <= 1:
|
|
return
|
|
|
|
stale: list[dict]
|
|
if dep_revision > 0:
|
|
stale = [i for i in active if _rs_revision(i) < dep_revision]
|
|
else:
|
|
active_sorted = sorted(
|
|
active,
|
|
key=lambda i: (
|
|
_rs_revision(i),
|
|
str((i.get("metadata") or {}).get("creationTimestamp") or ""),
|
|
),
|
|
)
|
|
stale = active_sorted[:-1]
|
|
|
|
for rs in stale:
|
|
rs_meta = rs.get("metadata") or {}
|
|
rs_name = str(rs_meta.get("name") or "").strip()
|
|
if not rs_name:
|
|
continue
|
|
_log(log, f"Scaling stale CNPG controller replicaset/{rs_name} to 0")
|
|
_kubectl(
|
|
["-n", "cnpg-system", "scale", f"rs/{rs_name}", "--replicas=0"],
|
|
env=env,
|
|
timeout=60,
|
|
)
|
|
|
|
if aggressive_cleanup:
|
|
template_hash = str((rs_meta.get("labels") or {}).get("pod-template-hash") or "").strip()
|
|
if template_hash:
|
|
_kubectl(
|
|
[
|
|
"-n",
|
|
"cnpg-system",
|
|
"delete",
|
|
"pod",
|
|
"-l",
|
|
f"app.kubernetes.io/name=cloudnative-pg,pod-template-hash={template_hash}",
|
|
"--ignore-not-found",
|
|
"--wait=false",
|
|
],
|
|
env=env,
|
|
timeout=60,
|
|
)
|
|
|
|
|
|
def _wait_controller_rollout(
|
|
env: dict | None = None,
|
|
timeout_seconds: int = 180,
|
|
) -> bool:
|
|
r = _kubectl(
|
|
[
|
|
"-n",
|
|
"cnpg-system",
|
|
"rollout",
|
|
"status",
|
|
"deploy/cnpg-controller-manager",
|
|
f"--timeout={timeout_seconds}s",
|
|
],
|
|
env=env,
|
|
timeout=timeout_seconds + 10,
|
|
)
|
|
return r.returncode == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def ensure_operator(
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
mode: str = "",
|
|
allow_reapply_if_unhealthy: bool = False,
|
|
aggressive_cleanup: bool = False,
|
|
) -> None:
|
|
"""Install the CloudNativePG operator if not already present, then wait for readiness."""
|
|
if _kubectl_ok(["-n", "cnpg-system", "get", "deploy", "cnpg-controller-manager"], env=env):
|
|
_reconcile_controller_replicasets(env=env, log=log, aggressive_cleanup=aggressive_cleanup)
|
|
if _controller_is_healthy(env=env):
|
|
_log(log, "CloudNative-PG operator already healthy; skipping operator re-apply.")
|
|
if mode == "k3s":
|
|
_tune_operator_for_k3s(env, log)
|
|
_wait_controller_rollout(env=env, timeout_seconds=300)
|
|
return
|
|
|
|
_log(log, "CloudNative-PG operator exists but is not healthy; waiting for recovery...")
|
|
if _wait_controller_rollout(env=env, timeout_seconds=180):
|
|
if mode == "k3s":
|
|
_tune_operator_for_k3s(env, log)
|
|
_wait_controller_rollout(env=env, timeout_seconds=300)
|
|
return
|
|
|
|
if not allow_reapply_if_unhealthy:
|
|
raise RuntimeError(
|
|
"CNPG operator deployment exists but is not ready; "
|
|
"refusing disruptive re-apply in non-initialize flow."
|
|
)
|
|
_log(log, "CNPG operator unhealthy after wait; attempting controlled re-apply...")
|
|
|
|
version = ""
|
|
if env:
|
|
version = (env.get("CNPG_OPERATOR_VERSION") or env.get("CNPG_VERSION") or "").strip()
|
|
if not version or version == "latest":
|
|
version = _CNPG_OPERATOR_FALLBACK_VERSION
|
|
url = _controller_manifest_url(version)
|
|
_log(log, f"Installing CloudNative-PG operator version {version}...")
|
|
r = _kubectl(["apply", "--server-side", "-f", url], env=env, timeout=120)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"Failed to install CNPG operator: {(r.stderr or r.stdout or '').strip()}")
|
|
|
|
if _kubectl_ok(["-n", "cnpg-system", "get", "deploy", "cnpg-controller-manager"], env=env, timeout=20):
|
|
_reconcile_controller_replicasets(env=env, log=log, aggressive_cleanup=aggressive_cleanup)
|
|
if not _wait_controller_rollout(env=env, timeout_seconds=180):
|
|
raise RuntimeError("CNPG controller rollout did not complete after operator apply")
|
|
if mode == "k3s":
|
|
_tune_operator_for_k3s(env, log)
|
|
if not _wait_controller_rollout(env=env, timeout_seconds=300):
|
|
raise RuntimeError("CNPG controller rollout did not complete after k3s tuning")
|
|
|
|
|
|
def _tune_operator_for_k3s(env: dict | None, log: _LogFn | None) -> None:
|
|
"""Relax CNPG controller probes/resources for single-node k3s environments."""
|
|
_log(log, "Tuning CNPG operator deployment probes/resources for k3s...")
|
|
patch = json.dumps({
|
|
"spec": {
|
|
"template": {
|
|
"spec": {
|
|
"tolerations": [
|
|
{
|
|
"key": "node-role.kubernetes.io/control-plane",
|
|
"operator": "Exists",
|
|
"effect": "NoSchedule",
|
|
},
|
|
{
|
|
"key": "node-role.kubernetes.io/master",
|
|
"operator": "Exists",
|
|
"effect": "NoSchedule",
|
|
},
|
|
],
|
|
"containers": [
|
|
{
|
|
"name": "manager",
|
|
"resources": {
|
|
"requests": {"cpu": "250m", "memory": "512Mi"},
|
|
"limits": {"cpu": "500m", "memory": "1Gi"},
|
|
},
|
|
"livenessProbe": {"timeoutSeconds": 5, "failureThreshold": 6},
|
|
"readinessProbe": {"timeoutSeconds": 5, "failureThreshold": 6},
|
|
"startupProbe": {"timeoutSeconds": 5, "failureThreshold": 60},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
_kubectl(
|
|
["-n", "cnpg-system", "patch", "deploy", "cnpg-controller-manager", "--type", "merge", "-p", patch],
|
|
env=env, timeout=30,
|
|
)
|
|
|
|
|
|
def _clear_controller_pin(env: dict | None = None, log: _LogFn | None = None) -> None:
|
|
"""Remove stale hard nodeSelector pinning for controller-manager."""
|
|
_log(log, "Clearing CNPG controller hard nodeSelector pin (if present)...")
|
|
patch = json.dumps({"spec": {"template": {"spec": {"nodeSelector": None}}}})
|
|
_kubectl(
|
|
["-n", "cnpg-system", "patch", "deployment", "cnpg-controller-manager", "--type", "merge", "-p", patch],
|
|
env=env,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def pin_controller(
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
mode: str = "",
|
|
) -> None:
|
|
"""Pin the CNPG controller manager to storage-designated nodes (k3s) or as configured."""
|
|
selector = (env or {}).get("CNPG_CONTROLLER_NODE_SELECTOR", "").strip()
|
|
|
|
if not selector and mode == "k3s":
|
|
r = _kubectl(["get", "nodes", "-l", "storage=primary", "--no-headers"], env=env, timeout=10)
|
|
if r.returncode == 0 and (r.stdout or "").strip():
|
|
selector = "storage=primary"
|
|
|
|
if not selector:
|
|
_clear_controller_pin(env=env, log=log)
|
|
_reconcile_controller_replicasets(env=env, log=log)
|
|
return
|
|
|
|
if "=" not in selector:
|
|
_log(log, f"WARN: CNPG_CONTROLLER_NODE_SELECTOR must be key=value (got '{selector}'). Skipping pin.")
|
|
return
|
|
|
|
key, value = selector.split("=", 1)
|
|
if not _kubectl_ok(["get", "nodes", "-l", f"{key}={value}", "--no-headers"], env=env):
|
|
_log(log, f"WARN: No nodes match CNPG_CONTROLLER_NODE_SELECTOR={selector}. Skipping pin.")
|
|
return
|
|
|
|
_log(log, f"Pinning cnpg-controller-manager to nodes with {selector}...")
|
|
patch = json.dumps({"spec": {"template": {"spec": {"nodeSelector": {key: value}}}}})
|
|
_kubectl(
|
|
["-n", "cnpg-system", "patch", "deployment", "cnpg-controller-manager",
|
|
"--type", "merge", "-p", patch],
|
|
env=env, timeout=30,
|
|
)
|
|
_reconcile_controller_replicasets(env=env, log=log)
|
|
|
|
|
|
def install_barman_plugin(
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
"""Install the Barman Cloud operator plugin, retrying on cert-manager webhook errors."""
|
|
url = ((env or {}).get("BARMAN_PLUGIN_MANIFEST_URL") or "").strip()
|
|
if not url:
|
|
tag = _resolve_barman_plugin_tag(env, log)
|
|
url = f"https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/{tag}/manifest.yaml"
|
|
|
|
_log(log, f"Installing Barman Cloud plugin from {url}...")
|
|
r = subprocess.run(
|
|
["kubectl", "apply", "-f", url],
|
|
env=env, capture_output=True, text=True, timeout=120,
|
|
)
|
|
if r.returncode != 0:
|
|
combined = (r.stdout or "") + (r.stderr or "")
|
|
if "webhook.cert-manager.io" in combined.lower() or "cert-manager" in combined.lower():
|
|
_log(log, "WARN: cert-manager webhook error; ensuring cert-manager and retrying...")
|
|
_ensure_cert_manager_for_barman(env=env, log=log)
|
|
r2 = subprocess.run(
|
|
["kubectl", "apply", "-f", url],
|
|
env=env, capture_output=True, text=True, timeout=120,
|
|
)
|
|
if r2.returncode != 0:
|
|
raise RuntimeError(
|
|
f"Failed to apply Barman Cloud plugin after cert-manager restart: "
|
|
f"{(r2.stderr or r2.stdout or '').strip()}"
|
|
)
|
|
else:
|
|
raise RuntimeError(
|
|
f"Failed to apply Barman Cloud plugin manifest: {(r.stderr or r.stdout or '').strip()}"
|
|
)
|
|
elif r.stdout.strip():
|
|
_log(log, r.stdout.strip())
|
|
|
|
# Wait for barman-cloud deployment if it was created
|
|
r3 = _kubectl(["-n", "cnpg-system", "get", "deploy", "barman-cloud"], env=env, timeout=15)
|
|
if r3.returncode == 0:
|
|
timeout_s = int((env or {}).get("BARMAN_DEPLOY_TIMEOUT", "300") or "300")
|
|
_kubectl(
|
|
["-n", "cnpg-system", "rollout", "status", "deploy/barman-cloud", f"--timeout={timeout_s}s"],
|
|
env=env, timeout=timeout_s + 10,
|
|
)
|
|
|
|
|
|
def _ensure_cert_manager_for_barman(
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
def _exists(kind: str, name: str) -> bool:
|
|
return _kubectl(["-n", "cert-manager", "get", kind, name], env=env, timeout=20).returncode == 0
|
|
|
|
need_bootstrap = not all(
|
|
[
|
|
_exists("service", "cert-manager-webhook"),
|
|
_exists("deployment", "cert-manager"),
|
|
_exists("deployment", "cert-manager-webhook"),
|
|
_exists("deployment", "cert-manager-cainjector"),
|
|
]
|
|
)
|
|
|
|
if need_bootstrap:
|
|
_log(log, "[CNPG] cert-manager resources missing; bootstrapping cert-manager...")
|
|
root = Path(
|
|
(env or {}).get("PROLE_HOME")
|
|
or (env or {}).get("PROLE_SERVICE")
|
|
or Path(__file__).resolve().parents[4]
|
|
)
|
|
init_script = root / "etc" / "init_certmgr.sh"
|
|
if init_script.exists():
|
|
cmd = ["bash", str(init_script)]
|
|
mode = str((env or {}).get("PROLE_MODE") or "").strip()
|
|
if mode:
|
|
cmd.extend(["--mode", mode])
|
|
cfg_hint = (
|
|
str((env or {}).get("PROLE_CONFIG_PATH") or "").strip()
|
|
or str((env or {}).get("PROLE_CFG_PATH") or "").strip()
|
|
or str((env or {}).get("PROLE_CFG") or "").strip()
|
|
)
|
|
if cfg_hint:
|
|
cmd.extend(["-c", cfg_hint])
|
|
cmd.append("start")
|
|
r = subprocess.run(
|
|
cmd,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600,
|
|
)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(
|
|
"Failed to bootstrap cert-manager via init_certmgr.sh: "
|
|
f"{(r.stderr or r.stdout or '').strip()}"
|
|
)
|
|
else:
|
|
_kubectl(["get", "namespace", "cert-manager"], env=env, timeout=20)
|
|
_kubectl(["create", "namespace", "cert-manager"], env=env, timeout=20)
|
|
certmgr_version = str((env or {}).get("CERTMGR_VERSION") or "v1.14.6").strip()
|
|
certmgr_url = (
|
|
"https://github.com/cert-manager/cert-manager/releases/download/"
|
|
f"{certmgr_version}/cert-manager.yaml"
|
|
)
|
|
r = subprocess.run(
|
|
["kubectl", "apply", "-f", certmgr_url],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(
|
|
f"Failed to bootstrap cert-manager from {certmgr_url}: "
|
|
f"{(r.stderr or r.stdout or '').strip()}"
|
|
)
|
|
|
|
for deploy in ["cert-manager", "cert-manager-webhook", "cert-manager-cainjector"]:
|
|
if _exists("deployment", deploy):
|
|
_kubectl(["-n", "cert-manager", "rollout", "restart", f"deploy/{deploy}"], env=env, timeout=45)
|
|
for deploy in ["cert-manager-webhook", "cert-manager", "cert-manager-cainjector"]:
|
|
if _exists("deployment", deploy):
|
|
_kubectl(
|
|
["-n", "cert-manager", "rollout", "status", f"deploy/{deploy}", "--timeout=240s"],
|
|
env=env,
|
|
timeout=250,
|
|
)
|
|
|
|
|
|
def _resolve_barman_plugin_tag(env: dict | None, log: _LogFn | None) -> str:
|
|
"""Fetch the latest Barman Cloud plugin release tag from GitHub, with fallback."""
|
|
fallback = ((env or {}).get("BARMAN_PLUGIN_FALLBACK_VERSION") or _BARMAN_PLUGIN_FALLBACK_VERSION).strip()
|
|
try:
|
|
import urllib.request as _req
|
|
import json as _json
|
|
with _req.urlopen(
|
|
"https://api.github.com/repos/cloudnative-pg/plugin-barman-cloud/releases/latest",
|
|
timeout=10,
|
|
) as resp:
|
|
data = _json.loads(resp.read())
|
|
tag = (data.get("tag_name") or "").strip()
|
|
if tag and tag != "null":
|
|
return tag
|
|
except Exception as exc:
|
|
_log(log, f"WARN: could not fetch latest Barman plugin version: {exc}; using v{fallback}")
|
|
return f"v{fallback}"
|
|
|
|
|
|
def _bootstrap_db_user_secrets(
|
|
namespace: str,
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
"""Create knoe-db-user and knoe-db-superuser secrets from DB_PASSWORD if missing.
|
|
|
|
Uses ``env["DB_PASSWORD"]`` and ``env.get("KNOE_DB_USER", "postgres")`` as the
|
|
credentials source. Skips silently if the secrets already exist or if no
|
|
password is available in env.
|
|
"""
|
|
pw = ((env or {}).get("DB_PASSWORD") or "").strip()
|
|
if not pw or pw.startswith("${"):
|
|
_log(log, "WARN: DB_PASSWORD not resolved in env; skipping db-user secret bootstrap.")
|
|
return
|
|
|
|
db_user = ((env or {}).get("KNOE_DB_USER") or "postgres").strip()
|
|
|
|
def _apply_if_missing(secret_name: str, username: str, password: str) -> None:
|
|
if _kubectl_ok(["-n", namespace, "get", "secret", secret_name], env=env):
|
|
return
|
|
_log(log, f"Creating missing secret '{secret_name}' in '{namespace}'...")
|
|
manifest_res = subprocess.run(
|
|
[
|
|
"kubectl", "create", "secret", "generic", secret_name,
|
|
"-n", namespace,
|
|
f"--from-literal=username={username}",
|
|
f"--from-literal=password={password}",
|
|
"--dry-run=client", "-o", "yaml",
|
|
],
|
|
text=True, capture_output=True, env=env,
|
|
)
|
|
if manifest_res.returncode != 0:
|
|
raise RuntimeError(f"Failed to render secret '{secret_name}':\n{manifest_res.stderr}")
|
|
apply_res = subprocess.run(
|
|
["kubectl", "apply", "-n", namespace, "-f", "-"],
|
|
input=manifest_res.stdout, text=True, capture_output=True, env=env,
|
|
)
|
|
if apply_res.returncode != 0:
|
|
raise RuntimeError(f"Failed to apply secret '{secret_name}':\n{apply_res.stderr}")
|
|
_log(log, f"Created secret '{secret_name}' in '{namespace}'.")
|
|
|
|
_apply_if_missing("knoe-db-user", db_user, pw)
|
|
_apply_if_missing("knoe-db-superuser", "postgres", pw)
|
|
|
|
|
|
def bootstrap_cnpg_tls_secrets(
|
|
namespace: str,
|
|
cluster_name: str,
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
"""Generate and apply self-signed CA + TLS secrets for CNPG if missing.
|
|
|
|
Creates two Kubernetes secrets (skipped if already present):
|
|
- ``{cluster_name}-ca`` : Opaque, keys ``ca.crt`` and ``ca.key``
|
|
- ``{cluster_name}-tls`` : kubernetes.io/tls, keys ``tls.crt`` and ``tls.key``
|
|
|
|
The CA uses CN=``Prole CNPG CA`` and is self-signed (EC P-256, SHA-256).
|
|
The server cert is signed by that CA and carries SANs matching the standard
|
|
CNPG service names for the cluster.
|
|
"""
|
|
ca_secret = f"{cluster_name}-ca"
|
|
tls_secret = f"{cluster_name}-tls"
|
|
|
|
# Ensure namespace exists before any secret operations
|
|
_ensure_namespace(namespace, env=env)
|
|
|
|
ca_exists = _kubectl_ok(["-n", namespace, "get", "secret", ca_secret], env=env)
|
|
tls_exists = _kubectl_ok(["-n", namespace, "get", "secret", tls_secret], env=env)
|
|
if ca_exists and tls_exists:
|
|
_log(log, f"CNPG TLS secrets already present in '{namespace}' — skipping bootstrap.")
|
|
return
|
|
|
|
_log(log, f"Bootstrapping CNPG TLS secrets in namespace '{namespace}'...")
|
|
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
one_year = datetime.timedelta(days=365)
|
|
|
|
# --- CA ---
|
|
ca_key = generate_private_key(SECP256R1())
|
|
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prole CNPG CA")])
|
|
ca_cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(ca_name)
|
|
.issuer_name(ca_name)
|
|
.public_key(ca_key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now)
|
|
.not_valid_after(now + one_year)
|
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False)
|
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
.sign(ca_key, hashes.SHA256())
|
|
)
|
|
|
|
# --- Server cert ---
|
|
srv_key = generate_private_key(SECP256R1())
|
|
san_dns = [
|
|
cluster_name,
|
|
f"{cluster_name}-rw",
|
|
f"{cluster_name}-rw.{namespace}.svc",
|
|
f"{cluster_name}-r",
|
|
f"{cluster_name}-ro",
|
|
]
|
|
primary_srv_cn = f"{cluster_name}.{namespace}.svc"
|
|
srv_cn = primary_srv_cn
|
|
if len(srv_cn) > 64:
|
|
for candidate in (f"{cluster_name}-rw", cluster_name, "cnpg-server"):
|
|
if len(candidate) <= 64:
|
|
srv_cn = candidate
|
|
break
|
|
_log(
|
|
log,
|
|
"CNPG TLS subject CN exceeds X.509 limit; "
|
|
f"using fallback CN '{srv_cn}' instead of '{primary_srv_cn}'.",
|
|
)
|
|
|
|
srv_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, srv_cn)])
|
|
srv_cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(srv_name)
|
|
.issuer_name(ca_name)
|
|
.public_key(srv_key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now)
|
|
.not_valid_after(now + one_year)
|
|
.add_extension(
|
|
x509.SubjectAlternativeName([x509.DNSName(d) for d in san_dns]),
|
|
critical=False,
|
|
)
|
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(srv_key.public_key()), critical=False)
|
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
|
|
.sign(ca_key, hashes.SHA256())
|
|
)
|
|
|
|
def _pem(obj) -> str:
|
|
return obj.public_bytes(serialization.Encoding.PEM).decode()
|
|
|
|
def _key_pem(k) -> str:
|
|
return k.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
serialization.NoEncryption(),
|
|
).decode()
|
|
|
|
def _apply_secret(manifest: str) -> None:
|
|
res = subprocess.run(
|
|
["kubectl", "apply", "-n", namespace, "-f", "-"],
|
|
input=manifest,
|
|
text=True,
|
|
capture_output=True,
|
|
env=env,
|
|
)
|
|
if res.returncode != 0:
|
|
raise RuntimeError(f"kubectl apply failed:\n{res.stderr}")
|
|
|
|
ca_crt_pem = _pem(ca_cert)
|
|
ca_key_pem = _key_pem(ca_key)
|
|
|
|
# Build CA secret manifest (Opaque)
|
|
ca_manifest = (
|
|
"apiVersion: v1\n"
|
|
"kind: Secret\n"
|
|
"metadata:\n"
|
|
f" name: {ca_secret}\n"
|
|
f" namespace: {namespace}\n"
|
|
"data:\n"
|
|
f" ca.crt: {base64.b64encode(ca_crt_pem.encode()).decode()}\n"
|
|
f" ca.key: {base64.b64encode(ca_key_pem.encode()).decode()}\n"
|
|
)
|
|
_apply_secret(ca_manifest)
|
|
_log(log, f"Applied secret '{ca_secret}' in '{namespace}'.")
|
|
|
|
tls_crt_pem = _pem(srv_cert)
|
|
tls_key_pem = _key_pem(srv_key)
|
|
|
|
# Build TLS secret manifest (kubernetes.io/tls)
|
|
tls_manifest = (
|
|
"apiVersion: v1\n"
|
|
"kind: Secret\n"
|
|
"type: kubernetes.io/tls\n"
|
|
"metadata:\n"
|
|
f" name: {tls_secret}\n"
|
|
f" namespace: {namespace}\n"
|
|
"data:\n"
|
|
f" tls.crt: {base64.b64encode(tls_crt_pem.encode()).decode()}\n"
|
|
f" tls.key: {base64.b64encode(tls_key_pem.encode()).decode()}\n"
|
|
)
|
|
_apply_secret(tls_manifest)
|
|
_log(log, f"Applied secret '{tls_secret}' in '{namespace}'.")
|
|
|
|
|
|
def initialize(
|
|
namespace: str,
|
|
cluster_name: str,
|
|
env: dict | None = None,
|
|
project_root: str | Path = ".",
|
|
log: _LogFn | None = None,
|
|
mode: str = "",
|
|
) -> None:
|
|
"""
|
|
Full CNPG initialization pipeline.
|
|
|
|
Steps (matching init_cloudnative_pg.sh initialize):
|
|
1. Ensure the CNPG operator is installed and ready
|
|
2. Pin the controller to storage nodes (k3s)
|
|
3. Install the Barman Cloud plugin
|
|
4. Preflight: verify required secrets exist
|
|
5. Apply the CNPG cluster manifest
|
|
6. Wait for cluster pods to become Ready
|
|
"""
|
|
_log(log, f"Initializing CNPG cluster '{cluster_name}' in namespace '{namespace}'...")
|
|
|
|
ensure_operator(
|
|
env=env,
|
|
log=log,
|
|
mode=mode,
|
|
allow_reapply_if_unhealthy=True,
|
|
aggressive_cleanup=True,
|
|
)
|
|
pin_controller(env=env, log=log, mode=mode)
|
|
install_barman_plugin(env=env, log=log)
|
|
|
|
# Bootstrap TLS secrets (create-if-missing; no rotation of existing certs)
|
|
bootstrap_cnpg_tls_secrets(namespace=namespace, cluster_name=cluster_name, env=env, log=log)
|
|
|
|
# Bootstrap DB user secrets from env if missing (create-if-missing only)
|
|
_bootstrap_db_user_secrets(namespace=namespace, env=env, log=log)
|
|
|
|
# Preflight: required secrets must exist before applying the cluster
|
|
missing = []
|
|
for secret in ["knoe-db-user", f"{cluster_name}-tls", f"{cluster_name}-ca"]:
|
|
if not _kubectl_ok(["-n", namespace, "get", "secret", secret], env=env):
|
|
missing.append(secret)
|
|
if missing:
|
|
raise RuntimeError(
|
|
f"Required secrets missing in namespace '{namespace}': {', '.join(missing)}. "
|
|
"The CNPG cluster cannot start without these secrets."
|
|
)
|
|
_log(log, "Pre-flight check passed: all required secrets present.")
|
|
# Release any Released PVs so new PVCs can bind (idempotent, k3s only)
|
|
if mode == "k3s":
|
|
_reconcile_unbound_synology_data_wal_pvs(env=env, log=log)
|
|
_release_stale_pv_claims(env=env, log=log)
|
|
manifest = _resolve_cnpg_manifest(project_root, env)
|
|
_apply_manifest(namespace, manifest, env, log)
|
|
|
|
wait_timeout = int((env or {}).get("CNPG_WAIT_TIMEOUT", "900") or "900")
|
|
_wait_cnpg_pods(namespace, cluster_name, env, timeout=wait_timeout, log=log)
|
|
|
|
_log(log, f"Initialization complete for CNPG cluster '{cluster_name}'.")
|
|
|
|
|
|
def deploy(
|
|
namespace: str,
|
|
cluster_name: str,
|
|
env: dict | None = None,
|
|
project_root: str | Path = ".",
|
|
log: _LogFn | None = None,
|
|
mode: str = "",
|
|
) -> None:
|
|
"""
|
|
Image-aware CNPG deploy pipeline.
|
|
|
|
Steps (matching init_cloudnative_pg.sh deploy_cluster):
|
|
1. Ensure the CNPG operator is installed and ready
|
|
2. Pin the controller to storage nodes (k3s)
|
|
3. Install the Barman Cloud plugin
|
|
4. Apply the CNPG cluster manifest
|
|
5. Reconcile instance count to match eligible node count
|
|
"""
|
|
_log(log, f"Deploying CNPG cluster '{cluster_name}' in namespace '{namespace}'...")
|
|
|
|
ensure_operator(
|
|
env=env,
|
|
log=log,
|
|
mode=mode,
|
|
allow_reapply_if_unhealthy=False,
|
|
aggressive_cleanup=False,
|
|
)
|
|
pin_controller(env=env, log=log, mode=mode)
|
|
install_barman_plugin(env=env, log=log)
|
|
|
|
manifest = _resolve_cnpg_manifest(project_root, env)
|
|
_apply_manifest(namespace, manifest, env, log)
|
|
_reconcile_instances(namespace, cluster_name, env, log)
|
|
|
|
_log(log, f"Deploy complete for CNPG cluster '{cluster_name}'.")
|
|
|
|
|
|
def rollout(
|
|
namespace: str,
|
|
cluster_name: str,
|
|
env: dict | None = None,
|
|
log: _LogFn | None = None,
|
|
) -> None:
|
|
"""
|
|
Rolling restart of a CNPG cluster via ordered pod deletion.
|
|
|
|
Steps (matching init_cloudnative_pg.sh rollout_cluster):
|
|
1. Identify the current primary pod
|
|
2. Delete replica pods one-by-one, waiting for readiness
|
|
3. Promote a replica to primary (if kubectl-cnpg plugin is available)
|
|
4. Delete and recreate the old primary pod
|
|
"""
|
|
_log(log, f"Starting rollout for CNPG cluster '{cluster_name}' in namespace '{namespace}'...")
|
|
|
|
# Identify current primary
|
|
r = _kubectl(
|
|
["-n", namespace, "get", "cluster", cluster_name, "-o", "jsonpath={.status.currentPrimary}"],
|
|
env=env, timeout=15,
|
|
)
|
|
primary = (r.stdout or "").strip()
|
|
if not primary:
|
|
raise RuntimeError(f"Could not identify primary instance for cluster '{cluster_name}'")
|
|
_log(log, f"Primary instance: {primary}")
|
|
|
|
# List all cluster pods
|
|
r2 = _kubectl(
|
|
["-n", namespace, "get", "pods", "-l", f"cnpg.io/cluster={cluster_name}",
|
|
"-o", "jsonpath={.items[*].metadata.name}"],
|
|
env=env, timeout=15,
|
|
)
|
|
pods = (r2.stdout or "").strip().split()
|
|
if not pods:
|
|
raise RuntimeError(f"No pods found for cluster '{cluster_name}'")
|
|
|
|
has_plugin = _kubectl_ok(["cnpg", "version"], env=env, timeout=10)
|
|
|
|
# Delete non-primary pods first
|
|
new_primary: str | None = None
|
|
for pod in pods:
|
|
if pod == primary:
|
|
continue
|
|
_log(log, f"Recreating non-primary pod: {pod}...")
|
|
_kubectl(["-n", namespace, "delete", "pod", pod], env=env, timeout=60)
|
|
_wait_pod_ready(namespace, pod, env, timeout=300, log=log)
|
|
if new_primary is None:
|
|
new_primary = pod
|
|
|
|
# Promote a replica if the cnpg plugin is available
|
|
if new_primary and has_plugin:
|
|
_log(log, f"Promoting {new_primary}...")
|
|
_kubectl(["cnpg", "promote", cluster_name, new_primary, "-n", namespace], env=env, timeout=30)
|
|
deadline = time.monotonic() + 300
|
|
while time.monotonic() < deadline:
|
|
r3 = _kubectl(
|
|
["-n", namespace, "get", "cluster", cluster_name,
|
|
"-o", "jsonpath={.status.currentPrimary}"],
|
|
env=env, timeout=10,
|
|
)
|
|
if (r3.stdout or "").strip() == new_primary:
|
|
_log(log, f"{new_primary} is now the primary.")
|
|
break
|
|
time.sleep(5)
|
|
elif new_primary and not has_plugin:
|
|
_log(log, "WARN: kubectl cnpg plugin not available; skipping explicit promotion.")
|
|
|
|
# Delete the old primary last
|
|
_log(log, f"Recreating old primary pod: {primary}...")
|
|
_kubectl(["-n", namespace, "delete", "pod", "-n", namespace, primary], env=env, timeout=60)
|
|
_wait_pod_ready(namespace, primary, env, timeout=300, log=log)
|
|
|
|
if has_plugin:
|
|
_log(log, "Rollout complete. Final cluster status:")
|
|
_kubectl(["cnpg", "status", cluster_name, "-n", namespace], env=env, timeout=30)
|
|
else:
|
|
_log(log, "Rollout complete.")
|