Skip to content

Drivers

reemote.drivers is the execution engine. run is the entry point used by deploy scripts and examples; the remaining functions are exposed so the two phases can be driven, inspected and rendered separately.

The command-execution path is asynchronous: send_command is a coroutine that runs each command over asyncssh (or forwards to a SimulatedHost in tests), and therefore run_apply and run are coroutines as well. Fact gathering is asynchronous for the same reason — a fact's process runs over asyncssh — so materialize and run_prepare are coroutines too. The formatters are pure local rendering and stay synchronous.

Fact gathering is per-host by default — FactCache.get reads one fact for one host. gather_fact is the driver-level fan-out across the whole inventory: it runs a fact's process for every host concurrently with asyncio.gather and returns a host-aligned mapping of host.name to that host's value (mirroring the baseline's get_facts, which returns {host: value}). Facts stay single-host — only the driver owns the concurrency — so process, FactCache and the SimulatedHost seams are untouched.

A request whose doas/sudo/su tri-state resolves to elevation (Privilege.ON, i.e. sudo=True; see Deploy scripts) is elevated by the driver, which collapses the tri-state to a concrete bool before sending. command_shell renders the shell text for a command descriptor — prefixing doas -n (or doas -n -u <user>) and sudo (or sudo -u <user>), and wrapping su <user> -c …, combining them in the fixed order doas then sudo then su (doas is the outermost escalation, su the innermost payload). The run-as users come from the host's inventory privilege section, never from the operation; root, being the default target of all three, collapses to a bare doas -n / sudo / su -c. This rendering is what format_log shows and is deliberately credential-free: when privilege carries a password, send_command elevates through Host.run_shell_privileged, which runs sudo -A against a generated askpass helper (or pipes a su_password to su's stdin) so the password is supplied without a TTY and never reaches the log (doas is passwordless, so it carries no credential at all). On the real transport an applied command that exits non-zero raises CommandError (fail-fast) instead of being silently logged as applied — unless doas, sudo or su reports the failure as its own authentication rejection (a wrong or missing password, or a denied doas rule), which raises PrivilegeError so a rejected credential is reported as a privilege problem rather than a broken command. See Privilege: sudo, su and doas.

A request whose when guard fails on a host is skipped by the driver in both phases: prepare records a skipped PlannedOperation without materializing the operation at all (no facts are read for it — the guard may exist precisely because the host cannot provide them), and apply logs a skipped LogEntry without sending anything. The request still consumes its lockstep slot, so the shared schedule stays aligned across hosts; format_plan and format_log render it as [skipped: when guard <predicate name>] — the reason names the failing predicate, not the request — a third outcome beside changed and no change. See Deploy scripts for the guard's static-data and stable-predicate rules.

Phase awareness

Only the drivers know which phase they are running in. Facts, operations and command descriptors are phase-unaware: the driver expresses the difference between prepare and apply entirely through the lifetime of the FactCache it hands out, and through whether it forwards a command to send_command.

deploy(host) generator
      │  yield OperationRequest
      ▼
run_prepare ──► materialize() ──► (changed, reason, commands)   [plan / dry run]
run_apply   ──► materialize() ──► await send_command() per cmd  [host mutated]

This invariant also powers the in-deploy guard for actions. run_prepare and run_apply are wrapped by _deploy_phase, which sets a ContextVar for their duration (propagating into the per-host tasks asyncio.gather spawns) and resets it cleanly even when a phase raises. Only the drivers toggle it, so facts, operations and actions stay phase-unaware; gather_action merely reads the flag and raises ActionInDeployError at the call site if an action is attempted inside a deploy. The guard is reinforced from the other side: materialize requires an async-generator operation, so wrapping an action class as op(Shell, ...) is refused with OperationError rather than being run as a deploy step.

The same two invocation points enforce the yield requests rule. Both run_prepare (through _prepare_host) and the lockstep pull in run_apply type-check every item the deploy body hands over and tally the requests it builds, so a body that returns no request stream (an op(...) call with no yield anywhere), yields something that is not an OperationRequest, or builds a request and drops it raises DeployRequestError — in a normal run always during prepare, before a command is sent, and with the hosts of an apply step aggregated into one message. The tally lives on each HostRuntime, so it is per host and per phase, and compose(..., dedupe=True) charges its own collapses so a deliberate duplicate is not mistaken for a lost request.

Concurrency across hosts

Hosts are independent — each owns its own connection, deploy generator and FactCache — so the driver issues the per-host work for a phase or lockstep step concurrently with asyncio.gather, then joins:

  • run_prepare gathers each host's whole prepare (collect its requests, materialize the plan).
  • run_apply gathers step 2 (materialize + send_command) for every active host; the request pull and the determinism check stay synchronous and complete before anything is sent.

The gather join is the barrier that preserves cross-host lockstep ordering, and because asyncio.gather returns results in input order, the plans and the run log stay deterministic regardless of the order the hosts finish. Commands within a single host still run sequentially, since some command sequences are order-dependent. gather_fact applies the same fan-out to a one-off fact read outside the two-phase flow.

Both gathers also report failures the same way, since concurrency means several hosts can fail at once. run_prepare collects the per-host HostConnectionError instances of an inventory, and run_apply the per-host PrivilegeError instances of a step, each merging them into a single aggregate naming every failed host instead of only the first to lose the race. gather_fact gives the same treatment to CommandNotFoundError: every host missing the command the fact runs is named in one aggregate. Any other exception propagates unchanged, so a CommandError or a genuine bug still aborts the run.

Reference

reemote.drivers

Drivers (design doc section 8).

The prepare phase collects the request sequence per host and materializes each operation to compute (changed, reason) for plan/--dry output.

The apply phase advances one generator per host in lockstep, recreating each host's FactCache at every step so generator-body fact reads see the effects of prior operations. Operations and facts never inspect a phase flag; only the driver changes behavior between phases.

Within each phase the per-host work is independent (each host owns its own connection, generator and FactCache), so it is issued concurrently across hosts with asyncio.gather: run_prepare gathers each host's whole prepare, and run_apply gathers step 2 (materialize + send) for all active hosts. The gather join is the barrier that preserves cross-host lockstep ordering, and asyncio.gather returns results in input order so plans and the run log stay deterministic. Commands within a single host still run sequentially, since some command sequences are order-dependent.

Command execution is asynchronous: send_command (and therefore run_apply and run) are coroutines, preparing the apply path for a real asyncssh-based transport. Fact gathering is asynchronous for the same reason: a fact's process will run over asyncssh, so FactCache.get, the operations that await it, and materialize are coroutines too. The whole prepare/apply path is therefore awaited end to end.

ApplyReport dataclass

ApplyReport(log: list[LogEntry] = list())

changed property

changed: bool

log class-attribute instance-attribute

log: list[LogEntry] = field(default_factory=list)

PlannedOperation dataclass

PlannedOperation(
    request: OperationRequest,
    changed: bool,
    reason: str,
    commands: tuple[Command, ...],
    skipped: bool = False,
)

One materialized request in a host's plan.

A skipped entry was not materialized at all: the request's when guard failed on this host, so no facts were read and no commands were generated. changed is False and reason names the failing predicate.

changed instance-attribute

changed: bool

commands instance-attribute

commands: tuple[Command, ...]

reason instance-attribute

reason: str

request instance-attribute

skipped class-attribute instance-attribute

skipped: bool = False

LogEntry dataclass

LogEntry(
    host: str,
    name: str,
    changed: bool,
    reason: str,
    commands: tuple[Command, ...] = (),
    sudo: bool = False,
    sudo_user: str | None = None,
    su: bool = False,
    su_user: str | None = None,
    doas: bool = False,
    doas_user: str | None = None,
    skipped: bool = False,
)

One executed (or skipped) operation in the apply-phase run log.

skipped marks an entry whose when guard failed on this host: it was neither materialized nor sent, changed is False and the privilege fields stay at their inert defaults (nothing ran, so nothing was elevated). This is a third outcome alongside changed and "no change": ineligible regardless of state, versus nothing to do.

changed instance-attribute

changed: bool

commands class-attribute instance-attribute

commands: tuple[Command, ...] = ()

doas class-attribute instance-attribute

doas: bool = False

doas_user class-attribute instance-attribute

doas_user: str | None = None

host instance-attribute

host: str

reason instance-attribute

reason: str

skipped class-attribute instance-attribute

skipped: bool = False

su class-attribute instance-attribute

su: bool = False

su_user class-attribute instance-attribute

su_user: str | None = None

sudo class-attribute instance-attribute

sudo: bool = False

sudo_user class-attribute instance-attribute

sudo_user: str | None = None

DeterminismError

Raised when the apply-phase request sequence diverges from the plan.

If a generator body branches on a mutable fact, the prepare and apply phases may produce different request sequences, breaking lockstep pairing (design doc section 11.1). The driver treats this as a hard error. The same applies to request values: a when predicate (or any other argument) constructed fresh on each phase invocation -- e.g. an inline lambda in a deploy body -- compares unequal across phases even when semantically identical, so guards must be stable function objects defined at module or composition level.

run async

