Core objects¶
reemote.core holds the value types that carry a deploy through both
phases. OperationRequest is what a deploy
script yields; OpContext is what the driver hands
back to an operation; FactCache is the boundary
that gives generator-body fact reads their sequential semantics.
sub_deploy and compose
are the composition helpers: pure request-stream transforms that wrap
sub-deploys with default meta-parameters (a name prefix, privilege elevation,
an eligibility guard) and concatenate them into one deploy-shaped callable,
optionally collapsing requests that would execute identically (dedupe by
effective identity — the plan name is excluded and the first occurrence
wins).
Privilege is the tri-state behind the
doas/sudo/su meta-parameters — ON, OFF and UNSET — which lets a
sub_deploy policy force elevation across a role while still honouring an
explicit sudo=False opt-out (see
Composing deploys). op and
sub_deploy also accept a plain bool (True → ON, False → OFF) and
default to UNSET; the driver collapses the tri-state to a concrete bool
before a command is sent, where only ON elevates.
The when meta-parameter is an eligibility guard: a
WhenPredicate (or tuple of predicates) over
static inventory data, captured on the request and evaluated by the driver
at the execution boundary. A guarded request stays in every host's sequence
— one shared schedule — and is skipped on hosts that fail the guard, without
being materialized at all; the plan and run log render it as
[skipped: when guard <name>]. Unlike the escalation policy's inner-wins
fill, guards compose by conjunction: a sub_deploy guard and an inner
guard must both pass. Predicates must be stable module-level functions
reading only static data, so both phases reach the same decision (see
Determinism).
OperationError is raised by an operation
whose prerequisites are not met on a host — server.timezone, for instance,
requires timedatectl and is supported on systemd systems only. It aborts
the run fail-fast while materializing (in a deploy run that is the prepare
phase, before anything is sent), with a message naming the host and the
missing prerequisite.
UnhashableArgumentError is raised
by the request model itself, while an OperationRequest is being built. A
request's args/kwargs values are hashed by
compose(..., dedupe=True), so a mutable container
there — packages=["nginx"], or a list nested inside a tuple — is rejected on
the spot (a TypeError subclass) naming the offending field, rather than
failing later from inside a set insertion in a composition helper.
DeployRequestError enforces the deploy
body's other rule: yield the requests, do not merely build them. It is raised
(also a TypeError subclass) when the body returns no iterable at all, when it
hands over something that is not an OperationRequest, or when it builds a
request with op() and drops it instead of yielding it — the last of these is
caught by a per-invocation tally of requests built versus requests yielded,
maintained through a ContextVar that op() charges on each construction.
compose's dedupe collapses are charged as dropped on purpose, so a legal
duplicate is never read as a lost request. Both phases run the checks —
see Drivers and
Deploy scripts.
reemote.core
¶
Core data structures for the generator-based two-phase execution model.
See the design document sections 4.1 (OperationRequest), 4.3 (OpContext), 4.4 (FactCache) and 4.6 (HostRuntime). None of these objects are aware of the phase they are used in; phase awareness lives entirely in the drivers.
OperationRequest
dataclass
¶
OperationRequest(
func: Callable,
args: tuple[Any, ...] = (),
kwargs: dict = dict(),
name: str = "",
sudo: Privilege = UNSET,
su: Privilege = UNSET,
doas: Privilege = UNSET,
when: WhenPredicate
| tuple[WhenPredicate, ...]
| None = None,
)
A plain, frozen, serializable descriptor of "an operation to perform".
frozen=True gives value equality, which is what the lockstep scheduler
compares to pair "the Nth operation for host A" with "the Nth operation for
host B" -- and to check an apply-phase request against the prepare-phase
plan. Hashing is a second, narrower need: the dict kwargs field is
unhashable, so __hash__ is defined explicitly over a hashable projection
of the fields, and it is compose(dedupe=True) (which puts _effective_key
tuples in a set) that calls it. Because that key includes every argument
value, an unhashable one -- packages=["nginx"] -- is rejected here, in
__post_init__, with UnhashableArgumentError.
sudo, su and doas are meta-parameters: like name they
describe how the driver should run the operation rather than being forwarded
to func. Each is a :class:Privilege tri-state, not a plain bool, so a
request can distinguish "elevate" (ON), "deliberately run unprivileged"
(OFF) and "no opinion" (UNSET, the default). Only ON elevates:
the driver collapses the tri-state with Privilege.elevates before
sending, wrapping the command with doas, sudo and/or su on the
real transport (the test framework's simulated seam ignores them, since
privilege has no in-memory effect). The tri-state is what lets a
sub_deploy policy force elevation while honouring an explicit opt-out
-- see sub_deploy. Note that these are only requests to elevate: how
to elevate -- the run-as users and the passwords -- comes from the host's
inventory privilege section, never from the operation, so a deploy
script cannot choose its own privilege parameters. When several are ON
the escalations combine in a fixed order, doas then sudo then su
(doas is the outermost escalation and su the innermost, so doas
runs sudo, which runs su inside it).
when is the eligibility meta-parameter: a guard predicate (or tuple
of predicates) over static host data, stored normalized as a tuple
(empty = always eligible). The driver evaluates the predicates at the
execution boundary -- a request whose guard fails on a host is skipped
there, but stays in the host's request sequence, so every host shares the
same schedule and lockstep pairing is untouched. Unlike sudo/su
(configuration values a wrapper fills in), guards compose by conjunction:
a sub_deploy guard and an inner guard must both pass. Predicates
must read only static inventory data (host.name, host.group,
host.tags, ...) -- never mutable facts -- so both phases reach the
same skip decision, and they must be stable function objects (module- or
composition-level definitions, like func itself): an inline
lambda in a deploy body is a fresh object on each phase invocation,
which the determinism guard correctly reports as divergence.
when
class-attribute
instance-attribute
¶
when: WhenPredicate | tuple[WhenPredicate, ...] | None = (
None
)
when_failure
¶
when_failure(host: Host) -> WhenPredicate | None
The first guard predicate host fails, or None when eligible.
Short-circuits in tuple order (outer sub_deploy guards first), so
the returned predicate names the outermost reason a host is skipped
-- which is what the plan and run log report. An empty when tuple
means no guard: every host is eligible.
OperationError
¶
Raised when an operation's prerequisites are not met on a host.
An operation may require a capability the host does not have -- e.g.
server.timezone requires timedatectl and is supported on systemd
systems only. Rather than silently skipping the host or yielding a
command that cannot run, the operation raises this while materializing,
which in a deploy run happens in the prepare phase -- before anything is
sent -- so the run aborts fail-fast like CommandError, with a message
naming the host and the missing prerequisite.
UnhashableArgumentError
¶
Raised when an OperationRequest carries an unhashable argument value.
Pairing hosts in lockstep and the determinism guard both compare requests by
value; hashing comes in separately, where compose(dedupe=True) builds
a set of _effective_key tuples -- and every argument value is part of
that key. An unhashable value (a list, a dict, a set, or one of
those nested inside a tuple) therefore breaks deduplication with a bare
TypeError raised from inside a set insertion, far from the deploy line
that caused it. So the request validates its own arguments at construction
time, while the offending call site is still on the traceback.
Subclasses TypeError -- what it replaces -- so existing handlers keep
working.
DeployRequestError
¶
Raised when a deploy body breaks the "yield requests" contract.
A deploy is a plain sync generator whose body hands the driver exactly one
OperationRequest per yield. Breaking that shape loses work in
silence, or fails with an error raised so deep in the driver that it names
nothing useful, so the driver checks the contract at the two points where a
deploy body is invoked (drivers._prepare_host and the lockstep pull in
drivers.run_apply) -- which in a normal run means the prepare phase,
before anything is sent:
- the body returned a non-iterable (typically
None) because it callsop()withoutyield, so there is no request stream at all; - a yielded item is not an
OperationRequest-- e.g. an operation called from the deploy body, which cannot even be invoked without theOpContextonly the driver holds; - the body built a request with
op()and dropped it instead of yielding it. This is the shape nothing else can catch: the sequence stays valid, is identically shorter in both phases (so the determinism guard holds) and the operation simply never runs. It is caught by the per-invocation built-versus-yielded tally in_RequestCounter.
Subclasses TypeError -- the bare error these misuses used to raise from
inside the driver -- so a caller handling TypeError keeps handling it,
and the message names the host, the phase and the missing yield.
Privilege
¶
Tri-state privilege-escalation request for the sudo/su/doas
meta-parameters.
A plain bool conflates two different intentions: "run this operation
unprivileged" and "no opinion, let a wrapper decide". Because they are the
same False, a sub_deploy(sudo=True) policy cannot tell a request
that deliberately opted out from one that was simply never flagged, and
elevates both. Privilege separates the three states so forced elevation
can honour an explicit opt-out:
ON-- elevate this operation's commands.OFF-- deliberately run unprivileged; a forced-elevation wrapper (sub_deploy(sudo=Privilege.ON)) respects it. This is the opt-out a plain bool cannot express.UNSET-- no opinion. A wrapper may fill it in; with no wrapper left it resolves to "do not elevate" at the execution boundary, so an unflagged top-level request stays unprivileged exactly assudo=Falsedid before.
The execution layer (drivers.command_shell, Host.run_shell_privileged,
LogEntry) deals in concrete bools, so the driver collapses a request's
tri-state with elevates() before sending: only ON elevates.
coerce
classmethod
¶
Normalize the accepted spellings to a Privilege member.
Accepts a Privilege (returned unchanged), None (-> UNSET)
and a plain bool for ergonomic call sites -- True -> ON,
False -> OFF -- so existing sudo=True/sudo=False keep
working while the omitted default stays UNSET.
elevates
¶
elevates() -> bool
The concrete elevation decision: True only for ON.
OFF and UNSET both collapse to False -- by the time a
request reaches the driver every wrapper has run, so a still-UNSET
flag means nothing claimed it and it must not elevate.
OpContext
dataclass
¶
Mutable context threaded through one operation invocation.
This is the mechanism by which an operation communicates changed
and reason back to the driver (design doc section 6.3, approach C).
FactCache
¶
FactCache(host: Host, phase: str)
Per-host, per-phase fact cache.
Also recreated at each lockstep step in the apply phase, which is what
gives generator-body fact reads sequential "act, then read" semantics.
get is a coroutine because a fact's process gathers its value
asynchronously (over asyncssh in the real implementation).
HostRuntime
dataclass
¶
HostRuntime(
host: Host,
generator: Generator,
fact_cache: FactCache,
current_request: OperationRequest | None = None,
current_ctx: OpContext | None = None,
done: bool = False,
deploy_counter: _RequestCounter = _RequestCounter(),
)
Per-host state maintained by the apply-phase driver.
deploy_counter is the built-versus-yielded tally for this host's
apply-phase generator, installed by run_apply around each request
pull. _prepare_host gives every runtime a fresh one, so a deploy body
that loses a request is caught in the prepare phase first and again here if
a caller drives apply on a runtime it built itself.
current_request
class-attribute
instance-attribute
¶
current_request: OperationRequest | None = None
deploy_counter
class-attribute
instance-attribute
¶
deploy_counter: _RequestCounter = field(
default_factory=_RequestCounter
)
op
¶
op(
func: Callable,
*,
name: str = "",
sudo: Privilege | bool = UNSET,
su: Privilege | bool = UNSET,
doas: Privilege | bool = UNSET,
when: WhenPredicate
| tuple[WhenPredicate, ...]
| None = None,
**kwargs,
) -> OperationRequest
Convenience helper for building OperationRequests in deploy scripts.
name, sudo, su, doas and when are meta-parameters
consumed by the driver, so they are captured here and never forwarded to
func; everything else in kwargs is passed through to the operation
as keyword arguments.
sudo/su/doas accept a :class:Privilege or a plain bool
(True -> ON, False -> OFF) and default to UNSET -- "no
opinion", so a sub_deploy policy can still fill them in.
OperationRequest normalizes the value, so the stored flag is always a
Privilege.
when is an eligibility guard: a predicate over static host data
(lambda-free at this call site -- pass a stable, module-level function
so both phases yield equal requests), a tuple of predicates (all must
pass), or None (the default, always eligible). A guarded request stays
in every host's sequence; the driver skips it on hosts that fail the
guard. OperationRequest normalizes the value to a tuple.
Collection arguments must be immutable: packages=("nginx",), never
packages=["nginx"]. OperationRequest checks each args/kwargs
value is hashable and raises UnhashableArgumentError on the spot, so a
list -- or a list nested inside a tuple -- is named at the deploy line
instead of failing later inside compose(dedupe=True).
Every call is charged to the deploy body's built-versus-yielded counter when
one is installed, which is what lets the driver catch a request that was
built here and then never yielded (yield op(...), not op(...)) with
DeployRequestError. Outside a tracked deploy invocation the count is a
no-op, so building requests anywhere else is unaffected.
sub_deploy
¶
sub_deploy(
sub: Callable[[Host], Iterable[OperationRequest]],
*,
name_prefix: str = "",
sudo: Privilege | bool = UNSET,
su: Privilege | bool = UNSET,
doas: Privilege | bool = UNSET,
when: WhenPredicate
| tuple[WhenPredicate, ...]
| None = None,
) -> Callable[[Host], Generator[OperationRequest]]
Wrap a sub-deploy, applying default meta-parameters to its requests.
The returned callable has the deploy-script shape -- (host) ->
generator of OperationRequest -- so a wrapped sub-deploy nests with
yield from, composes with compose and is accepted anywhere a
deploy function is (e.g. run).
sudo/su/doas are an escalation policy for the wrapped
requests, given as a :class:Privilege (or a bool: True -> ON). The
inner request wins whenever it has an opinion, and the policy only fills the
UNSET ones (see _inherit):
sub_deploy(sudo=Privilege.ON)(equivalentlysudo=True) forces elevation on every request that did not deliberately opt out -- an innersudo=False/Privilege.OFFstays unprivileged, while an unflagged (UNSET) one is elevated. This forced-elevation-with-opt-out is exactly what a plain bool could not express.sub_deploy(sudo=Privilege.OFF)forces theUNSETrequests unprivileged, leaving an innerONuntouched.- the default
Privilege.UNSETis no policy at all: every request passes through unchanged.
when is a role-level eligibility guard -- the analogue of Ansible's
block-level when. Unlike the escalation policy it composes by
conjunction, not inner-wins: the wrapper's predicates are prepended to
each request's own when tuple, so both must pass and a wrapper can
only ever restrict eligibility, never widen it (a guard the inside
could override would be no guard at all). The default None adds no
predicate. Concatenating tuples -- rather than building a composed
closure per request -- keeps the transform value-stable across phases,
so the determinism guard holds.
name_prefix prepends "<prefix>: " to each plan name (an unnamed
request falls back to its operation function's name, matching the
formatters).
The wrapper is a pure stream transform over the frozen dataclass
(dataclasses.replace produces the modified copies): it keeps no
state between calls and reads nothing but each request, so the prepare
and apply phases flatten it identically and the determinism guard holds.
compose
¶
compose(
*subs: Callable[[Host], Iterable[OperationRequest]],
dedupe: bool = False,
) -> Callable[[Host], Generator[OperationRequest]]
Concatenate sub-deploys into one deploy-shaped callable, in order.
Each sub is any callable taking a Host and producing
OperationRequest objects (a deploy generator function, or one wrapped
by sub_deploy); their requests are yielded in argument order, so
composition doubles as an explicit cross-role ordering statement (e.g.
repositories before the packages they provide).
With dedupe=True, requests collapse by effective identity --
func, args, kwargs, sudo, su, doas and when,
but not name (see _effective_key) -- to their first occurrence,
which keeps its name.
Independently written roles may therefore each declare the same
prerequisite under a different label or a different sub_deploy name
prefix and still collapse to one plan entry; sudo/su/doas still
separate requests, since they change what executes, and so does when,
since it changes whether it executes. OperationRequest
equality remains maximal (name included), because the determinism guard
pairs full values.
The seen set is created inside the returned deploy, per invocation:
every deploy(host) call (one per host per phase) dedupes from
scratch, keeping the transform pure so both phases produce the identical
collapsed sequence and the determinism guard holds.
A request dropped as a duplicate is charged to the deploy body's
built-versus-yielded counter as dropped, because it was built on purpose
and collapsed on purpose -- without it the driver's DeployRequestError
check would read a dedupe as a lost request.