diff --git a/README.md b/README.md index a1c904c..a97115a 100644 --- a/README.md +++ b/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,56 @@ docker run --rm \ +## 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` (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. + +
🐳 Docker volume options for SSH keys + +When running in Docker, mount a volume to persist the SSH keys: + +**Option 1: Named volume (default)** +```yaml +volumes: + - cf-ssh:/root/.ssh +``` + +**Option 2: Host path (easier to backup/inspect)** +```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. + +
+ ## Configuration Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory): @@ -344,6 +397,7 @@ 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. │ @@ -773,6 +827,21 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a + +
+See the output of cf ssh --help + + + + + + + + + + +
+ **Monitoring**
diff --git a/docker-compose.yml b/docker-compose.yml index d155fc7..bd841c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,11 @@ 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`) + # 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 environment: - SSH_AUTH_SOCK=/ssh-agent # Config file path (state stored alongside it) @@ -17,6 +22,9 @@ 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 environment: - SSH_AUTH_SOCK=/ssh-agent - CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml @@ -33,3 +41,7 @@ services: networks: mynetwork: external: true + +volumes: + cf-ssh: + # Persists SSH keys across container restarts diff --git a/src/compose_farm/cli/__init__.py b/src/compose_farm/cli/__init__.py index 65f6390..27d49ab 100644 --- a/src/compose_farm/cli/__init__.py +++ b/src/compose_farm/cli/__init__.py @@ -8,6 +8,7 @@ from compose_farm.cli import ( lifecycle, # noqa: F401 management, # noqa: F401 monitoring, # noqa: F401 + ssh, # noqa: F401 web, # noqa: F401 ) diff --git a/src/compose_farm/cli/ssh.py b/src/compose_farm/cli/ssh.py new file mode 100644 index 0000000..1113bd7 --- /dev/null +++ b/src/compose_farm/cli/ssh.py @@ -0,0 +1,284 @@ +"""SSH key management commands for compose-farm.""" + +from __future__ import annotations + +import subprocess +from typing import Annotated + +import typer + +from compose_farm.cli.app import app +from compose_farm.cli.common import ConfigOption, load_config_or_exit +from compose_farm.console import console, err_console +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 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 (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( # noqa: PLR0912 - branches are clear and readable + 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 + + 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}[/]") + + console.print(table) + + +# Register ssh subcommand on the shared app +app.add_typer(ssh_app, name="ssh", rich_help_panel="Configuration") diff --git a/src/compose_farm/executor.py b/src/compose_farm/executor.py index d6c89f2..1003245 100644 --- a/src/compose_farm/executor.py +++ b/src/compose_farm/executor.py @@ -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 @@ -73,12 +74,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( @@ -172,11 +182,16 @@ async def _run_ssh_command( "-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]) # 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, diff --git a/src/compose_farm/ssh_keys.py b/src/compose_farm/ssh_keys.py new file mode 100644 index 0000000..1477cb3 --- /dev/null +++ b/src/compose_farm/ssh_keys.py @@ -0,0 +1,65 @@ +"""SSH key utilities for compose-farm.""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Default key paths for compose-farm SSH key +SSH_KEY_PATH = Path.home() / ".ssh" / "compose-farm" +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() diff --git a/src/compose_farm/web/streaming.py b/src/compose_farm/web/streaming.py index b65a18f..5873092 100644 --- a/src/compose_farm/web/streaming.py +++ b/src/compose_farm/web/streaming.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio import os -from pathlib import Path from typing import TYPE_CHECKING, Any +from compose_farm.ssh_keys import get_ssh_auth_sock + if TYPE_CHECKING: from compose_farm.config import Config @@ -17,25 +18,6 @@ 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]] = {} @@ -69,7 +51,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 diff --git a/src/compose_farm/web/ws.py b/src/compose_farm/web/ws.py index ac95188..f68fbfc 100644 --- a/src/compose_farm/web/ws.py +++ b/src/compose_farm/web/ws.py @@ -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, diff --git a/tests/test_cli_ssh.py b/tests/test_cli_ssh.py new file mode 100644 index 0000000..e8ef893 --- /dev/null +++ b/tests/test_cli_ssh.py @@ -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 diff --git a/tests/test_ssh_keys.py b/tests/test_ssh_keys.py new file mode 100644 index 0000000..987f410 --- /dev/null +++ b/tests/test_ssh_keys.py @@ -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