mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
feat: harden persistent install wrappers
Tighten Docker-native bash and PowerShell wrapper validation for wrap and proxy flows, pin the bash wrapper to the install-time interpreter, clean up failed persistent container starts, and extend docs, CI, e2e, and native installer coverage for persistent Docker installs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
21896a095c
commit
b325a06aae
9 changed files with 2294 additions and 48 deletions
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
|
|
@ -101,6 +101,66 @@ jobs:
|
|||
run: |
|
||||
pytest tests/test_integrations/agno/ -v
|
||||
|
||||
docker-native-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build local Headroom image
|
||||
run: |
|
||||
docker build -t headroom-native-e2e:latest .
|
||||
|
||||
- name: Run Docker-native installer e2e
|
||||
env:
|
||||
HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
|
||||
run: |
|
||||
bash e2e/docker-native-install.sh
|
||||
|
||||
windows-native-wrapper:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest
|
||||
|
||||
- name: Run native installer wrapper tests
|
||||
run: |
|
||||
pytest tests/test_install/test_native_installers.py -q
|
||||
|
||||
macos-native-wrapper:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install bash and test dependencies
|
||||
run: |
|
||||
brew install bash
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest
|
||||
|
||||
- name: Run native installer wrapper tests
|
||||
run: |
|
||||
export PATH="$(brew --prefix bash)/bin:$PATH"
|
||||
pytest tests/test_install/test_native_installers.py -q
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
|||
10
docs/cli.md
10
docs/cli.md
|
|
@ -22,7 +22,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
|
|||
|
||||
| Command | Purpose | Docker-native parity |
|
||||
|---|---|---|
|
||||
| `headroom install ...` | Install and manage persistent deployments | **python-native / compose-managed Docker** |
|
||||
| `headroom install ...` | Install and manage persistent deployments | **python-native; Docker-native wrapper supports `persistent-docker` lifecycle subset** |
|
||||
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
|
||||
| `headroom learn` | Learn from past tool-call failures | **native in container** |
|
||||
| `headroom perf` | Summarize recent proxy performance | **native in container** |
|
||||
|
|
@ -30,7 +30,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
|
|||
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
|
||||
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
|
||||
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
|
||||
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **host-bridged** |
|
||||
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **python-native only** |
|
||||
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
|
||||
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
|
||||
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
|
||||
|
|
@ -630,6 +630,8 @@ headroom install apply --preset persistent-docker --scope user
|
|||
|
||||
`apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
|
||||
|
||||
Docker-native host wrappers expose a narrower `headroom install` subset for `persistent-docker` only: `apply`, `status`, `start`, `stop`, `restart`, and `remove`. Those wrapper flows preserve the same port and manifest behavior, but they intentionally reject `persistent-service`, `persistent-task`, and provider mutation flags like `--scope`, `--providers`, and `--target`.
|
||||
|
||||
### `headroom install status`
|
||||
|
||||
```bash
|
||||
|
|
@ -852,9 +854,9 @@ Legend:
|
|||
| `headroom mcp uninstall` | native | native in container | full |
|
||||
| `headroom mcp status` | native | native in container | full |
|
||||
| `headroom mcp serve` | native | native in container | full |
|
||||
| `headroom install ...` | native | compose-managed persistent Docker path | partial |
|
||||
| `headroom install apply|status|start|stop|restart|remove` | native | Docker-native wrapper for `persistent-docker`; compose remains an alternative | partial |
|
||||
| `headroom wrap claude` | native | host-bridged | partial |
|
||||
| `headroom wrap copilot` | native | host-bridged | partial |
|
||||
| `headroom wrap copilot` | native | not implemented in Docker-native wrapper | none |
|
||||
| `headroom wrap codex` | native | host-bridged | partial |
|
||||
| `headroom wrap aider` | native | host-bridged | partial |
|
||||
| `headroom wrap cursor` | native | host-bridged | partial |
|
||||
|
|
|
|||
|
|
@ -4,12 +4,20 @@ Run Headroom without installing Python or Node.js on the host. The install scrip
|
|||
|
||||
## One-line install
|
||||
|
||||
### macOS / Linux
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### macOS (bash 4.3+)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | "$(brew --prefix bash)/bin/bash"
|
||||
```
|
||||
|
||||
Stock `/bin/bash` on macOS is 3.2, so install a newer bash first (for example via Homebrew) and run the installer with that shell. The installed wrapper pins that same bash interpreter so later invocations stay on the supported runtime.
|
||||
|
||||
### Windows PowerShell
|
||||
|
||||
```powershell
|
||||
|
|
@ -19,7 +27,7 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
|
|||
## What the installer does
|
||||
|
||||
1. Verifies Docker is installed and available.
|
||||
2. Pulls `ghcr.io/chopratejas/headroom:latest`.
|
||||
2. Pulls `ghcr.io/chopratejas/headroom:latest` by default, or reuses / pulls `HEADROOM_DOCKER_IMAGE` when you set a custom image override.
|
||||
3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
|
||||
4. Updates shell startup files so the wrapper directory is on `PATH`.
|
||||
|
||||
|
|
@ -81,9 +89,28 @@ OpenClaw remains host-native in Docker-native mode:
|
|||
- plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
|
||||
- local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
|
||||
|
||||
## Persistent Docker lifecycle from the native wrapper
|
||||
|
||||
The Docker-native `headroom` wrapper now exposes the persistent Docker lifecycle directly:
|
||||
|
||||
```bash
|
||||
headroom install apply --profile default --preset persistent-docker
|
||||
headroom install status
|
||||
headroom install restart
|
||||
headroom install remove
|
||||
```
|
||||
|
||||
In Docker-native mode this surface is intentionally scoped to **persistent-docker**:
|
||||
|
||||
- supported: `apply`, `status`, `start`, `stop`, `restart`, `remove`
|
||||
- supported flags: `--profile`, `--port`, `--backend`, `--anyllm-provider`, `--region`, `--mode`, `--memory`, `--no-telemetry`, `--image`
|
||||
- not supported: `persistent-service`, `persistent-task`, or provider/user/system mutation flags such as `--scope`, `--providers`, and `--target`
|
||||
|
||||
Those broader lifecycle and config-mutation flows still belong to the Python-native `headroom install ...` command.
|
||||
|
||||
## Docker Compose support
|
||||
|
||||
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
|
||||
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell, or when you prefer compose over the native wrapper's `headroom install ...` surface.
|
||||
|
||||
### Persistent Docker runtime
|
||||
|
||||
|
|
@ -101,7 +128,7 @@ $env:HEADROOM_WORKSPACE = (Get-Location).Path
|
|||
docker compose -f docker/docker-compose.native.yml up -d proxy
|
||||
```
|
||||
|
||||
This is the recommended persistent-Docker path when you installed Headroom through the Docker-native host wrapper.
|
||||
This remains a supported persistent-Docker path when you want the proxy managed explicitly through Compose instead of the installed wrapper.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
|
@ -147,3 +174,4 @@ That keeps provider auth and runtime config working without maintaining a separa
|
|||
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
|
||||
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
|
||||
- For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
|
||||
- For Docker-native `headroom install ...`, the wrapper persists its profile manifest under `~/.headroom/deploy/<profile>/`.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
|
||||
|
||||
Use `headroom install` when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
|
||||
Use the Python-native `headroom install` CLI when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
|
||||
|
||||
## Runtime matrix
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ headroom install apply --preset persistent-docker --scope user --providers auto
|
|||
|
||||
This uses Docker's restart policy instead of an OS supervisor.
|
||||
|
||||
If you are using the Docker-native host wrapper instead of a Python install, use the compose-managed path documented in [Docker-Native Install](docker-install.md).
|
||||
If you are using the Docker-native host wrapper instead of a Python install, you can now use `headroom install apply|status|start|stop|restart|remove` for the `persistent-docker` preset directly from the installed wrapper. Service/task installs and provider/user/system mutation flows still belong to the Python-native CLI.
|
||||
|
||||
## Command surface
|
||||
|
||||
|
|
@ -125,14 +125,16 @@ Persistent deployments publish the same `readyz` and `health` endpoints as ad ho
|
|||
}
|
||||
```
|
||||
|
||||
`headroom wrap ...` checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, `wrap` attempts to recover it first.
|
||||
The Python-native `headroom wrap ...` flow checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, it attempts to recover it first.
|
||||
|
||||
The Docker-native host wrapper does **not** yet reuse or recover persistent profiles automatically; it still starts a fresh proxy container unless you opt into `--no-proxy`.
|
||||
|
||||
## Docker-native relationship
|
||||
|
||||
The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
|
||||
|
||||
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI and wrapped host-tool flows
|
||||
- `headroom install ...` -> persistent service, task, and native Docker lifecycle management
|
||||
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI, wrapped host-tool flows, and Docker-native `persistent-docker` lifecycle commands
|
||||
- `headroom install ...` -> full persistent service, task, and Docker lifecycle management, including provider/user/system mutation
|
||||
|
||||
For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
|
||||
|
||||
|
|
|
|||
81
e2e/docker-native-install.sh
Executable file
81
e2e/docker-native-install.sh
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE="${HEADROOM_DOCKER_IMAGE:?set HEADROOM_DOCKER_IMAGE to a built test image}"
|
||||
PROFILE="ci-smoke"
|
||||
TMP_HOME="$(mktemp -d)"
|
||||
PORT="$(python3 - <<'PY'
|
||||
import socket
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
PY
|
||||
)"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "headroom-${PROFILE}" >/dev/null 2>&1 || true
|
||||
rm -rf "${TMP_HOME}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "${TMP_HOME}/.local"
|
||||
export HOME="${TMP_HOME}"
|
||||
export PATH="${HOME}/.local/bin:${PATH}"
|
||||
|
||||
bash "${ROOT_DIR}/scripts/install.sh"
|
||||
|
||||
WRAPPER="${HOME}/.local/bin/headroom"
|
||||
[[ -x "${WRAPPER}" ]]
|
||||
|
||||
"${WRAPPER}" install -? | grep -Fq "persistent-docker preset only"
|
||||
|
||||
"${WRAPPER}" install apply \
|
||||
--profile "${PROFILE}" \
|
||||
--port "${PORT}" \
|
||||
--image "${IMAGE}" \
|
||||
--no-telemetry
|
||||
|
||||
status_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
|
||||
printf '%s\n' "${status_output}"
|
||||
grep -Fq "Status: running" <<<"${status_output}"
|
||||
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
|
||||
|
||||
python3 - <<'PY' "${HOME}" "${PROFILE}" "${PORT}"
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
home = Path(sys.argv[1])
|
||||
profile = sys.argv[2]
|
||||
port = int(sys.argv[3])
|
||||
manifest = json.loads((home / ".headroom" / "deploy" / profile / "manifest.json").read_text())
|
||||
assert manifest["preset"] == "persistent-docker"
|
||||
assert manifest["port"] == port
|
||||
assert manifest["telemetry_enabled"] is False
|
||||
PY
|
||||
|
||||
if apply_error="$("${WRAPPER}" install apply --scope user 2>&1)"; then
|
||||
echo "expected docker-native install apply --scope user to fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "does not support provider/user/system mutation flags" <<<"${apply_error}"
|
||||
|
||||
"${WRAPPER}" install stop --profile "${PROFILE}"
|
||||
stopped_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
|
||||
printf '%s\n' "${stopped_output}"
|
||||
grep -Fq "Status: stopped" <<<"${stopped_output}"
|
||||
|
||||
"${WRAPPER}" install start --profile "${PROFILE}"
|
||||
started_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
|
||||
printf '%s\n' "${started_output}"
|
||||
grep -Fq "Status: running" <<<"${started_output}"
|
||||
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
|
||||
|
||||
"${WRAPPER}" install restart --profile "${PROFILE}"
|
||||
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
|
||||
|
||||
"${WRAPPER}" install remove --profile "${PROFILE}"
|
||||
[[ ! -e "${HOME}/.headroom/deploy/${PROFILE}" ]]
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
|
||||
$InstallImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
|
||||
$InstallDir = Join-Path $HOME '.local\bin'
|
||||
if (-not (Test-Path (Join-Path $HOME '.local'))) {
|
||||
$InstallDir = Join-Path $HOME 'bin'
|
||||
|
|
@ -37,10 +38,11 @@ function Ensure-ProfileBlock {
|
|||
|
||||
$markerStart = '# >>> headroom docker-native >>>'
|
||||
$markerEnd = '# <<< headroom docker-native <<<'
|
||||
$escapedPathEntry = $PathEntry.Replace("'", "''")
|
||||
$block = @"
|
||||
$markerStart
|
||||
if (-not ((`$env:Path -split ';') -contains '$PathEntry')) {
|
||||
`$env:Path = '$PathEntry;' + `$env:Path
|
||||
if (-not ((`$env:Path -split ';') -contains '$escapedPathEntry')) {
|
||||
`$env:Path = '$escapedPathEntry;' + `$env:Path
|
||||
}
|
||||
$markerEnd
|
||||
"@
|
||||
|
|
@ -151,11 +153,27 @@ function Get-SharedDockerArgs {
|
|||
return ,$args.ToArray()
|
||||
}
|
||||
|
||||
function Add-TtyArgs {
|
||||
param($ArgsList)
|
||||
|
||||
if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected) {
|
||||
$ArgsList.Add('-it')
|
||||
return
|
||||
}
|
||||
if (-not [Console]::IsInputRedirected) {
|
||||
$ArgsList.Add('-i')
|
||||
}
|
||||
if (-not [Console]::IsOutputRedirected) {
|
||||
$ArgsList.Add('-t')
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-HeadroomDocker {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
||||
$dockerArgs.AddRange([string[]]@('run','--rm','-it'))
|
||||
$dockerArgs.AddRange([string[]]@('run','--rm'))
|
||||
Add-TtyArgs -ArgsList $dockerArgs
|
||||
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
||||
$dockerArgs.Add('--entrypoint')
|
||||
$dockerArgs.Add('headroom')
|
||||
|
|
@ -228,6 +246,538 @@ function Stop-ProxyContainer {
|
|||
}
|
||||
}
|
||||
|
||||
function Get-PersistentProfileRoot {
|
||||
param([string]$Profile)
|
||||
Assert-ValidProfileName -Profile $Profile
|
||||
return Join-Path (Join-Path $HostHome '.headroom\deploy') $Profile
|
||||
}
|
||||
|
||||
function Get-PersistentStatePath {
|
||||
param([string]$Profile)
|
||||
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'docker-native.json'
|
||||
}
|
||||
|
||||
function Get-PersistentManifestPath {
|
||||
param([string]$Profile)
|
||||
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'manifest.json'
|
||||
}
|
||||
|
||||
function Get-PersistentContainerName {
|
||||
param([string]$Profile)
|
||||
return "headroom-$Profile"
|
||||
}
|
||||
|
||||
function Assert-ValidProfileName {
|
||||
param([string]$Profile)
|
||||
if ($Profile -notmatch '^[A-Za-z0-9._-]+$' -or $Profile -in @('.', '..')) {
|
||||
Fail "Invalid profile name '$Profile'"
|
||||
}
|
||||
}
|
||||
|
||||
function Parse-PortValue {
|
||||
param([string]$Value)
|
||||
|
||||
$parsed = 0
|
||||
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1 -or $parsed -gt 65535) {
|
||||
Fail "Invalid port '$Value'"
|
||||
}
|
||||
return $parsed
|
||||
}
|
||||
|
||||
function Parse-PositiveIntegerValue {
|
||||
param([string]$Value)
|
||||
|
||||
$parsed = 0
|
||||
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1) {
|
||||
Fail "Invalid value '$Value'"
|
||||
}
|
||||
return $parsed
|
||||
}
|
||||
|
||||
function Require-OptionValue {
|
||||
param(
|
||||
[string[]]$Arguments,
|
||||
[int]$Index,
|
||||
[string]$Option
|
||||
)
|
||||
|
||||
if ($Index + 1 -ge $Arguments.Count) {
|
||||
Fail "Option $Option requires a value"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersistentDockerArgs {
|
||||
Ensure-HostDirs
|
||||
$args = New-Object System.Collections.Generic.List[string]
|
||||
$args.Add('--workdir')
|
||||
$args.Add($ContainerHome)
|
||||
$args.Add('--env')
|
||||
$args.Add("HOME=$ContainerHome")
|
||||
$args.Add('--env')
|
||||
$args.Add('PYTHONUNBUFFERED=1')
|
||||
$args.Add('--volume')
|
||||
$args.Add((Join-Path $HostHome '.headroom') + ":$ContainerHome/.headroom")
|
||||
$args.Add('--volume')
|
||||
$args.Add((Join-Path $HostHome '.claude') + ":$ContainerHome/.claude")
|
||||
$args.Add('--volume')
|
||||
$args.Add((Join-Path $HostHome '.codex') + ":$ContainerHome/.codex")
|
||||
$args.Add('--volume')
|
||||
$args.Add((Join-Path $HostHome '.gemini') + ":$ContainerHome/.gemini")
|
||||
|
||||
foreach ($entry in (Get-PassthroughEnvArgs)) {
|
||||
$args.Add($entry)
|
||||
}
|
||||
|
||||
return ,$args.ToArray()
|
||||
}
|
||||
|
||||
function Get-ManifestProxyArgs {
|
||||
param(
|
||||
[int]$Port,
|
||||
[string]$Backend,
|
||||
[string]$AnyllmProvider,
|
||||
[string]$Region,
|
||||
[string]$Mode,
|
||||
[bool]$Memory,
|
||||
[bool]$TelemetryEnabled
|
||||
)
|
||||
|
||||
$args = New-Object System.Collections.Generic.List[string]
|
||||
$args.AddRange([string[]]@('--host','127.0.0.1','--port',"$Port",'--mode',$Mode,'--backend',$Backend))
|
||||
if (-not $TelemetryEnabled) {
|
||||
$args.Add('--no-telemetry')
|
||||
}
|
||||
if ($Memory) {
|
||||
$args.AddRange([string[]]@('--memory','--memory-db-path',"$ContainerHome/.headroom/memory.db"))
|
||||
}
|
||||
if ($AnyllmProvider) {
|
||||
$args.AddRange([string[]]@('--anyllm-provider', $AnyllmProvider))
|
||||
}
|
||||
if ($Region) {
|
||||
$args.AddRange([string[]]@('--region', $Region))
|
||||
}
|
||||
|
||||
return ,$args.ToArray()
|
||||
}
|
||||
|
||||
function Write-PersistentState {
|
||||
param(
|
||||
[string]$Profile,
|
||||
[string]$Image,
|
||||
[int]$Port,
|
||||
[string]$Backend,
|
||||
[string]$AnyllmProvider,
|
||||
[string]$Region,
|
||||
[string]$Mode,
|
||||
[bool]$Memory,
|
||||
[bool]$TelemetryEnabled
|
||||
)
|
||||
|
||||
$root = Get-PersistentProfileRoot -Profile $Profile
|
||||
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||
$state = [ordered]@{
|
||||
profile = $Profile
|
||||
image = $Image
|
||||
port = $Port
|
||||
backend = $Backend
|
||||
anyllm_provider = $AnyllmProvider
|
||||
region = $Region
|
||||
proxy_mode = $Mode
|
||||
memory_enabled = $Memory
|
||||
telemetry_enabled = $TelemetryEnabled
|
||||
container_name = Get-PersistentContainerName -Profile $Profile
|
||||
health_url = "http://127.0.0.1:$Port/readyz"
|
||||
}
|
||||
$state | ConvertTo-Json -Depth 4 | Set-Content -Path (Get-PersistentStatePath -Profile $Profile) -Encoding utf8
|
||||
}
|
||||
|
||||
function Write-PersistentManifest {
|
||||
param(
|
||||
[string]$Profile,
|
||||
[string]$Image,
|
||||
[int]$Port,
|
||||
[string]$Backend,
|
||||
[string]$AnyllmProvider,
|
||||
[string]$Region,
|
||||
[string]$Mode,
|
||||
[bool]$Memory,
|
||||
[bool]$TelemetryEnabled,
|
||||
[string[]]$ProxyArgs
|
||||
)
|
||||
|
||||
$root = Get-PersistentProfileRoot -Profile $Profile
|
||||
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||
|
||||
$baseEnv = [ordered]@{
|
||||
HEADROOM_PORT = "$Port"
|
||||
HEADROOM_HOST = '127.0.0.1'
|
||||
HEADROOM_MODE = $Mode
|
||||
HEADROOM_BACKEND = $Backend
|
||||
}
|
||||
|
||||
$manifest = [ordered]@{
|
||||
profile = $Profile
|
||||
preset = 'persistent-docker'
|
||||
runtime_kind = 'docker'
|
||||
supervisor_kind = 'none'
|
||||
scope = 'user'
|
||||
provider_mode = 'manual'
|
||||
targets = @()
|
||||
port = $Port
|
||||
host = '127.0.0.1'
|
||||
backend = $Backend
|
||||
anyllm_provider = if ($AnyllmProvider) { $AnyllmProvider } else { $null }
|
||||
region = if ($Region) { $Region } else { $null }
|
||||
proxy_mode = $Mode
|
||||
memory_enabled = $Memory
|
||||
memory_db_path = "$ContainerHome/.headroom/memory.db"
|
||||
telemetry_enabled = $TelemetryEnabled
|
||||
image = $Image
|
||||
service_name = "headroom-$Profile"
|
||||
container_name = Get-PersistentContainerName -Profile $Profile
|
||||
health_url = "http://127.0.0.1:$Port/readyz"
|
||||
base_env = $baseEnv
|
||||
tool_envs = @{}
|
||||
proxy_args = $ProxyArgs
|
||||
mutations = @()
|
||||
artifacts = @()
|
||||
}
|
||||
|
||||
$manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Get-PersistentManifestPath -Profile $Profile) -Encoding utf8
|
||||
}
|
||||
|
||||
function Read-PersistentState {
|
||||
param([string]$Profile)
|
||||
|
||||
Assert-ValidProfileName -Profile $Profile
|
||||
$statePath = Get-PersistentStatePath -Profile $Profile
|
||||
if (-not (Test-Path $statePath)) {
|
||||
Fail "No docker-native persistent deployment profile named '$Profile'"
|
||||
}
|
||||
return Get-Content -Raw -Path $statePath | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Start-PersistentDockerInstall {
|
||||
param(
|
||||
[string]$Profile,
|
||||
[string]$Image,
|
||||
[int]$Port,
|
||||
[string]$Backend,
|
||||
[string]$AnyllmProvider,
|
||||
[string]$Region,
|
||||
[string]$Mode,
|
||||
[bool]$Memory,
|
||||
[bool]$TelemetryEnabled
|
||||
)
|
||||
|
||||
Assert-ValidProfileName -Profile $Profile
|
||||
$containerName = Get-PersistentContainerName -Profile $Profile
|
||||
$proxyArgs = Get-ManifestProxyArgs -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
|
||||
|
||||
docker rm -f $containerName | Out-Null 2>$null
|
||||
|
||||
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
||||
$dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"$Port`:$Port"))
|
||||
$dockerArgs.AddRange((Get-PersistentDockerArgs))
|
||||
$dockerArgs.Add($Image)
|
||||
$dockerArgs.Add('--host')
|
||||
$dockerArgs.Add('0.0.0.0')
|
||||
for ($i = 2; $i -lt $proxyArgs.Count; $i++) {
|
||||
$dockerArgs.Add($proxyArgs[$i])
|
||||
}
|
||||
|
||||
& docker @dockerArgs | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to start docker-native persistent deployment"
|
||||
}
|
||||
|
||||
try {
|
||||
Wait-Proxy -ContainerName $containerName -Port $Port
|
||||
} catch {
|
||||
docker rm -f $containerName | Out-Null 2>$null
|
||||
throw
|
||||
}
|
||||
Write-PersistentState -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
|
||||
Write-PersistentManifest -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled -ProxyArgs $proxyArgs
|
||||
}
|
||||
|
||||
function Stop-PersistentDockerInstall {
|
||||
param([string]$Profile)
|
||||
|
||||
$state = Read-PersistentState -Profile $Profile
|
||||
docker stop $state.container_name | Out-Null 2>$null
|
||||
docker rm -f $state.container_name | Out-Null 2>$null
|
||||
}
|
||||
|
||||
function Remove-PersistentDockerInstall {
|
||||
param([string]$Profile)
|
||||
|
||||
$state = Read-PersistentState -Profile $Profile
|
||||
docker stop $state.container_name | Out-Null 2>$null
|
||||
docker rm -f $state.container_name | Out-Null 2>$null
|
||||
$root = Get-PersistentProfileRoot -Profile $Profile
|
||||
if (Test-Path $root) {
|
||||
Remove-Item -Recurse -Force -Path $root
|
||||
}
|
||||
}
|
||||
|
||||
function Show-PersistentDockerInstallStatus {
|
||||
param([string]$Profile)
|
||||
|
||||
$state = Read-PersistentState -Profile $Profile
|
||||
$status = 'stopped'
|
||||
$ready = 'no'
|
||||
$running = docker ps --format '{{.Names}}'
|
||||
if ($running -contains $state.container_name) {
|
||||
$status = 'running'
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $state.health_url | Out-Null
|
||||
$ready = 'yes'
|
||||
} catch {
|
||||
$ready = 'no'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Profile: $($state.profile)"
|
||||
Write-Host 'Preset: persistent-docker'
|
||||
Write-Host 'Runtime: docker'
|
||||
Write-Host 'Supervisor: none'
|
||||
Write-Host "Port: $($state.port)"
|
||||
Write-Host "Status: $status"
|
||||
Write-Host "Ready: $ready"
|
||||
Write-Host "Health URL: $($state.health_url)"
|
||||
}
|
||||
|
||||
function Show-InstallHelp {
|
||||
$lines = @(
|
||||
'Usage: headroom install [OPTIONS] COMMAND [ARGS]...',
|
||||
'',
|
||||
' Manage persistent Docker-native Headroom deployments.',
|
||||
'',
|
||||
' The Docker-native wrapper currently supports the persistent-docker preset only.',
|
||||
' Use the Python-native `headroom install` command for persistent-service and',
|
||||
' persistent-task installs, or when you need provider/user/system config mutation.',
|
||||
'',
|
||||
'Options:',
|
||||
' -?, --help Show this message and exit.',
|
||||
'',
|
||||
'Commands:',
|
||||
' apply Install a persistent Docker deployment.',
|
||||
' remove Remove a persistent Docker deployment.',
|
||||
' restart Restart a persistent Docker deployment.',
|
||||
' start Start a persistent Docker deployment.',
|
||||
' status Show persistent Docker deployment status.',
|
||||
' stop Stop a persistent Docker deployment.'
|
||||
)
|
||||
Write-Host ($lines -join [Environment]::NewLine)
|
||||
}
|
||||
|
||||
function Show-InstallApplyHelp {
|
||||
$lines = @(
|
||||
'Usage: headroom install apply [OPTIONS]',
|
||||
'',
|
||||
' Install a persistent Docker deployment.',
|
||||
'',
|
||||
'Options:',
|
||||
' --preset [persistent-docker] Docker-native wrapper supports persistent-docker only.',
|
||||
' --runtime [docker] Docker-native wrapper supports runtime=docker only.',
|
||||
' --profile TEXT Deployment profile name. [default: default]',
|
||||
' -p, --port INTEGER Persistent proxy port. [default: 8787]',
|
||||
' --backend TEXT Proxy backend. [default: anthropic]',
|
||||
' --anyllm-provider TEXT Provider for any-llm backends.',
|
||||
' --region TEXT Cloud region for Bedrock / Vertex style backends.',
|
||||
' --mode TEXT Proxy optimization mode. [default: token]',
|
||||
' --memory Enable persistent memory in the runtime.',
|
||||
' --no-telemetry Disable anonymous telemetry in the runtime.',
|
||||
' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]',
|
||||
' -?, --help Show this message and exit.'
|
||||
)
|
||||
Write-Host ($lines -join [Environment]::NewLine)
|
||||
}
|
||||
|
||||
function Parse-InstallApplyArgs {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
$profile = 'default'
|
||||
$port = 8787
|
||||
$backend = 'anthropic'
|
||||
$anyllmProvider = $null
|
||||
$region = $null
|
||||
$mode = 'token'
|
||||
$memory = $false
|
||||
$telemetryEnabled = $true
|
||||
$image = $HeadroomImage
|
||||
|
||||
$i = 0
|
||||
while ($i -lt $Arguments.Count) {
|
||||
$arg = $Arguments[$i]
|
||||
switch -Regex ($arg) {
|
||||
'^--preset$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--preset'
|
||||
if ($Arguments[$i + 1] -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--preset=' {
|
||||
if (($arg -replace '^--preset=', '') -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--runtime$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--runtime'
|
||||
if ($Arguments[$i + 1] -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--runtime=' {
|
||||
if (($arg -replace '^--runtime=', '') -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^(--scope|--providers|--target)$' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
|
||||
'^(--scope=|--providers=|--target=)' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
|
||||
'^--profile$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
|
||||
$profile = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--profile=' {
|
||||
$profile = $arg -replace '^--profile=', ''
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^(--port|-p)$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$port = Parse-PortValue -Value $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^(--port=|-p=)' {
|
||||
$port = Parse-PortValue -Value ($arg -replace '^(--port=|-p=)', '')
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--backend$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--backend'
|
||||
$backend = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--backend=' {
|
||||
$backend = $arg -replace '^--backend=', ''
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--anyllm-provider$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--anyllm-provider'
|
||||
$anyllmProvider = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--anyllm-provider=' {
|
||||
$anyllmProvider = $arg -replace '^--anyllm-provider=', ''
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--region$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--region'
|
||||
$region = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--region=' {
|
||||
$region = $arg -replace '^--region=', ''
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--mode$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--mode'
|
||||
$mode = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--mode=' {
|
||||
$mode = $arg -replace '^--mode=', ''
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--memory$' {
|
||||
$memory = $true
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--no-telemetry$' {
|
||||
$telemetryEnabled = $false
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--image$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--image'
|
||||
$image = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--image=' {
|
||||
$image = $arg -replace '^--image=', ''
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^(--help|-\\?)$' {
|
||||
Show-InstallApplyHelp
|
||||
exit 0
|
||||
}
|
||||
default {
|
||||
Fail "Unsupported option for 'headroom install apply': $arg"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Profile = $profile
|
||||
Port = $port
|
||||
Backend = $backend
|
||||
AnyllmProvider = $anyllmProvider
|
||||
Region = $region
|
||||
Mode = $mode
|
||||
Memory = $memory
|
||||
TelemetryEnabled = $telemetryEnabled
|
||||
Image = $image
|
||||
}
|
||||
}
|
||||
|
||||
function Parse-InstallProfileArgs {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
$profile = 'default'
|
||||
$i = 0
|
||||
while ($i -lt $Arguments.Count) {
|
||||
$arg = $Arguments[$i]
|
||||
switch -Regex ($arg) {
|
||||
'^--profile$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
|
||||
$profile = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--profile=' {
|
||||
$profile = $arg -replace '^--profile=', ''
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^(--help|-\\?)$' {
|
||||
Show-InstallHelp
|
||||
exit 0
|
||||
}
|
||||
default {
|
||||
Fail "Unsupported option for 'headroom install': $arg"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $profile
|
||||
}
|
||||
|
||||
function Invoke-ClaudeRtkInit {
|
||||
$rtkPath = Join-Path $HostHome '.headroom\bin\rtk.exe'
|
||||
if (-not (Test-Path $rtkPath)) {
|
||||
|
|
@ -300,6 +850,7 @@ function Parse-OpenClawWrapArgs {
|
|||
$arg = $Arguments[$i]
|
||||
switch -Regex ($arg) {
|
||||
'^--plugin-path$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$pluginPath = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
|
|
@ -310,6 +861,7 @@ function Parse-OpenClawWrapArgs {
|
|||
continue
|
||||
}
|
||||
'^--plugin-spec$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$pluginSpec = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
|
|
@ -330,26 +882,29 @@ function Parse-OpenClawWrapArgs {
|
|||
continue
|
||||
}
|
||||
'^--proxy-port$' {
|
||||
$proxyPort = [int]$Arguments[$i + 1]
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$proxyPort = Parse-PortValue -Value $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--proxy-port=' {
|
||||
$proxyPort = [int]($arg -replace '^--proxy-port=', '')
|
||||
$proxyPort = Parse-PortValue -Value ($arg -replace '^--proxy-port=', '')
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--startup-timeout-ms$' {
|
||||
$startupTimeoutMs = [int]$Arguments[$i + 1]
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$startupTimeoutMs = Parse-PositiveIntegerValue -Value $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--startup-timeout-ms=' {
|
||||
$startupTimeoutMs = [int]($arg -replace '^--startup-timeout-ms=', '')
|
||||
$startupTimeoutMs = Parse-PositiveIntegerValue -Value ($arg -replace '^--startup-timeout-ms=', '')
|
||||
$i += 1
|
||||
continue
|
||||
}
|
||||
'^--gateway-provider-id$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$gatewayProviderIds.Add($Arguments[$i + 1])
|
||||
$i += 2
|
||||
continue
|
||||
|
|
@ -360,6 +915,7 @@ function Parse-OpenClawWrapArgs {
|
|||
continue
|
||||
}
|
||||
'^--python-path$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$pythonPath = $Arguments[$i + 1]
|
||||
$i += 2
|
||||
continue
|
||||
|
|
@ -762,7 +1318,7 @@ function Parse-WrapArgs {
|
|||
param([string[]]$Arguments)
|
||||
|
||||
$known = New-Object System.Collections.Generic.List[string]
|
||||
$host = New-Object System.Collections.Generic.List[string]
|
||||
$hostArgs = New-Object System.Collections.Generic.List[string]
|
||||
$port = 8787
|
||||
$noRtk = $false
|
||||
$noProxy = $false
|
||||
|
|
@ -777,20 +1333,21 @@ function Parse-WrapArgs {
|
|||
switch -Regex ($arg) {
|
||||
'^--$' {
|
||||
for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
|
||||
$host.Add($Arguments[$j])
|
||||
$hostArgs.Add($Arguments[$j])
|
||||
}
|
||||
$i = $Arguments.Count
|
||||
continue
|
||||
}
|
||||
'^--port$|^-p$' {
|
||||
$port = [int]$Arguments[$i + 1]
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$port = Parse-PortValue -Value $Arguments[$i + 1]
|
||||
$known.Add($arg)
|
||||
$known.Add($Arguments[$i + 1])
|
||||
$i += 2
|
||||
continue
|
||||
}
|
||||
'^--port=' {
|
||||
$port = [int]($arg -replace '^--port=', '')
|
||||
$port = Parse-PortValue -Value ($arg -replace '^--port=', '')
|
||||
$known.Add($arg)
|
||||
$i += 1
|
||||
continue
|
||||
|
|
@ -819,6 +1376,7 @@ function Parse-WrapArgs {
|
|||
continue
|
||||
}
|
||||
'^--backend$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$backend = $Arguments[$i + 1]
|
||||
$known.Add($arg)
|
||||
$known.Add($Arguments[$i + 1])
|
||||
|
|
@ -832,6 +1390,7 @@ function Parse-WrapArgs {
|
|||
continue
|
||||
}
|
||||
'^--anyllm-provider$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$anyllm = $Arguments[$i + 1]
|
||||
$known.Add($arg)
|
||||
$known.Add($Arguments[$i + 1])
|
||||
|
|
@ -845,6 +1404,7 @@ function Parse-WrapArgs {
|
|||
continue
|
||||
}
|
||||
'^--region$' {
|
||||
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
||||
$region = $Arguments[$i + 1]
|
||||
$known.Add($arg)
|
||||
$known.Add($Arguments[$i + 1])
|
||||
|
|
@ -859,7 +1419,7 @@ function Parse-WrapArgs {
|
|||
}
|
||||
default {
|
||||
for ($j = $i; $j -lt $Arguments.Count; $j++) {
|
||||
$host.Add($Arguments[$j])
|
||||
$hostArgs.Add($Arguments[$j])
|
||||
}
|
||||
$i = $Arguments.Count
|
||||
}
|
||||
|
|
@ -868,7 +1428,7 @@ function Parse-WrapArgs {
|
|||
|
||||
[pscustomobject]@{
|
||||
KnownArgs = $known.ToArray()
|
||||
HostArgs = $host.ToArray()
|
||||
HostArgs = $hostArgs.ToArray()
|
||||
Port = $port
|
||||
NoRtk = $noRtk
|
||||
NoProxy = $noProxy
|
||||
|
|
@ -886,7 +1446,8 @@ function Invoke-PrepareOnly {
|
|||
)
|
||||
|
||||
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
||||
$dockerArgs.AddRange([string[]]@('run','--rm','-it'))
|
||||
$dockerArgs.AddRange([string[]]@('run','--rm'))
|
||||
Add-TtyArgs -ArgsList $dockerArgs
|
||||
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
||||
$dockerArgs.Add('--env')
|
||||
$dockerArgs.Add("HEADROOM_RTK_TARGET=$(Get-RtkTarget)")
|
||||
|
|
@ -912,6 +1473,58 @@ if ($args.Count -eq 0) {
|
|||
}
|
||||
|
||||
switch ($args[0]) {
|
||||
'install' {
|
||||
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
|
||||
Show-InstallHelp
|
||||
exit 0
|
||||
}
|
||||
|
||||
$installCommand = $args[1]
|
||||
$installArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
|
||||
|
||||
switch ($installCommand) {
|
||||
'apply' {
|
||||
$parsed = Parse-InstallApplyArgs -Arguments $installArgs
|
||||
Start-PersistentDockerInstall -Profile $parsed.Profile -Image $parsed.Image -Port $parsed.Port -Backend $parsed.Backend -AnyllmProvider $parsed.AnyllmProvider -Region $parsed.Region -Mode $parsed.Mode -Memory $parsed.Memory -TelemetryEnabled $parsed.TelemetryEnabled
|
||||
Write-Host "Installed docker-native persistent deployment '$($parsed.Profile)' on port $($parsed.Port)."
|
||||
exit 0
|
||||
}
|
||||
'status' {
|
||||
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
||||
Show-PersistentDockerInstallStatus -Profile $profile
|
||||
exit 0
|
||||
}
|
||||
'start' {
|
||||
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
||||
$state = Read-PersistentState -Profile $profile
|
||||
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
|
||||
Write-Host "Started docker-native persistent deployment '$profile'."
|
||||
exit 0
|
||||
}
|
||||
'stop' {
|
||||
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
||||
Stop-PersistentDockerInstall -Profile $profile
|
||||
Write-Host "Stopped docker-native persistent deployment '$profile'."
|
||||
exit 0
|
||||
}
|
||||
'restart' {
|
||||
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
||||
$state = Read-PersistentState -Profile $profile
|
||||
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
|
||||
Write-Host "Restarted docker-native persistent deployment '$profile'."
|
||||
exit 0
|
||||
}
|
||||
'remove' {
|
||||
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
||||
Remove-PersistentDockerInstall -Profile $profile
|
||||
Write-Host "Removed docker-native persistent deployment '$profile'."
|
||||
exit 0
|
||||
}
|
||||
default {
|
||||
Fail "Unsupported install target: $installCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
'wrap' {
|
||||
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
|
||||
Invoke-HeadroomDocker -Arguments @('wrap','--help')
|
||||
|
|
@ -1028,17 +1641,20 @@ switch ($args[0]) {
|
|||
foreach ($arg in $args) { $forwardArgs.Add($arg) }
|
||||
for ($i = 1; $i -lt $args.Count; $i++) {
|
||||
if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
|
||||
$port = [int]$args[$i + 1]
|
||||
Require-OptionValue -Arguments $args -Index $i -Option $args[$i]
|
||||
$port = Parse-PortValue -Value $args[$i + 1]
|
||||
break
|
||||
}
|
||||
if ($args[$i] -match '^--port=') {
|
||||
$port = [int]($args[$i] -replace '^--port=', '')
|
||||
$port = Parse-PortValue -Value ($args[$i] -replace '^--port=', '')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
||||
$dockerArgs.AddRange([string[]]@('run','--rm','-it','-p',"$port`:$port"))
|
||||
$dockerArgs.AddRange([string[]]@('run','--rm'))
|
||||
Add-TtyArgs -ArgsList $dockerArgs
|
||||
$dockerArgs.AddRange([string[]]@('-p',"$port`:$port"))
|
||||
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
||||
$dockerArgs.Add('--entrypoint')
|
||||
$dockerArgs.Add('headroom')
|
||||
|
|
@ -1056,34 +1672,44 @@ switch ($args[0]) {
|
|||
}
|
||||
'@
|
||||
|
||||
$cmdWrapper = @'
|
||||
@echo off
|
||||
powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0headroom.ps1" %*
|
||||
'@
|
||||
$cmdWrapper = ([string][char]64) + "echo off`r`npowershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ""%~dp0headroom.ps1"" %*`r`n"
|
||||
|
||||
Set-Content -Path $wrapperPath -Value $wrapper
|
||||
Set-Content -Path $cmdPath -Value $cmdWrapper
|
||||
Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
|
||||
Set-Content -Path $cmdPath -Value $cmdWrapper -Encoding ascii
|
||||
}
|
||||
|
||||
Require-Command docker
|
||||
docker version | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Docker is installed but not available to the current user'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
Write-Wrapper -TargetDir $InstallDir
|
||||
Ensure-PathEntry -PathEntry $InstallDir
|
||||
Ensure-ProfileBlock -PathEntry $InstallDir
|
||||
|
||||
Write-Info "Pulling $ImageDefault"
|
||||
docker pull $ImageDefault | Out-Null
|
||||
if ($env:HEADROOM_DOCKER_IMAGE) {
|
||||
$null = docker image inspect $InstallImage 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Info "Using existing HEADROOM_DOCKER_IMAGE=$InstallImage"
|
||||
} else {
|
||||
Write-Info "Pulling $InstallImage"
|
||||
docker pull $InstallImage | Out-Null
|
||||
}
|
||||
} else {
|
||||
Write-Info "Pulling $ImageDefault"
|
||||
docker pull $ImageDefault | Out-Null
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Headroom Docker-native install complete.'
|
||||
Write-Host ''
|
||||
Write-Host ""
|
||||
Write-Host "Headroom Docker-native install complete."
|
||||
Write-Host ""
|
||||
Write-Host "Installed wrappers:"
|
||||
Write-Host " $InstallDir\headroom.ps1"
|
||||
Write-Host " $InstallDir\headroom.cmd"
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:'
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:"
|
||||
Write-Host " 1. Restart PowerShell"
|
||||
Write-Host " 2. Try: headroom proxy"
|
||||
Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@
|
|||
set -euo pipefail
|
||||
|
||||
IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
|
||||
INSTALL_IMAGE="${HEADROOM_DOCKER_IMAGE:-${IMAGE_DEFAULT}}"
|
||||
INSTALL_DIR="${HOME}/.local/bin"
|
||||
if [[ ! -d "${HOME}/.local" ]]; then
|
||||
INSTALL_DIR="${HOME}/bin"
|
||||
fi
|
||||
|
||||
BASH_PATH="${BASH:-$(command -v bash)}"
|
||||
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
|
||||
printf 'ERROR: Headroom Docker-native install requires bash >= 4.3\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info() {
|
||||
printf '==> %s\n' "$*"
|
||||
}
|
||||
|
|
@ -46,8 +53,9 @@ ${marker_end}"
|
|||
write_wrapper() {
|
||||
local wrapper_path="${INSTALL_DIR}/headroom"
|
||||
|
||||
cat >"${wrapper_path}" <<'WRAPPER'
|
||||
#!/usr/bin/env bash
|
||||
{
|
||||
printf '#!%s\n\n' "${BASH_PATH}"
|
||||
cat <<'WRAPPER'
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -55,6 +63,11 @@ HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-ghcr.io/chopratejas/headroom:latest}"
|
|||
HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
|
||||
HEADROOM_HOST_HOME="${HOME:?}"
|
||||
|
||||
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
|
||||
printf 'ERROR: Headroom Docker-native wrapper requires bash >= 4.3\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
warn() {
|
||||
printf 'WARN: %s\n' "$*" >&2
|
||||
}
|
||||
|
|
@ -168,7 +181,11 @@ wait_for_proxy() {
|
|||
local attempt
|
||||
|
||||
for attempt in $(seq 1 45); do
|
||||
if (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl --fail --silent "http://127.0.0.1:${port}/readyz" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
elif (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
|
@ -209,6 +226,531 @@ stop_proxy_container() {
|
|||
fi
|
||||
}
|
||||
|
||||
persistent_profile_root() {
|
||||
local profile="$1"
|
||||
validate_profile_name "${profile}"
|
||||
printf '%s/.headroom/deploy/%s\n' "${HEADROOM_HOST_HOME}" "${profile}"
|
||||
}
|
||||
|
||||
persistent_state_path() {
|
||||
local profile="$1"
|
||||
printf '%s/docker-native.env\n' "$(persistent_profile_root "${profile}")"
|
||||
}
|
||||
|
||||
persistent_manifest_path() {
|
||||
local profile="$1"
|
||||
printf '%s/manifest.json\n' "$(persistent_profile_root "${profile}")"
|
||||
}
|
||||
|
||||
persistent_container_name() {
|
||||
local profile="$1"
|
||||
validate_profile_name "${profile}"
|
||||
printf 'headroom-%s\n' "${profile}"
|
||||
}
|
||||
|
||||
validate_profile_name() {
|
||||
local profile="$1"
|
||||
[[ "${profile}" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid profile name '${profile}'"
|
||||
[[ "${profile}" != "." && "${profile}" != ".." ]] || die "Invalid profile name '${profile}'"
|
||||
}
|
||||
|
||||
validate_port() {
|
||||
local port="$1"
|
||||
[[ "${port}" =~ ^[0-9]+$ ]] || die "Invalid port '${port}'"
|
||||
((10#${port} >= 1 && 10#${port} <= 65535)) || die "Invalid port '${port}'"
|
||||
}
|
||||
|
||||
validate_positive_integer() {
|
||||
local value="$1"
|
||||
[[ "${value}" =~ ^[0-9]+$ ]] || die "Invalid value '${value}'"
|
||||
((10#${value} >= 1)) || die "Invalid value '${value}'"
|
||||
}
|
||||
|
||||
require_option_value() {
|
||||
(($# >= 2)) || die "Option $1 requires a value"
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
json_array_from_args() {
|
||||
local first=1
|
||||
local arg
|
||||
printf '['
|
||||
for arg in "$@"; do
|
||||
if [[ "${first}" -eq 0 ]]; then
|
||||
printf ','
|
||||
fi
|
||||
first=0
|
||||
printf '"%s"' "$(json_escape "${arg}")"
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
append_persistent_container_args() {
|
||||
local -n ref=$1
|
||||
|
||||
ensure_host_dirs
|
||||
ref+=(--workdir "${HEADROOM_CONTAINER_HOME}")
|
||||
ref+=(--env "HOME=${HEADROOM_CONTAINER_HOME}")
|
||||
ref+=(--env "PYTHONUNBUFFERED=1")
|
||||
ref+=(-v "${HEADROOM_HOST_HOME}/.headroom:${HEADROOM_CONTAINER_HOME}/.headroom")
|
||||
ref+=(-v "${HEADROOM_HOST_HOME}/.claude:${HEADROOM_CONTAINER_HOME}/.claude")
|
||||
ref+=(-v "${HEADROOM_HOST_HOME}/.codex:${HEADROOM_CONTAINER_HOME}/.codex")
|
||||
ref+=(-v "${HEADROOM_HOST_HOME}/.gemini:${HEADROOM_CONTAINER_HOME}/.gemini")
|
||||
|
||||
if command -v id >/dev/null 2>&1; then
|
||||
ref+=(--user "$(id -u):$(id -g)")
|
||||
fi
|
||||
|
||||
append_passthrough_envs "$1"
|
||||
}
|
||||
|
||||
build_manifest_proxy_args() {
|
||||
local -n out_args=$1
|
||||
local port="$2"
|
||||
local proxy_mode="$3"
|
||||
local backend="$4"
|
||||
local anyllm="$5"
|
||||
local region="$6"
|
||||
local memory_enabled="$7"
|
||||
local telemetry_enabled="$8"
|
||||
|
||||
out_args=(--host 127.0.0.1 --port "${port}" --mode "${proxy_mode}" --backend "${backend}")
|
||||
if [[ "${telemetry_enabled}" -eq 0 ]]; then
|
||||
out_args+=(--no-telemetry)
|
||||
fi
|
||||
if [[ "${memory_enabled}" -eq 1 ]]; then
|
||||
out_args+=(--memory --memory-db-path "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")
|
||||
fi
|
||||
if [[ -n "${anyllm}" ]]; then
|
||||
out_args+=(--anyllm-provider "${anyllm}")
|
||||
fi
|
||||
if [[ -n "${region}" ]]; then
|
||||
out_args+=(--region "${region}")
|
||||
fi
|
||||
}
|
||||
|
||||
write_persistent_state() {
|
||||
local profile="$1"
|
||||
local image="$2"
|
||||
local port="$3"
|
||||
local backend="$4"
|
||||
local anyllm="$5"
|
||||
local region="$6"
|
||||
local proxy_mode="$7"
|
||||
local memory_enabled="$8"
|
||||
local telemetry_enabled="$9"
|
||||
|
||||
local root
|
||||
root="$(persistent_profile_root "${profile}")"
|
||||
mkdir -p "${root}"
|
||||
|
||||
{
|
||||
printf 'PROFILE=%s\n' "${profile}"
|
||||
printf 'IMAGE=%s\n' "${image}"
|
||||
printf 'PORT=%s\n' "${port}"
|
||||
printf 'BACKEND=%s\n' "${backend}"
|
||||
printf 'ANYLLM_PROVIDER=%s\n' "${anyllm}"
|
||||
printf 'REGION=%s\n' "${region}"
|
||||
printf 'PROXY_MODE=%s\n' "${proxy_mode}"
|
||||
printf 'MEMORY_ENABLED=%s\n' "${memory_enabled}"
|
||||
printf 'TELEMETRY_ENABLED=%s\n' "${telemetry_enabled}"
|
||||
printf 'CONTAINER_NAME=%s\n' "$(persistent_container_name "${profile}")"
|
||||
printf 'HEALTH_URL=%s\n' "http://127.0.0.1:${port}/readyz"
|
||||
} >"$(persistent_state_path "${profile}")"
|
||||
}
|
||||
|
||||
write_persistent_manifest() {
|
||||
local profile="$1"
|
||||
local image="$2"
|
||||
local port="$3"
|
||||
local backend="$4"
|
||||
local anyllm="$5"
|
||||
local region="$6"
|
||||
local proxy_mode="$7"
|
||||
local memory_enabled="$8"
|
||||
local telemetry_enabled="$9"
|
||||
local -n proxy_args_ref=${10}
|
||||
|
||||
local root
|
||||
local manifest_path
|
||||
local anyllm_json="null"
|
||||
local region_json="null"
|
||||
local memory_json="false"
|
||||
local telemetry_json="true"
|
||||
|
||||
root="$(persistent_profile_root "${profile}")"
|
||||
manifest_path="$(persistent_manifest_path "${profile}")"
|
||||
mkdir -p "${root}"
|
||||
|
||||
if [[ -n "${anyllm}" ]]; then
|
||||
anyllm_json="\"$(json_escape "${anyllm}")\""
|
||||
fi
|
||||
if [[ -n "${region}" ]]; then
|
||||
region_json="\"$(json_escape "${region}")\""
|
||||
fi
|
||||
if [[ "${memory_enabled}" -eq 1 ]]; then
|
||||
memory_json="true"
|
||||
fi
|
||||
if [[ "${telemetry_enabled}" -eq 0 ]]; then
|
||||
telemetry_json="false"
|
||||
fi
|
||||
|
||||
cat >"${manifest_path}" <<EOF
|
||||
{
|
||||
"profile": "$(json_escape "${profile}")",
|
||||
"preset": "persistent-docker",
|
||||
"runtime_kind": "docker",
|
||||
"supervisor_kind": "none",
|
||||
"scope": "user",
|
||||
"provider_mode": "manual",
|
||||
"targets": [],
|
||||
"port": ${port},
|
||||
"host": "127.0.0.1",
|
||||
"backend": "$(json_escape "${backend}")",
|
||||
"anyllm_provider": ${anyllm_json},
|
||||
"region": ${region_json},
|
||||
"proxy_mode": "$(json_escape "${proxy_mode}")",
|
||||
"memory_enabled": ${memory_json},
|
||||
"memory_db_path": "$(json_escape "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")",
|
||||
"telemetry_enabled": ${telemetry_json},
|
||||
"image": "$(json_escape "${image}")",
|
||||
"service_name": "headroom-$(json_escape "${profile}")",
|
||||
"container_name": "$(json_escape "$(persistent_container_name "${profile}")")",
|
||||
"health_url": "http://127.0.0.1:${port}/readyz",
|
||||
"base_env": {
|
||||
"HEADROOM_PORT": "${port}",
|
||||
"HEADROOM_HOST": "127.0.0.1",
|
||||
"HEADROOM_MODE": "$(json_escape "${proxy_mode}")",
|
||||
"HEADROOM_BACKEND": "$(json_escape "${backend}")"
|
||||
},
|
||||
"tool_envs": {},
|
||||
"proxy_args": $(json_array_from_args "${proxy_args_ref[@]}"),
|
||||
"mutations": [],
|
||||
"artifacts": []
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
load_persistent_state() {
|
||||
local profile="$1"
|
||||
local state_path
|
||||
validate_profile_name "${profile}"
|
||||
state_path="$(persistent_state_path "${profile}")"
|
||||
[[ -f "${state_path}" ]] || die "No docker-native persistent deployment profile named '${profile}'"
|
||||
PROFILE=""
|
||||
IMAGE=""
|
||||
PORT=""
|
||||
BACKEND=""
|
||||
ANYLLM_PROVIDER=""
|
||||
REGION=""
|
||||
PROXY_MODE=""
|
||||
MEMORY_ENABLED=""
|
||||
TELEMETRY_ENABLED=""
|
||||
CONTAINER_NAME=""
|
||||
HEALTH_URL=""
|
||||
while IFS='=' read -r key value; do
|
||||
case "${key}" in
|
||||
PROFILE|IMAGE|PORT|BACKEND|ANYLLM_PROVIDER|REGION|PROXY_MODE|MEMORY_ENABLED|TELEMETRY_ENABLED|CONTAINER_NAME|HEALTH_URL)
|
||||
printf -v "${key}" '%s' "${value}"
|
||||
;;
|
||||
esac
|
||||
done <"${state_path}"
|
||||
}
|
||||
|
||||
start_persistent_docker_install() {
|
||||
local profile="$1"
|
||||
local image="$2"
|
||||
local port="$3"
|
||||
local backend="$4"
|
||||
local anyllm="$5"
|
||||
local region="$6"
|
||||
local proxy_mode="$7"
|
||||
local memory_enabled="$8"
|
||||
local telemetry_enabled="$9"
|
||||
|
||||
local container_name
|
||||
local proxy_args=()
|
||||
local args=()
|
||||
|
||||
validate_profile_name "${profile}"
|
||||
container_name="$(persistent_container_name "${profile}")"
|
||||
build_manifest_proxy_args proxy_args "${port}" "${proxy_mode}" "${backend}" "${anyllm}" "${region}" "${memory_enabled}" "${telemetry_enabled}"
|
||||
|
||||
docker rm -f "${container_name}" >/dev/null 2>&1 || true
|
||||
|
||||
args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}")
|
||||
append_persistent_container_args args
|
||||
args+=("${image}" --host 0.0.0.0 "${proxy_args[@]:2}")
|
||||
"${args[@]}" >/dev/null
|
||||
|
||||
if ! wait_for_proxy "${container_name}" "${port}"; then
|
||||
docker rm -f "${container_name}" >/dev/null 2>&1 || true
|
||||
die "Headroom persistent Docker deployment failed to start on port ${port}"
|
||||
fi
|
||||
|
||||
write_persistent_state "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
|
||||
write_persistent_manifest "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}" proxy_args
|
||||
}
|
||||
|
||||
stop_persistent_docker_install() {
|
||||
local profile="$1"
|
||||
local container_name
|
||||
|
||||
load_persistent_state "${profile}"
|
||||
container_name="${CONTAINER_NAME}"
|
||||
docker stop "${container_name}" >/dev/null 2>&1 || true
|
||||
docker rm -f "${container_name}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
status_persistent_docker_install() {
|
||||
local profile="$1"
|
||||
local status="stopped"
|
||||
local ready="no"
|
||||
|
||||
load_persistent_state "${profile}"
|
||||
if docker_container_exists "${CONTAINER_NAME}"; then
|
||||
status="running"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl --fail --silent "${HEALTH_URL}" >/dev/null; then
|
||||
ready="yes"
|
||||
fi
|
||||
elif (echo >/dev/tcp/127.0.0.1/"${PORT}") >/dev/null 2>&1; then
|
||||
ready="yes"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'Profile: %s\n' "${PROFILE}"
|
||||
printf 'Preset: persistent-docker\n'
|
||||
printf 'Runtime: docker\n'
|
||||
printf 'Supervisor: none\n'
|
||||
printf 'Port: %s\n' "${PORT}"
|
||||
printf 'Status: %s\n' "${status}"
|
||||
printf 'Ready: %s\n' "${ready}"
|
||||
printf 'Health URL: %s\n' "${HEALTH_URL}"
|
||||
}
|
||||
|
||||
remove_persistent_docker_install() {
|
||||
local profile="$1"
|
||||
local root
|
||||
|
||||
load_persistent_state "${profile}"
|
||||
docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
root="$(persistent_profile_root "${profile}")"
|
||||
rm -rf "${root}"
|
||||
}
|
||||
|
||||
print_install_help() {
|
||||
cat <<'EOF'
|
||||
Usage: headroom install [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Manage persistent Docker-native Headroom deployments.
|
||||
|
||||
The Docker-native wrapper currently supports the persistent-docker preset only.
|
||||
Use the Python-native `headroom install` command for persistent-service and
|
||||
persistent-task installs, or when you need provider/user/system config mutation.
|
||||
|
||||
Options:
|
||||
-?, --help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
apply Install a persistent Docker deployment.
|
||||
remove Remove a persistent Docker deployment.
|
||||
restart Restart a persistent Docker deployment.
|
||||
start Start a persistent Docker deployment.
|
||||
status Show persistent Docker deployment status.
|
||||
stop Stop a persistent Docker deployment.
|
||||
EOF
|
||||
}
|
||||
|
||||
print_install_apply_help() {
|
||||
cat <<'EOF'
|
||||
Usage: headroom install apply [OPTIONS]
|
||||
|
||||
Install a persistent Docker deployment.
|
||||
|
||||
Options:
|
||||
--preset [persistent-docker] Docker-native wrapper supports persistent-docker only.
|
||||
--runtime [docker] Docker-native wrapper supports runtime=docker only.
|
||||
--profile TEXT Deployment profile name. [default: default]
|
||||
-p, --port INTEGER Persistent proxy port. [default: 8787]
|
||||
--backend TEXT Proxy backend. [default: anthropic]
|
||||
--anyllm-provider TEXT Provider for any-llm backends.
|
||||
--region TEXT Cloud region for Bedrock / Vertex style backends.
|
||||
--mode TEXT Proxy optimization mode. [default: token]
|
||||
--memory Enable persistent memory in the runtime.
|
||||
--no-telemetry Disable anonymous telemetry in the runtime.
|
||||
--image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]
|
||||
-?, --help Show this message and exit.
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_install_apply_args() {
|
||||
local -n out_profile=$1
|
||||
local -n out_port=$2
|
||||
local -n out_backend=$3
|
||||
local -n out_anyllm=$4
|
||||
local -n out_region=$5
|
||||
local -n out_mode=$6
|
||||
local -n out_memory=$7
|
||||
local -n out_telemetry=$8
|
||||
local -n out_image=$9
|
||||
shift 9
|
||||
|
||||
out_profile="default"
|
||||
out_port=8787
|
||||
out_backend="anthropic"
|
||||
out_anyllm=""
|
||||
out_region=""
|
||||
out_mode="token"
|
||||
out_memory=0
|
||||
out_telemetry=1
|
||||
out_image="${HEADROOM_IMAGE}"
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--preset)
|
||||
require_option_value "$@"
|
||||
[[ "$2" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
|
||||
shift 2
|
||||
;;
|
||||
--preset=*)
|
||||
[[ "${1#*=}" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
|
||||
shift
|
||||
;;
|
||||
--runtime)
|
||||
require_option_value "$@"
|
||||
[[ "$2" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
|
||||
shift 2
|
||||
;;
|
||||
--runtime=*)
|
||||
[[ "${1#*=}" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
|
||||
shift
|
||||
;;
|
||||
--scope|--providers|--target)
|
||||
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
|
||||
;;
|
||||
--scope=*|--providers=*|--target=*)
|
||||
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
|
||||
;;
|
||||
--profile)
|
||||
require_option_value "$@"
|
||||
out_profile="$2"
|
||||
shift 2
|
||||
;;
|
||||
--profile=*)
|
||||
out_profile="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--port|-p)
|
||||
require_option_value "$@"
|
||||
out_port="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port=*|-p=*)
|
||||
out_port="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--backend)
|
||||
require_option_value "$@"
|
||||
out_backend="$2"
|
||||
shift 2
|
||||
;;
|
||||
--backend=*)
|
||||
out_backend="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--anyllm-provider)
|
||||
require_option_value "$@"
|
||||
out_anyllm="$2"
|
||||
shift 2
|
||||
;;
|
||||
--anyllm-provider=*)
|
||||
out_anyllm="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--region)
|
||||
require_option_value "$@"
|
||||
out_region="$2"
|
||||
shift 2
|
||||
;;
|
||||
--region=*)
|
||||
out_region="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--mode)
|
||||
require_option_value "$@"
|
||||
out_mode="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mode=*)
|
||||
out_mode="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--memory)
|
||||
out_memory=1
|
||||
shift
|
||||
;;
|
||||
--no-telemetry)
|
||||
out_telemetry=0
|
||||
shift
|
||||
;;
|
||||
--image)
|
||||
require_option_value "$@"
|
||||
out_image="$2"
|
||||
shift 2
|
||||
;;
|
||||
--image=*)
|
||||
out_image="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--help|-?)
|
||||
print_install_apply_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "Unsupported option for 'headroom install apply': $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
validate_port "${out_port}"
|
||||
}
|
||||
|
||||
parse_install_profile_arg() {
|
||||
local -n out_profile=$1
|
||||
shift
|
||||
|
||||
out_profile="default"
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--profile)
|
||||
require_option_value "$@"
|
||||
out_profile="$2"
|
||||
shift 2
|
||||
;;
|
||||
--profile=*)
|
||||
out_profile="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--help|-?)
|
||||
print_install_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "Unsupported option for 'headroom install': $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_claude_rtk_init() {
|
||||
local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
|
||||
if [[ ! -x "${rtk_bin}" ]]; then
|
||||
|
|
@ -251,12 +793,15 @@ parse_wrap_args() {
|
|||
break
|
||||
;;
|
||||
--port|-p)
|
||||
require_option_value "$@"
|
||||
out_port="$2"
|
||||
validate_port "${out_port}"
|
||||
out_known+=("$1" "$2")
|
||||
shift 2
|
||||
;;
|
||||
--port=*)
|
||||
out_port="${1#*=}"
|
||||
validate_port "${out_port}"
|
||||
out_known+=("$1")
|
||||
shift
|
||||
;;
|
||||
|
|
@ -280,6 +825,7 @@ parse_wrap_args() {
|
|||
shift
|
||||
;;
|
||||
--backend)
|
||||
require_option_value "$@"
|
||||
out_backend="$2"
|
||||
out_known+=("$1" "$2")
|
||||
shift 2
|
||||
|
|
@ -290,6 +836,7 @@ parse_wrap_args() {
|
|||
shift
|
||||
;;
|
||||
--anyllm-provider)
|
||||
require_option_value "$@"
|
||||
out_anyllm="$2"
|
||||
out_known+=("$1" "$2")
|
||||
shift 2
|
||||
|
|
@ -300,6 +847,7 @@ parse_wrap_args() {
|
|||
shift
|
||||
;;
|
||||
--region)
|
||||
require_option_value "$@"
|
||||
out_region="$2"
|
||||
out_known+=("$1" "$2")
|
||||
shift 2
|
||||
|
|
@ -381,6 +929,7 @@ parse_openclaw_wrap_args() {
|
|||
while (($#)); do
|
||||
case "$1" in
|
||||
--plugin-path)
|
||||
require_option_value "$@"
|
||||
out_plugin_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
|
|
@ -389,6 +938,7 @@ parse_openclaw_wrap_args() {
|
|||
shift
|
||||
;;
|
||||
--plugin-spec)
|
||||
require_option_value "$@"
|
||||
out_plugin_spec="$2"
|
||||
shift 2
|
||||
;;
|
||||
|
|
@ -405,22 +955,29 @@ parse_openclaw_wrap_args() {
|
|||
shift
|
||||
;;
|
||||
--proxy-port)
|
||||
require_option_value "$@"
|
||||
out_proxy_port="$2"
|
||||
validate_port "${out_proxy_port}"
|
||||
shift 2
|
||||
;;
|
||||
--proxy-port=*)
|
||||
out_proxy_port="${1#*=}"
|
||||
validate_port "${out_proxy_port}"
|
||||
shift
|
||||
;;
|
||||
--startup-timeout-ms)
|
||||
require_option_value "$@"
|
||||
out_startup_timeout_ms="$2"
|
||||
validate_positive_integer "${out_startup_timeout_ms}"
|
||||
shift 2
|
||||
;;
|
||||
--startup-timeout-ms=*)
|
||||
out_startup_timeout_ms="${1#*=}"
|
||||
validate_positive_integer "${out_startup_timeout_ms}"
|
||||
shift
|
||||
;;
|
||||
--gateway-provider-id)
|
||||
require_option_value "$@"
|
||||
out_gateway_provider_ids+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
|
|
@ -429,6 +986,7 @@ parse_openclaw_wrap_args() {
|
|||
shift
|
||||
;;
|
||||
--python-path)
|
||||
require_option_value "$@"
|
||||
out_python_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
|
|
@ -795,6 +1353,57 @@ main() {
|
|||
fi
|
||||
|
||||
case "$1" in
|
||||
install)
|
||||
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
|
||||
print_install_help
|
||||
return
|
||||
fi
|
||||
|
||||
local install_command="$2"
|
||||
shift 2
|
||||
case "${install_command}" in
|
||||
apply)
|
||||
local profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image
|
||||
parse_install_apply_args profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image "$@"
|
||||
start_persistent_docker_install "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
|
||||
printf "Installed docker-native persistent deployment '%s' on port %s.\n" "${profile}" "${port}"
|
||||
;;
|
||||
status)
|
||||
local profile
|
||||
parse_install_profile_arg profile "$@"
|
||||
status_persistent_docker_install "${profile}"
|
||||
;;
|
||||
start)
|
||||
local profile
|
||||
parse_install_profile_arg profile "$@"
|
||||
load_persistent_state "${profile}"
|
||||
start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
|
||||
printf "Started docker-native persistent deployment '%s'.\n" "${profile}"
|
||||
;;
|
||||
stop)
|
||||
local profile
|
||||
parse_install_profile_arg profile "$@"
|
||||
stop_persistent_docker_install "${profile}"
|
||||
printf "Stopped docker-native persistent deployment '%s'.\n" "${profile}"
|
||||
;;
|
||||
restart)
|
||||
local profile
|
||||
parse_install_profile_arg profile "$@"
|
||||
load_persistent_state "${profile}"
|
||||
start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
|
||||
printf "Restarted docker-native persistent deployment '%s'.\n" "${profile}"
|
||||
;;
|
||||
remove)
|
||||
local profile
|
||||
parse_install_profile_arg profile "$@"
|
||||
remove_persistent_docker_install "${profile}"
|
||||
printf "Removed docker-native persistent deployment '%s'.\n" "${profile}"
|
||||
;;
|
||||
*)
|
||||
die "Unsupported install target: ${install_command}"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
wrap)
|
||||
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
|
||||
run_headroom wrap --help
|
||||
|
|
@ -911,12 +1520,15 @@ EOF
|
|||
while (($#)); do
|
||||
case "$1" in
|
||||
--port|-p)
|
||||
require_option_value "$@"
|
||||
port="$2"
|
||||
validate_port "${port}"
|
||||
args+=("$1" "$2")
|
||||
shift 2
|
||||
;;
|
||||
--port=*)
|
||||
port="${1#*=}"
|
||||
validate_port "${port}"
|
||||
args+=("$1")
|
||||
shift
|
||||
;;
|
||||
|
|
@ -942,6 +1554,7 @@ EOF
|
|||
|
||||
main "$@"
|
||||
WRAPPER
|
||||
} >"${wrapper_path}"
|
||||
|
||||
chmod +x "${wrapper_path}"
|
||||
}
|
||||
|
|
@ -957,8 +1570,17 @@ main() {
|
|||
append_path_block "${HOME}/.zshrc"
|
||||
append_path_block "${HOME}/.profile"
|
||||
|
||||
info "Pulling ${IMAGE_DEFAULT}"
|
||||
docker pull "${IMAGE_DEFAULT}" >/dev/null
|
||||
if [[ -n "${HEADROOM_DOCKER_IMAGE:-}" ]]; then
|
||||
if docker image inspect "${INSTALL_IMAGE}" >/dev/null 2>&1; then
|
||||
info "Using existing HEADROOM_DOCKER_IMAGE=${INSTALL_IMAGE}"
|
||||
else
|
||||
info "Pulling ${INSTALL_IMAGE}"
|
||||
docker pull "${INSTALL_IMAGE}" >/dev/null
|
||||
fi
|
||||
else
|
||||
info "Pulling ${IMAGE_DEFAULT}"
|
||||
docker pull "${IMAGE_DEFAULT}" >/dev/null
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
|
|
|
|||
731
tests/test_install/test_native_installers.py
Normal file
731
tests/test_install/test_native_installers.py
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
FAKE_DOCKER = r"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
STATE_PATH = Path(os.environ["FAKE_DOCKER_STATE"])
|
||||
LOG_PATH = Path(os.environ["FAKE_DOCKER_LOG"])
|
||||
|
||||
|
||||
def load_state() -> dict[str, dict[str, dict[str, int]]]:
|
||||
if not STATE_PATH.exists():
|
||||
return {"containers": {}}
|
||||
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_state(state: dict[str, dict[str, dict[str, int]]]) -> None:
|
||||
STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
|
||||
def cleanup_dead(state: dict[str, dict[str, dict[str, int]]]) -> dict[str, dict[str, dict[str, int]]]:
|
||||
save_state(state)
|
||||
return state
|
||||
|
||||
|
||||
def host_port_from_publish(value: str) -> int:
|
||||
parts = value.split(":")
|
||||
if len(parts) == 2:
|
||||
return int(parts[0])
|
||||
if len(parts) >= 3:
|
||||
return int(parts[-2])
|
||||
raise ValueError(f"Unsupported publish value: {value}")
|
||||
|
||||
|
||||
def start_server(port: int) -> int:
|
||||
code = '''
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
port = int(sys.argv[1])
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||
'''
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-c", code, str(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return process.pid
|
||||
|
||||
|
||||
def stop_container(state: dict[str, dict[str, dict[str, int]]], name: str) -> None:
|
||||
data = state["containers"].pop(name, None)
|
||||
if not data:
|
||||
return
|
||||
try:
|
||||
os.kill(int(data["pid"]), signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
save_state(state)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LOG_PATH.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(args) + "\n")
|
||||
|
||||
if not args:
|
||||
return 0
|
||||
|
||||
state = cleanup_dead(load_state())
|
||||
command = args[0]
|
||||
|
||||
if command == "pull":
|
||||
return 0
|
||||
|
||||
if command == "run":
|
||||
detached = "-d" in args
|
||||
if not detached:
|
||||
return 0
|
||||
|
||||
name = None
|
||||
publish = None
|
||||
for index, arg in enumerate(args):
|
||||
if arg == "--name":
|
||||
name = args[index + 1]
|
||||
elif arg == "-p":
|
||||
publish = args[index + 1]
|
||||
|
||||
if name is None or publish is None:
|
||||
raise SystemExit("missing --name or -p in fake docker run")
|
||||
|
||||
port = host_port_from_publish(publish)
|
||||
state["containers"][name] = {"pid": start_server(port), "port": port}
|
||||
save_state(state)
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
if command == "ps":
|
||||
names = sorted(state["containers"])
|
||||
if "--format" in args:
|
||||
print("\n".join(names))
|
||||
return 0
|
||||
|
||||
if command == "stop":
|
||||
for name in args[1:]:
|
||||
if not name.startswith("-"):
|
||||
stop_container(state, name)
|
||||
return 0
|
||||
|
||||
if command == "rm":
|
||||
for name in args[1:]:
|
||||
if not name.startswith("-"):
|
||||
stop_container(state, name)
|
||||
return 0
|
||||
|
||||
if command == "logs":
|
||||
if len(args) > 1:
|
||||
print(f"fake logs for {args[1]}")
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
"""
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _write_fake_docker_shims(tmp_path: Path) -> Path:
|
||||
shim_dir = tmp_path / "fake-docker"
|
||||
shim_dir.mkdir()
|
||||
|
||||
fake_docker = shim_dir / "fake_docker.py"
|
||||
fake_docker.write_text(FAKE_DOCKER, encoding="utf-8")
|
||||
|
||||
docker_sh = shim_dir / "docker"
|
||||
docker_sh.write_text(
|
||||
f'#!/usr/bin/env bash\nexec "{sys.executable}" "{fake_docker}" "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
docker_sh.chmod(0o755)
|
||||
|
||||
docker_cmd = shim_dir / "docker.cmd"
|
||||
docker_cmd.write_text(
|
||||
f'@echo off\r\n"{sys.executable}" "{fake_docker}" %*\r\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
openclaw_sh = shim_dir / "openclaw"
|
||||
openclaw_sh.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
||||
openclaw_sh.chmod(0o755)
|
||||
|
||||
openclaw_cmd = shim_dir / "openclaw.cmd"
|
||||
openclaw_cmd.write_text("@echo off\r\nexit /b 0\r\n", encoding="utf-8")
|
||||
|
||||
return shim_dir
|
||||
|
||||
|
||||
def _build_env(home: Path, tmp_path: Path) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
shim_dir = _write_fake_docker_shims(tmp_path)
|
||||
env["HOME"] = str(home)
|
||||
env["USERPROFILE"] = str(home)
|
||||
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
|
||||
env["FAKE_DOCKER_STATE"] = str(tmp_path / "fake-docker-state.json")
|
||||
env["FAKE_DOCKER_LOG"] = str(tmp_path / "fake-docker.log")
|
||||
return env
|
||||
|
||||
|
||||
def _cleanup_fake_docker(env: dict[str, str]) -> None:
|
||||
state_path = Path(env["FAKE_DOCKER_STATE"])
|
||||
if not state_path.exists():
|
||||
return
|
||||
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
for container in state.get("containers", {}).values():
|
||||
try:
|
||||
os.kill(int(container["pid"]), signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _read_fake_docker_log(env: dict[str, str]) -> list[list[str]]:
|
||||
log_path = Path(env["FAKE_DOCKER_LOG"])
|
||||
if not log_path.exists():
|
||||
return []
|
||||
return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line]
|
||||
|
||||
|
||||
def _run(
|
||||
command: list[str],
|
||||
*,
|
||||
env: dict[str, str],
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or shutil.which("bash") is None,
|
||||
reason="bash installer coverage runs on non-Windows hosts",
|
||||
)
|
||||
def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
|
||||
home = tmp_path / "home"
|
||||
(home / ".local").mkdir(parents=True)
|
||||
env = _build_env(home, tmp_path)
|
||||
|
||||
try:
|
||||
_run(["bash", str(REPO_ROOT / "scripts" / "install.sh")], env=env, cwd=REPO_ROOT)
|
||||
|
||||
wrapper = home / ".local" / "bin" / "headroom"
|
||||
assert wrapper.exists()
|
||||
|
||||
help_result = _run([str(wrapper), "install", "-?"], env=env)
|
||||
assert "persistent-docker preset only" in help_result.stdout
|
||||
_run([str(wrapper), "--help"], env=env)
|
||||
|
||||
invalid_profile = _run(
|
||||
[str(wrapper), "install", "status", "--profile", ".."],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_profile.returncode != 0
|
||||
assert "Invalid profile name '..'" in invalid_profile.stderr
|
||||
missing_profile_value = _run(
|
||||
[str(wrapper), "install", "apply", "--profile"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_profile_value.returncode != 0
|
||||
assert "Option --profile requires a value" in missing_profile_value.stderr
|
||||
missing_proxy_port = _run(
|
||||
[str(wrapper), "proxy", "--port"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_proxy_port.returncode != 0
|
||||
assert "Option --port requires a value" in missing_proxy_port.stderr
|
||||
invalid_proxy_port = _run(
|
||||
[str(wrapper), "proxy", "--port", "abc"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_proxy_port.returncode != 0
|
||||
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
|
||||
missing_wrap_port = _run(
|
||||
[str(wrapper), "wrap", "claude", "--port"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_wrap_port.returncode != 0
|
||||
assert "Option --port requires a value" in missing_wrap_port.stderr
|
||||
invalid_wrap_port = _run(
|
||||
[str(wrapper), "wrap", "claude", "--port", "abc"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_wrap_port.returncode != 0
|
||||
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
|
||||
missing_openclaw_proxy_port = _run(
|
||||
[str(wrapper), "wrap", "openclaw", "--proxy-port"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_openclaw_proxy_port.returncode != 0
|
||||
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
|
||||
invalid_openclaw_proxy_port = _run(
|
||||
[str(wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_openclaw_proxy_port.returncode != 0
|
||||
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
|
||||
for invalid_port in ("abc", "0", "65536"):
|
||||
invalid_port_result = _run(
|
||||
[str(wrapper), "install", "apply", "--port", invalid_port],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_port_result.returncode != 0
|
||||
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
|
||||
|
||||
port = _free_port()
|
||||
_run(
|
||||
[
|
||||
str(wrapper),
|
||||
"install",
|
||||
"apply",
|
||||
"--profile",
|
||||
"smoke",
|
||||
"--port",
|
||||
str(port),
|
||||
"--memory",
|
||||
"--no-telemetry",
|
||||
"--image",
|
||||
"fake/headroom:test",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
|
||||
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert manifest["preset"] == "persistent-docker"
|
||||
assert manifest["port"] == port
|
||||
assert manifest["memory_enabled"] is True
|
||||
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
|
||||
assert manifest["telemetry_enabled"] is False
|
||||
|
||||
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.env"
|
||||
state_text = state_path.read_text(encoding="utf-8")
|
||||
assert f"PORT={port!r}" in state_text
|
||||
|
||||
docker_calls = _read_fake_docker_log(env)
|
||||
help_call = next(
|
||||
call
|
||||
for call in docker_calls
|
||||
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
|
||||
)
|
||||
assert "-it" not in help_call
|
||||
install_call = next(
|
||||
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
|
||||
)
|
||||
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
|
||||
|
||||
status_result = _run(
|
||||
[str(wrapper), "install", "status", "--profile", "smoke"],
|
||||
env=env,
|
||||
)
|
||||
assert "Status: running" in status_result.stdout
|
||||
|
||||
_run([str(wrapper), "install", "stop", "--profile", "smoke"], env=env)
|
||||
stopped_result = _run(
|
||||
[str(wrapper), "install", "status", "--profile", "smoke"],
|
||||
env=env,
|
||||
)
|
||||
assert "Status: stopped" in stopped_result.stdout
|
||||
|
||||
_run([str(wrapper), "install", "start", "--profile", "smoke"], env=env)
|
||||
restarted_result = _run(
|
||||
[str(wrapper), "install", "status", "--profile", "smoke"],
|
||||
env=env,
|
||||
)
|
||||
assert "Status: running" in restarted_result.stdout
|
||||
|
||||
rejected = _run(
|
||||
[str(wrapper), "install", "apply", "--scope", "user"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert rejected.returncode != 0
|
||||
assert "does not support provider/user/system mutation flags" in rejected.stderr
|
||||
|
||||
_run([str(wrapper), "install", "restart", "--profile", "smoke"], env=env)
|
||||
_run([str(wrapper), "install", "remove", "--profile", "smoke"], env=env)
|
||||
assert not manifest_path.parent.exists()
|
||||
finally:
|
||||
_cleanup_fake_docker(env)
|
||||
|
||||
|
||||
def _powershell_executable() -> str | None:
|
||||
return shutil.which("pwsh") or shutil.which("powershell") or shutil.which("powershell.exe")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name != "nt" or _powershell_executable() is None,
|
||||
reason="Windows PowerShell coverage runs on Windows hosts only",
|
||||
)
|
||||
def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
|
||||
powershell = _powershell_executable()
|
||||
assert powershell is not None
|
||||
|
||||
home = tmp_path / "home"
|
||||
(home / ".local").mkdir(parents=True)
|
||||
env = _build_env(home, tmp_path)
|
||||
|
||||
try:
|
||||
_run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(REPO_ROOT / "scripts" / "install.ps1"),
|
||||
],
|
||||
env=env,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
|
||||
wrapper = home / ".local" / "bin" / "headroom.ps1"
|
||||
assert wrapper.exists()
|
||||
cmd_wrapper = home / ".local" / "bin" / "headroom.cmd"
|
||||
assert cmd_wrapper.exists()
|
||||
|
||||
help_result = _run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"-?",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
_run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"proxy",
|
||||
"--help",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
assert "persistent-docker preset only" in help_result.stdout
|
||||
cmd_help_result = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "install", "-?"],
|
||||
env=env,
|
||||
)
|
||||
assert "persistent-docker preset only" in cmd_help_result.stdout
|
||||
_run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"--help",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
invalid_profile = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_profile.returncode != 0
|
||||
assert "Invalid profile name '..'" in invalid_profile.stderr
|
||||
missing_profile_value = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--profile"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_profile_value.returncode != 0
|
||||
assert "Option --profile requires a value" in missing_profile_value.stderr
|
||||
missing_proxy_port = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_proxy_port.returncode != 0
|
||||
assert "Option --port requires a value" in missing_proxy_port.stderr
|
||||
invalid_proxy_port = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port", "abc"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_proxy_port.returncode != 0
|
||||
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
|
||||
missing_wrap_port = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_wrap_port.returncode != 0
|
||||
assert "Option --port requires a value" in missing_wrap_port.stderr
|
||||
invalid_wrap_port = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port", "abc"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_wrap_port.returncode != 0
|
||||
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
|
||||
missing_openclaw_proxy_port = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert missing_openclaw_proxy_port.returncode != 0
|
||||
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
|
||||
invalid_openclaw_proxy_port = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_openclaw_proxy_port.returncode != 0
|
||||
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
|
||||
for invalid_port in ("abc", "0", "65536"):
|
||||
invalid_port_result = _run(
|
||||
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--port", invalid_port],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert invalid_port_result.returncode != 0
|
||||
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
|
||||
|
||||
port = _free_port()
|
||||
_run(
|
||||
[
|
||||
"cmd.exe",
|
||||
"/c",
|
||||
str(cmd_wrapper),
|
||||
"install",
|
||||
"apply",
|
||||
"--profile",
|
||||
"smoke",
|
||||
"--port",
|
||||
str(port),
|
||||
"--memory",
|
||||
"--no-telemetry",
|
||||
"--image",
|
||||
"fake/headroom:test",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
|
||||
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
|
||||
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert manifest["preset"] == "persistent-docker"
|
||||
assert manifest["port"] == port
|
||||
assert manifest["memory_enabled"] is True
|
||||
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
|
||||
assert manifest["telemetry_enabled"] is False
|
||||
assert state["container_name"] == "headroom-smoke"
|
||||
|
||||
docker_calls = _read_fake_docker_log(env)
|
||||
help_call = next(
|
||||
call
|
||||
for call in docker_calls
|
||||
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
|
||||
)
|
||||
assert "-it" not in help_call
|
||||
proxy_help_call = next(
|
||||
call
|
||||
for call in docker_calls
|
||||
if call[:2] == ["run", "--rm"] and "-p" in call and "proxy" in call and "--help" in call
|
||||
)
|
||||
assert "-it" not in proxy_help_call
|
||||
install_call = next(
|
||||
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
|
||||
)
|
||||
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
|
||||
|
||||
status_result = _run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"status",
|
||||
"--profile",
|
||||
"smoke",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
assert "Status: running" in status_result.stdout
|
||||
|
||||
_run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"stop",
|
||||
"--profile",
|
||||
"smoke",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
stopped_result = _run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"status",
|
||||
"--profile",
|
||||
"smoke",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
assert "Status: stopped" in stopped_result.stdout
|
||||
|
||||
_run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"start",
|
||||
"--profile",
|
||||
"smoke",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
started_result = _run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"status",
|
||||
"--profile",
|
||||
"smoke",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
assert "Status: running" in started_result.stdout
|
||||
|
||||
rejected = _run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"apply",
|
||||
"--scope",
|
||||
"user",
|
||||
],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert rejected.returncode != 0
|
||||
assert "does not support provider/user/system mutation flags" in rejected.stderr
|
||||
|
||||
_run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"restart",
|
||||
"--profile",
|
||||
"smoke",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
_run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(wrapper),
|
||||
"install",
|
||||
"remove",
|
||||
"--profile",
|
||||
"smoke",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
assert not manifest_path.parent.exists()
|
||||
finally:
|
||||
_cleanup_fake_docker(env)
|
||||
94
tests/test_install/test_supervisors.py
Normal file
94
tests/test_install/test_supervisors.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.install.models import DeploymentManifest, SupervisorKind
|
||||
from headroom.install.supervisors import (
|
||||
_linux_service_unit,
|
||||
_linux_task_spec,
|
||||
_macos_launchd_plist,
|
||||
_render_windows_runner,
|
||||
)
|
||||
|
||||
|
||||
def _manifest(
|
||||
*, profile: str = "default", scope: str = "user", supervisor: str = "service"
|
||||
) -> DeploymentManifest:
|
||||
return DeploymentManifest(
|
||||
profile=profile,
|
||||
preset="persistent-service",
|
||||
runtime_kind="python",
|
||||
supervisor_kind=supervisor,
|
||||
scope=scope,
|
||||
provider_mode="manual",
|
||||
targets=[],
|
||||
port=8787,
|
||||
host="127.0.0.1",
|
||||
backend="anthropic",
|
||||
service_name=f"headroom-{profile}",
|
||||
)
|
||||
|
||||
|
||||
def test_linux_service_unit_uses_user_systemd_path(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
manifest = _manifest()
|
||||
|
||||
unit_path, content = _linux_service_unit(manifest, tmp_path / "run-headroom.sh")
|
||||
|
||||
assert unit_path == tmp_path / ".config" / "systemd" / "user" / "headroom-default.service"
|
||||
assert "ExecStart=" + str(tmp_path / "run-headroom.sh") in content
|
||||
assert "Restart=on-failure" in content
|
||||
|
||||
|
||||
def test_linux_task_spec_for_user_scope_includes_crontab_markers(tmp_path: Path) -> None:
|
||||
manifest = _manifest(profile="smoke", supervisor=SupervisorKind.TASK.value)
|
||||
|
||||
cron_path, content = _linux_task_spec(manifest, tmp_path / "ensure-headroom.sh")
|
||||
|
||||
assert cron_path is None
|
||||
assert "# >>> headroom smoke >>>" in content
|
||||
assert "# <<< headroom smoke <<<" in content
|
||||
assert "@reboot" in content
|
||||
assert "*/5 * * * *" in content
|
||||
|
||||
|
||||
def test_macos_launchd_plist_switches_between_keepalive_and_interval(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
service_manifest = _manifest(supervisor=SupervisorKind.SERVICE.value)
|
||||
service_path, service_content = _macos_launchd_plist(
|
||||
service_manifest, tmp_path / "run-headroom.sh"
|
||||
)
|
||||
assert service_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.default.plist"
|
||||
assert "<key>KeepAlive</key>" in service_content
|
||||
assert "<key>StartInterval</key>" not in service_content
|
||||
|
||||
task_manifest = _manifest(profile="tasky", supervisor=SupervisorKind.TASK.value)
|
||||
task_path, task_content = _macos_launchd_plist(
|
||||
task_manifest, tmp_path / "ensure-headroom.sh", interval=300
|
||||
)
|
||||
assert task_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.tasky.plist"
|
||||
assert "<key>StartInterval</key>" in task_content
|
||||
assert "<integer>300</integer>" in task_content
|
||||
|
||||
|
||||
def test_render_windows_runner_writes_ps1_and_cmd_wrappers(tmp_path: Path) -> None:
|
||||
ps1_path = tmp_path / "run-headroom.ps1"
|
||||
cmd_path = tmp_path / "run-headroom.cmd"
|
||||
|
||||
records = _render_windows_runner(
|
||||
ps1_path,
|
||||
cmd_path,
|
||||
["C:\\Program Files\\Python\\python.exe", "headroom", "install", "agent", "run"],
|
||||
)
|
||||
|
||||
assert [record.path for record in records] == [str(ps1_path), str(cmd_path)]
|
||||
ps1_content = ps1_path.read_text(encoding="utf-8")
|
||||
cmd_content = cmd_path.read_text(encoding="utf-8")
|
||||
assert '& "C:\\Program Files\\Python\\python.exe" headroom install agent run' in ps1_content
|
||||
assert (
|
||||
'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-headroom.ps1" %*'
|
||||
in cmd_content
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue