Compare commits

...

12 Commits

Author SHA1 Message Date
Bas Nijholt
3a3591a0f7 feat(web): Allow reconnection to running tasks after navigation (#38) 2025-12-18 14:27:06 -08:00
Bas Nijholt
7f8ea49d7f fix(web): Enable TTY for self-update SSH to show progress bars (#40)
* fix(web): Add PATH for self-update SSH command

Non-interactive SSH sessions don't source profile files, so `cf` isn't
found when installed in ~/.local/bin. Prepend common install locations
to PATH before running the remote command.

* fix(web): Enable TTY for self-update SSH to show progress bars
2025-12-18 14:19:21 -08:00
Bas Nijholt
1e67bde96c fix(web): Add PATH for self-update SSH command (#39)
Non-interactive SSH sessions don't source profile files, so `cf` isn't
found when installed in ~/.local/bin. Prepend common install locations
to PATH before running the remote command.
2025-12-18 14:17:03 -08:00
Bas Nijholt
d8353dbb7e fix: Skip socket paths in preflight volume checks (#37)
Socket paths like SSH_AUTH_SOCK are machine-local and shouldn't be
validated on remote hosts during preflight checks.
2025-12-18 13:59:06 -08:00
Bas Nijholt
2e6146a94b feat(ps): Add service filtering to ps command (#33) 2025-12-18 13:31:18 -08:00
Bas Nijholt
87849a8161 fix(web): Run self-updates via SSH to survive container restart (#35) 2025-12-18 13:10:30 -08:00
Bas Nijholt
c8bf792a9a refactor: Store SSH keys in subdirectory for cleaner volume mounting (#36)
* refactor: Store SSH keys in subdirectory for cleaner volume mounting

Change SSH key location from ~/.ssh/compose-farm (file) to
~/.ssh/compose-farm/id_ed25519 (file in directory).

This allows docker-compose to mount just the compose-farm directory
to /root/.ssh without exposing all host SSH keys to the container.

Also make host path the default option in docker-compose.yml with
clearer comments about the two options.

* docs: Update README for new SSH key directory structure

* docs: Clarify cf ssh setup must run inside container
2025-12-18 13:07:41 -08:00
Bas Nijholt
d37295fbee feat(web): Add distinct color for Dashboard/Console in command palette (#34)
Give Dashboard and Console a purple accent to visually distinguish
them from service navigation items in the Command K palette.
2025-12-18 12:38:28 -08:00
Bas Nijholt
266f541d35 fix(web): Auto-scroll Command K palette when navigating with arrow keys (#32)
When using arrow keys to navigate through the command palette list,
items outside the visible area now scroll into view automatically.
2025-12-18 12:30:29 -08:00
Bas Nijholt
aabdd550ba feat(cli): Add progress bar to ssh status host connectivity check (#31)
Use run_parallel_with_progress for visual feedback during host checks.
Results are now sorted alphabetically for consistent output.

Also adds code style rule to CLAUDE.md about keeping imports at top level.
2025-12-18 12:21:47 -08:00
Bas Nijholt
8ff60a1e3e refactor(ssh): Unify ssh_status to use run_command like check command (#29) 2025-12-18 12:17:47 -08:00
Bas Nijholt
2497bd727a feat(web): Navigate to dashboard for Apply/Refresh from command palette (#28)
When triggering Apply or Refresh from the command palette on a non-dashboard
page, navigate to the dashboard first and then execute the action, opening
the terminal output.
2025-12-18 12:12:50 -08:00
16 changed files with 411 additions and 146 deletions

View File

@@ -43,6 +43,10 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
7. **State tracking**: Tracks where services are deployed for auto-migration
8. **Pre-flight checks**: Verifies NFS mounts and Docker networks exist before starting/migrating
## Code Style
- **Imports at top level**: Never add imports inside functions unless they are explicitly marked with `# noqa: PLC0415` and a comment explaining it speeds up CLI startup. Heavy modules like `pydantic`, `yaml`, and `rich.table` are lazily imported to keep `cf --help` fast.
## Communication Notes
- Clarify ambiguous wording (e.g., homophones like "right"/"write", "their"/"there").

View File

@@ -190,25 +190,31 @@ cf ssh setup
cf ssh status
```
This creates `~/.ssh/compose-farm` (ED25519, no passphrase) and copies the public key to each host's `authorized_keys`. Compose Farm tries the SSH agent first, then falls back to this key.
This creates `~/.ssh/compose-farm/id_ed25519` (ED25519, no passphrase) and copies the public key to each host's `authorized_keys`. Compose Farm tries the SSH agent first, then falls back to this key.
<details><summary>🐳 Docker volume options for SSH keys</summary>
When running in Docker, mount a volume to persist the SSH keys:
When running in Docker, mount a volume to persist the SSH keys. Choose ONE option and use it for both `cf` and `web` services:
**Option 1: Named volume (default)**
```yaml
volumes:
- cf-ssh:/root/.ssh
```
**Option 2: Host path (easier to backup/inspect)**
**Option 1: Host path (default)** - keys at `~/.ssh/compose-farm/id_ed25519`
```yaml
volumes:
- ~/.ssh/compose-farm:/root/.ssh
```
Run `cf ssh setup` once after starting the container (while the SSH agent still works), and the keys will persist across restarts.
**Option 2: Named volume** - managed by Docker
```yaml
volumes:
- cf-ssh:/root/.ssh
```
Run setup once after starting the container (while the SSH agent still works):
```bash
docker compose exec web cf ssh setup
```
The keys will persist across restarts.
</details>
@@ -401,7 +407,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
│ logs Show service logs. │
│ ps Show status of all services. │
│ ps Show status of services.
│ stats Show overview statistics for hosts and services. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Server ─────────────────────────────────────────────────────────────────────╮
@@ -898,11 +904,19 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf ps [OPTIONS]
Usage: cf ps [OPTIONS] [SERVICES]...
Show status of all services.
Show status of services.
Without arguments: shows all services (same as --all). With service names:
shows only those services. With --host: shows services on that host.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --host -H TEXT Filter to services on this host │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯

View File

@@ -6,10 +6,11 @@ services:
# Compose directory (contains compose files AND compose-farm.yaml config)
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
# SSH keys for passwordless auth (generated by `cf ssh setup`)
# Option 1: Named volume (default) - managed by Docker
- cf-ssh:/root/.ssh
# Option 2: Host path - easier to backup/inspect, uncomment to use:
# - ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
# Choose ONE option below (use the same option for both cf and web services):
# Option 1: Host path (default) - keys at ~/.ssh/compose-farm/id_ed25519
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
# Option 2: Named volume - managed by Docker, shared between services
# - cf-ssh:/root/.ssh
environment:
- SSH_AUTH_SOCK=/ssh-agent
# Config file path (state stored alongside it)
@@ -22,12 +23,16 @@ services:
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
# SSH keys - use same option as cf service above
- cf-ssh:/root/.ssh
# - ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
# SSH keys - use the SAME option as cf service above
# Option 1: Host path (default)
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
# Option 2: Named volume
# - cf-ssh:/root/.ssh
environment:
- SSH_AUTH_SOCK=/ssh-agent
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
# Used to detect self-updates and run via SSH to survive container restart
- CF_WEB_SERVICE=compose-farm
labels:
- traefik.enable=true
- traefik.http.routers.compose-farm.rule=Host(`compose-farm.${DOMAIN}`)
@@ -44,4 +49,4 @@ networks:
volumes:
cf-ssh:
# Persists SSH keys across container restarts
# Only used if Option 2 is selected above

View File

@@ -144,17 +144,45 @@ def get_services(
services: list[str],
all_services: bool,
config_path: Path | None,
*,
host: str | None = None,
default_all: bool = False,
) -> tuple[list[str], Config]:
"""Resolve service list and load config.
Handles three mutually exclusive selection methods:
- Explicit service names
- --all flag
- --host filter
Args:
services: Explicit service names
all_services: Whether --all was specified
config_path: Path to config file
host: Filter to services on this host
default_all: If True, default to all services when nothing specified (for ps)
Supports "." as shorthand for the current directory name.
"""
validate_service_selection(services, all_services, host)
config = load_config_or_exit(config_path)
if host is not None:
validate_host(config, host)
svc_list = [s for s in config.services if host in config.get_hosts(s)]
if not svc_list:
print_warning(f"No services configured for host [magenta]{host}[/]")
raise typer.Exit(0)
return svc_list, config
if all_services:
return list(config.services.keys()), config
if not services:
print_error("Specify services or use [bold]--all[/]")
if default_all:
return list(config.services.keys()), config
print_error("Specify services or use [bold]--all[/] / [bold]--host[/]")
raise typer.Exit(1)
# Resolve "." to current directory name
@@ -286,6 +314,22 @@ def validate_host_for_service(cfg: Config, service: str, host: str) -> None:
raise typer.Exit(1)
def validate_service_selection(
services: list[str] | None,
all_services: bool,
host: str | None,
) -> None:
"""Validate that only one service selection method is used.
The three selection methods (explicit services, --all, --host) are mutually
exclusive. This ensures consistent behavior across all commands.
"""
methods = sum([bool(services), all_services, host is not None])
if methods > 1:
print_error("Use only one of: service names, [bold]--all[/], or [bold]--host[/]")
raise typer.Exit(1)
def run_host_operation(
cfg: Config,
svc_list: list[str],

View File

@@ -21,19 +21,16 @@ from compose_farm.cli.common import (
maybe_regenerate_traefik,
report_results,
run_async,
run_host_operation,
)
from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success
from compose_farm.executor import run_on_services, run_sequential_on_services
from compose_farm.operations import stop_orphaned_services, up_services
from compose_farm.state import (
add_service_to_host,
get_orphaned_services,
get_service_host,
get_services_needing_migration,
get_services_not_in_state,
remove_service,
remove_service_from_host,
)
@@ -45,14 +42,7 @@ def up(
config: ConfigOption = None,
) -> None:
"""Start services (docker compose up -d). Auto-migrates if host changed."""
svc_list, cfg = get_services(services or [], all_services, config)
# Per-host operation: run on specific host only
if host:
run_host_operation(cfg, svc_list, host, "up -d", "Starting", add_service_to_host)
return
# Normal operation: use up_services with migration logic
svc_list, cfg = get_services(services or [], all_services, config, host=host)
results = run_async(up_services(cfg, svc_list, raw=True))
maybe_regenerate_traefik(cfg, results)
report_results(results)
@@ -72,7 +62,7 @@ def down(
config: ConfigOption = None,
) -> None:
"""Stop services (docker compose down)."""
# Handle --orphaned flag
# Handle --orphaned flag (mutually exclusive with other selection methods)
if orphaned:
if services or all_services or host:
print_error(
@@ -95,14 +85,7 @@ def down(
report_results(results)
return
svc_list, cfg = get_services(services or [], all_services, config)
# Per-host operation: run on specific host only
if host:
run_host_operation(cfg, svc_list, host, "down", "Stopping", remove_service_from_host)
return
# Normal operation
svc_list, cfg = get_services(services or [], all_services, config, host=host)
raw = len(svc_list) == 1
results = run_async(run_on_services(cfg, svc_list, "down", raw=raw))

View File

@@ -149,8 +149,14 @@ def _check_ssh_connectivity(cfg: Config) -> list[str]:
async def check_host(host_name: str) -> tuple[str, bool]:
host = cfg.hosts[host_name]
result = await run_command(host, "echo ok", host_name, stream=False)
return host_name, result.success
try:
result = await asyncio.wait_for(
run_command(host, "echo ok", host_name, stream=False),
timeout=5.0,
)
return host_name, result.success
except TimeoutError:
return host_name, False
results = run_parallel_with_progress(
"Checking SSH connectivity",

View File

@@ -20,9 +20,8 @@ from compose_farm.cli.common import (
report_results,
run_async,
run_parallel_with_progress,
validate_host,
)
from compose_farm.console import console, print_error, print_warning
from compose_farm.console import console
from compose_farm.executor import run_command, run_on_services
from compose_farm.state import get_services_needing_migration, group_services_by_host, load_state
@@ -127,22 +126,7 @@ def logs(
config: ConfigOption = None,
) -> None:
"""Show service logs."""
if all_services and host is not None:
print_error("Cannot combine [bold]--all[/] and [bold]--host[/]")
raise typer.Exit(1)
cfg = load_config_or_exit(config)
# Determine service list based on options
if host is not None:
validate_host(cfg, host)
# Include services where host is in the list of configured hosts
svc_list = [s for s in cfg.services if host in cfg.get_hosts(s)]
if not svc_list:
print_warning(f"No services configured for host [magenta]{host}[/]")
return
else:
svc_list, cfg = get_services(services or [], all_services, config)
svc_list, cfg = get_services(services or [], all_services, config, host=host)
# Default to fewer lines when showing multiple services
many_services = all_services or host is not None or len(svc_list) > 1
@@ -156,11 +140,19 @@ def logs(
@app.command(rich_help_panel="Monitoring")
def ps(
services: ServicesArg = None,
all_services: AllOption = False,
host: HostOption = None,
config: ConfigOption = None,
) -> None:
"""Show status of all services."""
cfg = load_config_or_exit(config)
results = run_async(run_on_services(cfg, list(cfg.services.keys()), "ps"))
"""Show status of services.
Without arguments: shows all services (same as --all).
With service names: shows only those services.
With --host: shows services on that host.
"""
svc_list, cfg = get_services(services or [], all_services, config, host=host, default_all=True)
results = run_async(run_on_services(cfg, svc_list, "ps"))
report_results(results)

View File

@@ -2,14 +2,20 @@
from __future__ import annotations
import asyncio
import subprocess
from typing import Annotated
from typing import TYPE_CHECKING, Annotated
import typer
from compose_farm.cli.app import app
from compose_farm.cli.common import ConfigOption, load_config_or_exit
from compose_farm.cli.common import ConfigOption, load_config_or_exit, run_parallel_with_progress
from compose_farm.console import console, err_console
from compose_farm.executor import run_command
if TYPE_CHECKING:
from compose_farm.config import Host
from compose_farm.ssh_keys import (
SSH_KEY_PATH,
SSH_PUBKEY_PATH,
@@ -123,7 +129,7 @@ def ssh_keygen(
) -> None:
"""Generate SSH key (does not distribute to hosts).
Creates an ED25519 key at ~/.ssh/compose-farm with no passphrase.
Creates an ED25519 key at ~/.ssh/compose-farm/id_ed25519 with no passphrase.
Use 'cf ssh setup' to also distribute the key to all configured hosts.
"""
success = _generate_key(force=force)
@@ -138,8 +144,8 @@ def ssh_setup(
) -> None:
"""Generate SSH key and distribute to all configured hosts.
Creates an ED25519 key at ~/.ssh/compose-farm (no passphrase) and
copies the public key to authorized_keys on each host.
Creates an ED25519 key at ~/.ssh/compose-farm/id_ed25519 (no passphrase)
and copies the public key to authorized_keys on each host.
For each host, tries SSH agent first. If agent is unavailable,
prompts for password.
@@ -192,7 +198,7 @@ def ssh_setup(
@ssh_app.command("status")
def ssh_status( # noqa: PLR0912 - branches are clear and readable
def ssh_status(
config: ConfigOption = None,
) -> None:
"""Show SSH key status and host connectivity."""
@@ -232,50 +238,42 @@ def ssh_status( # noqa: PLR0912 - branches are clear and readable
console.print(" [dim]No remote hosts configured[/]")
return
async def check_host(item: tuple[str, Host]) -> tuple[str, str, str]:
"""Check connectivity to a single host."""
host_name, host = item
target = f"{host.user}@{host.address}"
if host.port != _DEFAULT_SSH_PORT:
target += f":{host.port}"
try:
result = await asyncio.wait_for(
run_command(host, "echo ok", host_name, stream=False),
timeout=5.0,
)
status = "[green]OK[/]" if result.success else "[red]Auth failed[/]"
except TimeoutError:
status = "[red]Timeout (5s)[/]"
except Exception as e:
status = f"[red]Error: {e}[/]"
return host_name, target, status
# Check connectivity in parallel with progress bar
results = run_parallel_with_progress(
"Checking hosts",
list(remote_hosts.items()),
check_host,
)
# Build table from results
table = Table(show_header=True, header_style="bold")
table.add_column("Host")
table.add_column("Address")
table.add_column("Status")
for host_name, host in remote_hosts.items():
target = f"{host.user}@{host.address}"
if host.port != _DEFAULT_SSH_PORT:
target += f":{host.port}"
# Test connectivity with a simple command
cmd = [
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"BatchMode=yes", # Fail immediately if password required
"-o",
"ConnectTimeout=5",
]
# Add key file if it exists
if key_exists():
cmd.extend(["-i", str(SSH_KEY_PATH)])
if host.port != _DEFAULT_SSH_PORT:
cmd.extend(["-p", str(host.port)])
cmd.extend([f"{host.user}@{host.address}", "echo ok"])
try:
result = subprocess.run(
cmd, check=False, capture_output=True, timeout=10, env=get_ssh_env()
)
if result.returncode == 0:
table.add_row(host_name, target, "[green]OK[/]")
else:
table.add_row(host_name, target, "[red]Auth failed[/]")
except subprocess.TimeoutExpired:
table.add_row(host_name, target, "[red]Timeout[/]")
except Exception as e:
table.add_row(host_name, target, f"[red]Error: {e}[/]")
# Sort by host name for consistent order
for host_name, target, status in sorted(results, key=lambda r: r[0]):
table.add_row(host_name, target, status)
console.print(table)

View File

@@ -7,14 +7,14 @@ from __future__ import annotations
import os
import re
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from pathlib import Path
from .config import Config
# Port parsing constants
@@ -141,23 +141,42 @@ def _resolve_host_path(host_path: str, compose_dir: Path) -> str | None:
return None # Named volume
def _is_socket(path: str) -> bool:
"""Check if a path is a socket (e.g., SSH agent socket)."""
try:
return stat.S_ISSOCK(Path(path).stat().st_mode)
except (FileNotFoundError, PermissionError, OSError):
return False
def _parse_volume_item(
item: str | dict[str, Any],
env: dict[str, str],
compose_dir: Path,
) -> str | None:
"""Parse a single volume item and return host path if it's a bind mount."""
"""Parse a single volume item and return host path if it's a bind mount.
Skips socket paths (e.g., SSH_AUTH_SOCK) since they're machine-local
and shouldn't be validated on remote hosts.
"""
host_path: str | None = None
if isinstance(item, str):
interpolated = _interpolate(item, env)
parts = interpolated.split(":")
if len(parts) >= _MIN_VOLUME_PARTS:
return _resolve_host_path(parts[0], compose_dir)
host_path = _resolve_host_path(parts[0], compose_dir)
elif isinstance(item, dict) and item.get("type") == "bind":
source = item.get("source")
if source:
interpolated = _interpolate(str(source), env)
return _resolve_host_path(interpolated, compose_dir)
return None
host_path = _resolve_host_path(interpolated, compose_dir)
# Skip sockets - they're machine-local (e.g., SSH agent)
if host_path and _is_socket(host_path):
return None
return host_path
def parse_host_volumes(config: Config, service: str) -> list[str]:

View File

@@ -23,6 +23,43 @@ LOCAL_ADDRESSES = frozenset({"local", "localhost", "127.0.0.1", "::1"})
_DEFAULT_SSH_PORT = 22
def build_ssh_command(host: Host, command: str, *, tty: bool = False) -> list[str]:
"""Build SSH command args for executing a command on a remote host.
Args:
host: Host configuration with address, port, user
command: Command to run on the remote host
tty: Whether to allocate a TTY (for interactive/progress bar commands)
Returns:
List of command args suitable for subprocess
"""
ssh_args = [
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR",
]
if tty:
ssh_args.insert(1, "-tt") # Force TTY allocation
key_path = get_key_path()
if key_path:
ssh_args.extend(["-i", str(key_path)])
if host.port != _DEFAULT_SSH_PORT:
ssh_args.extend(["-p", str(host.port)])
ssh_args.append(f"{host.user}@{host.address}")
ssh_args.append(command)
return ssh_args
@lru_cache(maxsize=1)
def _get_local_ips() -> frozenset[str]:
"""Get all IP addresses of the current machine."""
@@ -172,23 +209,7 @@ async def _run_ssh_command(
"""Run a command on a remote host via SSH with streaming output."""
if raw:
# Use native ssh with TTY for proper progress bar rendering
ssh_args = [
"ssh",
"-tt", # Force TTY allocation even without stdin TTY
"-o",
"StrictHostKeyChecking=no", # Match asyncssh known_hosts=None behavior
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR", # Suppress warnings about known_hosts
]
# Add key file if it exists (fallback for when agent is unavailable)
key_path = get_key_path()
if key_path:
ssh_args.extend(["-i", str(key_path)])
if host.port != _DEFAULT_SSH_PORT:
ssh_args.extend(["-p", str(host.port)])
ssh_args.extend([f"{host.user}@{host.address}", command])
ssh_args = build_ssh_command(host, command, tty=True)
# Run in thread to avoid blocking the event loop
# Use get_ssh_env() to auto-detect SSH agent socket
result = await asyncio.to_thread(subprocess.run, ssh_args, check=False, env=get_ssh_env())

View File

@@ -6,7 +6,9 @@ import os
from pathlib import Path
# Default key paths for compose-farm SSH key
SSH_KEY_PATH = Path.home() / ".ssh" / "compose-farm"
# Keys are stored in a subdirectory for cleaner docker volume mounting
SSH_KEY_DIR = Path.home() / ".ssh" / "compose-farm"
SSH_KEY_PATH = SSH_KEY_DIR / "id_ed25519"
SSH_PUBKEY_PATH = SSH_KEY_PATH.with_suffix(".pub")

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import sys
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
@@ -12,19 +13,35 @@ from pydantic import ValidationError
from compose_farm.web.deps import STATIC_DIR, get_config
from compose_farm.web.routes import actions, api, pages
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
async def _task_cleanup_loop() -> None:
"""Periodically clean up stale completed tasks."""
while True:
await asyncio.sleep(TASK_TTL_SECONDS // 2) # Run every 5 minutes
cleanup_stale_tasks()
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
# Startup: pre-load config (ignore errors - handled per-request)
with suppress(ValidationError, FileNotFoundError):
get_config()
# Start background cleanup task
cleanup_task = asyncio.create_task(_task_cleanup_loop())
yield
# Shutdown: nothing to clean up
# Shutdown: cancel cleanup task
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
def create_app() -> FastAPI:

View File

@@ -17,6 +17,10 @@ const editors = {};
let monacoLoaded = false;
let monacoLoading = false;
// LocalStorage key prefix for active tasks (scoped by page)
const TASK_KEY_PREFIX = 'cf_task:';
const getTaskKey = () => TASK_KEY_PREFIX + window.location.pathname;
// Language detection from file path
const LANGUAGE_MAP = {
'yaml': 'yaml', 'yml': 'yaml',
@@ -131,11 +135,18 @@ function initTerminal(elementId, taskId) {
const { term, fitAddon } = createTerminal(container);
const ws = createWebSocket(`/ws/terminal/${taskId}`);
const taskKey = getTaskKey();
ws.onopen = () => {
term.write(`${ANSI.DIM}[Connected]${ANSI.RESET}${ANSI.CRLF}`);
setTerminalLoading(true);
localStorage.setItem(taskKey, taskId);
};
ws.onmessage = (event) => {
term.write(event.data);
if (event.data.includes('[Done]') || event.data.includes('[Failed]')) {
localStorage.removeItem(taskKey);
}
};
ws.onmessage = (event) => term.write(event.data);
ws.onclose = () => setTerminalLoading(false);
ws.onerror = (error) => {
term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`);
@@ -407,16 +418,50 @@ function initPage() {
initSaveButton();
}
/**
* Attempt to reconnect to an active task from localStorage
*/
function tryReconnectToTask() {
const taskId = localStorage.getItem(getTaskKey());
if (!taskId) return;
// Wait for xterm to be loaded
const tryInit = (attempts) => {
if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') {
expandTerminal();
initTerminal('terminal-output', taskId);
} else if (attempts > 0) {
setTimeout(() => tryInit(attempts - 1), 100);
}
};
tryInit(20);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initPage();
initKeyboardShortcuts();
// Try to reconnect to any active task
tryReconnectToTask();
// Handle ?action= parameter (from command palette navigation)
const params = new URLSearchParams(window.location.search);
const action = params.get('action');
if (action && window.location.pathname === '/') {
// Clear the URL parameter
history.replaceState({}, '', '/');
// Trigger the action
htmx.ajax('POST', `/api/${action}`, {swap: 'none'});
}
});
// Re-initialize after HTMX swaps main content
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'main-content') {
initPage();
// Try to reconnect when navigating back to dashboard
tryReconnectToTask();
}
});
@@ -491,21 +536,29 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
});
}
const colors = { service: '#22c55e', action: '#eab308', nav: '#3b82f6' };
const colors = { service: '#22c55e', action: '#eab308', nav: '#3b82f6', app: '#a855f7' };
let commands = [];
let filtered = [];
let selected = 0;
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
const nav = (url) => () => window.location.href = url;
// Navigate to dashboard and trigger action (or just POST if already on dashboard)
const dashboardAction = (endpoint) => () => {
if (window.location.pathname === '/') {
htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
} else {
window.location.href = `/?action=${endpoint}`;
}
};
const cmd = (type, name, desc, action, icon = null) => ({ type, name, desc, action, icon });
function buildCommands() {
const actions = [
cmd('action', 'Apply', 'Make reality match config', post('/api/apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', post('/api/refresh'), icons.refresh_cw),
cmd('nav', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
cmd('nav', 'Console', 'Go to console', nav('/console'), icons.terminal),
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
cmd('app', 'Console', 'Go to console', nav('/console'), icons.terminal),
];
// Add service-specific actions if on a service page
@@ -545,6 +598,9 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
<span class="opacity-40 text-xs">${c.desc}</span>
</a>
`).join('') || '<div class="opacity-50 p-2">No matches</div>';
// Scroll selected item into view
const sel = list.querySelector(`[data-idx="${selected}"]`);
if (sel) sel.scrollIntoView({ block: 'nearest' });
}
function open() {

View File

@@ -4,13 +4,18 @@ from __future__ import annotations
import asyncio
import os
import time
from typing import TYPE_CHECKING, Any
from compose_farm.executor import build_ssh_command
from compose_farm.ssh_keys import get_ssh_auth_sock
if TYPE_CHECKING:
from compose_farm.config import Config
# Environment variable to identify the web service (for self-update detection)
CF_WEB_SERVICE = os.environ.get("CF_WEB_SERVICE", "")
# ANSI escape codes for terminal output
RED = "\x1b[31m"
GREEN = "\x1b[32m"
@@ -21,6 +26,25 @@ CRLF = "\r\n"
# In-memory task registry
tasks: dict[str, dict[str, Any]] = {}
# How long to keep completed tasks (10 minutes)
TASK_TTL_SECONDS = 600
def cleanup_stale_tasks() -> int:
"""Remove tasks that completed more than TASK_TTL_SECONDS ago.
Returns the number of tasks removed.
"""
cutoff = time.time() - TASK_TTL_SECONDS
stale = [
tid
for tid, task in tasks.items()
if task.get("completed_at") and task["completed_at"] < cutoff
]
for tid in stale:
tasks.pop(tid, None)
return len(stale)
async def stream_to_task(task_id: str, message: str) -> None:
"""Send a message to a task's output buffer."""
@@ -73,10 +97,86 @@ async def run_cli_streaming(
exit_code = await process.wait()
tasks[task_id]["status"] = "completed" if exit_code == 0 else "failed"
tasks[task_id]["completed_at"] = time.time()
except Exception as e:
await stream_to_task(task_id, f"{RED}Error: {e}{RESET}{CRLF}")
tasks[task_id]["status"] = "failed"
tasks[task_id]["completed_at"] = time.time()
def _is_self_update(service: str, command: str) -> bool:
"""Check if this is a self-update (updating the web service itself).
Self-updates need special handling because running 'down' on the container
we're running in would kill the process before 'up' can execute.
"""
if not CF_WEB_SERVICE or service != CF_WEB_SERVICE:
return False
# Commands that involve 'down' need SSH: update, restart, down
return command in ("update", "restart", "down")
async def _run_cli_via_ssh(
config: Config,
args: list[str],
task_id: str,
) -> None:
"""Run a cf CLI command via SSH to the host.
Used for self-updates to ensure the command survives container restart.
"""
try:
# Get the host for the web service
host = config.get_host(CF_WEB_SERVICE)
# Build the remote command - prepend common install locations to PATH
# since non-interactive SSH doesn't source profile files
cf_cmd = f"cf {' '.join(args)} --config={config.config_path}"
remote_cmd = f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH {cf_cmd}"
# Show what we're doing (display the cf command, not the bash wrapper)
await stream_to_task(
task_id,
f"{DIM}$ ssh {host.user}@{host.address} {cf_cmd}{RESET}{CRLF}",
)
await stream_to_task(
task_id,
f"{GREEN}Running via SSH (self-update protection){RESET}{CRLF}",
)
# Build SSH command using shared helper (tty=True for progress bars)
ssh_args = build_ssh_command(host, remote_cmd, tty=True)
# Set up environment with SSH agent
env = {**os.environ, "FORCE_COLOR": "1", "TERM": "xterm-256color"}
ssh_sock = get_ssh_auth_sock()
if ssh_sock:
env["SSH_AUTH_SOCK"] = ssh_sock
process = await asyncio.create_subprocess_exec(
*ssh_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=env,
)
# Stream output
if process.stdout:
async for line in process.stdout:
text = line.decode("utf-8", errors="replace")
if text.endswith("\n") and not text.endswith("\r\n"):
text = text[:-1] + "\r\n"
await stream_to_task(task_id, text)
exit_code = await process.wait()
tasks[task_id]["status"] = "completed" if exit_code == 0 else "failed"
tasks[task_id]["completed_at"] = time.time()
except Exception as e:
await stream_to_task(task_id, f"{RED}Error: {e}{RESET}{CRLF}")
tasks[task_id]["status"] = "failed"
tasks[task_id]["completed_at"] = time.time()
async def run_compose_streaming(
@@ -93,4 +193,9 @@ async def run_compose_streaming(
# Build CLI args
cli_args = [cli_cmd, service, *extra_args]
await run_cli_streaming(config, cli_args, task_id)
# Use SSH for self-updates to survive container restart
if _is_self_update(service, cli_cmd):
await _run_cli_via_ssh(config, cli_args, task_id)
else:
await run_cli_streaming(config, cli_args, task_id)

View File

@@ -285,5 +285,4 @@ async def terminal_websocket(websocket: WebSocket, task_id: str) -> None:
await asyncio.sleep(0.05)
except WebSocketDisconnect:
pass
finally:
tasks.pop(task_id, None)
# Task stays in memory for reconnection; cleanup_stale_tasks() handles expiry

View File

@@ -150,7 +150,7 @@ class TestLogsHostFilter:
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):
@@ -174,7 +174,7 @@ class TestLogsHostFilter:
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):