run(
    inventory: list[dict],
    deploy_func: Callable,
    *,
    host_factory: Callable[[dict], Host] = from_inventory,
    dry: bool = False,
) -> tuple[
    dict[str, list[PlannedOperation]],
    ApplyReport | None,
    list[Host],
]

Top-level entry point: prepare, then apply (unless dry).

inventory is the user-supplied list of host dictionaries (see hosts.py). It is validated up front with validate_inventory -- it must be a list of dictionaries, each carrying at least host and connection -- and raises InventoryError on bad input before any host is materialized. Each entry is then materialized into a Host via host_factory. The default factory builds plain library hosts; the pytest suites inject a factory building SimulatedHost objects (the test-framework class in tests/simulated_host.py) so the simulation stays out of the library. The examples/ scripts use the default factory and connect for real, so they mutate the hosts they target; the two exceptions are examples/demonstrations/composition/composed_group_tag_roles.py, which injects a simulated factory, and examples/actions/action_in_deploy_guard.py, whose guard fires during prepare, before any transport is opened.

Returns (plans, report, hosts) so callers can inspect the final host state. A coroutine: both the prepare phase (which materializes the plan) and the apply phase (which executes commands via the async send_command) are awaited.

run_prepare async

run_prepare(
    hosts: Iterable[Host], deploy_func: Callable
) -> tuple[
    list[HostRuntime], dict[str, list[PlannedOperation]]
]

Prepare phase: invoke each host's generator and materialize the plan.

Returns (runtimes, plans). Fact reads go through the prepare-phase cache and reflect pre-deployment state; nothing is executed. Each host is prepared concurrently via asyncio.gather (hosts are independent), and the results come back in input order, so runtimes and plans stay deterministic. A coroutine, because materializing awaits operations and their facts.

The _deploy_phase wrapper holds the in-deploy guard for the whole phase: a gather_action call from a deploy generator raises ActionInDeployError immediately (actions execute at once and would mutate hosts during prepare).

Because the hosts connect concurrently, several may fail to be reached at once. Per-host HostConnectionError instances are therefore collected (via return_exceptions=True) and merged into a single aggregate naming every unreachable host, so the caller learns about all of them together rather than only the first to fail. Any other exception is a genuine error and propagates unchanged.

run_apply async

run_apply(
    runtimes: Iterable[HostRuntime],
    plans: dict[str, list[PlannedOperation]] | None = None,
) -> ApplyReport

Apply phase: advance all host generators in lockstep.

All hosts execute their Nth request before any host pulls request N+1. Within a step the per-host execution (materialize + send) runs concurrently across hosts via asyncio.gather; the gather join is the barrier between steps, and results are collected in host order so the log stays deterministic. The request pull and the determinism check stay synchronous and complete before anything is sent. If plans is given, each pulled request sequence is checked against the plan; a mismatch raises DeterminismError. The _deploy_phase wrapper holds the in-deploy guard here too, so an action called from a generator body raises ActionInDeployError in apply exactly as it does in prepare.

Because a step's hosts execute concurrently, several may have their elevation rejected at once. Per-host PrivilegeError instances are therefore collected (via return_exceptions=True) and merged into a single aggregate naming every host whose credentials failed, so one bad sudo_password is reported for the whole inventory at once -- the same treatment run_prepare gives HostConnectionError. Any other exception is a genuine error and propagates unchanged, so a CommandError or a bug still aborts the run.

The pull is also where the "yield requests" contract is enforced in apply (mirroring _prepare_host): each pull runs inside the runtime's _RequestCounter so op() calls in the body are tallied, every pulled item is type-checked as an OperationRequest, and a body that finishes having built more requests than it yielded raises DeployRequestError -- aggregated across the hosts of the step, since a lockstep step pulls from every active host at once. A runtime the caller assembled itself, with no deploy accounting to speak of, still gets the type check.

A coroutine, because commands are executed via the async send_command.

materialize async

materialize(
    host: Host, cache: FactCache, request: OperationRequest
)

Fully materialize an operation's async command generator.

Returns (ctx, commands). Materializing computes ctx.changed (design doc section 6.3, cost of approach C); it is a coroutine because the operation awaits facts, which gather their values asynchronously.

An operation must be an async generator function. Anything else -- e.g. an action class passed to op() -- raises OperationError here rather than failing obscurely in the async for: actions execute immediately and cannot run inside a deploy (see reemote.actions).

gather_fact async

gather_fact(
    hosts: Iterable[Host],
    fact_cls: type,
    *args: Any,
    **kwargs: Any,
) -> dict[str, Any]

Gather one fact across all hosts concurrently.

