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 ashost.group(Nonewhen unset).tags— an unordered list of strings, exposed ashost.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 tosudo -u(sincesudodefaults toroot, asudo_userofrootcollapses to a baresudo) — andsudo_password, the sudo password. Both apply only when an operation is flaggedsudo=True.su_user— the target of a defaultsu <user> -c(sincesudefaults toroot, asu_userofroot, or none at all, collapses to a baresu -c) — andsu_password, the su password. Both apply only when an operation is flaggedsu=True. Only default su is supported: no login shell (su -l), no environment preservation, no shell override.doas_user— the target of a passwordlessdoas -n -u <user>(sincedoasdefaults toroot, adoas_userofroot, or none at all, collapses to a baredoas -n). It applies only when an operation is flaggeddoas=True. There is deliberately nodoas_password:doashas no askpass mechanism and asyncssh channels offer no TTY, so reemote always runsdoas -n(non-interactive — it fails rather than prompts) and the host must grant the login user apermit nopassrule 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 ofrun_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 ofrun_shell_privileged. The escalation is built identically (the shared_build_privileged_commandproduces the execution string and the credential-free rendering), but the command's own exit travels back instead of raisingCommandError. The one exception is doas's, sudo's or su's own authentication rejection, which still raisesPrivilegeError: 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.
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.
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)
)
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.
privilege
class-attribute
instance-attribute
¶
privilege: dict[str, Any] = Field(default_factory=dict)
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.