0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
429 lines
14 KiB
Python
429 lines
14 KiB
Python
"""Persistent install / deployment CLI commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
from copy import deepcopy
|
|
|
|
import click
|
|
|
|
from headroom.install.health import probe_json, probe_ready
|
|
from headroom.install.models import (
|
|
ConfigScope,
|
|
DeploymentManifest,
|
|
InstallPreset,
|
|
ProviderSelectionMode,
|
|
RuntimeKind,
|
|
SupervisorKind,
|
|
)
|
|
from headroom.install.planner import build_manifest
|
|
from headroom.install.providers import apply_mutations, revert_mutations
|
|
from headroom.install.runtime import (
|
|
acquire_runtime_start_lock,
|
|
run_foreground,
|
|
runtime_status,
|
|
start_detached_agent,
|
|
start_persistent_docker,
|
|
stop_runtime,
|
|
wait_ready,
|
|
)
|
|
from headroom.install.state import (
|
|
ManifestError,
|
|
delete_manifest,
|
|
load_manifest,
|
|
save_manifest,
|
|
)
|
|
from headroom.install.supervisors import (
|
|
install_supervisor,
|
|
remove_supervisor,
|
|
start_supervisor,
|
|
stop_supervisor,
|
|
)
|
|
|
|
from .main import main
|
|
|
|
|
|
@main.group()
|
|
def install() -> None:
|
|
"""Install and manage persistent Headroom deployments."""
|
|
|
|
|
|
def _require_manifest(profile: str) -> DeploymentManifest:
|
|
try:
|
|
manifest = load_manifest(profile)
|
|
except ManifestError as e:
|
|
raise click.ClickException(str(e)) from None
|
|
if manifest is None:
|
|
raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
|
|
return manifest
|
|
|
|
|
|
def _start_deployment(manifest: DeploymentManifest, *, assume_start_lock: bool = False) -> None:
|
|
if not assume_start_lock:
|
|
with acquire_runtime_start_lock(manifest.profile) as acquired:
|
|
if not acquired:
|
|
click.echo(f"Deployment '{manifest.profile}' start is already in progress.")
|
|
return
|
|
_start_deployment(manifest, assume_start_lock=True)
|
|
return
|
|
|
|
if probe_ready(manifest.health_url):
|
|
return
|
|
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value and shutil.which("docker") is None:
|
|
raise click.ClickException(
|
|
"Docker is required for this deployment but 'docker' was not found on PATH."
|
|
)
|
|
if runtime_status(manifest) == "running":
|
|
if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS):
|
|
return
|
|
stop_runtime(manifest)
|
|
|
|
try:
|
|
if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
|
|
start_persistent_docker(manifest)
|
|
elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
|
start_supervisor(manifest)
|
|
else:
|
|
start_detached_agent(manifest.profile)
|
|
except FileNotFoundError as e:
|
|
# A required external binary (docker, launchctl, systemctl) is missing.
|
|
raise click.ClickException(f"Cannot start deployment '{manifest.profile}': {e}") from None
|
|
except subprocess.CalledProcessError as e:
|
|
raise click.ClickException(
|
|
f"Cannot start deployment '{manifest.profile}': command failed "
|
|
f"({' '.join(map(str, e.cmd)) if isinstance(e.cmd, list | tuple) else e.cmd})"
|
|
) from None
|
|
|
|
if not wait_ready(manifest, timeout_seconds=45):
|
|
raise click.ClickException(
|
|
f"Deployment '{manifest.profile}' did not become ready after start."
|
|
)
|
|
|
|
|
|
def _stop_deployment(manifest: DeploymentManifest) -> None:
|
|
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
|
stop_supervisor(manifest)
|
|
stop_runtime(manifest)
|
|
|
|
|
|
def _remove_deployment(manifest: DeploymentManifest) -> None:
|
|
try:
|
|
_stop_deployment(manifest)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
remove_supervisor(manifest)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
revert_mutations(manifest)
|
|
except Exception:
|
|
pass
|
|
delete_manifest(manifest.profile)
|
|
|
|
|
|
def _restore_deployment(manifest: DeploymentManifest) -> None:
|
|
restored = deepcopy(manifest)
|
|
restored.mutations = apply_mutations(restored)
|
|
restored.artifacts = install_supervisor(restored)
|
|
save_manifest(restored)
|
|
_start_deployment(restored)
|
|
|
|
|
|
def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None:
|
|
if manifest.supervisor_kind == SupervisorKind.TASK.value:
|
|
raise click.ClickException(
|
|
f"Deployment '{manifest.profile}' uses persistent-task scheduling; "
|
|
f"`headroom install {action}` is not supported for task deployments."
|
|
)
|
|
|
|
|
|
@install.command("apply")
|
|
@click.option(
|
|
"--preset",
|
|
type=click.Choice([preset.value for preset in InstallPreset]),
|
|
default=InstallPreset.PERSISTENT_SERVICE.value,
|
|
show_default=True,
|
|
help="Persistent runtime preset to install.",
|
|
)
|
|
@click.option(
|
|
"--runtime",
|
|
type=click.Choice([runtime.value for runtime in RuntimeKind]),
|
|
default=RuntimeKind.PYTHON.value,
|
|
show_default=True,
|
|
help="Runtime used to execute Headroom for service/task modes.",
|
|
)
|
|
@click.option(
|
|
"--scope",
|
|
type=click.Choice([scope.value for scope in ConfigScope]),
|
|
default=ConfigScope.USER.value,
|
|
show_default=True,
|
|
help="Where to apply persistent configuration.",
|
|
)
|
|
@click.option(
|
|
"--providers",
|
|
"provider_mode",
|
|
type=click.Choice([mode.value for mode in ProviderSelectionMode]),
|
|
default=ProviderSelectionMode.AUTO.value,
|
|
show_default=True,
|
|
help="Target selection mode for direct tool configuration.",
|
|
)
|
|
@click.option(
|
|
"--target",
|
|
"targets",
|
|
multiple=True,
|
|
type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw", "opencode"]),
|
|
help="Tool target to configure when --providers manual is used.",
|
|
)
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
@click.option(
|
|
"--port",
|
|
"-p",
|
|
default=8787,
|
|
type=click.IntRange(1, 65535),
|
|
show_default=True,
|
|
help="Persistent proxy port.",
|
|
)
|
|
@click.option(
|
|
"--backend",
|
|
default="anthropic",
|
|
show_default=True,
|
|
help="Proxy backend for the persistent runtime.",
|
|
)
|
|
@click.option(
|
|
"--anyllm-provider",
|
|
default=None,
|
|
help="Provider for any-llm backends when --backend anyllm is used.",
|
|
)
|
|
@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
|
|
@click.option(
|
|
"--mode", "proxy_mode", default="token", show_default=True, help="Proxy optimization mode."
|
|
)
|
|
@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
|
|
@click.option(
|
|
"--telemetry",
|
|
is_flag=True,
|
|
help="Opt in to anonymous telemetry in the runtime (off by default).",
|
|
)
|
|
@click.option(
|
|
"--no-telemetry",
|
|
is_flag=True,
|
|
help="Force anonymous telemetry off in the runtime (already the default).",
|
|
)
|
|
@click.option(
|
|
"--image",
|
|
default="ghcr.io/chopratejas/headroom:latest",
|
|
show_default=True,
|
|
help="Docker image to use when runtime=docker or preset=persistent-docker.",
|
|
)
|
|
@click.option(
|
|
"--no-http2",
|
|
is_flag=True,
|
|
help="Disable HTTP/2 in the persistent runtime (enabled by default).",
|
|
)
|
|
def install_apply(
|
|
preset: str,
|
|
runtime: str,
|
|
scope: str,
|
|
provider_mode: str,
|
|
targets: tuple[str, ...],
|
|
profile: str,
|
|
port: int,
|
|
backend: str,
|
|
anyllm_provider: str | None,
|
|
region: str | None,
|
|
proxy_mode: str,
|
|
memory: bool,
|
|
telemetry: bool,
|
|
no_telemetry: bool,
|
|
image: str,
|
|
no_http2: bool,
|
|
) -> None:
|
|
"""Install a persistent Headroom deployment."""
|
|
|
|
if anyllm_provider and backend != "anyllm":
|
|
click.echo(
|
|
f"Warning: --anyllm-provider is ignored unless --backend anyllm "
|
|
f"(got --backend {backend})."
|
|
)
|
|
|
|
if preset == InstallPreset.PERSISTENT_DOCKER.value:
|
|
runtime = RuntimeKind.DOCKER.value
|
|
|
|
manifest = build_manifest(
|
|
profile=profile,
|
|
preset=preset,
|
|
runtime_kind=runtime,
|
|
scope=scope,
|
|
provider_mode=provider_mode,
|
|
targets=list(targets),
|
|
port=port,
|
|
backend=backend,
|
|
anyllm_provider=anyllm_provider,
|
|
region=region,
|
|
proxy_mode=proxy_mode,
|
|
memory_enabled=memory,
|
|
telemetry_enabled=telemetry and not no_telemetry,
|
|
image=image,
|
|
no_http2=no_http2,
|
|
)
|
|
|
|
try:
|
|
existing = load_manifest(profile)
|
|
except ManifestError as e:
|
|
# A corrupt existing manifest shouldn't block a fresh apply; overwrite it.
|
|
click.echo(f"Warning: {e}; overwriting.")
|
|
existing = None
|
|
if existing is not None:
|
|
click.echo(f"Updating existing deployment profile '{profile}'...")
|
|
_remove_deployment(existing)
|
|
|
|
try:
|
|
manifest.mutations = apply_mutations(manifest)
|
|
manifest.artifacts = install_supervisor(manifest)
|
|
save_manifest(manifest)
|
|
_start_deployment(manifest)
|
|
except Exception as exc:
|
|
_remove_deployment(manifest)
|
|
if existing is not None:
|
|
click.echo(f"Restoring previous deployment '{profile}'...")
|
|
_restore_deployment(existing)
|
|
# Surface non-Click errors (OSError, CalledProcessError, …) as a clean
|
|
# message rather than a raw traceback; Click errors pass through as-is.
|
|
if isinstance(exc, click.ClickException | click.Abort):
|
|
raise
|
|
raise click.ClickException(f"Failed to install deployment '{profile}': {exc}") from exc
|
|
|
|
click.echo(
|
|
f"Installed persistent deployment '{profile}' "
|
|
f"({manifest.preset}, runtime={manifest.runtime_kind}, scope={manifest.scope})."
|
|
)
|
|
click.echo(f"Health: {manifest.health_url}")
|
|
if manifest.targets:
|
|
click.echo(f"Targets: {', '.join(manifest.targets)}")
|
|
|
|
|
|
@install.command("status")
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
def install_status(profile: str) -> None:
|
|
"""Show persistent deployment status."""
|
|
|
|
manifest = _require_manifest(profile)
|
|
payload = probe_json(manifest.health_url.replace("/readyz", "/health"))
|
|
click.echo(f"Profile: {manifest.profile}")
|
|
click.echo(f"Preset: {manifest.preset}")
|
|
click.echo(f"Runtime: {manifest.runtime_kind}")
|
|
click.echo(f"Supervisor: {manifest.supervisor_kind}")
|
|
click.echo(f"Scope: {manifest.scope}")
|
|
click.echo(f"Port: {manifest.port}")
|
|
click.echo(f"Status: {runtime_status(manifest)}")
|
|
click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")
|
|
if payload and isinstance(payload, dict):
|
|
click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
|
|
click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}")
|
|
|
|
|
|
@install.command("start")
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
def install_start(profile: str) -> None:
|
|
"""Start a persistent deployment."""
|
|
|
|
manifest = _require_manifest(profile)
|
|
_reject_task_lifecycle(manifest, "start")
|
|
_start_deployment(manifest)
|
|
click.echo(f"Started deployment '{profile}'.")
|
|
|
|
|
|
@install.command("stop")
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
def install_stop(profile: str) -> None:
|
|
"""Stop a persistent deployment."""
|
|
|
|
manifest = _require_manifest(profile)
|
|
_reject_task_lifecycle(manifest, "stop")
|
|
_stop_deployment(manifest)
|
|
click.echo(f"Stopped deployment '{profile}'.")
|
|
|
|
|
|
@install.command("restart")
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
def install_restart(profile: str) -> None:
|
|
"""Restart a persistent deployment."""
|
|
|
|
manifest = _require_manifest(profile)
|
|
_reject_task_lifecycle(manifest, "restart")
|
|
_stop_deployment(manifest)
|
|
_start_deployment(manifest)
|
|
click.echo(f"Restarted deployment '{profile}'.")
|
|
|
|
|
|
@install.command("remove")
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
def install_remove(profile: str) -> None:
|
|
"""Remove a persistent deployment and undo managed config."""
|
|
|
|
manifest = _require_manifest(profile)
|
|
try:
|
|
if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
|
|
stop_supervisor(manifest)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
stop_runtime(manifest)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
remove_supervisor(manifest)
|
|
except Exception:
|
|
pass
|
|
revert_mutations(manifest)
|
|
delete_manifest(profile)
|
|
click.echo(f"Removed deployment '{profile}'.")
|
|
|
|
|
|
@install.group("agent", hidden=True)
|
|
def install_agent() -> None:
|
|
"""Hidden runtime helpers used by persistent supervisors."""
|
|
|
|
|
|
@install_agent.command("run")
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
def install_agent_run(profile: str) -> None:
|
|
"""Run the persistent runtime in the foreground."""
|
|
|
|
manifest = _require_manifest(profile)
|
|
raise SystemExit(run_foreground(manifest))
|
|
|
|
|
|
_STARTUP_READY_TIMEOUT_SECONDS = 15
|
|
|
|
|
|
@install_agent.command("ensure")
|
|
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
|
def install_agent_ensure(profile: str) -> None:
|
|
"""Ensure a persistent deployment is healthy, starting it when needed."""
|
|
|
|
manifest = _require_manifest(profile)
|
|
if probe_ready(manifest.health_url):
|
|
click.echo(f"Deployment '{profile}' is already healthy.")
|
|
return
|
|
with acquire_runtime_start_lock(manifest.profile) as acquired:
|
|
if not acquired:
|
|
click.echo(f"Deployment '{profile}' start is already in progress.")
|
|
return
|
|
# Double-check after acquiring the lock — another ensure may have
|
|
# started the runtime while we waited for the lock.
|
|
if probe_ready(manifest.health_url):
|
|
click.echo(f"Deployment '{profile}' is already healthy.")
|
|
return
|
|
if runtime_status(manifest) == "running":
|
|
# Runtime exists but isn't ready yet — give it a grace period
|
|
# before deciding it's wedged and restarting.
|
|
if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS):
|
|
click.echo(f"Deployment '{profile}' is healthy.")
|
|
return
|
|
stop_runtime(manifest)
|
|
_start_deployment(manifest, assume_start_lock=True)
|
|
click.echo(f"Deployment '{profile}' is healthy.")
|