The driver-level fan-out that FactCache.get performs for a single host: it runs fact_cls.process for every host at once via asyncio.gather and returns a host-aligned mapping of host.name to that host's gathered value. Hosts are independent -- each owns its own connection and (in tests) its own simulated state -- so the per-host reads overlap on the network instead of running one host at a time.

asyncio.gather preserves input order in its result list, so zipping the hosts with the gathered values keeps the mapping deterministic regardless of the order in which individual hosts finish. A dict keyed by host name is returned rather than a positional list so a caller can look a value up without re-deriving the inventory order (the mapping is keyed by host name, {host: value}).

fact_cls is a fact class exposing an async def process(host, ...) classmethod (e.g. DnfRepositories); args/kwargs are forwarded to it verbatim, exactly as FactCache.get does. A coroutine, because each fact's process gathers its value asynchronously.

Because the hosts are read concurrently, several may lack the command the fact runs at once. Per-host CommandNotFoundError instances (exit 127) are therefore collected (via return_exceptions=True) and merged into a single aggregate naming every host missing the command -- the same treatment run_prepare gives HostConnectionError -- rather than reporting only the first to fail. Any other exception is a genuine error and propagates unchanged.

command_shell

command_shell(
    cmd: Command,
    sudo: bool = False,
    sudo_user: str | None = None,
    su: bool = False,
    su_user: str | None = None,
    doas: bool = False,
    doas_user: str | None = None,
) -> str

Render the shell string a command descriptor runs as (the loggable form).

Command descriptors stay dumb (design doc section 4.5); turning one into shell text is the driver's job. With doas, sudo and/or su this is the elevated form the apply log shows; it is deliberately credential-free -- when the host's privilege carries a password the real transport runs it under an askpass helper (see Host.run_shell_privileged), plumbing this rendering never exposes so the password cannot leak into format_log. send_command also calls it with the flags off to get the plain descriptor shell it hands to the transport. A TransferCommand is never elevated -- it copies over the connection's scp channel as the login user -- so its rendering is returned without any doas/sudo/su prefix even when the flags are set.

The escalations combine in the fixed order doas then sudo then su: doas is the outermost prefix and su the innermost, so a request flagged all three renders as doas -n [-u <doas_user>] sudo [-u <sudo_user>] su <su_user> -c '<shell>'.

sudo_user/su_user/doas_user are the run-as users taken from the host's inventory privilege section (never from the operation). sudo_user is emitted as sudo -u <user> except that root -- sudo's own default target -- collapses to a bare sudo. su_user is emitted as su <user> -c except that None/root -- su's default target -- collapses to a bare su -c. doas_user is emitted as doas -n -u <user> except that None/root collapses to a bare doas -n (doas is passwordless, so no credential is ever rendered).

send_command async

send_command(
    host: Host,
    cmd: Command,
    *,
    sudo: bool = False,
    su: bool = False,
    doas: bool = False,
) -> None

Execute one command descriptor on a host.

Two paths, chosen by the seams the host provides:

  • The test framework's SimulatedHost exposes an execute_command seam; when present it applies the command's simulated effect (so subsequent fact reads observe the change) and nothing is sent anywhere. sudo/su/doas are irrelevant here -- privilege has no in-memory effect -- so they are not forwarded. This is the path pytest always takes.
  • Any other host -- a plain library Host built from a real inventory entry -- dispatches on the descriptor kind. A TransferCommand (e.g. files.put's PutCommand, files.get's GetCommand) copies over the connection's own transport via the descriptor's transfer (asyncssh's scp), never through a shell, so the sudo/su/doas escalation flags do not apply to it and are ignored. A WaitCommand (e.g. server.wait_service's WaitForFactCommand) likewise runs no shell: the driver awaits the descriptor's wait, which polls the host until its condition holds or raises WaitTimeoutError; the escalation flags are ignored for it too. Anything else runs the command's shell over asyncssh, using the host's connection data. An elevated request (doas, sudo and/or su) goes through host.run_shell_privileged, which applies the run-as users and any passwords (via askpass helpers) sourced from the inventory, in the fixed order doas then sudo then su; an unprivileged one goes through host.run_shell. Either way a non-zero exit raises CommandError (fail-fast), so a command that fails -- a permission error, a broken package transaction -- is never silently reported as applied. A failure doas, sudo or su reports as its own authentication rejection (a wrong or missing password, or a doas denial) raises PrivilegeError instead, which run_apply aggregates across hosts exactly as run_prepare aggregates HostConnectionError.

format_plan

format_plan(
    plans: dict[str, list[PlannedOperation]],
) -> str

Render plan output as shown in design doc section 10.

format_log

format_log(report: ApplyReport) -> str

Render the apply-phase run log, including generated commands.