Skip to content

Hosts and the inventory

Hosts and their SSH information are specified as a simple inventory: a list of dictionaries, each describing one host. Every entry must carry at least a host (its name) and a connection (the SSH connection data); privilege, group and tags are optional and any extra keys are preserved. The inventory is the parameter the library functions accept; Host.from_inventory materializes each entry into a Host whose connection data reaches both operations (send_command) and facts (each process).

The inventory is validated with pydantic before any host is materialized. Each entry is checked against the InventoryEntry schema — host and connection are required, privilege, group and tags optional, and unrecognised keys are allowed (they land in Host.extra). validate_inventory checks a whole inventory up front (it must be a list of dictionaries), and run calls it before building any host, so a malformed inventory raises InventoryError rather than failing later over SSH.

SimulatedHost is part of the test framework, not of the inventory or the library: it lives in tests/simulated_host.py and is the in-memory stand-in holding the state facts read (packages, links) and the mutations commands apply (execute_command), which is what makes the "fresh fact cache per lockstep step" property observable. The library never imports or constructs it; tests inject it via run's host_factory argument and it overrides the seams send_command and the facts otherwise use to run over a real asyncssh connection. A plain Host built from a real inventory entry connects over asyncssh instead: it opens a connection lazily from its connection data, runs each command with run_shell, copies IO-like sources to the host with upload_file and remote files into IO-like destinations with download_file (both asyncssh's scp, via a temporary local file — the seams files.put's non-shell PutCommand and files.get's GetCommand ride), and run closes the connection when it finishes.

When a host cannot be reached, Host.connect wraps the underlying transport failure — an OSError (reset, timeout, unreachable) or an asyncssh.Error (host-key or authentication rejection) — in a single HostConnectionError naming that host, so callers never have to catch asyncssh internals or read a raw transport traceback. The message stays generic (which host, or hosts, could not be reached); the original error is still available as __cause__ for diagnosis. Because run_prepare connects the whole inventory concurrently, it collects the per-host failures and merges them into one aggregate naming every unreachable host, rather than surfacing only the first to fail.

SSH connection options

The connection mapping keeps asyncssh's own key names, so a curated set of asyncssh.connect options is forwarded verbatim. The target address may be given as address (or host/hostname), the login name as username (or user), and a single client key as the key_path shorthand for client_keys. Path-valued options have ~ expanded.

Option Purpose
port TCP port to connect to
username / user Login name
password Password authentication
key_path Single client private key (shorthand for client_keys)
client_keys Client private keys (list)
passphrase Passphrase for encrypted keys
identities Certificates/identities to present
agent_path SSH agent socket (None uses the default)
agent_forwarding Forward the agent to the remote host
config OpenSSH config file path(s), or True for the default
known_hosts Known-hosts file path(s); None skips host key checking
proxy_command Jump-host ProxyCommand string
tunnel Bastion tunnel, as "[user@]host[:port]" or a connection
kex_algs / encryption_algs / mac_algs / server_host_key_algs Algorithm pinning for compliance or legacy servers
preferred_auth Auth method order, e.g. ["publickey", "password"]
login_timeout Auth window in seconds
keepalive_interval / keepalive_count_max / tcp_keepalive Dead-peer detection

Anything asyncssh supports that is not first-class above — GSSAPI, X.509, socket plumbing, rekeying and the like — can still be passed through the asyncssh_connect_kwargs mapping, which is merged last and may override the options above, for example:

{
    "host": "web1",
    "connection": {
        "address": "192.168.0.240",
        "username": "kjarvis",
        "proxy_command": "ssh -W %h:%p bastion",
        "known_hosts": None,
        "asyncssh_connect_kwargs": {"family": 0, "local_addr": ("10.0.0.5", 0)},
    },
}

Forbidden options

A few asyncssh options are expressly forbidden rather than merely omitted from the curated list: an inventory that requests one raises InventoryError instead of the option being silently dropped or passed on to asyncssh.

kbdint_auth (keyboard-interactive authentication) is forbidden. It produces interactive challenge/MFA prompts, which asyncssh's non-interactive channels cannot answer, so reemote does not support keyboard-interactive auth at all. The rejection also covers the asyncssh_connect_kwargs passthrough — because that mapping is merged last and could otherwise override the curated options, a forbidden key cannot be smuggled through it either. The forbidden set is defined by _FORBIDDEN_OPTIONS in reemote.hosts and is checked by _connect_options before any connection is attempted.

