mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description `headroom install apply` regenerates the deployment manifest on every run, and that regeneration silently drops any manually-added `--no-http2` override. The HTTP/2 workaround itself is already real and already supported by `headroom proxy`, but persistent installs had no first-class way to keep it. This PR adds `--no-http2` to `install apply`, threads it into `build_manifest()`, and persists the flag in `manifest.proxy_args` so it survives reapply. Closes #1615 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added `--no-http2` to `headroom install apply`, and forwarded the flag into `build_manifest()`. - Extended `headroom/install/planner.py` so `build_manifest(..., no_http2=True)` persists `--no-http2` into `manifest.proxy_args`. - Added planner-level regression coverage for both the override path and the default-preservation path. - Added CLI-level regression coverage that proves `install apply --no-http2` forwards correctly and that the help surface advertises the flag. - `CHANGELOG.md` intentionally not touched: repo policy generates changelog entries from conventional commits rather than manual PR edits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_install/test_planner.py` and `uv run pytest tests/test_cli/test_install_cli.py`) - [x] Linting passes (`uv run ruff check .` and `uv run ruff format . --check`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text > rtk uv run pytest tests/test_install/test_planner.py -k no_http2 -q collected 7 items / 5 deselected / 2 selected tests\test_install\test_planner.py .. [100%] 2 passed, 5 deselected in 0.18s > rtk uv run pytest tests/test_cli/test_install_cli.py -k no_http2 -q collected 19 items / 17 deselected / 2 selected tests\test_cli\test_install_cli.py .. [100%] 2 passed, 17 deselected in 0.23s > rtk uv run pytest tests/test_install/test_runtime.py -q collected 19 items tests\test_install\test_runtime.py ..........F........ [100%] FAILED tests/test_install/test_runtime.py::test_runtime_start_lock_blocks_another_process 1 failed, 18 passed in 0.44s (Confirmed pre-existing on unmodified origin/main via `git stash` in this worktree, identical failure with none of this PR's changes applied. Environment-specific lock-file flakiness in this sandbox, unrelated to install-manifest persistence; runtime.py was not touched by this change.) > rtk uv run ruff check headroom/cli/install.py headroom/install/planner.py tests/test_install/test_planner.py tests/test_cli/test_install_cli.py All checks passed! > rtk uv run ruff format --check headroom/cli/install.py headroom/install/planner.py tests/test_install/test_planner.py tests/test_cli/test_install_cli.py 4 files already formatted ``` ## Real Behavior Proof - Environment: local source checkout with `uv` dev environment, using the existing install CLI and manifest builder, in worktree `D:\Repos\headroom-pr-1615-persist-install-http2-override`. - Exact command / steps: ran `headroom install apply --help` through `CliRunner`, ran a direct `build_manifest(..., no_http2=True)` proof, and ran the focused planner, CLI, runtime, and lint checks. - Observed result: on `origin/main`, `install apply --help` lacked `--no-http2` and `build_manifest(..., no_http2=True)` raised `TypeError: build_manifest() got an unexpected keyword argument 'no_http2'`; on this branch, `install apply --help` lists `--no-http2`, `build_manifest(..., no_http2=True)` returns a manifest whose `proxy_args` contains exactly one `--no-http2` entry (`['--host', '127.0.0.1', '--port', '8787', '--mode', 'token', '--backend', 'anthropic', '--telemetry', '--no-http2']`), persistent installs now preserve the existing HTTP/2 disable flag across `install apply` regeneration, and runtime behavior still comes entirely from replaying manifest `proxy_args` (`runtime.py` was not modified). - Not tested: a full persistent-service supervisor round-trip or full CI suite locally. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable (not applicable, changelog entries are generated from conventional commits per repo policy) ## Additional Notes This stays scoped to the install-manifest persistence seam only; it does not revisit HTTP/2 default policy, retry behavior, or proxy transport construction. Attribution: the implementation shape follows the persistence pattern already established by #1365, and the remaining install-layer gap was confirmed by `sarkarsital1959` in the 2026-07-01 comment on #1615.
636 lines
22 KiB
Python
636 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import click
|
|
from click.testing import CliRunner
|
|
|
|
from headroom.cli.main import main
|
|
|
|
|
|
def test_install_apply_starts_service_supervisor(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-service"
|
|
runtime_kind = "python"
|
|
supervisor_kind = "service"
|
|
scope = "user"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
targets = ["claude", "codex"]
|
|
mutations = []
|
|
artifacts = []
|
|
|
|
manifest = Manifest()
|
|
|
|
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: manifest)
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
|
|
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
|
|
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.save_manifest", lambda deployment: calls.append("save")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_supervisor", lambda deployment: calls.append("start_service")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_detached_agent", lambda profile: calls.append("start_agent")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_persistent_docker",
|
|
lambda deployment: calls.append("start_docker"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "apply"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Installed persistent deployment 'default'" in result.output
|
|
assert "Targets: claude, codex" in result.output
|
|
assert calls == ["save", "start_service"]
|
|
|
|
|
|
def test_install_apply_forwards_no_http2_to_build_manifest(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
captured: dict[str, object] = {}
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-service"
|
|
runtime_kind = "python"
|
|
supervisor_kind = "service"
|
|
scope = "user"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
targets = ["claude"]
|
|
mutations = []
|
|
artifacts = []
|
|
|
|
manifest = Manifest()
|
|
|
|
def fake_build_manifest(**kwargs):
|
|
captured.update(kwargs)
|
|
return manifest
|
|
|
|
monkeypatch.setattr("headroom.cli.install.build_manifest", fake_build_manifest)
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
|
|
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
|
|
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
|
|
monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None)
|
|
monkeypatch.setattr("headroom.cli.install.start_supervisor", lambda deployment: None)
|
|
monkeypatch.setattr("headroom.cli.install.start_detached_agent", lambda profile: None)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "apply", "--no-http2"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["no_http2"] is True
|
|
|
|
|
|
def test_install_apply_help_lists_no_http2() -> None:
|
|
runner = CliRunner()
|
|
|
|
result = runner.invoke(main, ["install", "apply", "--help"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "--no-http2" in result.output
|
|
|
|
|
|
def test_install_status_includes_backend_from_health_probe(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-service"
|
|
runtime_kind = "python"
|
|
supervisor_kind = "service"
|
|
scope = "user"
|
|
port = 8787
|
|
backend = "anthropic"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running")
|
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.probe_json",
|
|
lambda url: {"config": {"backend": "anthropic"}},
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "status"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Status: running" in result.output
|
|
assert "Healthy: yes" in result.output
|
|
assert "Backend: anthropic" in result.output
|
|
|
|
|
|
def test_install_restart_uses_internal_helpers(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-service"
|
|
runtime_kind = "python"
|
|
supervisor_kind = "service"
|
|
scope = "user"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.stop_supervisor", lambda manifest: calls.append("stop_supervisor")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.stop_runtime", lambda manifest: calls.append("stop_runtime")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_supervisor", lambda manifest: calls.append("start_supervisor")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.wait_ready", lambda manifest, timeout_seconds=45: True
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "restart"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Restarted deployment 'default'." in result.output
|
|
assert calls == ["stop_supervisor", "stop_runtime", "start_supervisor"]
|
|
|
|
|
|
def test_install_apply_rejects_invalid_profile() -> None:
|
|
runner = CliRunner()
|
|
|
|
result = runner.invoke(main, ["install", "apply", "--profile", "../bad"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "Invalid profile name '../bad'" in result.output
|
|
|
|
|
|
def test_install_apply_rejects_provider_scope_targets_without_support() -> None:
|
|
runner = CliRunner()
|
|
|
|
result = runner.invoke(
|
|
main,
|
|
["install", "apply", "--scope", "provider", "--providers", "manual", "--target", "copilot"],
|
|
)
|
|
|
|
assert result.exit_code != 0
|
|
assert "Provider scope supports only claude, codex, openclaw, and opencode" in result.output
|
|
|
|
|
|
def test_install_apply_accepts_opencode_target(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
captured: dict[str, object] = {}
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-service"
|
|
runtime_kind = "python"
|
|
supervisor_kind = "service"
|
|
scope = "provider"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
targets = ["opencode"]
|
|
mutations = []
|
|
artifacts = []
|
|
|
|
manifest = Manifest()
|
|
|
|
def fake_build_manifest(**kwargs):
|
|
captured.update(kwargs)
|
|
return manifest
|
|
|
|
monkeypatch.setattr("headroom.cli.install.build_manifest", fake_build_manifest)
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
|
|
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
|
|
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
|
|
monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None)
|
|
monkeypatch.setattr("headroom.cli.install.start_supervisor", lambda deployment: None)
|
|
monkeypatch.setattr("headroom.cli.install.start_detached_agent", lambda profile: None)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
|
|
)
|
|
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"install",
|
|
"apply",
|
|
"--scope",
|
|
"provider",
|
|
"--providers",
|
|
"manual",
|
|
"--target",
|
|
"opencode",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["targets"] == ["opencode"]
|
|
assert "Targets: opencode" in result.output
|
|
|
|
|
|
def test_install_apply_restores_previous_deployment_after_failed_update(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
def __init__(self, profile: str, targets: list[str]) -> None:
|
|
self.profile = profile
|
|
self.preset = "persistent-service"
|
|
self.runtime_kind = "python"
|
|
self.supervisor_kind = "service"
|
|
self.scope = "user"
|
|
self.health_url = "http://127.0.0.1:8787/readyz"
|
|
self.targets = targets
|
|
self.mutations = []
|
|
self.artifacts = []
|
|
|
|
new_manifest = Manifest("default", ["claude"])
|
|
existing_manifest = Manifest("default", ["codex"])
|
|
|
|
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: new_manifest)
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: existing_manifest)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.apply_mutations",
|
|
lambda deployment: calls.append(f"apply:{','.join(deployment.targets)}") or [],
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.install_supervisor",
|
|
lambda deployment: calls.append(f"supervisor:{','.join(deployment.targets)}") or [],
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.save_manifest",
|
|
lambda deployment: calls.append(f"save:{','.join(deployment.targets)}"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.stop_supervisor",
|
|
lambda deployment: calls.append(f"stop-supervisor:{','.join(deployment.targets)}"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.stop_runtime",
|
|
lambda deployment: calls.append(f"stop-runtime:{','.join(deployment.targets)}"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.remove_supervisor",
|
|
lambda deployment: calls.append(f"remove-supervisor:{','.join(deployment.targets)}"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.revert_mutations",
|
|
lambda deployment: calls.append(f"revert:{','.join(deployment.targets)}"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.delete_manifest",
|
|
lambda profile: calls.append(f"delete:{profile}"),
|
|
)
|
|
|
|
def _start(deployment) -> None:
|
|
calls.append(f"start:{','.join(deployment.targets)}")
|
|
if deployment is new_manifest:
|
|
raise click.ClickException("boom")
|
|
|
|
monkeypatch.setattr("headroom.cli.install._start_deployment", _start)
|
|
|
|
result = runner.invoke(main, ["install", "apply"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "Restoring previous deployment 'default'" in result.output
|
|
assert calls == [
|
|
"stop-supervisor:codex",
|
|
"stop-runtime:codex",
|
|
"remove-supervisor:codex",
|
|
"revert:codex",
|
|
"delete:default",
|
|
"apply:claude",
|
|
"supervisor:claude",
|
|
"save:claude",
|
|
"start:claude",
|
|
"stop-supervisor:claude",
|
|
"stop-runtime:claude",
|
|
"remove-supervisor:claude",
|
|
"revert:claude",
|
|
"delete:default",
|
|
"apply:codex",
|
|
"supervisor:codex",
|
|
"save:codex",
|
|
"start:codex",
|
|
]
|
|
|
|
|
|
def test_install_start_rejects_task_lifecycle(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-task"
|
|
runtime_kind = "python"
|
|
supervisor_kind = "task"
|
|
scope = "user"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
|
|
result = runner.invoke(main, ["install", "start"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "headroom install start" in result.output
|
|
|
|
|
|
def test_install_apply_uses_docker_runtime_for_persistent_docker(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-docker"
|
|
runtime_kind = "docker"
|
|
supervisor_kind = "none"
|
|
scope = "user"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
targets: list[str] = []
|
|
mutations = []
|
|
artifacts = []
|
|
|
|
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
|
|
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
|
|
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
|
|
monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_persistent_docker",
|
|
lambda deployment: calls.append("start_docker"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
|
|
)
|
|
# _start_deployment guards the persistent-docker preset with
|
|
# `shutil.which("docker")`. Fake docker as present so the test exercises the
|
|
# runtime-selection path itself rather than the host's docker install —
|
|
# otherwise it passes on dev machines with Docker but fails on CI runners
|
|
# (e.g. macos-latest) that have no docker on PATH.
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.shutil.which",
|
|
lambda name, *args, **kwargs: "/usr/local/bin/docker" if name == "docker" else None,
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "apply", "--preset", "persistent-docker"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert calls == ["start_docker"]
|
|
|
|
|
|
def test_install_remove_continues_when_runtime_teardown_errors(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
preset = "persistent-service"
|
|
runtime_kind = "python"
|
|
supervisor_kind = "service"
|
|
scope = "user"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.stop_supervisor",
|
|
lambda manifest: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.stop_runtime",
|
|
lambda manifest: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.remove_supervisor", lambda manifest: calls.append("remove_supervisor")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.revert_mutations", lambda manifest: calls.append("revert")
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.delete_manifest", lambda profile: calls.append("delete")
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "remove"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert calls == ["remove_supervisor", "revert", "delete"]
|
|
|
|
|
|
def test_install_agent_ensure_reports_already_healthy(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True)
|
|
|
|
result = runner.invoke(main, ["install", "agent", "ensure"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "already healthy" in result.output
|
|
|
|
|
|
def test_install_agent_run_exits_with_foreground_status(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.run_foreground", lambda manifest: 7)
|
|
|
|
result = runner.invoke(main, ["install", "agent", "run"])
|
|
|
|
assert result.exit_code == 7
|
|
|
|
|
|
def test_install_agent_ensure_no_spawn_when_lock_not_acquired(monkeypatch) -> None:
|
|
"""Ensure does not spawn a runtime when the start lock is contended."""
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
|
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def fake_lock(profile):
|
|
yield False
|
|
|
|
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_detached_agent",
|
|
lambda profile: calls.append("start_agent"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_persistent_docker",
|
|
lambda manifest: calls.append("start_docker"),
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "agent", "ensure"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "already in progress" in result.output
|
|
assert calls == []
|
|
|
|
|
|
def test_install_agent_ensure_stops_wedged_runtime_before_restart(monkeypatch) -> None:
|
|
"""Ensure stops a wedged runtime (running but not ready) before starting fresh."""
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
preset = "persistent-task"
|
|
supervisor_kind = "none"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
|
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running")
|
|
monkeypatch.setattr("headroom.cli.install.wait_ready", lambda manifest, timeout_seconds: False)
|
|
monkeypatch.setattr("headroom.cli.install.stop_runtime", lambda manifest: calls.append("stop"))
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_detached_agent",
|
|
lambda profile: calls.append("start_agent"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_persistent_docker",
|
|
lambda manifest: calls.append("start_docker"),
|
|
)
|
|
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def fake_lock(profile):
|
|
yield True
|
|
|
|
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install._start_deployment", lambda manifest: calls.append("start_deployment")
|
|
)
|
|
|
|
result = runner.invoke(main, ["install", "agent", "ensure"])
|
|
assert result.exit_code == 0, result.output
|
|
# stop must come before start_deployment — that's the bug guard.
|
|
assert calls.index("stop") < calls.index("start_deployment")
|
|
assert "start_agent" not in calls
|
|
assert "start_docker" not in calls
|
|
|
|
|
|
def test_install_agent_ensure_starts_when_stopped_and_lock_acquired(monkeypatch) -> None:
|
|
"""Ensure starts a runtime when none is running and lock is acquired."""
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
preset = "persistent-task"
|
|
supervisor_kind = "none"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
|
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "stopped")
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_detached_agent",
|
|
lambda profile: calls.append("start_agent"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_persistent_docker",
|
|
lambda manifest: calls.append("start_docker"),
|
|
)
|
|
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def fake_lock(profile):
|
|
yield True
|
|
|
|
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
|
monkeypatch.setattr("headroom.cli.install.wait_ready", lambda manifest, timeout_seconds: True)
|
|
|
|
result = runner.invoke(main, ["install", "agent", "ensure"])
|
|
assert result.exit_code == 0, result.output
|
|
assert calls == ["start_agent"]
|
|
|
|
|
|
def test_install_agent_ensure_no_duplicate_spawn_after_lock_recheck(monkeypatch) -> None:
|
|
"""Ensure does not spawn if proxy becomes ready between initial probe and lock."""
|
|
runner = CliRunner()
|
|
calls: list[str] = []
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
# First probe_ready (before lock) returns False, second (after lock) returns True
|
|
probe_results = iter([False, True])
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: next(probe_results))
|
|
|
|
monkeypatch.setattr(
|
|
"headroom.cli.install.start_detached_agent",
|
|
lambda profile: calls.append("start_agent"),
|
|
)
|
|
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def fake_lock(profile):
|
|
yield True
|
|
|
|
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
|
|
|
result = runner.invoke(main, ["install", "agent", "ensure"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "already healthy" in result.output
|
|
assert calls == []
|
|
|
|
|
|
def test_install_agent_ensure_propagates_start_deployment_failure(monkeypatch) -> None:
|
|
"""Ensure must exit non-zero and surface the error when _start_deployment fails.
|
|
|
|
Regression for review feedback on PR #1301: the previous implementation wrapped
|
|
the guarded block in `except Exception` and returned normally, which made
|
|
a failed ensure indistinguishable from a successful one. Automation callers
|
|
need a non-zero exit code to detect that the deployment did not come up.
|
|
"""
|
|
runner = CliRunner()
|
|
|
|
class Manifest:
|
|
profile = "default"
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
preset = "persistent-task"
|
|
supervisor_kind = "none"
|
|
|
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
|
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "stopped")
|
|
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def fake_lock(profile):
|
|
yield True
|
|
|
|
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
|
|
|
def boom(manifest):
|
|
raise click.ClickException("simulated start failure")
|
|
|
|
monkeypatch.setattr("headroom.cli.install._start_deployment", boom)
|
|
|
|
result = runner.invoke(main, ["install", "agent", "ensure"])
|
|
assert result.exit_code != 0, f"expected non-zero exit, got {result.exit_code}: {result.output}"
|
|
assert "simulated start failure" in result.output
|