mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-11 09:24:29 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
967d68b14a | ||
|
|
b7614aeab7 | ||
|
|
d931784935 | ||
|
|
4755065229 | ||
|
|
e86bbf7681 | ||
|
|
be136eb916 | ||
|
|
78a223878f | ||
|
|
f5be23d626 | ||
|
|
3bdc483c2a | ||
|
|
3a3591a0f7 | ||
|
|
7f8ea49d7f | ||
|
|
1e67bde96c | ||
|
|
d8353dbb7e | ||
|
|
2e6146a94b | ||
|
|
87849a8161 | ||
|
|
c8bf792a9a | ||
|
|
d37295fbee | ||
|
|
266f541d35 | ||
|
|
aabdd550ba | ||
|
|
8ff60a1e3e | ||
|
|
2497bd727a | ||
|
|
e37d9d87ba | ||
|
|
80a1906d90 | ||
|
|
282de12336 | ||
|
|
2c5308aea3 |
@@ -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").
|
||||
@@ -53,6 +57,11 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
|
||||
- **NEVER merge anything into main.** Always commit directly or use fast-forward/rebase.
|
||||
- Never force push.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Never include unchecked checklists (e.g., `- [ ] ...`) in PR descriptions. Either omit the checklist or use checked items.
|
||||
- **NEVER run `gh pr merge`**. PRs are merged via the GitHub UI, not the CLI.
|
||||
|
||||
## Releases
|
||||
|
||||
Use `gh release create` to create releases. The tag is created automatically.
|
||||
|
||||
89
README.md
89
README.md
@@ -23,6 +23,9 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
|
||||
- [Best practices](#best-practices)
|
||||
- [What Compose Farm doesn't do](#what-compose-farm-doesnt-do)
|
||||
- [Installation](#installation)
|
||||
- [SSH Authentication](#ssh-authentication)
|
||||
- [SSH Agent (default)](#ssh-agent-default)
|
||||
- [Dedicated SSH Key (recommended for Docker/Web UI)](#dedicated-ssh-key-recommended-for-dockerweb-ui)
|
||||
- [Configuration](#configuration)
|
||||
- [Multi-Host Services](#multi-host-services)
|
||||
- [Config Command](#config-command)
|
||||
@@ -159,6 +162,62 @@ docker run --rm \
|
||||
|
||||
</details>
|
||||
|
||||
## SSH Authentication
|
||||
|
||||
Compose Farm uses SSH to run commands on remote hosts. There are two authentication methods:
|
||||
|
||||
### SSH Agent (default)
|
||||
|
||||
Works out of the box if you have an SSH agent running with your keys loaded:
|
||||
|
||||
```bash
|
||||
# Verify your agent has keys
|
||||
ssh-add -l
|
||||
|
||||
# Run compose-farm commands
|
||||
cf up --all
|
||||
```
|
||||
|
||||
### Dedicated SSH Key (recommended for Docker/Web UI)
|
||||
|
||||
When running compose-farm in Docker, the SSH agent connection can be lost (e.g., after container restart). The `cf ssh` command sets up a dedicated key that persists:
|
||||
|
||||
```bash
|
||||
# Generate key and copy to all configured hosts
|
||||
cf ssh setup
|
||||
|
||||
# Check status
|
||||
cf ssh status
|
||||
```
|
||||
|
||||
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. Choose ONE option and use it for both `cf` and `web` services:
|
||||
|
||||
**Option 1: Host path (default)** - keys at `~/.ssh/compose-farm/id_ed25519`
|
||||
```yaml
|
||||
volumes:
|
||||
- ~/.ssh/compose-farm:/root/.ssh
|
||||
```
|
||||
|
||||
**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>
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory):
|
||||
@@ -344,10 +403,11 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ check Validate configuration, traefik labels, mounts, and networks. │
|
||||
│ init-network Create Docker network on hosts with consistent settings. │
|
||||
│ config Manage compose-farm configuration files. │
|
||||
│ ssh Manage SSH keys for passwordless authentication. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ 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 ─────────────────────────────────────────────────────────────────────╮
|
||||
@@ -773,6 +833,21 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>See the output of <code>cf ssh --help</code></summary>
|
||||
|
||||
<!-- CODE:BASH:START -->
|
||||
<!-- echo '```yaml' -->
|
||||
<!-- export NO_COLOR=1 -->
|
||||
<!-- export TERM=dumb -->
|
||||
<!-- export TERMINAL_WIDTH=90 -->
|
||||
<!-- cf ssh --help -->
|
||||
<!-- echo '```' -->
|
||||
<!-- CODE:END -->
|
||||
|
||||
</details>
|
||||
|
||||
**Monitoring**
|
||||
|
||||
<details>
|
||||
@@ -829,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. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -5,6 +5,12 @@ services:
|
||||
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
|
||||
# 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`)
|
||||
# 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)
|
||||
@@ -17,9 +23,16 @@ services:
|
||||
volumes:
|
||||
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
|
||||
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
|
||||
# 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}`)
|
||||
@@ -33,3 +46,7 @@ services:
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
cf-ssh:
|
||||
# Only used if Option 2 is selected above
|
||||
|
||||
@@ -8,6 +8,7 @@ from compose_farm.cli import (
|
||||
lifecycle, # noqa: F401
|
||||
management, # noqa: F401
|
||||
monitoring, # noqa: F401
|
||||
ssh, # noqa: F401
|
||||
web, # noqa: F401
|
||||
)
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
282
src/compose_farm/cli/ssh.py
Normal file
282
src/compose_farm/cli/ssh.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""SSH key management commands for compose-farm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
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, 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,
|
||||
get_pubkey_content,
|
||||
get_ssh_env,
|
||||
key_exists,
|
||||
)
|
||||
|
||||
_DEFAULT_SSH_PORT = 22
|
||||
_PUBKEY_DISPLAY_THRESHOLD = 60
|
||||
|
||||
ssh_app = typer.Typer(
|
||||
name="ssh",
|
||||
help="Manage SSH keys for passwordless authentication.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
_ForceOption = Annotated[
|
||||
bool,
|
||||
typer.Option("--force", "-f", help="Regenerate key even if it exists."),
|
||||
]
|
||||
|
||||
|
||||
def _generate_key(*, force: bool = False) -> bool:
|
||||
"""Generate an ED25519 SSH key with no passphrase.
|
||||
|
||||
Returns True if key was generated, False if skipped.
|
||||
"""
|
||||
if key_exists() and not force:
|
||||
console.print(f"[yellow]![/] SSH key already exists: {SSH_KEY_PATH}")
|
||||
console.print("[dim]Use --force to regenerate[/]")
|
||||
return False
|
||||
|
||||
# Create .ssh directory if it doesn't exist
|
||||
SSH_KEY_PATH.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
|
||||
# Remove existing key if forcing regeneration
|
||||
if force:
|
||||
SSH_KEY_PATH.unlink(missing_ok=True)
|
||||
SSH_PUBKEY_PATH.unlink(missing_ok=True)
|
||||
|
||||
console.print(f"[dim]Generating SSH key at {SSH_KEY_PATH}...[/]")
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[ # noqa: S607
|
||||
"ssh-keygen",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"", # No passphrase
|
||||
"-f",
|
||||
str(SSH_KEY_PATH),
|
||||
"-C",
|
||||
"compose-farm",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
err_console.print(f"[red]Failed to generate SSH key:[/] {e.stderr.decode()}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
err_console.print("[red]ssh-keygen not found. Is OpenSSH installed?[/]")
|
||||
return False
|
||||
|
||||
# Set correct permissions
|
||||
SSH_KEY_PATH.chmod(0o600)
|
||||
SSH_PUBKEY_PATH.chmod(0o644)
|
||||
|
||||
console.print(f"[green]Generated SSH key:[/] {SSH_KEY_PATH}")
|
||||
return True
|
||||
|
||||
|
||||
def _copy_key_to_host(host_name: str, address: str, user: str, port: int) -> bool:
|
||||
"""Copy public key to a host's authorized_keys.
|
||||
|
||||
Uses ssh-copy-id which handles agent vs password fallback automatically.
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
target = f"{user}@{address}"
|
||||
console.print(f"[dim]Copying key to {host_name} ({target})...[/]")
|
||||
|
||||
cmd = ["ssh-copy-id"]
|
||||
|
||||
# Disable strict host key checking (consistent with executor.py)
|
||||
cmd.extend(["-o", "StrictHostKeyChecking=no"])
|
||||
cmd.extend(["-o", "UserKnownHostsFile=/dev/null"])
|
||||
|
||||
if port != _DEFAULT_SSH_PORT:
|
||||
cmd.extend(["-p", str(port)])
|
||||
|
||||
cmd.extend(["-i", str(SSH_PUBKEY_PATH), target])
|
||||
|
||||
try:
|
||||
# Don't capture output so user can see password prompt
|
||||
result = subprocess.run(cmd, check=False, env=get_ssh_env())
|
||||
if result.returncode == 0:
|
||||
console.print(f"[green]Key copied to {host_name}[/]")
|
||||
return True
|
||||
err_console.print(f"[red]Failed to copy key to {host_name}[/]")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
err_console.print("[red]ssh-copy-id not found. Is OpenSSH installed?[/]")
|
||||
return False
|
||||
|
||||
|
||||
@ssh_app.command("keygen")
|
||||
def ssh_keygen(
|
||||
force: _ForceOption = False,
|
||||
) -> None:
|
||||
"""Generate SSH key (does not distribute to hosts).
|
||||
|
||||
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)
|
||||
if not success and not key_exists():
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@ssh_app.command("setup")
|
||||
def ssh_setup(
|
||||
config: ConfigOption = None,
|
||||
force: _ForceOption = False,
|
||||
) -> None:
|
||||
"""Generate SSH key and distribute to all configured hosts.
|
||||
|
||||
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.
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
|
||||
# Skip localhost hosts
|
||||
remote_hosts = {
|
||||
name: host
|
||||
for name, host in cfg.hosts.items()
|
||||
if host.address.lower() not in ("localhost", "127.0.0.1")
|
||||
}
|
||||
|
||||
if not remote_hosts:
|
||||
console.print("[yellow]No remote hosts configured.[/]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Generate key if needed
|
||||
if not key_exists() or force:
|
||||
if not _generate_key(force=force):
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print(f"[dim]Using existing key: {SSH_KEY_PATH}[/]")
|
||||
|
||||
console.print()
|
||||
console.print(f"[bold]Distributing key to {len(remote_hosts)} host(s)...[/]")
|
||||
console.print()
|
||||
|
||||
# Copy key to each host
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for host_name, host in remote_hosts.items():
|
||||
if _copy_key_to_host(host_name, host.address, host.user, host.port):
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
console.print()
|
||||
if failed == 0:
|
||||
console.print(
|
||||
f"[green]Setup complete.[/] {succeeded}/{len(remote_hosts)} hosts configured."
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[yellow]Setup partially complete.[/] {succeeded}/{len(remote_hosts)} hosts configured, "
|
||||
f"[red]{failed} failed[/]."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@ssh_app.command("status")
|
||||
def ssh_status(
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Show SSH key status and host connectivity."""
|
||||
from rich.table import Table # noqa: PLC0415
|
||||
|
||||
cfg = load_config_or_exit(config)
|
||||
|
||||
# Key status
|
||||
console.print("[bold]SSH Key Status[/]")
|
||||
console.print()
|
||||
|
||||
if key_exists():
|
||||
console.print(f" [green]Key exists:[/] {SSH_KEY_PATH}")
|
||||
pubkey = get_pubkey_content()
|
||||
if pubkey:
|
||||
# Show truncated public key
|
||||
if len(pubkey) > _PUBKEY_DISPLAY_THRESHOLD:
|
||||
console.print(f" [dim]Public key:[/] {pubkey[:30]}...{pubkey[-20:]}")
|
||||
else:
|
||||
console.print(f" [dim]Public key:[/] {pubkey}")
|
||||
else:
|
||||
console.print(f" [yellow]No key found:[/] {SSH_KEY_PATH}")
|
||||
console.print(" [dim]Run 'cf ssh setup' to generate and distribute a key[/]")
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Host Connectivity[/]")
|
||||
console.print()
|
||||
|
||||
# Skip localhost hosts
|
||||
remote_hosts = {
|
||||
name: host
|
||||
for name, host in cfg.hosts.items()
|
||||
if host.address.lower() not in ("localhost", "127.0.0.1")
|
||||
}
|
||||
|
||||
if not remote_hosts:
|
||||
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")
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Register ssh subcommand on the shared app
|
||||
app.add_typer(ssh_app, name="ssh", rich_help_panel="Configuration")
|
||||
@@ -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]:
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from rich.markup import escape
|
||||
|
||||
from .console import console, err_console
|
||||
from .ssh_keys import get_key_path, get_ssh_auth_sock, get_ssh_env
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -22,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."""
|
||||
@@ -73,12 +111,21 @@ def is_local(host: Host) -> bool:
|
||||
|
||||
def ssh_connect_kwargs(host: Host) -> dict[str, Any]:
|
||||
"""Get kwargs for asyncssh.connect() from a Host config."""
|
||||
return {
|
||||
kwargs: dict[str, Any] = {
|
||||
"host": host.address,
|
||||
"port": host.port,
|
||||
"username": host.user,
|
||||
"known_hosts": None,
|
||||
}
|
||||
# Add SSH agent path (auto-detect forwarded agent if needed)
|
||||
agent_path = get_ssh_auth_sock()
|
||||
if agent_path:
|
||||
kwargs["agent_path"] = agent_path
|
||||
# Add key file fallback for when SSH agent is unavailable
|
||||
key_path = get_key_path()
|
||||
if key_path:
|
||||
kwargs["client_keys"] = [str(key_path)]
|
||||
return kwargs
|
||||
|
||||
|
||||
async def _run_local_command(
|
||||
@@ -162,21 +209,10 @@ 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
|
||||
]
|
||||
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
|
||||
result = await asyncio.to_thread(subprocess.run, ssh_args, check=False)
|
||||
# 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())
|
||||
return CommandResult(
|
||||
service=service,
|
||||
exit_code=result.returncode,
|
||||
|
||||
67
src/compose_farm/ssh_keys.py
Normal file
67
src/compose_farm/ssh_keys.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""SSH key utilities for compose-farm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Default key paths for compose-farm SSH key
|
||||
# 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")
|
||||
|
||||
|
||||
def get_ssh_auth_sock() -> str | None:
|
||||
"""Get SSH_AUTH_SOCK, auto-detecting forwarded agent if needed.
|
||||
|
||||
Checks in order:
|
||||
1. SSH_AUTH_SOCK environment variable (if socket exists)
|
||||
2. Forwarded agent sockets in ~/.ssh/agent/ (most recent first)
|
||||
|
||||
Returns the socket path or None if no valid socket found.
|
||||
"""
|
||||
sock = os.environ.get("SSH_AUTH_SOCK")
|
||||
if sock and Path(sock).is_socket():
|
||||
return sock
|
||||
|
||||
# Try to find a forwarded SSH agent socket
|
||||
agent_dir = Path.home() / ".ssh" / "agent"
|
||||
if agent_dir.is_dir():
|
||||
sockets = sorted(
|
||||
agent_dir.glob("s.*.sshd.*"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||
)
|
||||
for s in sockets:
|
||||
if s.is_socket():
|
||||
return str(s)
|
||||
return None
|
||||
|
||||
|
||||
def get_ssh_env() -> dict[str, str]:
|
||||
"""Get environment dict for SSH subprocess with auto-detected agent.
|
||||
|
||||
Returns a copy of the current environment with SSH_AUTH_SOCK set
|
||||
to the auto-detected agent socket (if found).
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
sock = get_ssh_auth_sock()
|
||||
if sock:
|
||||
env["SSH_AUTH_SOCK"] = sock
|
||||
return env
|
||||
|
||||
|
||||
def key_exists() -> bool:
|
||||
"""Check if the compose-farm SSH key pair exists."""
|
||||
return SSH_KEY_PATH.exists() and SSH_PUBKEY_PATH.exists()
|
||||
|
||||
|
||||
def get_key_path() -> Path | None:
|
||||
"""Get the SSH key path if it exists, None otherwise."""
|
||||
return SSH_KEY_PATH if key_exists() else None
|
||||
|
||||
|
||||
def get_pubkey_content() -> str | None:
|
||||
"""Get the public key content if it exists, None otherwise."""
|
||||
if not SSH_PUBKEY_PATH.exists():
|
||||
return None
|
||||
return SSH_PUBKEY_PATH.read_text().strip()
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/* Sidebar inputs - remove focus outline (DaisyUI 5 uses outline + outline-offset) */
|
||||
#sidebar .input:focus,
|
||||
#sidebar .input:focus-within,
|
||||
#sidebar .select:focus {
|
||||
outline: none;
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
/* Editors (Monaco) - wrapper makes it resizable */
|
||||
.editor-wrapper {
|
||||
resize: vertical;
|
||||
@@ -53,3 +61,65 @@
|
||||
background-position: 16em center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Command palette FAB - rainbow glow effect */
|
||||
@property --cmd-pos { syntax: "<number>"; inherits: true; initial-value: 100; }
|
||||
@property --cmd-blur { syntax: "<number>"; inherits: true; initial-value: 10; }
|
||||
@property --cmd-scale { syntax: "<number>"; inherits: true; initial-value: 1; }
|
||||
@property --cmd-opacity { syntax: "<number>"; inherits: true; initial-value: 0.3; }
|
||||
|
||||
#cmd-fab {
|
||||
--g: linear-gradient(to right, #fff, #fff, #0ff, #00f, #8000ff, #e066a3, #f00, #ff0, #bfff80, #fff, #fff);
|
||||
all: unset;
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 50;
|
||||
cursor: pointer;
|
||||
transform: scale(var(--cmd-scale));
|
||||
transition: --cmd-pos 3s, --cmd-blur 0.3s, --cmd-opacity 0.3s, --cmd-scale 0.2s cubic-bezier(.76,-.25,.51,1.13);
|
||||
}
|
||||
|
||||
.cmd-fab-inner {
|
||||
display: block;
|
||||
padding: 0.6em 1em;
|
||||
background: #1d232a;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cmd-fab-inner > span {
|
||||
background: var(--g) no-repeat calc(var(--cmd-pos) * 1%) 0 / 900%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.15ch;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cmd-fab-inner::before, .cmd-fab-inner::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cmd-fab-inner::before {
|
||||
inset: -1.5px;
|
||||
background: var(--g) no-repeat calc(var(--cmd-pos) * 1%) 0 / 900%;
|
||||
border-radius: 9px;
|
||||
z-index: -1;
|
||||
opacity: var(--cmd-opacity);
|
||||
}
|
||||
|
||||
.cmd-fab-inner::after {
|
||||
inset: 0;
|
||||
background: #000;
|
||||
transform: translateY(10px);
|
||||
z-index: -2;
|
||||
filter: blur(calc(var(--cmd-blur) * 1px));
|
||||
}
|
||||
|
||||
#cmd-fab:hover { --cmd-scale: 1.05; --cmd-pos: 0; --cmd-blur: 30; --cmd-opacity: 1; }
|
||||
#cmd-fab:hover .cmd-fab-inner::after { background: var(--g); opacity: 0.3; }
|
||||
#cmd-fab:active { --cmd-scale: 0.98; --cmd-blur: 15; }
|
||||
|
||||
@@ -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,57 @@ 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);
|
||||
}
|
||||
|
||||
// Play intro animation on command palette button
|
||||
function playFabIntro() {
|
||||
const fab = document.getElementById('cmd-fab');
|
||||
if (!fab) return;
|
||||
setTimeout(() => {
|
||||
fab.style.setProperty('--cmd-pos', '0');
|
||||
fab.style.setProperty('--cmd-opacity', '1');
|
||||
fab.style.setProperty('--cmd-blur', '30');
|
||||
setTimeout(() => {
|
||||
fab.style.removeProperty('--cmd-pos');
|
||||
fab.style.removeProperty('--cmd-opacity');
|
||||
fab.style.removeProperty('--cmd-blur');
|
||||
}, 3000);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initPage();
|
||||
initKeyboardShortcuts();
|
||||
playFabIntro();
|
||||
|
||||
// Try to reconnect to any active task
|
||||
tryReconnectToTask();
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -482,41 +534,67 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const fab = document.getElementById('cmd-fab');
|
||||
if (!dialog || !input || !list) return;
|
||||
|
||||
const colors = { service: '#22c55e', action: '#eab308', nav: '#3b82f6' };
|
||||
// Load icons from template (rendered server-side from icons.html)
|
||||
const iconTemplate = document.getElementById('cmd-icons');
|
||||
const icons = {};
|
||||
if (iconTemplate) {
|
||||
iconTemplate.content.querySelectorAll('[data-icon]').forEach(el => {
|
||||
icons[el.dataset.icon] = el.innerHTML;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
const cmd = (type, name, desc, action) => ({ type, name, desc, action });
|
||||
const nav = (url) => () => {
|
||||
htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
|
||||
history.pushState({}, '', 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 {
|
||||
// Navigate via HTMX, then trigger action after swap
|
||||
htmx.ajax('GET', '/', {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
|
||||
history.pushState({}, '', '/');
|
||||
htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
|
||||
});
|
||||
}
|
||||
};
|
||||
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')),
|
||||
cmd('action', 'Refresh', 'Update state from reality', post('/api/refresh')),
|
||||
cmd('nav', 'Dashboard', 'Go to dashboard', nav('/')),
|
||||
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
|
||||
const match = window.location.pathname.match(/^\/service\/(.+)$/);
|
||||
if (match) {
|
||||
const svc = decodeURIComponent(match[1]);
|
||||
const svcCmd = (name, desc, endpoint) => cmd('service', name, `${desc} ${svc}`, post(`/api/service/${svc}/${endpoint}`));
|
||||
const svcCmd = (name, desc, endpoint, icon) => cmd('service', name, `${desc} ${svc}`, post(`/api/service/${svc}/${endpoint}`), icon);
|
||||
actions.unshift(
|
||||
svcCmd('Up', 'Start', 'up'),
|
||||
svcCmd('Down', 'Stop', 'down'),
|
||||
svcCmd('Restart', 'Restart', 'restart'),
|
||||
svcCmd('Pull', 'Pull', 'pull'),
|
||||
svcCmd('Update', 'Pull + restart', 'update'),
|
||||
svcCmd('Logs', 'View logs for', 'logs'),
|
||||
svcCmd('Up', 'Start', 'up', icons.play),
|
||||
svcCmd('Down', 'Stop', 'down', icons.square),
|
||||
svcCmd('Restart', 'Restart', 'restart', icons.rotate_cw),
|
||||
svcCmd('Pull', 'Pull', 'pull', icons.cloud_download),
|
||||
svcCmd('Update', 'Pull + restart', 'update', icons.refresh_cw),
|
||||
svcCmd('Logs', 'View logs for', 'logs', icons.file_text),
|
||||
);
|
||||
}
|
||||
|
||||
// Add nav commands for all services from sidebar
|
||||
const services = [...document.querySelectorAll('#sidebar-services li[data-svc] a[href]')].map(a => {
|
||||
const name = a.getAttribute('href').replace('/service/', '');
|
||||
return cmd('nav', name, 'Go to service', nav(`/service/${name}`));
|
||||
return cmd('nav', name, 'Go to service', nav(`/service/${name}`), icons.box);
|
||||
});
|
||||
|
||||
commands = [...actions, ...services];
|
||||
@@ -531,10 +609,13 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
function render() {
|
||||
list.innerHTML = filtered.map((c, i) => `
|
||||
<a class="flex justify-between items-center px-3 py-2 rounded-r cursor-pointer hover:bg-base-200 border-l-4 ${i === selected ? 'bg-base-300' : ''}" style="border-left-color: ${colors[c.type] || '#666'}" data-idx="${i}">
|
||||
<span><span class="opacity-50 text-xs mr-2">${c.type}</span>${c.name}</span>
|
||||
<span class="flex items-center gap-2">${c.icon || ''}<span>${c.name}</span></span>
|
||||
<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() {
|
||||
|
||||
@@ -4,12 +4,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
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"
|
||||
@@ -17,28 +23,28 @@ DIM = "\x1b[2m"
|
||||
RESET = "\x1b[0m"
|
||||
CRLF = "\r\n"
|
||||
|
||||
|
||||
def _get_ssh_auth_sock() -> str | None:
|
||||
"""Get SSH_AUTH_SOCK, auto-detecting forwarded agent if needed."""
|
||||
sock = os.environ.get("SSH_AUTH_SOCK")
|
||||
if sock and Path(sock).is_socket():
|
||||
return sock
|
||||
|
||||
# Try to find a forwarded SSH agent socket
|
||||
agent_dir = Path.home() / ".ssh" / "agent"
|
||||
if agent_dir.is_dir():
|
||||
sockets = sorted(
|
||||
agent_dir.glob("s.*.sshd.*"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||
)
|
||||
for s in sockets:
|
||||
if s.is_socket():
|
||||
return str(s)
|
||||
return None
|
||||
|
||||
|
||||
# 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."""
|
||||
@@ -69,7 +75,7 @@ async def run_cli_streaming(
|
||||
env = {"FORCE_COLOR": "1", "TERM": "xterm-256color", "COLUMNS": "120"}
|
||||
|
||||
# Ensure SSH agent is available (auto-detect if needed)
|
||||
ssh_sock = _get_ssh_auth_sock()
|
||||
ssh_sock = get_ssh_auth_sock()
|
||||
if ssh_sock:
|
||||
env["SSH_AUTH_SOCK"] = ssh_sock
|
||||
|
||||
@@ -91,10 +97,110 @@ 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.
|
||||
Uses setsid to run command in a new session (completely detached), with
|
||||
output going to a log file. We tail the log to stream output. When SSH
|
||||
dies (container killed), the tail dies but the setsid process continues.
|
||||
"""
|
||||
try:
|
||||
# Get the host for the web service
|
||||
host = config.get_host(CF_WEB_SERVICE)
|
||||
|
||||
cf_cmd = f"cf {' '.join(args)} --config={config.config_path}"
|
||||
log_file = "/tmp/cf-self-update.log" # noqa: S108
|
||||
|
||||
# Build the remote command:
|
||||
# 1. setsid runs command in new session (survives SSH disconnect)
|
||||
# 2. Output goes to log file
|
||||
# 3. tail -f streams the log (dies when SSH dies, but command continues)
|
||||
# 4. wait for tail or timeout after command should be done
|
||||
remote_cmd = (
|
||||
f"rm -f {log_file} && "
|
||||
f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH "
|
||||
f"setsid sh -c '{cf_cmd} > {log_file} 2>&1' & "
|
||||
f"sleep 0.3 && "
|
||||
f"tail -f {log_file} 2>/dev/null"
|
||||
)
|
||||
|
||||
# Show what we're doing
|
||||
await stream_to_task(
|
||||
task_id,
|
||||
f"{DIM}$ {cf_cmd}{RESET}{CRLF}",
|
||||
)
|
||||
await stream_to_task(
|
||||
task_id,
|
||||
f"{GREEN}Running via SSH (detached with setsid){RESET}{CRLF}",
|
||||
)
|
||||
|
||||
# Build SSH command (no TTY needed, output comes from tail)
|
||||
ssh_args = build_ssh_command(host, remote_cmd, tty=False)
|
||||
|
||||
# Set up environment with SSH agent
|
||||
env = {**os.environ}
|
||||
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 until SSH dies (container killed) or command completes
|
||||
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()
|
||||
|
||||
# Exit code 255 means SSH connection closed (container died during down)
|
||||
# This is expected for self-updates - setsid ensures command continues
|
||||
if exit_code == 255: # noqa: PLR2004
|
||||
await stream_to_task(
|
||||
task_id,
|
||||
f"{CRLF}{GREEN}Container restarting... refresh the page in a few seconds.{RESET}{CRLF}",
|
||||
)
|
||||
tasks[task_id]["status"] = "completed"
|
||||
else:
|
||||
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(
|
||||
@@ -111,4 +217,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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "partials/components.html" import page_header %}
|
||||
{% from "partials/icons.html" import terminal, save %}
|
||||
{% from "partials/components.html" import page_header, collapse %}
|
||||
{% from "partials/icons.html" import terminal, file_code, save %}
|
||||
{% block title %}Console - Compose Farm{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -20,19 +20,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Terminal -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="font-semibold flex items-center gap-2">{{ terminal() }} Terminal</h3>
|
||||
<span class="text-xs opacity-50">Full shell access to selected host</span>
|
||||
</div>
|
||||
{% call collapse("Terminal", checked=True, icon=terminal(), subtitle="Full shell access to selected host") %}
|
||||
<div id="console-terminal" class="w-full bg-base-300 rounded-lg overflow-hidden resize-y" style="height: 384px; min-height: 200px;"></div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="mb-6">
|
||||
{% call collapse("Editor", checked=True, icon=file_code()) %}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-semibold">Editor</h3>
|
||||
<input type="text" id="console-file-path" class="input input-sm input-bordered w-96" placeholder="Enter file path (e.g., ~/docker-compose.yaml)" value="{{ config_path }}">
|
||||
<button class="btn btn-sm btn-outline" onclick="loadFile()">Open</button>
|
||||
</div>
|
||||
@@ -42,17 +37,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="console-editor" class="resize-y overflow-hidden rounded-lg" style="height: 512px; min-height: 200px;"></div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let consoleTerminal = null;
|
||||
let consoleWs = null;
|
||||
let consoleEditor = null;
|
||||
let currentFilePath = null;
|
||||
let currentHost = null;
|
||||
// Use var to allow re-declaration on HTMX navigation
|
||||
var consoleTerminal = null;
|
||||
var consoleWs = null;
|
||||
var consoleEditor = null;
|
||||
var currentFilePath = null;
|
||||
var currentHost = null;
|
||||
|
||||
// Helper to show status with monospace path
|
||||
function setEditorStatus(prefix, path) {
|
||||
const statusEl = document.getElementById('editor-status');
|
||||
const escaped = path.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
statusEl.innerHTML = `${prefix} <code class="font-mono">${escaped}</code>`;
|
||||
}
|
||||
|
||||
function connectConsole() {
|
||||
const hostSelect = document.getElementById('console-host-select');
|
||||
@@ -156,7 +157,7 @@ async function loadFile() {
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = `Loading ${path}...`;
|
||||
setEditorStatus('Loading', path + '...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/console/file?host=${encodeURIComponent(currentHost)}&path=${encodeURIComponent(path)}`);
|
||||
@@ -173,7 +174,7 @@ async function loadFile() {
|
||||
consoleEditor.setValue(data.content);
|
||||
monaco.editor.setModelLanguage(consoleEditor.getModel(), language);
|
||||
currentFilePath = path; // Only set after content is loaded
|
||||
statusEl.textContent = `Loaded: ${path}`;
|
||||
setEditorStatus('Loaded:', path);
|
||||
} else {
|
||||
statusEl.textContent = 'Editor not ready';
|
||||
}
|
||||
@@ -200,7 +201,7 @@ async function saveFile() {
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = `Saving ${currentFilePath}...`;
|
||||
setEditorStatus('Saving', currentFilePath + '...');
|
||||
|
||||
try {
|
||||
const content = consoleEditor.getValue();
|
||||
@@ -216,31 +217,27 @@ async function saveFile() {
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = `Saved: ${currentFilePath}`;
|
||||
setEditorStatus('Saved:', currentFilePath);
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize editor and auto-connect to first host
|
||||
function init() {
|
||||
initConsoleEditor();
|
||||
// Auto-connect to first host if available
|
||||
const hostSelect = document.getElementById('console-host-select');
|
||||
if (hostSelect && hostSelect.options.length > 0) {
|
||||
connectConsole();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-init after HTMX swap
|
||||
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||
if (evt.detail.target.id === 'main-content') {
|
||||
// Re-init if we're on console page
|
||||
if (window.location.pathname === '/console') {
|
||||
consoleEditor = null;
|
||||
initConsoleEditor();
|
||||
}
|
||||
}
|
||||
});
|
||||
// On HTMX navigation, dependencies (app.js) are already loaded.
|
||||
// On hard refresh, this script runs before app.js, so wait for DOMContentLoaded.
|
||||
if (typeof createTerminal === 'function') {
|
||||
init();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{% from "partials/icons.html" import search %}
|
||||
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, check, home, terminal, box %}
|
||||
|
||||
<!-- Icons for command palette (referenced by JS) -->
|
||||
<template id="cmd-icons">
|
||||
<span data-icon="play">{{ play() }}</span>
|
||||
<span data-icon="square">{{ square() }}</span>
|
||||
<span data-icon="rotate_cw">{{ rotate_cw() }}</span>
|
||||
<span data-icon="cloud_download">{{ cloud_download() }}</span>
|
||||
<span data-icon="refresh_cw">{{ refresh_cw() }}</span>
|
||||
<span data-icon="file_text">{{ file_text() }}</span>
|
||||
<span data-icon="check">{{ check() }}</span>
|
||||
<span data-icon="home">{{ home() }}</span>
|
||||
<span data-icon="terminal">{{ terminal() }}</span>
|
||||
<span data-icon="box">{{ box() }}</span>
|
||||
</template>
|
||||
<dialog id="cmd-palette" class="modal">
|
||||
<div class="modal-box max-w-lg p-0">
|
||||
<label class="input input-lg bg-base-100 border-0 border-b border-base-300 w-full rounded-none rounded-t-box sticky top-0 z-10 focus-within:outline-none">
|
||||
@@ -14,8 +28,8 @@
|
||||
</dialog>
|
||||
|
||||
<!-- Floating button to open command palette -->
|
||||
<button id="cmd-fab" class="btn btn-circle glass shadow-lg fixed bottom-6 right-6 z-50 hover:ring hover:ring-base-content/50" title="Command Palette (⌘K)">
|
||||
<span class="flex items-center gap-0.5 text-sm font-semibold">
|
||||
<span class="opacity-70">⌘</span><span>K</span>
|
||||
</span>
|
||||
<button id="cmd-fab" class="fixed bottom-6 right-6 z-50" title="Command Palette (⌘K)">
|
||||
<div class="cmd-fab-inner">
|
||||
<span>⌘ + K</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
{% endmacro %}
|
||||
|
||||
{# Collapsible section #}
|
||||
{% macro collapse(title, id=None, checked=False, badge=None, icon=None) %}
|
||||
{% macro collapse(title, id=None, checked=False, badge=None, icon=None, subtitle=None) %}
|
||||
<div class="collapse collapse-arrow bg-base-100 shadow mb-4">
|
||||
<input type="checkbox" {% if id %}id="{{ id }}"{% endif %} {% if checked %}checked{% endif %} />
|
||||
<div class="collapse-title font-medium flex items-center gap-2">
|
||||
<div class="collapse-title font-semibold flex items-center gap-2">
|
||||
{% if icon %}{{ icon }}{% endif %}{{ title }}
|
||||
{% if badge %}<code class="text-xs ml-2 opacity-60">{{ badge }}</code>{% endif %}
|
||||
{% if subtitle %}<span class="text-xs opacity-50 font-normal">{{ subtitle }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
{{ caller() }}
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-base-content/60 px-3 py-1">Services <span class="opacity-50" id="sidebar-count">({{ services | length }})</span></h4>
|
||||
<div class="px-2 mb-2 flex flex-col gap-1">
|
||||
<label class="input input-xs input-bordered flex items-center gap-2 bg-base-200">
|
||||
<label class="input input-xs flex items-center gap-2 bg-base-200">
|
||||
{{ search(14) }}<input type="text" id="sidebar-filter" placeholder="Filter..." onkeyup="sidebarFilter()" />
|
||||
</label>
|
||||
<select id="sidebar-host-select" class="select select-xs select-bordered bg-base-200 w-full" onchange="sidebarFilter()">
|
||||
<select id="sidebar-host-select" class="select select-xs bg-base-200 w-full" onchange="sidebarFilter()">
|
||||
<option value="">All hosts</option>
|
||||
{% for h in hosts %}<option value="{{ h }}">{{ h }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -18,7 +18,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from compose_farm.executor import is_local, ssh_connect_kwargs
|
||||
from compose_farm.web.deps import get_config
|
||||
from compose_farm.web.streaming import CRLF, DIM, GREEN, RED, RESET, _get_ssh_auth_sock, tasks
|
||||
from compose_farm.web.streaming import CRLF, DIM, GREEN, RED, RESET, tasks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Host
|
||||
@@ -155,13 +155,10 @@ async def _run_remote_exec(
|
||||
websocket: WebSocket, host: Host, exec_cmd: str, *, agent_forwarding: bool = False
|
||||
) -> None:
|
||||
"""Run docker exec on remote host via SSH with PTY."""
|
||||
# Get SSH agent socket for authentication
|
||||
agent_path = _get_ssh_auth_sock()
|
||||
|
||||
# ssh_connect_kwargs includes agent_path and client_keys fallback
|
||||
async with asyncssh.connect(
|
||||
**ssh_connect_kwargs(host),
|
||||
agent_forwarding=agent_forwarding,
|
||||
agent_path=agent_path,
|
||||
) as conn:
|
||||
proc: asyncssh.SSHClientProcess[Any] = await conn.create_process(
|
||||
exec_cmd,
|
||||
@@ -264,7 +261,9 @@ async def terminal_websocket(websocket: WebSocket, task_id: str) -> None:
|
||||
await websocket.accept()
|
||||
|
||||
if task_id not in tasks:
|
||||
await websocket.send_text(f"{RED}Error: Task not found{RESET}{CRLF}")
|
||||
await websocket.send_text(
|
||||
f"{DIM}Task not found (expired or container restarted).{RESET}{CRLF}"
|
||||
)
|
||||
await websocket.close(code=4004)
|
||||
return
|
||||
|
||||
@@ -288,5 +287,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
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
114
tests/test_cli_ssh.py
Normal file
114
tests/test_cli_ssh.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for CLI ssh commands."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from compose_farm.cli.app import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestSshKeygen:
|
||||
"""Tests for cf ssh keygen command."""
|
||||
|
||||
def test_keygen_generates_key(self, tmp_path: Path) -> None:
|
||||
"""Generate SSH key when none exists."""
|
||||
key_path = tmp_path / "compose-farm"
|
||||
pubkey_path = tmp_path / "compose-farm.pub"
|
||||
|
||||
with (
|
||||
patch("compose_farm.cli.ssh.SSH_KEY_PATH", key_path),
|
||||
patch("compose_farm.cli.ssh.SSH_PUBKEY_PATH", pubkey_path),
|
||||
patch("compose_farm.cli.ssh.key_exists", return_value=False),
|
||||
):
|
||||
result = runner.invoke(app, ["ssh", "keygen"])
|
||||
|
||||
# Command runs (may fail if ssh-keygen not available in test env)
|
||||
assert result.exit_code in (0, 1)
|
||||
|
||||
def test_keygen_skips_if_exists(self, tmp_path: Path) -> None:
|
||||
"""Skip key generation if key already exists."""
|
||||
key_path = tmp_path / "compose-farm"
|
||||
pubkey_path = tmp_path / "compose-farm.pub"
|
||||
|
||||
with (
|
||||
patch("compose_farm.cli.ssh.SSH_KEY_PATH", key_path),
|
||||
patch("compose_farm.cli.ssh.SSH_PUBKEY_PATH", pubkey_path),
|
||||
patch("compose_farm.cli.ssh.key_exists", return_value=True),
|
||||
):
|
||||
result = runner.invoke(app, ["ssh", "keygen"])
|
||||
|
||||
assert "already exists" in result.output
|
||||
|
||||
|
||||
class TestSshStatus:
|
||||
"""Tests for cf ssh status command."""
|
||||
|
||||
def test_status_shows_no_key(self, tmp_path: Path) -> None:
|
||||
"""Show message when no key exists."""
|
||||
config_file = tmp_path / "compose-farm.yaml"
|
||||
config_file.write_text("""
|
||||
hosts:
|
||||
local:
|
||||
address: localhost
|
||||
services:
|
||||
test: local
|
||||
""")
|
||||
|
||||
with patch("compose_farm.cli.ssh.key_exists", return_value=False):
|
||||
result = runner.invoke(app, ["ssh", "status", f"--config={config_file}"])
|
||||
|
||||
assert "No key found" in result.output
|
||||
|
||||
def test_status_shows_key_exists(self, tmp_path: Path) -> None:
|
||||
"""Show key info when key exists."""
|
||||
config_file = tmp_path / "compose-farm.yaml"
|
||||
config_file.write_text("""
|
||||
hosts:
|
||||
local:
|
||||
address: localhost
|
||||
services:
|
||||
test: local
|
||||
""")
|
||||
|
||||
with (
|
||||
patch("compose_farm.cli.ssh.key_exists", return_value=True),
|
||||
patch("compose_farm.cli.ssh.get_pubkey_content", return_value="ssh-ed25519 AAAA..."),
|
||||
):
|
||||
result = runner.invoke(app, ["ssh", "status", f"--config={config_file}"])
|
||||
|
||||
assert "Key exists" in result.output
|
||||
|
||||
|
||||
class TestSshSetup:
|
||||
"""Tests for cf ssh setup command."""
|
||||
|
||||
def test_setup_no_remote_hosts(self, tmp_path: Path) -> None:
|
||||
"""Show message when no remote hosts configured."""
|
||||
config_file = tmp_path / "compose-farm.yaml"
|
||||
config_file.write_text("""
|
||||
hosts:
|
||||
local:
|
||||
address: localhost
|
||||
services:
|
||||
test: local
|
||||
""")
|
||||
|
||||
result = runner.invoke(app, ["ssh", "setup", f"--config={config_file}"])
|
||||
|
||||
assert "No remote hosts" in result.output
|
||||
|
||||
|
||||
class TestSshHelp:
|
||||
"""Tests for cf ssh help."""
|
||||
|
||||
def test_ssh_help(self) -> None:
|
||||
"""Show help for ssh command."""
|
||||
result = runner.invoke(app, ["ssh", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "setup" in result.output
|
||||
assert "status" in result.output
|
||||
assert "keygen" in result.output
|
||||
245
tests/test_ssh_keys.py
Normal file
245
tests/test_ssh_keys.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tests for ssh_keys module."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from compose_farm.config import Host
|
||||
from compose_farm.executor import ssh_connect_kwargs
|
||||
from compose_farm.ssh_keys import (
|
||||
SSH_KEY_PATH,
|
||||
get_key_path,
|
||||
get_pubkey_content,
|
||||
get_ssh_auth_sock,
|
||||
get_ssh_env,
|
||||
key_exists,
|
||||
)
|
||||
|
||||
|
||||
class TestGetSshAuthSock:
|
||||
"""Tests for get_ssh_auth_sock function."""
|
||||
|
||||
def test_returns_env_var_when_socket_exists(self) -> None:
|
||||
"""Return SSH_AUTH_SOCK env var if the socket exists."""
|
||||
mock_path = MagicMock()
|
||||
mock_path.is_socket.return_value = True
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"SSH_AUTH_SOCK": "/tmp/agent.sock"}),
|
||||
patch("compose_farm.ssh_keys.Path", return_value=mock_path),
|
||||
):
|
||||
result = get_ssh_auth_sock()
|
||||
assert result == "/tmp/agent.sock"
|
||||
|
||||
def test_returns_none_when_env_var_not_socket(self, tmp_path: Path) -> None:
|
||||
"""Return None if SSH_AUTH_SOCK points to non-socket."""
|
||||
regular_file = tmp_path / "not_a_socket"
|
||||
regular_file.touch()
|
||||
with (
|
||||
patch.dict(os.environ, {"SSH_AUTH_SOCK": str(regular_file)}),
|
||||
patch("compose_farm.ssh_keys.Path.home", return_value=tmp_path),
|
||||
):
|
||||
# Should fall through to agent dir check, which won't exist
|
||||
result = get_ssh_auth_sock()
|
||||
assert result is None
|
||||
|
||||
def test_finds_agent_in_ssh_agent_dir(self, tmp_path: Path) -> None:
|
||||
"""Find agent socket in ~/.ssh/agent/ directory."""
|
||||
# Create agent directory structure with a regular file
|
||||
agent_dir = tmp_path / ".ssh" / "agent"
|
||||
agent_dir.mkdir(parents=True)
|
||||
sock_path = agent_dir / "s.12345.sshd.67890"
|
||||
sock_path.touch() # Create as regular file
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("compose_farm.ssh_keys.Path.home", return_value=tmp_path),
|
||||
patch.object(Path, "is_socket", return_value=True),
|
||||
):
|
||||
os.environ.pop("SSH_AUTH_SOCK", None)
|
||||
result = get_ssh_auth_sock()
|
||||
assert result == str(sock_path)
|
||||
|
||||
def test_returns_none_when_no_agent_found(self, tmp_path: Path) -> None:
|
||||
"""Return None when no SSH agent socket is found."""
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("compose_farm.ssh_keys.Path.home", return_value=tmp_path),
|
||||
):
|
||||
os.environ.pop("SSH_AUTH_SOCK", None)
|
||||
result = get_ssh_auth_sock()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetSshEnv:
|
||||
"""Tests for get_ssh_env function."""
|
||||
|
||||
def test_returns_env_with_ssh_auth_sock(self) -> None:
|
||||
"""Return env dict with SSH_AUTH_SOCK set."""
|
||||
with patch("compose_farm.ssh_keys.get_ssh_auth_sock", return_value="/tmp/agent.sock"):
|
||||
result = get_ssh_env()
|
||||
assert result["SSH_AUTH_SOCK"] == "/tmp/agent.sock"
|
||||
# Should include other env vars too
|
||||
assert "PATH" in result or len(result) > 1
|
||||
|
||||
def test_returns_env_without_ssh_auth_sock_when_none(self, tmp_path: Path) -> None:
|
||||
"""Return env without SSH_AUTH_SOCK when no agent found."""
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("compose_farm.ssh_keys.Path.home", return_value=tmp_path),
|
||||
):
|
||||
os.environ.pop("SSH_AUTH_SOCK", None)
|
||||
result = get_ssh_env()
|
||||
# SSH_AUTH_SOCK should not be set if no agent found
|
||||
assert result.get("SSH_AUTH_SOCK") is None
|
||||
|
||||
|
||||
class TestKeyExists:
|
||||
"""Tests for key_exists function."""
|
||||
|
||||
def test_returns_true_when_both_keys_exist(self, tmp_path: Path) -> None:
|
||||
"""Return True when both private and public keys exist."""
|
||||
key_path = tmp_path / "compose-farm"
|
||||
pubkey_path = tmp_path / "compose-farm.pub"
|
||||
key_path.touch()
|
||||
pubkey_path.touch()
|
||||
|
||||
with (
|
||||
patch("compose_farm.ssh_keys.SSH_KEY_PATH", key_path),
|
||||
patch("compose_farm.ssh_keys.SSH_PUBKEY_PATH", pubkey_path),
|
||||
):
|
||||
assert key_exists() is True
|
||||
|
||||
def test_returns_false_when_private_key_missing(self, tmp_path: Path) -> None:
|
||||
"""Return False when private key doesn't exist."""
|
||||
key_path = tmp_path / "compose-farm"
|
||||
pubkey_path = tmp_path / "compose-farm.pub"
|
||||
pubkey_path.touch() # Only public key exists
|
||||
|
||||
with (
|
||||
patch("compose_farm.ssh_keys.SSH_KEY_PATH", key_path),
|
||||
patch("compose_farm.ssh_keys.SSH_PUBKEY_PATH", pubkey_path),
|
||||
):
|
||||
assert key_exists() is False
|
||||
|
||||
def test_returns_false_when_public_key_missing(self, tmp_path: Path) -> None:
|
||||
"""Return False when public key doesn't exist."""
|
||||
key_path = tmp_path / "compose-farm"
|
||||
pubkey_path = tmp_path / "compose-farm.pub"
|
||||
key_path.touch() # Only private key exists
|
||||
|
||||
with (
|
||||
patch("compose_farm.ssh_keys.SSH_KEY_PATH", key_path),
|
||||
patch("compose_farm.ssh_keys.SSH_PUBKEY_PATH", pubkey_path),
|
||||
):
|
||||
assert key_exists() is False
|
||||
|
||||
|
||||
class TestGetKeyPath:
|
||||
"""Tests for get_key_path function."""
|
||||
|
||||
def test_returns_path_when_key_exists(self) -> None:
|
||||
"""Return key path when key exists."""
|
||||
with patch("compose_farm.ssh_keys.key_exists", return_value=True):
|
||||
result = get_key_path()
|
||||
assert result == SSH_KEY_PATH
|
||||
|
||||
def test_returns_none_when_key_missing(self) -> None:
|
||||
"""Return None when key doesn't exist."""
|
||||
with patch("compose_farm.ssh_keys.key_exists", return_value=False):
|
||||
result = get_key_path()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetPubkeyContent:
|
||||
"""Tests for get_pubkey_content function."""
|
||||
|
||||
def test_returns_content_when_exists(self, tmp_path: Path) -> None:
|
||||
"""Return public key content when file exists."""
|
||||
pubkey_content = "ssh-ed25519 AAAA... compose-farm"
|
||||
pubkey_path = tmp_path / "compose-farm.pub"
|
||||
pubkey_path.write_text(pubkey_content + "\n")
|
||||
|
||||
with patch("compose_farm.ssh_keys.SSH_PUBKEY_PATH", pubkey_path):
|
||||
result = get_pubkey_content()
|
||||
assert result == pubkey_content
|
||||
|
||||
def test_returns_none_when_missing(self, tmp_path: Path) -> None:
|
||||
"""Return None when public key doesn't exist."""
|
||||
pubkey_path = tmp_path / "compose-farm.pub" # Doesn't exist
|
||||
|
||||
with patch("compose_farm.ssh_keys.SSH_PUBKEY_PATH", pubkey_path):
|
||||
result = get_pubkey_content()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSshConnectKwargs:
|
||||
"""Tests for ssh_connect_kwargs function."""
|
||||
|
||||
def test_basic_kwargs(self) -> None:
|
||||
"""Return basic connection kwargs."""
|
||||
host = Host(address="example.com", port=22, user="testuser")
|
||||
|
||||
with (
|
||||
patch("compose_farm.executor.get_ssh_auth_sock", return_value=None),
|
||||
patch("compose_farm.executor.get_key_path", return_value=None),
|
||||
):
|
||||
result = ssh_connect_kwargs(host)
|
||||
|
||||
assert result["host"] == "example.com"
|
||||
assert result["port"] == 22
|
||||
assert result["username"] == "testuser"
|
||||
assert result["known_hosts"] is None
|
||||
assert "agent_path" not in result
|
||||
assert "client_keys" not in result
|
||||
|
||||
def test_includes_agent_path_when_available(self) -> None:
|
||||
"""Include agent_path when SSH agent is available."""
|
||||
host = Host(address="example.com")
|
||||
|
||||
with (
|
||||
patch("compose_farm.executor.get_ssh_auth_sock", return_value="/tmp/agent.sock"),
|
||||
patch("compose_farm.executor.get_key_path", return_value=None),
|
||||
):
|
||||
result = ssh_connect_kwargs(host)
|
||||
|
||||
assert result["agent_path"] == "/tmp/agent.sock"
|
||||
|
||||
def test_includes_client_keys_when_key_exists(self, tmp_path: Path) -> None:
|
||||
"""Include client_keys when compose-farm key exists."""
|
||||
host = Host(address="example.com")
|
||||
key_path = tmp_path / "compose-farm"
|
||||
|
||||
with (
|
||||
patch("compose_farm.executor.get_ssh_auth_sock", return_value=None),
|
||||
patch("compose_farm.executor.get_key_path", return_value=key_path),
|
||||
):
|
||||
result = ssh_connect_kwargs(host)
|
||||
|
||||
assert result["client_keys"] == [str(key_path)]
|
||||
|
||||
def test_includes_both_agent_and_key(self, tmp_path: Path) -> None:
|
||||
"""Include both agent_path and client_keys when both available."""
|
||||
host = Host(address="example.com")
|
||||
key_path = tmp_path / "compose-farm"
|
||||
|
||||
with (
|
||||
patch("compose_farm.executor.get_ssh_auth_sock", return_value="/tmp/agent.sock"),
|
||||
patch("compose_farm.executor.get_key_path", return_value=key_path),
|
||||
):
|
||||
result = ssh_connect_kwargs(host)
|
||||
|
||||
assert result["agent_path"] == "/tmp/agent.sock"
|
||||
assert result["client_keys"] == [str(key_path)]
|
||||
|
||||
def test_custom_port(self) -> None:
|
||||
"""Handle custom SSH port."""
|
||||
host = Host(address="example.com", port=2222)
|
||||
|
||||
with (
|
||||
patch("compose_farm.executor.get_ssh_auth_sock", return_value=None),
|
||||
patch("compose_farm.executor.get_key_path", return_value=None),
|
||||
):
|
||||
result = ssh_connect_kwargs(host)
|
||||
|
||||
assert result["port"] == 2222
|
||||
Reference in New Issue
Block a user