"""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"