prole/tests/installer/test_core_classes.py

672 lines
23 KiB
Python

"""Tests for installer core classes: state, milestone, controller, build."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest import mock
from knoe.build import get_build_command, BuildMilestone
from knoe.core.actions import KnoeConsoleInstaller
from knoe.core.controller import KnoeController
from knoe.milestone import Milestone
from knoe.state import InstallerState
# ===== InstallerState =====
class TestInstallerState:
def test_defaults(self):
s = InstallerState()
assert s.current_id is None
assert s.data == {}
assert s.inputs == {}
assert s.config_data == {}
assert s.errors == []
assert s.progress == {}
assert s.history == []
assert s.completed == set()
assert s.controller is None
def test_mark_completed(self):
s = InstallerState()
s.mark_completed("step1")
s.mark_completed("step2")
assert "step1" in s.completed
assert "step2" in s.completed
def test_mark_completed_idempotent(self):
s = InstallerState()
s.mark_completed("step1")
s.mark_completed("step1")
assert len(s.completed) == 1
def test_with_controller(self):
s = InstallerState(controller="fake")
assert s.controller == "fake"
def test_inputs_and_config(self):
s = InstallerState(
inputs={"key": "val"},
config_data={"section": {"k": "v"}},
)
assert s.inputs["key"] == "val"
assert s.config_data["section"]["k"] == "v"
# ===== Milestone base class =====
class _DummyMilestone(Milestone):
"""Concrete subclass for testing the abstract base."""
def execute(self, state, progress=None):
if progress:
progress("running", 0.5)
class TestMilestoneBase:
def test_init(self):
m = _DummyMilestone("test_id", "Test Title")
assert m.id == "test_id"
assert m.title == "Test Title"
def test_validate_default(self):
m = _DummyMilestone("x", "X")
assert m.validate(InstallerState()) is None
def test_next_default(self):
m = _DummyMilestone("x", "X")
assert m.next(InstallerState()) is None
def test_execute_with_progress(self):
m = _DummyMilestone("x", "X")
msgs = []
m.execute(InstallerState(), progress=lambda msg, p: msgs.append((msg, p)))
assert ("running", 0.5) in msgs
def test_parse_bool(self):
m = _DummyMilestone("x", "X")
assert m._parse_bool("true") is True
assert m._parse_bool("false") is False
assert m._parse_bool(None) is False
assert m._parse_bool(None, default=True) is True
def test_run_cmd_list(self):
m = _DummyMilestone("x", "X")
rc = m._run_cmd(["echo", "hello"])
assert rc == 0
def test_run_cmd_string(self):
m = _DummyMilestone("x", "X")
rc = m._run_cmd("echo hello")
assert rc == 0
def test_run_cmd_with_stdout(self):
m = _DummyMilestone("x", "X")
lines = []
rc = m._run_cmd(
["echo", "test_output"], on_stdout=lambda l: lines.append(l.strip())
)
assert rc == 0
assert "test_output" in lines
def test_run_cmd_failure(self):
m = _DummyMilestone("x", "X")
rc = m._run_cmd(["false"])
assert rc != 0
def test_run_cmd_bad_command(self):
m = _DummyMilestone("x", "X")
rc = m._run_cmd(["/nonexistent/command"])
assert rc == 1
def test_get_script_env_basic(self):
m = _DummyMilestone("x", "X")
state = InstallerState(
inputs={
"init_password.db_namespace": "myns",
"init_password.db_username": "testuser",
"init_password.db_password": "secret123",
"init_cluster.cluster_env": "dev",
"kerberos_config.realm": "EXAMPLE.COM",
"kerberos_config.kdc": "10.0.0.1",
"kerberos_config.user": "admin",
"kerberos_config.password": "krbpass",
"kerberos_config.enabled": "True",
"init_cluster.at_rest_encryption_enabled": "true",
},
config_data={},
)
env = m._get_script_env(state)
assert env["NAMESPACE"] == "myns"
assert env["DB_PASSWORD"] == "secret123"
assert env["KNOE_DB_USER"] == "testuser"
assert env["KNOE_MODE"] == "k3d"
assert env["KRB5_REALM"] == "EXAMPLE.COM"
assert env["KERBEROS_ENABLED"] == "True"
assert env["AT_REST_ENCRYPTION_ENABLED"] == "true"
assert env["REALM"] == "EXAMPLE.COM"
assert env["DOMAIN"] == "example.com"
def test_get_script_env_defaults(self):
m = _DummyMilestone("x", "X")
state = InstallerState(inputs={}, config_data={})
env = m._get_script_env(state)
assert env["NAMESPACE"] == "default"
assert env["KNOE_MODE"] == "k3d"
assert env["KERBEROS_ENABLED"] == "False"
def test_get_script_env_grafana_from_config(self):
m = _DummyMilestone("x", "X")
state = InstallerState(
inputs={"init_password.db_password": "dbpw"},
config_data={"Monitoring": {"GRAFANA_ADMIN_PASSWORD": "grafanapw"}},
)
env = m._get_script_env(state)
assert env["GRAFANA_ADMIN_PASSWORD"] == "grafanapw"
def test_get_script_env_grafana_fallback(self):
m = _DummyMilestone("x", "X")
state = InstallerState(
inputs={"init_password.db_password": "dbpw"},
config_data={},
)
env = m._get_script_env(state)
assert env["GRAFANA_ADMIN_PASSWORD"] == "dbpw"
def test_get_script_env_k3s_mode(self):
m = _DummyMilestone("x", "X")
state = InstallerState(
inputs={
"init_cluster.cluster_env": "service",
"init_cluster.k3s_server_url": "myserver:6443",
"init_cluster.k3s_token": "mytoken",
},
config_data={},
)
env = m._get_script_env(state)
assert env["KNOE_MODE"] == "k3s"
assert env["PROLE_K3S_SERVER"] == "https://myserver:6443"
assert env["PROLE_K3S_TOKEN"] == "mytoken"
def test_get_script_env_argocd_from_config(self):
m = _DummyMilestone("x", "X")
state = InstallerState(
inputs={},
config_data={
"Global": {
"ARGOCD_NAMESPACE": "custom-argocd",
"REGISTRY_NAMESPACE": "custom-reg",
}
},
)
env = m._get_script_env(state)
assert env["ARGOCD_NAMESPACE"] == "custom-argocd"
assert env["REGISTRY_NAMESPACE"] == "custom-reg"
# ===== build.py =====
class TestBuildHelpers:
def test_get_build_command(self, tmp_path):
cmd = get_build_command(tmp_path, "dev")
assert "cd" in cmd
assert str(tmp_path) in cmd
assert "# Dev" in cmd
def test_get_build_command_service(self, tmp_path):
cmd = get_build_command(tmp_path, "service")
assert "# Service" in cmd
def test_get_build_command_none_env(self, tmp_path):
cmd = get_build_command(tmp_path, None)
assert "# Dev" in cmd
class TestBuildMilestone:
def test_init(self, tmp_path):
m = BuildMilestone(tmp_path, "dev", "next_step")
assert m.id == "build"
assert m.title == "Build"
assert m._next_id == "next_step"
def test_validate_exists(self, tmp_path):
m = BuildMilestone(tmp_path)
assert m.validate(InstallerState()) is None
def test_validate_missing(self):
m = BuildMilestone(Path("/nonexistent/path"))
result = m.validate(InstallerState())
assert result is not None
assert len(result) > 0
def test_execute(self, tmp_path):
m = BuildMilestone(tmp_path, "dev")
state = InstallerState()
msgs = []
m.execute(state, progress=lambda msg, p: msgs.append(msg))
assert "build.command" in state.data
assert "Build command prepared" in msgs
def test_next(self, tmp_path):
m = BuildMilestone(tmp_path, next_id="deploy")
assert m.next(InstallerState()) == "deploy"
def test_next_none(self, tmp_path):
m = BuildMilestone(tmp_path)
assert m.next(InstallerState()) is None
# ===== KnoeController =====
class TestKnoeController:
def test_init(self, tmp_path):
c = KnoeController(tmp_path)
assert c.project_root == tmp_path
assert c.verbose is False
assert c.state is not None
installer = KnoeConsoleInstaller(c)
defaults = installer._default_inputs()
assert defaults.get("kerberos_config.user") == "administrator"
assert defaults.get("kerberos_config.password") == ""
def test_init_verbose(self, tmp_path):
c = KnoeController(tmp_path, verbose=True)
assert c.verbose is True
def test_check_docker_running(self, tmp_path):
c = KnoeController(tmp_path)
result = c.check_docker_running()
assert isinstance(result, bool)
def test_get_knoe_db_version_defaults(self, tmp_path):
c = KnoeController(tmp_path)
version = c.get_knoe_db_version()
assert "17.7" in version # default pg version
assert "-" in version
def test_get_knoe_db_version_custom(self, tmp_path):
pg_dir = tmp_path / "conf" / "postgresql"
pg_dir.mkdir(parents=True)
(pg_dir / ".version").write_text("16.2")
db_dir = tmp_path / "knoe-db"
db_dir.mkdir()
(db_dir / ".version").write_text("42")
c = KnoeController(tmp_path)
version = c.get_knoe_db_version()
assert version == "16.2-042"
def test_run_milestones(self, tmp_path):
c = KnoeController(tmp_path)
m1 = _DummyMilestone("step1", "Step 1")
m2 = _DummyMilestone("step2", "Step 2")
msgs = []
c.run_milestones([m1, m2], progress_callback=lambda msg, p: msgs.append(msg))
assert "step1" in c.state.completed
assert "step2" in c.state.completed
def test_run_milestones_no_callback(self, tmp_path):
c = KnoeController(tmp_path)
m = _DummyMilestone("s1", "S1")
c.run_milestones([m])
assert "s1" in c.state.completed
def test_run_script_missing(self, tmp_path):
c = KnoeController(tmp_path)
# Script doesn't exist but should not crash
rc = c.run_script("nonexistent_script.sh", args=[])
# Will fail since the script doesn't exist
assert rc != 0 or rc is None
def test_silent_installer_runs_supabase_preload_before_deploy(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": "pw123",
"init_password.db_password_confirm": "pw123",
},
), \
mock.patch.object(installer, "_write_cfg"), \
mock.patch.object(installer, "_perform_cluster_reset"), \
mock.patch.object(installer, "_close_log_file"):
captured = {}
def fake_run_milestones(milestones, progress_callback=None):
captured["ids"] = [m.id for m in milestones]
c.run_milestones = fake_run_milestones
rc = installer.run()
assert rc == 0
assert "supabase_images_preload" in captured["ids"]
assert "supabase" in captured["ids"]
assert captured["ids"].index("supabase_images_preload") < captured["ids"].index("supabase")
def test_silent_installer_does_not_generate_db_password_when_openbao_ref_present(
self, tmp_path
):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
openbao_ref = "${OPENBAO:kv/knoe/test/db#password}"
with (
mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": openbao_ref,
"init_password.db_password_confirm": openbao_ref,
},
),
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
):
def fake_run_milestones(milestones, progress_callback=None):
# No-op: we only care about the silent installer preflight logic.
return None
c.run_milestones = fake_run_milestones
rc = installer.run()
assert rc == 0
assert installer.inputs.get("init_password.db_password") == openbao_ref
# No early cfg write for generated password; only the normal writes.
assert write_cfg.call_count == 2
def test_silent_installer_fails_when_password_missing_in_non_interactive_mode(
self, tmp_path
):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object(
installer, "_load_db_password_from_1password", return_value=""
),
mock.patch.object(installer, "_write_cfg"),
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict(os.environ, {"CI": "1"}, clear=False),
):
rc = installer.run()
assert rc == 2
def test_silent_installer_loads_db_password_from_1password(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object(
installer,
"_load_db_password_from_1password",
return_value="vaultpw123",
),
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
):
captured = {}
def fake_run_milestones(milestones, progress_callback=None):
captured["ids"] = [m.id for m in milestones]
c.run_milestones = fake_run_milestones
rc = installer.run()
assert rc == 0
assert installer.inputs.get("init_password.db_password") == "vaultpw123"
assert installer.inputs.get("init_password.db_password_confirm") == "vaultpw123"
assert "deployment" in captured["ids"]
assert write_cfg.call_count == 2
def test_silent_installer_persists_bootstrap_password_to_1password(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
fake_stdin = mock.Mock()
fake_stdin.isatty.return_value = True
fake_stdout = mock.Mock()
fake_stdout.isatty.return_value = True
env_no_pytest = dict(os.environ)
env_no_pytest.pop("PYTEST_CURRENT_TEST", None)
env_no_pytest.pop("CI", None)
with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object(
installer, "_load_db_password_from_1password", return_value=""
),
mock.patch.object(
installer,
"_prompt_for_master_password",
return_value="bootstrap_pw_123",
),
mock.patch.object(
installer, "_persist_db_password_to_1password"
) as persist_pw,
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict("knoe.core.actions.os.environ", env_no_pytest, clear=True),
mock.patch.object(sys, "__stdin__", fake_stdin),
mock.patch.object(sys, "__stdout__", fake_stdout),
):
c.run_milestones = lambda milestones, progress_callback=None: None
rc = installer.run()
assert rc == 0
persist_pw.assert_called_once_with("bootstrap_pw_123")
assert installer.inputs.get("init_password.db_password") == "bootstrap_pw_123"
assert installer.inputs.get("init_password.db_password_confirm") == "bootstrap_pw_123"
assert write_cfg.call_count == 3
def test_silent_installer_bootstraps_vault_from_env_password(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": "env_pw_123",
"init_password.db_password_confirm": "env_pw_123",
},
),
mock.patch.object(
installer, "_load_db_password_from_1password", return_value=""
),
mock.patch.object(
installer, "_persist_db_password_to_1password"
) as persist_pw,
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict(
"knoe.core.actions.os.environ", {"KNOE_DB_PASSWORD": "env_pw_123"}, clear=False
),
):
c.run_milestones = lambda milestones, progress_callback=None: None
rc = installer.run()
assert rc == 0
persist_pw.assert_called_once_with("env_pw_123")
assert write_cfg.call_count == 2
def test_silent_installer_prefers_vault_when_env_password_differs(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": "env_pw_123",
"init_password.db_password_confirm": "env_pw_123",
},
),
mock.patch.object(
installer,
"_load_db_password_from_1password",
return_value="vault_pw_999",
),
mock.patch.object(
installer, "_persist_db_password_to_1password"
) as persist_pw,
mock.patch.object(installer, "_write_cfg"),
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict(
"knoe.core.actions.os.environ", {"KNOE_DB_PASSWORD": "env_pw_123"}, clear=False
),
):
c.run_milestones = lambda milestones, progress_callback=None: None
rc = installer.run()
assert rc == 0
persist_pw.assert_not_called()
assert installer.inputs.get("init_password.db_password") == "vault_pw_999"
assert installer.inputs.get("init_password.db_password_confirm") == "vault_pw_999"
# ===== Milestone concrete subclasses (from core/milestones.py) =====
class TestDependenciesMilestone:
def test_init(self):
from knoe.core.milestones import DependenciesMilestone
m = DependenciesMilestone()
assert m.id == "dependencies"
assert m.title == "Dependency Verification"
class TestNetworkScanMilestone:
def test_init(self):
from knoe.core.milestones import NetworkScanMilestone
m = NetworkScanMilestone()
assert m.id == "network_scan"
class TestEnvSetupMilestone:
def test_execute(self):
from knoe.core.milestones import EnvSetupMilestone
m = EnvSetupMilestone()
state = InstallerState(
inputs={
"env_setup.KNOE_HOME": "/opt/knoe",
"env_setup.KNOE_CONF": "/opt/knoe/conf",
"env_setup.NAMESPACE": "testns",
},
config_data={},
)
msgs = []
m.execute(state, progress=lambda msg, p: msgs.append(msg))
assert state.config_data["System Environment"]["KNOE_HOME"] == "/opt/knoe"
assert state.config_data["System Environment"]["NAMESPACE"] == "testns"
assert "Environment setup complete" in msgs
class TestSecretManagementMilestone:
def test_execute_plain(self):
from knoe.core.milestones import SecretManagementMilestone
m = SecretManagementMilestone()
state = InstallerState(
inputs={
"init_password.db_password": "plaintext123",
},
)
msgs = []
m.execute(state, progress=lambda msg, p: msgs.append(msg))
assert state.inputs["init_password.db_password_confirm"] == "plaintext123"
def test_execute_no_passwords(self):
from knoe.core.milestones import SecretManagementMilestone
m = SecretManagementMilestone()
state = InstallerState(inputs={})
m.execute(state)
class TestDatabaseCreationMilestone:
def test_execute_generates_password(self):
from knoe.core.milestones import DatabaseCreationMilestone
m = DatabaseCreationMilestone()
state = InstallerState(
inputs={"env_setup.DATABASE_NAMESPACE": "testns"},
config_data={},
)
m.execute(state)
assert state.inputs.get("init_password.db_password")
assert len(state.inputs["init_password.db_password"]) >= 20
assert state.config_data["Database Creation"]["DATABASE_NAMESPACE"] == "testns"
def test_execute_with_password(self):
from knoe.core.milestones import DatabaseCreationMilestone
m = DatabaseCreationMilestone()
state = InstallerState(
inputs={
"init_password.db_password": "mypassword",
"env_setup.DATABASE_NAMESPACE": "ns1",
},
config_data={},
)
m.execute(state)
assert state.inputs["init_password.db_password"] == "mypassword"
def test_execute_unresolvable_openbao(self):
from knoe.core.milestones import DatabaseCreationMilestone
m = DatabaseCreationMilestone()
state = InstallerState(
inputs={
"init_password.db_password": "${OPENBAO:kv/knoe/ns/db#password}",
"env_setup.DATABASE_NAMESPACE": "ns1",
},
config_data={},
)
m.execute(state)
pw = state.inputs["init_password.db_password"]
assert not pw.startswith("${")
assert len(pw) >= 20
class TestDockerBuildMilestone:
def test_disabled(self):
from knoe.core.milestones import DockerBuildMilestone
m = DockerBuildMilestone()
state = InstallerState(
inputs={"init_db_build.run_build": "False"},
config_data={},
)
m.execute(state) # Should not raise
class TestDeploymentMilestone:
def test_init(self):
from knoe.core.milestones import DeploymentMilestone
m = DeploymentMilestone()
assert m.id == "deployment"