"""Tests for installer core classes: state, milestone, controller, build.""" from __future__ import annotations import os from pathlib import Path from unittest import mock import pytest from installer.state import InstallerState from installer.milestone import Milestone, ProgressCallback from installer.build import get_build_command, BuildMilestone from installer.core.controller import ProleController # ===== 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['PROLE_DB_USER'] == 'testuser' assert env['PROLE_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['PROLE_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['PROLE_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 # ===== ProleController ===== class TestProleController: def test_init(self, tmp_path): c = ProleController(tmp_path) assert c.project_root == tmp_path assert c.verbose is False assert c.state is not None def test_init_verbose(self, tmp_path): c = ProleController(tmp_path, verbose=True) assert c.verbose is True def test_check_docker_running(self, tmp_path): c = ProleController(tmp_path) result = c.check_docker_running() assert isinstance(result, bool) def test_get_prole_db_version_defaults(self, tmp_path): c = ProleController(tmp_path) version = c.get_prole_db_version() assert '17.7' in version # default pg version assert '-' in version def test_get_prole_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 / 'prole-db' db_dir.mkdir() (db_dir / '.version').write_text('42') c = ProleController(tmp_path) version = c.get_prole_db_version() assert version == '16.2-042' def test_run_milestones(self, tmp_path): c = ProleController(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 = ProleController(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 = ProleController(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 # ===== Milestone concrete subclasses (from core/milestones.py) ===== class TestDependenciesMilestone: def test_init(self): from installer.core.milestones import DependenciesMilestone m = DependenciesMilestone() assert m.id == 'dependencies' assert m.title == 'Dependency Verification' class TestNetworkScanMilestone: def test_init(self): from installer.core.milestones import NetworkScanMilestone m = NetworkScanMilestone() assert m.id == 'network_scan' class TestEnvSetupMilestone: def test_execute(self): from installer.core.milestones import EnvSetupMilestone m = EnvSetupMilestone() state = InstallerState( inputs={ 'env_setup.PROLE_HOME': '/opt/prole', 'env_setup.PROLE_CONF': '/opt/prole/conf', 'env_setup.NAMESPACE': 'testns', }, config_data={}, ) msgs = [] m.execute(state, progress=lambda msg, p: msgs.append(msg)) assert state.config_data['System Environment']['PROLE_HOME'] == '/opt/prole' assert state.config_data['System Environment']['NAMESPACE'] == 'testns' assert 'Environment setup complete' in msgs class TestSecretManagementMilestone: def test_execute_plain(self): from installer.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 installer.core.milestones import SecretManagementMilestone m = SecretManagementMilestone() state = InstallerState(inputs={}) m.execute(state) class TestDatabaseCreationMilestone: def test_execute_generates_password(self): from installer.core.milestones import DatabaseCreationMilestone m = DatabaseCreationMilestone() state = InstallerState( inputs={'env_setup.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']['DB_NAME'] == 'testns' def test_execute_with_password(self): from installer.core.milestones import DatabaseCreationMilestone m = DatabaseCreationMilestone() state = InstallerState( inputs={ 'init_password.db_password': 'mypassword', 'env_setup.NAMESPACE': 'ns1', }, config_data={}, ) m.execute(state) assert state.inputs['init_password.db_password'] == 'mypassword' def test_execute_unresolvable_openbao(self): from installer.core.milestones import DatabaseCreationMilestone m = DatabaseCreationMilestone() state = InstallerState( inputs={ 'init_password.db_password': '${OPENBAO:kv/prole/ns/db#password}', 'env_setup.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 installer.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 installer.core.milestones import DeploymentMilestone m = DeploymentMilestone() assert m.id == 'deployment'