prole/tests/installer/test_monitor.py
chrisfu 5618b662dd Remove prole-db-manager; simplify deployment via prole-authority; fix pg18 downgrade & cluster name
Summary:
Removed the prole-db-manager microservice and simplified deployment to use
prole-authority as the internal management and authorization point. Fixed two
blocking bugs that prevented silent install from completing on knoe-dev-cluster.

Removed: prole-db-manager
- Deleted db-manager-deployment.yaml and db-manager-service.yaml from opentofu manifests
- Deleted src/db-manager/ (Dockerfile, server.js, package.json, tests)
- Removed prole-db-manager port-forward mapping from installer/core/env.py
- Removed init_db_manager.sh from Initialization Scripts (milestones.py, actions.py)
- Removed init_certmgr.sh and init_db_manager.sh tabs from services screen (services.py)
- Removed live k8s Deployment/Service from knoe-dev-cluster

Fixed: PostgreSQL version downgrade error (pg17 -> pg18)
- Created conf/postgresql/.version with value 18
- Updated k8s/prole/prole-db.yaml and prole-db-recovery.yaml.tpl imageName to prole-db:18-089
- Fixed _init_database_options_state() to restore saved version_type from prole.cfg
  so db_version_type defaults to v18 (pg18) instead of silently reverting to pg17
- Added database_options.* keys to _collect_input_snapshot() in cfg.py so
  distribution, version_type, and all extension toggles persist to prole.cfg

Fixed: Cluster name inconsistency
- Removed stale prole-dev-cluster references; all scripts now use knoe-dev-cluster
- Added knoe-dev-cluster to mode-detection case in etc/prole_cfg.sh

Config: conf/prole.cfg
- Set kerberos_config.enabled = False, KERBEROS_AUTO_ENABLED = False
- Added database_options.distribution = percona, version_type = v18
- Added all 13 extension flags set to True (postgis, pgvector, pgcrypto, pgaudit,
  pg_repack, pg_stat_statements, pg_buffercache, pg_freespacemap, pgrowlocks,
  postgres_fdw, dblink, pg_stat_monitor, pgbadger)

Verification:
./install.py -s -l -v -c conf/prole.cfg completed successfully.
CNPG deployed prole-db:18-089 to knoe-dev-cluster; all milestones passed.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-01 20:40:44 -08:00

321 lines
11 KiB
Python

