mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-16 03:32:14 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87849a8161 | ||
|
|
c8bf792a9a | ||
|
|
d37295fbee | ||
|
|
266f541d35 |
26
README.md
26
README.md
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,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)
|
||||
@@ -144,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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -501,7 +501,7 @@ 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;
|
||||
@@ -522,8 +522,8 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const actions = [
|
||||
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
|
||||
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
|
||||
cmd('nav', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
|
||||
cmd('nav', 'Console', 'Go to console', nav('/console'), icons.terminal),
|
||||
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
|
||||
@@ -563,6 +563,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() {
|
||||
|
||||
@@ -6,11 +6,15 @@ import asyncio
|
||||
import os
|
||||
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"
|
||||
@@ -79,6 +83,76 @@ async def run_cli_streaming(
|
||||
tasks[task_id]["status"] = "failed"
|
||||
|
||||
|
||||
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
|
||||
remote_cmd = f"cf {' '.join(args)} --config={config.config_path}"
|
||||
|
||||
# Show what we're doing
|
||||
await stream_to_task(
|
||||
task_id,
|
||||
f"{DIM}$ ssh {host.user}@{host.address} {remote_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
|
||||
ssh_args = build_ssh_command(host, remote_cmd)
|
||||
|
||||
# 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"
|
||||
|
||||
except Exception as e:
|
||||
await stream_to_task(task_id, f"{RED}Error: {e}{RESET}{CRLF}")
|
||||
tasks[task_id]["status"] = "failed"
|
||||
|
||||
|
||||
async def run_compose_streaming(
|
||||
config: Config,
|
||||
service: str,
|
||||
@@ -93,4 +167,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)
|
||||
|
||||
Reference in New Issue
Block a user