Connectors

The connection mapping may also select an alternative transport with a connector key. The default "ssh" builds a plain Host (see the ssh connector for its connection options); "docker" builds a DockerHost, which drives a local Docker/Podman container through the very same seams (shell, transfers, close) — see the docker connector for its connection options and container lifecycle. Any other connector value raises InventoryError. The dispatch applies only to the default Host.from_inventory factory; a subclass factory (such as the test framework's SimulatedHost.from_inventory) always builds its own class.

Groups and tags

An entry may carry two optional, static classifiers that a deploy script reads off the materialized Host to branch per host:

  • group — a single string naming the group the host belongs to, exposed as host.group (None when unset).
  • tags — an unordered list of strings, exposed as host.tags (empty when unset).
{
    "host": "web1",
    "connection": {
        "address": "192.168.0.240",
        "username": "kjarvis",
        "key_path": "/home/kim/.ssh/id_ed25519",
    },
    "group": "webservers",
    "tags": ["ubuntu_host", "web"],
}

Both come from the inventory, so a deploy generator can select work by host name, group or tag:

def deploy(host):
    if host.name == "web1":
        yield from role_web(host)
    if host.group == "webservers":
        yield from role_web(host)
    if "ubuntu_host" in host.tags:
        yield from role_apt_install_nginx(host)

Privilege: sudo, su and doas

An entry may carry an optional privilege mapping holding elevation settings. Five keys are recognised:

  • sudo_user — the run-as user the driver passes to sudo -u (since sudo defaults to root, a sudo_user of root collapses to a bare sudo) — and sudo_password, the sudo password. Both apply only when an operation is flagged sudo=True.
  • su_user — the target of a default su <user> -c (since su defaults to root, a su_user of root, or none at all, collapses to a bare su -c) — and su_password, the su password. Both apply only when an operation is flagged su=True. Only default su is supported: no login shell (su -l), no environment preservation, no shell override.
  • doas_user — the target of a passwordless doas -n -u <user> (since doas defaults to root, a doas_user of root, or none at all, collapses to a bare doas -n). It applies only when an operation is flagged doas=True. There is deliberately no doas_password: doas has no askpass mechanism and asyncssh channels offer no TTY, so reemote always runs doas -n (non-interactive — it fails rather than prompts) and the host must grant the login user a permit nopass rule in /etc/doas.conf.
{
    "host": "web1",
    "connection": {"address": "192.168.0.240", "username": "kjarvis"},
    "privilege": {"sudo_user": "deploy", "sudo_password": "s3cr3t"},
}

Privilege parameters come from the inventory, never from a deploy script: an operation asks to elevate with doas=True, sudo=True and/or su=True (see the Guide) but cannot choose a run-as user or supply a password.

Combining them. When an operation carries several flags the escalations are applied in a fixed order — doas then sudo then su — so doas is the outermost escalation and su the innermost payload: doas -n [-u <doas_user>] sudo [-u <sudo_user>] su <su_user> -c '<command>'.

Passwords. Without sudo_password, sudo elevation assumes passwordless sudo (NOPASSWD or a cached credential) and runs a bare sudo; with one, the real transport runs sudo -A against a generated askpass helper, so the password is supplied without a TTY (asyncssh channels are non-interactive). su has no askpass mechanism, so with a su_password the helper's output is piped to su's stdin (env REEMOTE_SU_PASSWORD=… <helper> | su …); when that pipeline also runs under sudo it is wrapped in sh -c so the pipe stays intact inside sudo's elevation (the same wrap applies when doas is the outermost wrapper around an su-password pipeline with no sudo in between). Passwords are plain text in the inventory — the only supported form — and are shell-quoted into the command sent over the encrypted channel; they never appear in the rendered apply log, which shows the credential-free sudo -u <user> … / su <user> -c … form. doas carries no password at all, so its rendering is simply doas -n [-u <user>] ….

An applied command that exits non-zero raises CommandError (fail-fast), carrying the host, the credential-free command text, the exit status and the captured stderr, so a permission error or a failed package transaction surfaces instead of being silently logged as applied. Fact reads tolerate other non-zero exits (e.g. readlink on an absent link), but exit 127 — the shell's command-not-found status — raises CommandNotFoundError on every path, even with check off: a command a fact relies on that is not installed (e.g. lsb_release on a minimal CentOS install) must never parse as an empty fact default. It subclasses CommandError, so existing error handling catches it unchanged. A fact that deliberately tolerates an absent tool guards its command in the shell itself (Which's command -v … || true, Last's fallback chain), masking the 127 before it reaches the transport. Because gather_fact reads hosts concurrently, it merges the per-host errors into one aggregate naming every host missing the command — the same treatment run_prepare gives HostConnectionError — so a whole fleet without lsb_release is reported in a single message.

A rejected credential

A failure doas, sudo or su reports as its own authorization/authentication rejection — a wrong password, a missing one where it is demanded, a denied doas rule, or a run-as user the login user may not become — raises PrivilegeError instead: the privilege counterpart of HostConnectionError, reported the same way.

reemote invokes sudo -A exactly once, but sudo re-runs the askpass helper up to its own passwd_tries limit (3 by default), so one bad password costs three identical attempts and three Sorry, try again. lines before sudo gives up with sudo: 3 incorrect password attempts. (su does not retry — it fails at once, and doas -n never prompts at all.) Matching those signatures — for both Todd Miller's sudo and sudo-rs, su's Authentication failure, and doas's Operation not permitted / Authentication required / Authentication failed — collapses that into a single error whose message names the affected host(s) and nothing else, so neither the retry noise nor the password reaches the report. The detail is preserved for diagnosis rather than discarded: failures carries (host_name, command, cause) triples, where the command is the credential-free rendering and the cause is the CommandError holding the exit status and captured stderr (also chained as __cause__). run_apply merges the per-host failures of a concurrent lockstep step into one aggregate via PrivilegeError.aggregate, so every host whose credentials were rejected is reported at once.

Raw-result seams for actions

The fail-fast paths above serve facts and operations, where a non-zero exit is a problem the library must surface. Actions need the opposite: the command's own exit status must travel back as data, so the caller decides what a bespoke command's failure means. Two raw-result seams provide that without re-interpreting the outcome:

  • run_shell_result — the unprivileged counterpart of run_shell: it opens the connection lazily, sends the command verbatim and returns the transport's completion record untouched.
  • run_shell_privileged_result — the raw-result variant of run_shell_privileged. The escalation is built identically (the shared _build_privileged_command produces the execution string and the credential-free rendering), but the command's own exit travels back instead of raising CommandError. The one exception is doas's, sudo's or su's own authentication rejection, which still raises PrivilegeError: a rejected credential is a library-level failure, never a mere exit status.

Shell.process rides these seams and copies the record into a library-owned Result, so no asyncssh type reaches the public API.

reemote.hosts

Hosts: the library-side Host, materialized from an inventory entry.

Hosts and their SSH connection information are specified by the user as a simple inventory: a list of dictionaries, each describing one host, e.g.::

[
    {"host": "web1",
     "connection": {"key_path": "~/.ssh/id_ed25519"},
     "group": "webservers",
     "tags": ["ubuntu_host", "web"]},
    {"host": "web2",
     "connection": {"key_path": "~/.ssh/id_ed25519"},
     "privilege": {"sudo_user": "deploy"}},
]

Host.from_inventory materializes one entry into a Host carrying the connection, privilege (and optional extra) data, which the operations (send_command) and the facts (each process) use to run over asyncssh.

Every entry is validated with pydantic before it is materialized: the inventory must be a list of dictionaries and each entry must carry at least a host (its name) and a connection (the SSH connection data), while privilege, group and tags are optional and any other keys are preserved. group (a single label) and tags (an unordered list of string labels) are static classifiers a deploy script reads off the host to branch per host (host.group, host.tags). Host.from_inventory validates a single entry; validate_inventory validates a whole inventory up front (drivers.run calls it before materializing any host). Both raise InventoryError on bad input.

A plain Host owns a lazily-established asyncssh connection built from its connection data: connect opens it, run_shell runs one command over it, upload_file copies an IO-like source to the remote host and download_file copies a remote file into an IO-like destination (both over asyncssh's scp), and aclose tears it down. The library calls those from send_command and the facts, so a real inventory entry (one whose connection carries an address) executes for real, while the test framework's SimulatedHost overrides the execute_command/packages/ links/remote_files seams and never connects. Hosts that stand in for a real machine -- holding the state facts read and the mutations commands apply -- belong to the test framework and live in tests/, never in this package.

An entry may also select an alternative transport with connection.connector: the default "ssh" builds a plain Host, while "docker" builds a DockerHost (reemote.connectors.docker) that drives a local Docker/Podman container through the very same seams. Any other connector value is rejected with InventoryError.

CommandError

CommandError(
    host: str,
    command: str,
    exit_status: int | None,
    stderr: str,
)

Raised when an applied command exits non-zero on the real transport.

Carries the host name, the credential-free command text (the same rendering format_log shows -- for an elevated command this is sudo -u <user> ..., never the askpass string that carries the password), the exit status and the captured stderr, so a failed apply is reported instead of being silently logged as applied. Fact gathering tolerates other non-zero exits (e.g. readlink on an absent link), but exit 127 -- the shell's command-not-found status -- raises CommandNotFoundError on every path.

exit_status instance-attribute

exit_status = exit_status

host instance-attribute

host = host

stderr instance-attribute

stderr = stderr or ''

CommandNotFoundError

CommandNotFoundError(
    host: str,
    command: str,
    exit_status: int | None,
    stderr: str,
)

Raised when a command a fact or operation relies on is not installed.

A shell that cannot find the command to run exits with status 127 (e.g. lsb_release on a minimal CentOS install). Host.run_shell raises this for any exit 127 -- even with check off -- so a fact never silently parses a missing command's empty output into its default value. It subclasses CommandError, so a caller catching that also catches this, and carries the same host name, command text, exit status and captured stderr (the shell's own command not found line). A fact that deliberately tolerates an absent tool guards its command in the shell itself (Which's command -v ... || true, Last's fallback chain), which masks the 127 before it reaches the transport.

drivers.gather_fact merges the per-host errors of a concurrent fan-out into one aggregate via aggregate -- the same treatment run_prepare gives HostConnectionError -- so every host missing the command is reported at once instead of only the first to fail.

aggregate classmethod

aggregate(
    errors: Iterable[CommandNotFoundError],
) -> CommandNotFoundError

Merge several per-host errors into one naming every affected host.

A single error is returned unchanged. For several, the merged error names every host in place of one (command not found on web1, web2 ...) and keeps the first error's command text, exit status and stderr -- the same fact runs the same command everywhere, so the detail is representative -- with the first error chained as __cause__ for diagnosis.

Host dataclass

Host(
    name: str,
    connection: dict = dict(),
    privilege: dict = dict(),
    group: str | None = None,
    tags: list[str] = list(),
    extra: dict = dict(),
)

One target host, materialized from an inventory entry.

connection holds the SSH information passed to the asyncssh transport (and therefore reaching both operations and facts); privilege holds optional elevation settings: sudo_user/sudo_password (applied when an operation is flagged sudo=True), su_user/su_password (applied when flagged su=True) and doas_user (applied when flagged doas=True). sudo_user is the run-as user the driver passes to sudo -u; su_user is the target of a default su <user> -c; doas_user is the target of a passwordless doas -n [-u <user>] (doas has no askpass, so it is passwordless-only -- the host must grant a permit nopass rule). When several flags are set the escalations combine in the fixed order doas then sudo then su. Privilege parameters come from here, the inventory, and never from an operation: a deploy script can ask to elevate but cannot choose the run-as user. group (an optional single label) and tags (an optional unordered list of string labels) are static, inventory-supplied classifiers a deploy script reads to branch per host -- e.g. if host.group == "webservers": or if "web" in host.tags:. Any unrecognised inventory keys are preserved in extra, so the inventory format is extensible without library changes.

connection keys understood by the transport keep asyncssh's own names: _connect_options forwards a curated set of asyncssh.connect options verbatim (port, password, passphrase, client_keys, identities, agent_path, agent_forwarding, config, known_hosts, proxy_command, tunnel, the *_algs/ preferred_auth knobs and the keepalive_*/login_timeout timeouts). Keyboard-interactive auth (kbdint_auth) is expressly forbidden rather than forwarded -- see _FORBIDDEN_OPTIONS. Three friendly aliases are accepted for convenience: address/host/hostname for the SSH target, username/user for the login name and key_path as a single-key shorthand for client_keys. Anything asyncssh supports that is not first-class can still be supplied through the asyncssh_connect_kwargs mapping, which is merged last. A connection is opened lazily on first use and cached on the instance; SimulatedHost never triggers this because it overrides the execute_command/packages/links seams instead.

connection class-attribute instance-attribute

connection: dict = field(default_factory=dict)

extra class-attribute instance-attribute

extra: dict = field(default_factory=dict)

privilege class-attribute instance-attribute

privilege: dict = field(default_factory=dict)

tags class-attribute instance-attribute

tags: list[str] = field(default_factory=list)

aclose async

aclose() -> None

Close the cached connection, removing any askpass helpers first.

connect async

connect() -> Any

Lazily open (and cache) this host's asyncssh connection.

A transport-level failure -- an OSError (reset, timeout, host unreachable) or an asyncssh.Error (host-key or authentication rejection) -- is wrapped in HostConnectionError naming this host, so callers get one library-specific exception instead of a raw asyncssh traceback. The original error is chained as __cause__.

download_file async

download_file(remote: str, dest: IO[Any]) -> None

Copy remote on this host into an IO-like destination via scp.

The mirror of upload_file and the non-shell transport seam files.get rides: instead of running shell text, send_command dispatches a GetCommand here, and the copy goes over the cached connection's scp protocol as the login user (no privilege escalation applies to it).

dest is a seekable, writable IO-like object, never a path -- the mirror of upload_file's source. Because asyncssh's scp copies between paths, the remote file is first fetched into a temporary local file, then its bytes are written into dest (decoded as UTF-8 for a text stream) and the stream rewound, and the temporary file is removed. A connection failure still surfaces as HostConnectionError from connect; a failed copy -- an absent or unreadable remote source, a dropped transfer -- raises CommandError whose command text is the scp <remote> -> <dest> rendering, with no exit status (scp reports an exception, not a shell exit).

from_inventory classmethod

from_inventory(entry: dict) -> Host

Build a host from one validated inventory dictionary.

The entry is validated with pydantic (InventoryEntry) first, so it must carry at least host and connection; privilege, group and tags are optional and every other key lands in extra. Raises InventoryError on invalid input.

The connection.connector key selects the transport: the default "ssh" builds this class, while "docker" builds a DockerHost (its connection mapping is validated by validate_docker_connection). Any other value raises InventoryError. The dispatch applies only to the plain Host factory -- a subclass factory (e.g. the test framework's SimulatedHost.from_inventory) always builds its own class.

run_shell async

run_shell(shell: str, *, check: bool = False) -> str

Run one shell command over SSH and return its stdout as text.

check defaults off so fact gathering tolerates a non-zero exit (e.g. readlink on an absent link exits non-zero with empty stdout). The apply path passes check=True, so a command that exits non-zero raises CommandError instead of being silently treated as applied. Exit 127 is the exception on both paths: it is the shell's command-not-found status, so a command the caller relies on is not installed and CommandNotFoundError (a CommandError subclass) is raised regardless of check -- a missing tool must never parse as an empty fact default. A command that deliberately tolerates absence guards itself with || true (Which, Last), masking the 127 in the shell before it reaches here.

run_shell_doas async

run_shell_doas(shell: str) -> str

Run one shell command elevated with doas and return its stdout.

A thin wrapper over run_shell_privileged for the doas-only case. doas_user becomes doas -n -u <user> (with None/root collapsing to a bare doas -n). doas is passwordless here: -n makes it non-interactive (it fails rather than prompts), because doas has no askpass mechanism and asyncssh channels offer no TTY, so the host must grant the login user a permit nopass rule in /etc/doas.conf. A non-zero exit raises CommandError (credential-free) or, when doas reports its own authorization/authentication rejection, PrivilegeError.

run_shell_privileged async

run_shell_privileged(
    shell: str,
    *,
    sudo: bool = False,
    su: bool = False,
    doas: bool = False,
) -> str

Run one shell command elevated with doas, sudo and/or su.

Elevation parameters come from this host's inventory privilege data, never from the caller. The escalations combine in a fixed order -- doas then sudo then su -- so doas is the outermost wrapper and su the innermost payload (doas -n ... sudo ... su <user> -c ...), matching the order in which the flags are applied.

sudo uses sudo_user/sudo_password (see run_shell_sudo); su uses su_user/su_password and runs a default (non-login) su <user> -c; doas uses doas_user and is passwordless (doas -n; see run_shell_doas). su has no askpass mechanism, so with an su_password the generated helper pipes the password to su's stdin; when that pipeline runs under sudo or doas it is wrapped in sh -c so the pipe stays intact inside the outer escalation (both exec their command directly rather than through a shell).

A non-zero exit raises CommandError whose command text is the credential-free rendering -- never the askpass command, which carries a password. When sudo's, su's or doas's output identifies the failure as its own authentication rejection (a wrong or missing password, a denied doas rule, a forbidden run-as user) a PrivilegeError is raised instead, so the caller gets one actionable error naming the host.

run_shell_privileged_result async

run_shell_privileged_result(
    shell: str,
    *,
    sudo: bool = False,
    su: bool = False,
    doas: bool = False,
) -> Any

Run one elevated shell command and return the raw transport result.

The raw-result variant of run_shell_privileged, and the seam an elevated action (reemote.actions.Shell with sudo/su/doas) rides: the escalation is built identically (same _build_privileged_command, same askpass helpers, same doas then sudo then su order), but the bespoke command's own exit status travels back in the result instead of raising CommandError -- the caller decides what a non-zero exit means.

Library-level failures still raise: a connection failure surfaces as HostConnectionError from connect, and sudo's, su's or doas's own authentication rejection (a wrong or missing password, a denied doas rule, a forbidden run-as user) raises PrivilegeError with the credential-free rendering and the would-be CommandError as its cause -- a rejected credential is never reported as a mere exit status. The returned record is the transport's own; Shell.process copies it into the library-owned Result TypedDict.

run_shell_result async

run_shell_result(shell: str) -> Any

Run one shell command and return the raw transport result.

The unchecked counterpart of run_shell, and the seam actions (reemote.actions) ride: no exit status is interpreted here -- not even 127 -- so the caller receives exit_status, stdout and stderr exactly as the bespoke command produced them. A connection failure still raises HostConnectionError from connect. The returned record is the transport's own (asyncssh's SSHCompletedProcess); Shell.process copies it into the library-owned Result TypedDict so no asyncssh type reaches the public API.

run_shell_su async

run_shell_su(shell: str) -> str

Run one shell command elevated with default su and return stdout.

A thin wrapper over run_shell_privileged for the su-only case. su_user becomes su <user> -c (with None/root collapsing to a bare su -c); when su_password is set the password is piped to su's stdin through a generated askpass helper, since su has no -A mechanism. Login shells are not supported. A non-zero exit raises CommandError (credential-free) or, when su reports its own authentication rejection, PrivilegeError.

run_shell_sudo async

run_shell_sudo(shell: str) -> str

Run one shell command elevated with sudo and return its stdout.

A thin wrapper over run_shell_privileged for the sudo-only case. Elevation parameters come from this host's inventory privilege data, never from the caller: sudo_user becomes sudo -u <user> (with root collapsing to a bare sudo), and when sudo_password is set the command runs under sudo -A with a generated askpass helper, so the password is supplied without a TTY and never appears in the command text the drivers log. With no password, plain passwordless sudo is used. A non-zero exit raises CommandError (credential-free) or, when sudo reports its own authentication rejection, PrivilegeError.

upload_file async

upload_file(src: IO[Any], remote: str) -> None

Copy an IO-like source to remote on this host via asyncssh's scp.

The non-shell transport seam files.put rides: instead of running shell text, send_command dispatches a PutCommand here, and the copy goes over the cached connection's scp protocol as the login user (no privilege escalation applies to it).

src is a seekable IO-like object, never a path -- reemote deliberately supports only the stream form. The stream is rewound and read once (a text stream's payload is encoded as UTF-8), and because asyncssh's scp copies between paths -- its source may be a local path or an (conn, path) tuple, not a file-like object -- the bytes are materialized into a temporary local file for the copy and removed afterwards. A connection failure still surfaces as HostConnectionError from connect; a failed copy -- an unwritable destination, a dropped transfer -- raises CommandError whose command text is the scp <source> -> <remote> rendering, with no exit status (scp reports an exception, not a shell exit).

HostConnectionError

HostConnectionError(
    failures: Iterable[tuple[str, str, BaseException]],
    *,
    transport: str = "SSH",
)

Raised when a connection to one or more hosts cannot be established.

Host.connect wraps the underlying transport failure -- an OSError such as a reset or timeout, or an asyncssh.Error such as a host-key or authentication rejection -- in this single, library-specific exception, so callers never have to catch asyncssh internals. DockerHost.connect reuses it for container targets (a missing CLI binary, a failed run/start), labelling the message with transport="docker" instead of the default "SSH". The message stays generic: it names the unreachable host(s) and nothing else, while the original exception is preserved as __cause__ (and per host in failures) for diagnosis. drivers.run_prepare merges the per-host failures from a concurrent inventory into one aggregate via aggregate, so every unreachable host is reported at once instead of only the first to fail.

failures is a tuple of (host_name, address, cause) triples.

failures instance-attribute

failures: tuple[tuple[str, str, BaseException], ...] = (
    tuple(failures)
)

host_names property

host_names: tuple[str, ...]

Names of the hosts that could not be reached.

transport instance-attribute

transport = transport

aggregate classmethod

aggregate(
    errors: Iterable[HostConnectionError],
) -> HostConnectionError

Merge several per-host errors into one naming every failed host.

InventoryEntry

Pydantic schema for one inventory entry.

An entry must carry at least host (the host name) and connection (the SSH connection data -- possibly an empty mapping for a host only ever driven through the test framework's SimulatedHost); privilege, group and tags are optional. group is a single label naming the group the host belongs to; tags is an unordered list of string labels. Both are static, inventory-supplied values a deploy script can branch on (host.group, host.tags). Any other keys are allowed and preserved: extra="allow" keeps them in model_extra, which Host.from_inventory folds into Host.extra, so the inventory format stays extensible without schema changes.

connection instance-attribute

connection: dict[str, Any]

host instance-attribute

host: str

model_config class-attribute instance-attribute

model_config = ConfigDict(extra='allow')

privilege class-attribute instance-attribute

privilege: dict[str, Any] = Field(default_factory=dict)

tags class-attribute instance-attribute

tags: list[str] = Field(default_factory=list)

InventoryError

Raised when an inventory or one of its entries fails validation.

Wraps pydantic's ValidationError -- and the structural checks that the inventory is a list and each entry a dictionary -- so callers have a single, library-specific exception to catch, with a message naming the offending entry.

PrivilegeError

PrivilegeError(
    failures: Iterable[tuple[str, str, BaseException]],
)

Raised when a host rejects the privilege escalation it was asked to perform.

Host.run_shell_privileged recognises sudo's, su's and doas's own authentication failures -- a wrong password, a missing one where the tool demands it, a doas rule that denies the login user, or a run-as user the login user may not become -- and wraps them here instead of raising the generic CommandError any non-zero exit would otherwise produce. The report mirrors HostConnectionError: the message stays generic, naming the affected host(s) and nothing else, so sudo's three "Sorry, try again" retries never become the user-facing error, and the password cannot reach it either. The detail is kept for diagnosis rather than discarded -- each failures entry carries the credential-free command text and the CommandError (exit status plus captured stderr) as its cause, and the first cause is chained as __cause__.

drivers.run_apply merges the per-host failures from a concurrent lockstep step into one aggregate via aggregate, so every host whose elevation was rejected is reported at once instead of only the first to fail.

failures is a tuple of (host_name, command, cause) triples.

failures instance-attribute

failures: tuple[tuple[str, str, BaseException], ...] = (
    tuple(failures)
)

host_names property

host_names: tuple[str, ...]

Names of the hosts that rejected the configured elevation.

aggregate classmethod

aggregate(
    errors: Iterable[PrivilegeError],
) -> PrivilegeError

Merge several per-host errors into one naming every failed host.

validate_inventory

validate_inventory(inventory: Any) -> list[InventoryEntry]

Validate a whole inventory: a list of dictionaries, each carrying at least a host and a connection key.

Returns the validated InventoryEntry models. Raises InventoryError when inventory is not a list or an entry fails the InventoryEntry schema (not a dictionary, missing a required key, or a wrong type); the wrapped pydantic error identifies each offending entry by its list index. drivers.run calls this up front so a malformed inventory fails before any host is materialized or connected.