"""
Unit tests for installer/core/monitor.py
Covers the pure-logic helpers:
_parse_pf_cfg, write_port_forwards_cfg, check_port_conflicts, _build_kubectl_cmd,
_start_port_forwards
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch, call
import pytest
from installer.core.monitor import (
_parse_pf_cfg,
write_port_forwards_cfg,
check_port_conflicts,
_build_kubectl_cmd,
_start_port_forwards,
)
# ---------------------------------------------------------------------------
# _parse_pf_cfg
# ---------------------------------------------------------------------------
def test_parse_pf_cfg_missing_file(tmp_path):
result = _parse_pf_cfg(tmp_path / "nonexistent.cfg")
assert result == []
def test_parse_pf_cfg_empty_file(tmp_path):
cfg = tmp_path / "pf.cfg"
cfg.write_text("")
assert _parse_pf_cfg(cfg) == []
def test_parse_pf_cfg_comments_and_blanks(tmp_path):
cfg = tmp_path / "pf.cfg"
cfg.write_text("# this is a comment\n\n \n")
assert _parse_pf_cfg(cfg) == []
def test_parse_pf_cfg_no_colon(tmp_path):
cfg = tmp_path / "pf.cfg"
cfg.write_text("some line without colon\n")
assert _parse_pf_cfg(cfg) == []
def test_parse_pf_cfg_single_entry(tmp_path):
cfg = tmp_path / "pf.cfg"
cfg.write_text("argocd: local=8080 remote=80 ns=argocd svc=argocd-server address=127.0.0.1\n")
result = _parse_pf_cfg(cfg)
assert len(result) == 1
m = result[0]
assert m["id"] == "argocd"
assert m["local"] == "8080"
assert m["remote"] == "80"
assert m["ns"] == "argocd"
assert m["svc"] == "argocd-server"
assert m["address"] == "127.0.0.1"
def test_parse_pf_cfg_multiple_entries(tmp_path):
cfg = tmp_path / "pf.cfg"
cfg.write_text(
"# Port forwards\n"
"argocd: local=8080 remote=80 ns=argocd svc=argocd-server address=127.0.0.1\n"
"openbao: local=8200 remote=8200 ns=default svc=openbao address=127.0.0.1\n"
)
result = _parse_pf_cfg(cfg)
assert len(result) == 2
assert result[0]["id"] == "argocd"
assert result[1]["id"] == "openbao"
assert result[1]["local"] == "8200"
def test_parse_pf_cfg_token_without_equals(tmp_path):
"""Tokens without '=' should be silently ignored."""
cfg = tmp_path / "pf.cfg"
cfg.write_text("myid: local=9000 badtoken remote=80\n")
result = _parse_pf_cfg(cfg)
assert len(result) == 1
assert result[0]["local"] == "9000"
assert result[0]["remote"] == "80"
# 'badtoken' has no '=' so it's not added
assert "badtoken" not in result[0]
def test_parse_pf_cfg_id_with_extra_whitespace(tmp_path):
cfg = tmp_path / "pf.cfg"
cfg.write_text(" myservice : local=1234 remote=5678\n")
result = _parse_pf_cfg(cfg)
assert len(result) == 1
assert result[0]["id"] == "myservice"
# ---------------------------------------------------------------------------
# write_port_forwards_cfg
# ---------------------------------------------------------------------------
def test_write_port_forwards_cfg_empty(tmp_path):
out = tmp_path / "out.cfg"
write_port_forwards_cfg(out, [])
content = out.read_text()
assert "# Port forward configuration" in content
assert "# Format:" in content
def test_write_port_forwards_cfg_skips_no_id(tmp_path):
out = tmp_path / "out.cfg"
# Mapping string with no id= token
write_port_forwards_cfg(out, ["hostPort=8080;servicePort=80"])
content = out.read_text()
# No entry line should be written (id is empty)
lines = [l for l in content.splitlines() if l and not l.startswith("#")]
assert lines == []
def test_write_port_forwards_cfg_single_mapping(tmp_path):
out = tmp_path / "out.cfg"
mapping = "id=argocd;namespace=argocd;target=svc/argocd-server;hostPort=8080;servicePort=80;address=127.0.0.1"
write_port_forwards_cfg(out, [mapping])
content = out.read_text()
assert "argocd: local=8080 remote=80 ns=argocd svc=argocd-server address=127.0.0.1" in content
def test_write_port_forwards_cfg_target_without_svc_prefix(tmp_path):
out = tmp_path / "out.cfg"
mapping = "id=myapp;namespace=default;target=myapp-service;hostPort=9000;servicePort=9000;address=0.0.0.0"
write_port_forwards_cfg(out, [mapping])
content = out.read_text()
# target doesn't start with svc/, so svc = target as-is
assert "svc=myapp-service" in content
def test_write_port_forwards_cfg_roundtrip(tmp_path):
"""Written file can be parsed back by _parse_pf_cfg."""
out = tmp_path / "pf.cfg"
mappings = [
"id=argocd;namespace=argocd;target=svc/argocd-server;hostPort=8080;servicePort=80;address=127.0.0.1",
"id=openbao;namespace=default;target=svc/openbao;hostPort=8200;servicePort=8200;address=127.0.0.1",
]
write_port_forwards_cfg(out, mappings)
parsed = _parse_pf_cfg(out)
assert len(parsed) == 2
assert parsed[0]["id"] == "argocd"
assert parsed[1]["id"] == "openbao"
assert parsed[0]["local"] == "8080"
assert parsed[1]["remote"] == "8200"
# ---------------------------------------------------------------------------
# check_port_conflicts
# ---------------------------------------------------------------------------
def test_check_port_conflicts_empty():
assert check_port_conflicts([]) == []
def test_check_port_conflicts_no_conflicts():
mappings = [
{"id": "a", "local": "8080"},
{"id": "b", "local": "8200"},
{"id": "c", "local": "9000"},
]
assert check_port_conflicts(mappings) == []
def test_check_port_conflicts_single_conflict():
mappings = [
{"id": "a", "local": "8080"},
{"id": "b", "local": "8080"},
]
result = check_port_conflicts(mappings)
assert len(result) == 1
assert "8080" in result[0]
assert "a" in result[0]
assert "b" in result[0]
def test_check_port_conflicts_multiple_conflicts():
mappings = [
{"id": "a", "local": "8080"},
{"id": "b", "local": "8080"},
{"id": "c", "local": "9000"},
{"id": "d", "local": "9000"},
]
result = check_port_conflicts(mappings)
assert len(result) == 2
def test_check_port_conflicts_missing_local_key():
"""Mappings without 'local' key use '' as port and should not crash."""
mappings = [
{"id": "a"},
{"id": "b"},
]
result = check_port_conflicts(mappings)
# Both have empty port '' — should detect conflict
assert len(result) == 1
def test_check_port_conflicts_missing_id_key():
"""Mappings without 'id' key should not crash."""
mappings = [
{"local": "8080"},
{"local": "8080"},
]
result = check_port_conflicts(mappings)
assert len(result) == 1
assert "?" in result[0]
# ---------------------------------------------------------------------------
# _build_kubectl_cmd
# ---------------------------------------------------------------------------
def test_build_kubectl_cmd_basic():
m = {"ns": "argocd", "svc": "argocd-server", "local": "8080", "remote": "80", "address": "127.0.0.1"}
cmd = _build_kubectl_cmd(m)
assert cmd == ["kubectl", "port-forward", "-n", "argocd", "svc/argocd-server", "8080:80"]
# address is 127.0.0.1 so --address is NOT added
assert "--address" not in cmd
def test_build_kubectl_cmd_non_localhost_address():
m = {"ns": "default", "svc": "myapp", "local": "9000", "remote": "9000", "address": "0.0.0.0"}
cmd = _build_kubectl_cmd(m)
assert "--address" in cmd
assert "0.0.0.0" in cmd
def test_build_kubectl_cmd_svc_prefix_not_doubled():
"""svc/ prefix should only appear once."""
m = {"ns": "default", "svc": "svc/myapp", "local": "9000", "remote": "9000", "address": "127.0.0.1"}
cmd = _build_kubectl_cmd(m)
svc_arg = cmd[4]
assert svc_arg == "svc/myapp"
assert not svc_arg.startswith("svc/svc/")
def test_build_kubectl_cmd_adds_svc_prefix():
"""Service name without svc/ prefix should get one."""
m = {"ns": "default", "svc": "myapp", "local": "9000", "remote": "9000", "address": "127.0.0.1"}
cmd = _build_kubectl_cmd(m)
assert "svc/myapp" in cmd
def test_build_kubectl_cmd_defaults():
"""Missing keys should fall back to defaults."""
cmd = _build_kubectl_cmd({})
assert cmd[0] == "kubectl"
assert "-n" in cmd
assert "default" in cmd # default namespace
def test_build_kubectl_cmd_empty_address():
"""Empty address string should not add --address flag."""
m = {"ns": "default", "svc": "myapp", "local": "9000", "remote": "9000", "address": ""}
cmd = _build_kubectl_cmd(m)
assert "--address" not in cmd
# ---------------------------------------------------------------------------
# _start_port_forwards
# ---------------------------------------------------------------------------
def test_start_port_forwards_empty():
result = _start_port_forwards([])
assert result == []
def test_start_port_forwards_success(capsys):
mapping = {"id": "argocd", "ns": "argocd", "svc": "argocd-server", "local": "8080", "remote": "80", "address": "127.0.0.1"}
mock_proc = MagicMock(spec=subprocess.Popen)
with patch("installer.core.monitor.subprocess.Popen", return_value=mock_proc) as mock_popen:
result = _start_port_forwards([mapping])
assert len(result) == 1
m_out, proc_out = result[0]
assert m_out is mapping
assert proc_out is mock_proc
out = capsys.readouterr().out
assert "argocd" in out
assert "" in out
def test_start_port_forwards_failure(capsys):
mapping = {"id": "broken", "ns": "default", "svc": "nosvc", "local": "9999", "remote": "9999", "address": "127.0.0.1"}
with patch("installer.core.monitor.subprocess.Popen", side_effect=OSError("not found")):
result = _start_port_forwards([mapping])
assert len(result) == 1
_, proc = result[0]
assert proc is None
out = capsys.readouterr().out
assert "" in out
assert "broken" in out
def test_start_port_forwards_verbose(capsys):
mapping = {"id": "test", "ns": "default", "svc": "myapp", "local": "8000", "remote": "8000", "address": "127.0.0.1"}
mock_proc = MagicMock(spec=subprocess.Popen)
with patch("installer.core.monitor.subprocess.Popen", return_value=mock_proc):
_start_port_forwards([mapping], verbose=True)
out = capsys.readouterr().out
assert "[PORT-FWD]" in out
def test_start_port_forwards_multiple(capsys):
mappings = [
{"id": "a", "ns": "default", "svc": "svc-a", "local": "8001", "remote": "80", "address": "127.0.0.1"},
{"id": "b", "ns": "default", "svc": "svc-b", "local": "8002", "remote": "80", "address": "127.0.0.1"},
]
mock_proc = MagicMock(spec=subprocess.Popen)
with patch("installer.core.monitor.subprocess.Popen", return_value=mock_proc):
result = _start_port_forwards(mappings)
assert len(result) == 2
assert result[0][0]["id"] == "a"
assert result[1][0]["id"] == "b"