prole/tests/installer/test_config_helpers.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

366 lines
13 KiB
Python

"""
Unit tests for previously untested installer/config.py helpers.
Covers:
_get_secret_key_file, _get_file_key, normalize_version,
_extract_inline_vault_block, _try_read_ansible_vault_value,
get_dep_info (check_cmd/version_cmd paths), load_dependencies,
get_ui_icon_image_path, get_ui_background_image_path, get_properties
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from installer.config import (
_get_secret_key_file,
_get_file_key,
normalize_version,
_extract_yaml_scalar_from_text,
_extract_inline_vault_block,
_try_read_ansible_vault_value,
get_dep_info,
load_dependencies,
get_ui_icon_image_path,
get_ui_background_image_path,
get_properties,
)
# ---------------------------------------------------------------------------
# _get_secret_key_file
# ---------------------------------------------------------------------------
def test_get_secret_key_file_returns_path():
result = _get_secret_key_file()
assert isinstance(result, Path)
# ---------------------------------------------------------------------------
# _get_file_key
# ---------------------------------------------------------------------------
def test_get_file_key_generates_new_key(tmp_path):
key_path = tmp_path / "subdir" / "key.b64"
key = _get_file_key(key_path)
assert isinstance(key, bytes)
assert len(key) == 32
assert key_path.exists()
def test_get_file_key_reads_existing_key(tmp_path):
import base64
key_path = tmp_path / "key.b64"
raw_key = os.urandom(32)
key_path.write_text(base64.urlsafe_b64encode(raw_key).decode("utf-8"))
key = _get_file_key(key_path)
assert key == raw_key
def test_get_file_key_with_wrong_length_content(tmp_path):
"""If existing key file has content that decodes to non-32 bytes, still returns bytes."""
key_path = tmp_path / "key.b64"
# 'dGVzdA==' is valid base64 but decodes to only 4 bytes ('test')
key_path.write_text("dGVzdA==")
key = _get_file_key(key_path)
assert isinstance(key, bytes)
# ---------------------------------------------------------------------------
# normalize_version — edge cases
# ---------------------------------------------------------------------------
def test_normalize_version_empty():
assert normalize_version("") == ""
def test_normalize_version_simple():
assert normalize_version("1.2.3") == "01.02.03"
def test_normalize_version_no_regex_match():
"""Text with no digit sequence matching the pattern falls back to findall."""
# A string with digits but no contiguous group matching pattern
result = normalize_version("v5")
assert "05" in result
def test_normalize_version_no_digits():
"""Text with no digits at all returns stripped text."""
result = normalize_version("nodigits")
assert result == "nodigits"
def test_normalize_version_prefix():
result = normalize_version("v1.2.3-beta")
assert result == "01.02.03"
# ---------------------------------------------------------------------------
# _extract_yaml_scalar_from_text
# ---------------------------------------------------------------------------
def test_extract_yaml_scalar_simple():
text = 'key: somevalue\nother: x\n'
assert _extract_yaml_scalar_from_text(text, "key") == "somevalue"
def test_extract_yaml_scalar_missing_key():
assert _extract_yaml_scalar_from_text("a: b\n", "missing") == ""
# ---------------------------------------------------------------------------
# _extract_inline_vault_block
# ---------------------------------------------------------------------------
def test_extract_inline_vault_block_not_found():
text = "key: value\nother: x\n"
result = _extract_inline_vault_block(text, "missing")
assert result == ""
def test_extract_inline_vault_block_plain_value():
"""Key found but block doesn't start with $ANSIBLE_VAULT → return ''."""
text = "mykey:\n just some plain text\n more text\n"
result = _extract_inline_vault_block(text, "mykey")
assert result == ""
def test_extract_inline_vault_block_ansible_vault():
text = (
"mykey:\n"
" $ANSIBLE_VAULT;1.1;AES256\n"
" 6162636465666768\n"
)
result = _extract_inline_vault_block(text, "mykey")
assert "$ANSIBLE_VAULT" in result
def test_extract_inline_vault_block_no_block_lines():
"""Key found but indented block is empty → return ''."""
text = "mykey:\nother: x\n"
result = _extract_inline_vault_block(text, "mykey")
assert result == ""
# ---------------------------------------------------------------------------
# _try_read_ansible_vault_value
# ---------------------------------------------------------------------------
def test_try_read_ansible_vault_value_file_not_found(tmp_path):
result = _try_read_ansible_vault_value(tmp_path / "nonexistent.cfg", "mykey")
assert result == ""
def test_try_read_ansible_vault_value_plain_text(tmp_path):
vault = tmp_path / "prole.cfg"
vault.write_text("db_password: supersecret\n")
result = _try_read_ansible_vault_value(vault, "db_password")
assert result == "supersecret"
def test_try_read_ansible_vault_value_plain_text_vault_marker_skipped(tmp_path):
"""Plain value starting with $ANSIBLE_VAULT is treated as encrypted → skip."""
vault = tmp_path / "prole.cfg"
vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n")
# No vault password file → returns ""
with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": ""}, clear=False), \
patch("installer.config.shutil.which", return_value=None):
result = _try_read_ansible_vault_value(vault, "db_password")
assert result == ""
def test_try_read_ansible_vault_value_no_password_file(tmp_path):
vault = tmp_path / "prole.cfg"
vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n")
with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": ""}, clear=False), \
patch("installer.config.shutil.which", return_value=None):
result = _try_read_ansible_vault_value(vault, "db_password")
assert result == ""
def test_try_read_ansible_vault_value_with_vault_success(tmp_path):
vault = tmp_path / "prole.cfg"
vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n")
pw_file = tmp_path / ".vault_pass"
pw_file.write_text("mypassword\n")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "db_password: decryptedvalue\n"
with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": str(pw_file)}, clear=False), \
patch("installer.config.shutil.which", return_value="/usr/bin/ansible-vault"), \
patch("installer.config.subprocess.run", return_value=mock_result):
result = _try_read_ansible_vault_value(vault, "db_password")
assert result == "decryptedvalue"
def test_try_read_ansible_vault_value_vault_fails_no_inline(tmp_path):
vault = tmp_path / "prole.cfg"
vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n")
pw_file = tmp_path / ".vault_pass"
pw_file.write_text("mypassword\n")
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": str(pw_file)}, clear=False), \
patch("installer.config.shutil.which", return_value="/usr/bin/ansible-vault"), \
patch("installer.config.subprocess.run", return_value=mock_result):
result = _try_read_ansible_vault_value(vault, "db_password")
assert result == ""
def test_try_read_ansible_vault_value_uses_project_root_vault_pass(tmp_path):
"""Falls back to PROJECT_ROOT/.vault_pass if no env var set."""
vault = tmp_path / "prole.cfg"
vault.write_text("mykey: plainvalue\n")
with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": ""}, clear=False):
result = _try_read_ansible_vault_value(vault, "mykey")
assert result == "plainvalue"
# ---------------------------------------------------------------------------
# get_dep_info — check_cmd and version_cmd paths
# ---------------------------------------------------------------------------
def _mock_run_results(*return_codes_and_outputs):
"""Build a side_effect list for subprocess.run."""
results = []
for rc, out in return_codes_and_outputs:
m = MagicMock()
m.returncode = rc
m.stdout = out
results.append(m)
return results
def test_get_dep_info_bin_not_found():
dep = {"id": "mytool", "name": "mytool", "bin": "mytool"}
with patch("installer.config.subprocess.run",
return_value=MagicMock(returncode=1, stdout="")):
ok, loc, ver = get_dep_info(dep)
assert ok is False
def test_get_dep_info_bin_found():
dep = {"id": "mytool", "name": "mytool", "bin": "mytool"}
with patch("installer.config.subprocess.run",
return_value=MagicMock(returncode=0, stdout="/usr/bin/mytool")):
ok, loc, ver = get_dep_info(dep)
assert ok is True
assert loc == "/usr/bin/mytool"
def test_get_dep_info_check_cmd_success():
dep = {"id": "docker", "name": "docker", "bin": "docker",
"check_cmd": "docker info"}
run_results = [
MagicMock(returncode=0, stdout="/usr/bin/docker"), # command -v
MagicMock(returncode=0, stdout="Docker version 24.0"), # check_cmd
]
with patch("installer.config.subprocess.run", side_effect=run_results):
ok, loc, ver = get_dep_info(dep)
assert ok is True
assert "Docker" in (ver or "")
def test_get_dep_info_version_cmd_success():
dep = {"id": "kubectl", "name": "kubectl", "bin": "kubectl",
"version_cmd": "kubectl version --client --short"}
run_results = [
MagicMock(returncode=0, stdout="/usr/bin/kubectl"), # command -v
MagicMock(returncode=0, stdout="Client Version: v1.28.0"), # version_cmd
]
with patch("installer.config.subprocess.run", side_effect=run_results):
ok, loc, ver = get_dep_info(dep)
assert ok is True
assert ver == "Client Version: v1.28.0"
def test_get_dep_info_exception_returns_false():
dep = {"id": "broken", "name": "broken", "bin": "broken"}
with patch("installer.config.subprocess.run", side_effect=OSError("broken")):
ok, loc, ver = get_dep_info(dep)
assert ok is False
# ---------------------------------------------------------------------------
# load_dependencies
# ---------------------------------------------------------------------------
def test_load_dependencies_no_refresh():
deps = load_dependencies(refresh=False)
assert isinstance(deps, list)
assert all(isinstance(d, dict) for d in deps)
def test_load_dependencies_with_refresh():
with patch("installer.config.get_dep_info",
return_value=(True, "/usr/bin/test", "1.0")):
deps = load_dependencies(refresh=True)
assert isinstance(deps, list)
for dep in deps:
assert dep.get("installed") is True
def test_load_dependencies_refresh_exception_handled():
"""If get_dep_info raises, dep.installed should be False."""
with patch("installer.config.get_dep_info", side_effect=RuntimeError("oops")):
deps = load_dependencies(refresh=True)
for dep in deps:
assert dep.get("installed") is False
# ---------------------------------------------------------------------------
# get_ui_icon_image_path / get_ui_background_image_path
# ---------------------------------------------------------------------------
def test_get_ui_icon_image_path_returns_path():
result = get_ui_icon_image_path()
assert isinstance(result, Path)
def test_get_ui_icon_image_path_existing_file(tmp_path):
img = tmp_path / "myicon.png"
img.write_bytes(b"PNG")
with patch("installer.config.get_config_value", return_value="myicon.png"), \
patch("installer.config.PROJECT_ROOT", tmp_path):
result = get_ui_icon_image_path()
assert result == img.resolve()
def test_get_ui_background_image_path_returns_path():
result = get_ui_background_image_path()
assert isinstance(result, Path)
def test_get_ui_background_image_path_existing_file(tmp_path):
img = tmp_path / "bg.png"
img.write_bytes(b"PNG")
with patch("installer.config.get_config_value", return_value="bg.png"), \
patch("installer.config.PROJECT_ROOT", tmp_path):
result = get_ui_background_image_path()
assert result == img.resolve()
# ---------------------------------------------------------------------------
# get_properties
# ---------------------------------------------------------------------------
def test_get_properties_returns_dict():
result = get_properties()
assert isinstance(result, dict)
def test_get_properties_consistent(tmp_path):
"""Calling get_properties twice returns consistent results."""
r1 = get_properties()
r2 = get_properties()
assert r1 == r2