ssh: Introduce LocalHost vs. Remote via Host interface
Motivation: local builds and deployments without ssh Add a new interface `Host` which is implemented bei either `Remote` or `Localhost` This simplifies all interactions with hosts. THe caller does ot need to know if the Host is remote or local in mot cases anymore
This commit is contained in:
85
pkgs/clan-cli/clan_lib/ssh/host.py
Normal file
85
pkgs/clan-cli/clan_lib/ssh/host.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Base Host interface for both local and remote command execution."""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
from clan_lib.cmd import CmdOut, RunOpts
|
||||
|
||||
cmdlog = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Host(ABC):
|
||||
"""
|
||||
Abstract base class for host command execution.
|
||||
This provides a common interface for both local and remote hosts.
|
||||
"""
|
||||
|
||||
command_prefix: str
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def target(self) -> str:
|
||||
"""Return a descriptive target string for this host."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def user(self) -> str:
|
||||
"""Return the user for this host."""
|
||||
|
||||
@abstractmethod
|
||||
def run(
|
||||
self,
|
||||
cmd: list[str],
|
||||
opts: RunOpts | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
tty: bool = False,
|
||||
verbose_ssh: bool = False,
|
||||
quiet: bool = False,
|
||||
control_master: bool = True,
|
||||
) -> CmdOut:
|
||||
"""
|
||||
Run a command on the host.
|
||||
|
||||
Args:
|
||||
cmd: Command to execute
|
||||
opts: Run options
|
||||
extra_env: Additional environment variables
|
||||
tty: Whether to allocate a TTY (for remote hosts)
|
||||
verbose_ssh: Enable verbose SSH output (for remote hosts)
|
||||
quiet: Suppress command logging
|
||||
control_master: Use SSH ControlMaster (for remote hosts)
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
@abstractmethod
|
||||
def become_root(self) -> Iterator["Host"]:
|
||||
"""
|
||||
Context manager to execute commands as root.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
@abstractmethod
|
||||
def host_connection(self) -> Iterator["Host"]:
|
||||
"""
|
||||
Context manager to manage host connections.
|
||||
For remote hosts, this manages SSH ControlMaster connections.
|
||||
For local hosts, this is a no-op that returns self.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def nix_ssh_env(
|
||||
self,
|
||||
env: dict[str, str] | None = None,
|
||||
control_master: bool = True,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables for Nix operations.
|
||||
Remote hosts will add NIX_SSHOPTS, local hosts won't.
|
||||
"""
|
||||
111
pkgs/clan-cli/clan_lib/ssh/localhost.py
Normal file
111
pkgs/clan-cli/clan_lib/ssh/localhost.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from clan_lib.cmd import CmdOut, RunOpts, run
|
||||
from clan_lib.colors import AnsiColor
|
||||
from clan_lib.ssh.host import Host
|
||||
|
||||
cmdlog = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalHost(Host):
|
||||
"""
|
||||
A Host implementation that executes commands locally without SSH.
|
||||
"""
|
||||
|
||||
command_prefix: str = "localhost"
|
||||
_user: str = field(default_factory=lambda: os.environ.get("USER", "root"))
|
||||
_askpass_path: str | None = None
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
"""Return a descriptive target string for localhost."""
|
||||
return "localhost"
|
||||
|
||||
@property
|
||||
def user(self) -> str:
|
||||
"""Return the user for localhost."""
|
||||
return self._user
|
||||
|
||||
def run(
|
||||
self,
|
||||
cmd: list[str],
|
||||
opts: RunOpts | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
tty: bool = False,
|
||||
verbose_ssh: bool = False,
|
||||
quiet: bool = False,
|
||||
control_master: bool = True,
|
||||
) -> CmdOut:
|
||||
"""
|
||||
Run a command locally instead of via SSH.
|
||||
"""
|
||||
if opts is None:
|
||||
opts = RunOpts()
|
||||
|
||||
# Set up environment
|
||||
env = opts.env or os.environ.copy()
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
# Handle sudo if needed
|
||||
if self._askpass_path is not None:
|
||||
# Prepend sudo command
|
||||
sudo_cmd = ["sudo", "-A", "--"]
|
||||
cmd = sudo_cmd + cmd
|
||||
env["SUDO_ASKPASS"] = self._askpass_path
|
||||
|
||||
# Set options
|
||||
opts.env = env
|
||||
opts.prefix = opts.prefix or self.command_prefix
|
||||
|
||||
# Log the command
|
||||
displayed_cmd = " ".join(cmd)
|
||||
if not quiet:
|
||||
cmdlog.info(
|
||||
f"$ {displayed_cmd}",
|
||||
extra={
|
||||
"command_prefix": self.command_prefix,
|
||||
"color": AnsiColor.GREEN.value,
|
||||
},
|
||||
)
|
||||
|
||||
# Run locally
|
||||
return run(cmd, opts)
|
||||
|
||||
@contextmanager
|
||||
def become_root(self) -> Iterator["LocalHost"]:
|
||||
"""
|
||||
Context manager to execute commands as root.
|
||||
"""
|
||||
if self._user == "root":
|
||||
yield self
|
||||
return
|
||||
|
||||
# For local execution, we can use sudo with askpass if GUI is available
|
||||
# This is a simplified version - could be enhanced with sudo askpass proxy
|
||||
yield self
|
||||
|
||||
@contextmanager
|
||||
def host_connection(self) -> Iterator["LocalHost"]:
|
||||
"""
|
||||
For LocalHost, this is a no-op that just returns self.
|
||||
"""
|
||||
yield self
|
||||
|
||||
def nix_ssh_env(
|
||||
self,
|
||||
env: dict[str, str] | None = None,
|
||||
control_master: bool = True,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
LocalHost doesn't need SSH environment variables.
|
||||
"""
|
||||
if env is None:
|
||||
env = {}
|
||||
# Don't set NIX_SSHOPTS for localhost
|
||||
return env
|
||||
36
test_host_interface.py
Normal file
36
test_host_interface.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for Host interface with LocalHost implementation."""
|
||||
|
||||
from clan_lib.cmd import RunOpts
|
||||
from clan_lib.ssh.host import Host
|
||||
from clan_lib.ssh.localhost import LocalHost
|
||||
|
||||
|
||||
def test_localhost() -> None:
|
||||
# Create LocalHost instance
|
||||
localhost = LocalHost(command_prefix="local-test")
|
||||
|
||||
# Verify it's a Host instance
|
||||
assert isinstance(localhost, Host), "LocalHost should be an instance of Host"
|
||||
|
||||
# Test basic command execution
|
||||
result = localhost.run(["echo", "Hello from LocalHost"])
|
||||
assert result.returncode == 0, f"Command failed with code {result.returncode}"
|
||||
assert result.stdout.strip() == "Hello from LocalHost", (
|
||||
f"Unexpected output: {result.stdout}"
|
||||
)
|
||||
|
||||
# Test with environment variable
|
||||
result = localhost.run(
|
||||
["printenv", "TEST_VAR"],
|
||||
opts=RunOpts(check=False), # Don't check return code
|
||||
extra_env={"TEST_VAR": "LocalHost works!"},
|
||||
)
|
||||
assert result.returncode == 0, f"Command failed with code {result.returncode}"
|
||||
assert result.stdout.strip() == "LocalHost works!", (
|
||||
f"Expected 'LocalHost works!', got '{result.stdout.strip()}'"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_localhost()
|
||||
Reference in New Issue
Block a user