"""Local-stack contracts: profile files, env merge, image pin, port remap.""" from __future__ import annotations import json import os import subprocess import pytest from honcho_cli.local.docker import ( DockerError, allocate_host_ports, pin_image, seed_config_toml, ) from honcho_cli.local.env import managed_env, read_env_value, render_stack, upsert_env from honcho_cli.local.profile import LocalProfile, load_profile, save_profile @pytest.fixture def cfg_dir(tmp_path, monkeypatch): monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", tmp_path / "config.json") for k in [k for k in os.environ if k.startswith("HONCHO_")]: monkeypatch.delenv(k) return tmp_path def test_profile_roundtrip_has_no_secrets(cfg_dir): profile = LocalProfile( name="local", api_port=8001, image="ghcr.io/plastic-labs/honcho@sha256:abc", ) save_profile(profile) loaded = load_profile("local") assert loaded.api_port == 8001 assert loaded.image.endswith("@sha256:abc") on_disk = json.loads(profile.profile_file().read_text()) assert "LLM" not in json.dumps(on_disk) assert set(on_disk) == {"apiPort", "dbPort", "redisPort", "image"} def test_upsert_preserves_extra_env_keys(tmp_path): path = tmp_path / ".env" path.write_text("CUSTOM_FLAG=keep-me\n# user comment\n") upsert_env(path, managed_env(LocalProfile(name="local"))) text = path.read_text() assert "CUSTOM_FLAG=keep-me" in text assert "user comment" in text assert text.count("Generated by honcho start") == 1 def test_upsert_writes_non_managed_and_preserves_later(tmp_path): path = tmp_path / ".env" first = managed_env(LocalProfile(name="local")) first["DERIVER_MODEL_CONFIG__MODEL"] = "gpt-test" upsert_env(path, first) upsert_env(path, managed_env(LocalProfile(name="local"))) later = path.read_text() assert "DERIVER_MODEL_CONFIG__MODEL=gpt-test" in later def test_render_stack_uses_published_image(cfg_dir): profile = LocalProfile(name="local") render_stack(profile) compose = profile.compose_file().read_text() assert "ghcr.io/plastic-labs/honcho" in compose assert "build:" not in compose assert compose.count("./config.toml:/app/config.toml:ro") == 2 assert read_env_value(profile.env_file(), "AUTH_USE_AUTH") == "false" assert oct(profile.env_file().stat().st_mode)[-3:] == "600" def test_pin_latest_to_matching_digest(monkeypatch): pulls: list[str] = [] def fake_run(args, *, check=False): if args[:1] == ["pull"]: pulls.append(args[1]) return subprocess.CompletedProcess(args, 0, stdout="", stderr="") if args[:2] == ["image", "inspect"]: body = json.dumps( [ "ghcr.io/plastic-labs/honcho@sha256:deadbeef", "ghcr.io/other/honcho@sha256:nope", ] ) return subprocess.CompletedProcess(args, 0, stdout=body, stderr="") raise AssertionError(args) monkeypatch.setattr("honcho_cli.local.docker._run_docker", fake_run) assert pin_image("ghcr.io/plastic-labs/honcho:latest") == ( "ghcr.io/plastic-labs/honcho@sha256:deadbeef" ) assert pulls == ["ghcr.io/plastic-labs/honcho:latest"] def test_seed_config_toml_writes_once(cfg_dir, monkeypatch): profile = LocalProfile( name="local", image="ghcr.io/plastic-labs/honcho@sha256:abc" ) monkeypatch.setattr( "honcho_cli.local.docker._copy_from_image", lambda image, paths: "[deriver]\nWORKERS = 2\n", ) assert seed_config_toml(profile) is True profile.config_file().write_text( profile.config_file().read_text() + "# user edit\n" ) assert seed_config_toml(profile) is False assert "# user edit" in profile.config_file().read_text() def test_busy_port_remaps_unless_pinned(monkeypatch): monkeypatch.setattr( "honcho_cli.local.docker.port_available", lambda port, host="127.0.0.1": port != 6379, ) profile, remapped = allocate_host_ports(LocalProfile(name="local")) assert profile.redis_port == 6380 assert remapped["redis"] == (6379, 6380) with pytest.raises(DockerError) as exc: allocate_host_ports(LocalProfile(name="local"), pinned=frozenset({"redis"})) assert exc.value.code == "PORT_IN_USE" assert exc.value.details["flag"] == "--redis-port"