"""ORCH-101 (TC-03/TC-04/TC-05/TC-11, AC-1/AC-2/AC-8): host values come from Settings, defaults equal the previous production literals, and the ORCH-058 anti-prod invariant is an executable guard. Functional where the seam allows (launcher.agent_git_env, plane_sync link build, image_freshness guard — monkeypatch, no network), structural where the env dict is built inline inside a never-raise actor (self_deploy/post_deploy: the scanner in test_no_host_hardcodes.py already proves the literals are gone; here we pin that the replacement reads the RIGHT Settings keys). """ from pathlib import Path from src.config import Settings, settings REPO_ROOT = Path(__file__).resolve().parents[1] # --------------------------------------------------------------------------- # AC-2 / BR-5: defaults of the new keys equal the previous hardcoded values. # Judged on the class fields (immune to ambient env / .env of the test host). # --------------------------------------------------------------------------- def test_new_settings_defaults_equal_previous_production_values(): fields = Settings.model_fields assert fields["agent_home_dir"].default == "/home/slin" assert fields["agent_git_name"].default == "claude-bot" assert fields["git_email_domain"].default == "mva154.local" assert fields["staging_port"].default == 8501 # Registry E (BR-5): pre-existing defaults are NOT changed by ORCH-101. assert fields["deploy_prod_target_port"].default == 8500 assert fields["deploy_host_repo_path"].default == "/home/slin/repos/orchestrator" assert fields["host_repos_dir"].default == "/home/slin/repos" assert fields["deploy_ssh_user"].default == "slin" assert fields["gitea_owner"].default == "admin" # --------------------------------------------------------------------------- # TC-04: launcher.agent_git_env — single Settings-driven source for BOTH the # agent Popen and the post-run git commit/push. # --------------------------------------------------------------------------- def test_agent_git_env_reads_settings(monkeypatch): from src.agents import launcher monkeypatch.setattr(launcher.settings, "agent_home_dir", "/home/deploy") monkeypatch.setattr(launcher.settings, "agent_git_name", "robo-bot") monkeypatch.setattr(launcher.settings, "git_email_domain", "newhost.example") env = launcher.agent_git_env() assert env["HOME"] == "/home/deploy" assert env["GIT_AUTHOR_NAME"] == "robo-bot" assert env["GIT_COMMITTER_NAME"] == "robo-bot" assert env["GIT_AUTHOR_EMAIL"] == "robo-bot@newhost.example" assert env["GIT_COMMITTER_EMAIL"] == "robo-bot@newhost.example" def test_agent_git_env_default_identity_matches_previous_hardcode(monkeypatch): from src.agents import launcher # Pin the resolved values to the class defaults (ambient-env immune). monkeypatch.setattr(launcher.settings, "agent_home_dir", "/home/slin") monkeypatch.setattr(launcher.settings, "agent_git_name", "claude-bot") monkeypatch.setattr(launcher.settings, "git_email_domain", "mva154.local") env = launcher.agent_git_env() assert env["HOME"] == "/home/slin" assert env["GIT_AUTHOR_EMAIL"] == "claude-bot@mva154.local" assert env["GIT_COMMITTER_EMAIL"] == "claude-bot@mva154.local" def test_agent_git_env_preserves_ambient_environ(monkeypatch): from src.agents import launcher monkeypatch.setenv("ORCH101_CANARY", "yes") assert launcher.agent_git_env()["ORCH101_CANARY"] == "yes" def test_both_launcher_sites_use_the_helper(): """Structural: the Popen env AND the post-run git env share one source.""" src = (REPO_ROOT / "src/agents/launcher.py").read_text(encoding="utf-8") assert "env=agent_git_env()" in src # agent Popen site assert "git_env = agent_git_env()" in src # post-run commit/push site # --------------------------------------------------------------------------- # TC-05: system actors (deploy-finalizer / post-deploy-monitor) — HOME + email # domain from Settings, actor NAMES stay platform literals (ADR-001 D2). # --------------------------------------------------------------------------- def test_system_actor_envs_read_settings(): sd = (REPO_ROOT / "src/self_deploy.py").read_text(encoding="utf-8") pd = (REPO_ROOT / "src/post_deploy.py").read_text(encoding="utf-8") for source, actor in ((sd, "deploy-finalizer"), (pd, "post-deploy-monitor")): assert '"HOME": settings.agent_home_dir' in source assert f'f"{actor}@{{settings.git_email_domain}}"' in source assert f'"GIT_AUTHOR_NAME": "{actor}"' in source # platform literal kept # --------------------------------------------------------------------------- # TC-03: plane_sync.notify_stage_change builds links from Settings # (gitea_public_url fallback gitea_url + gitea_owner). No network: every # outbound seam is monkeypatched. # --------------------------------------------------------------------------- def _capture_stage_change_msg(monkeypatch, new_stage="development"): import src.db as db from src import plane_sync monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda wi, pid=None: "proj") monkeypatch.setattr(plane_sync, "update_issue_state", lambda *a, **k: None) sent = {} monkeypatch.setattr( plane_sync, "add_comment", lambda wi, msg, pid=None, author=None: sent.setdefault("msg", msg), ) class _Cursor: def fetchone(self): return ("feature/ORCH-1-demo", "demo-repo") class _Conn: def execute(self, *a): return _Cursor() def close(self): pass monkeypatch.setattr(db, "get_db", lambda: _Conn()) plane_sync.notify_stage_change("ORCH-1", "analysis", new_stage) return sent["msg"] def test_stage_change_link_uses_public_url_and_owner(monkeypatch): from src import plane_sync monkeypatch.setattr(plane_sync.settings, "gitea_public_url", "https://git.example.org/") monkeypatch.setattr(plane_sync.settings, "gitea_owner", "acme") msg = _capture_stage_change_msg(monkeypatch) assert "https://git.example.org/acme/demo-repo/src/branch/feature/ORCH-1-demo" in msg assert "mva154" not in msg and "duckdns" not in msg assert "/admin/" not in msg # the hardcoded owner is gone def test_stage_change_link_falls_back_to_gitea_url(monkeypatch): from src import plane_sync monkeypatch.setattr(plane_sync.settings, "gitea_public_url", "") monkeypatch.setattr(plane_sync.settings, "gitea_url", "http://gitea.lan:3000") monkeypatch.setattr(plane_sync.settings, "gitea_owner", "owner1") msg = _capture_stage_change_msg(monkeypatch) assert "http://gitea.lan:3000/owner1/demo-repo/src/branch/" in msg def test_stage_change_hardcoded_base_removed_from_source(): src = (REPO_ROOT / "src/plane_sync.py").read_text(encoding="utf-8") assert "git.mva154.duckdns.org" not in src assert 'gitea_base + "/admin/"' not in src # --------------------------------------------------------------------------- # TC-11 / AC-8: ORCH-058 invariant — the freshness path can never be aimed at # the prod target. staging_port is a config key (default 8501) WITH a # fail-closed guard; service/profile names stay platform constants. # --------------------------------------------------------------------------- def test_staging_port_guard_refuses_prod_port(monkeypatch): from src import image_freshness as imf monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) monkeypatch.setattr(imf.settings, "image_freshness_repos", "") monkeypatch.setattr(imf.settings, "staging_port", 8500) monkeypatch.setattr(imf.settings, "deploy_prod_target_port", 8500) ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-1", "feature/x") assert ok is False assert "misconfiguration" in reason and "ORCH-058" in reason assert "refused" in reason # loud refusal, no silent 8501 fallback def test_staging_port_default_passes_guard(monkeypatch, tmp_path): from src import image_freshness as imf monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) monkeypatch.setattr(imf.settings, "image_freshness_repos", "") monkeypatch.setattr(imf.settings, "staging_port", 8501) monkeypatch.setattr(imf.settings, "deploy_prod_target_port", 8500) # Point the worktree somewhere empty: the check must get PAST the guard and # fail-close later on the missing validated revision (proves the guard # itself did not fire on the default 8501/8500 split). monkeypatch.setattr(imf.settings, "worktrees_dir", str(tmp_path / "none")) monkeypatch.setattr(imf.settings, "repos_dir", str(tmp_path / "none")) ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-1", "feature/x") assert ok is False assert "misconfiguration" not in reason assert "validated revision" in reason def test_staging_service_names_stay_platform_constants(): from src import image_freshness as imf assert imf._STAGING_SERVICE == "orchestrator-staging" assert imf._STAGING_COMPOSE_PROFILE == "staging" def test_rebuild_staging_passes_configured_port_and_repo(monkeypatch): """D4+D7: the wired path passes TARGET_PORT from settings.staging_port and REPO from settings.deploy_host_repo_path EXPLICITLY (explicit-pass discipline of ORCH-058 kept; config is the single truth on the wired path).""" from src import image_freshness as imf monkeypatch.setattr(imf.settings, "deploy_ssh_user", "u") monkeypatch.setattr(imf.settings, "deploy_ssh_host", "127.0.0.1") monkeypatch.setattr(imf.settings, "staging_port", 9501) monkeypatch.setattr(imf.settings, "deploy_host_repo_path", "/srv/orchestrator") captured = {} class _R: returncode = 0 stdout = "" stderr = "" def fake_run(cmd, **kw): captured["cmd"] = cmd return _R() monkeypatch.setattr(imf.subprocess, "run", fake_run) ok, _ = imf.rebuild_staging_image("orchestrator", "feature/x", "abc1234") assert ok is True inner = captured["cmd"][-1] assert "TARGET_PORT=9501" in inner assert "REPO=/srv/orchestrator" in inner assert "TARGET_SERVICE=orchestrator-staging" in inner def test_build_deploy_command_passes_repo_explicitly(monkeypatch): """D7: the detached prod deploy passes REPO= so the hook env-override is actually exercised on a parametrised host (hook default = manual runs only).""" from src import self_deploy monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "u") monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "127.0.0.1") monkeypatch.setattr(self_deploy.settings, "deploy_host_repo_path", "/srv/orchestrator") cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-101", "feature/x") remote = cmd[-1] assert "REPO=/srv/orchestrator" in remote # Exit-code contract untouched: the hook is still invoked with --deploy and # the wrapper still writes the result sentinel. assert "--deploy" in remote and "echo $?" in remote # --------------------------------------------------------------------------- # ADR-001 D3 anti-drift: SELF_HOSTING_REPO is a PLATFORM CONSTANT (a tirage # convention — the platform repo MUST be named `orchestrator`), NOT a config key. # --------------------------------------------------------------------------- def test_self_hosting_repo_is_platform_constant(): from src.qg.checks import SELF_HOSTING_REPO, is_self_hosting_repo assert SELF_HOSTING_REPO == "orchestrator" assert is_self_hosting_repo("orchestrator") is True assert is_self_hosting_repo("enduro-trails") is False # NOT configurable: no Settings key may claim this fact (D3 — a typo would # either aim deploy machinery at a foreign repo or mute all self-gates). assert "self_hosting_repo" not in Settings.model_fields def test_settings_instance_importable(): """The shared settings instance carries the new keys (smoke).""" assert hasattr(settings, "agent_home_dir") assert hasattr(settings, "agent_git_name") assert hasattr(settings, "git_email_domain") assert hasattr(settings, "staging_port")