Composing deploys¶
Scripts under examples/demonstrations/composition/ — scenarios that cross
module boundaries to demonstrate the composition helpers
sub_deploy and compose
— one scenario each, in the order the guide
introduces them — plus a role-selection scenario keyed on the inventory's
group/tags, a when-guard scenario that keeps one shared schedule
across hosts, and three two-deploy scenarios that move a mutable read — a
fact snapshot or the apply report — into the gap between two complete
prepare/apply cycles. All read the shared
project-root inventory.json (validated up front) and guard their dnf work
to the centos group with a stable is_centos predicate, so the Alpine and
Ubuntu hosts are skipped without their facts being read; every one follows the
same dry-run-then-apply flow, and
composed_group_tag_roles runs in memory against SimulatedHost instead of
over SSH, selecting dnf/apt/apk roles per group. See the
Examples overview for the shared "connects for real"
warning and the error-reporting contract every script follows.
A shared baseline, deduplicated¶
composed_baseline_dedupe
¶
Example: independently written roles sharing a baseline, deduplicated.
This is a usage example, not part of the library. It builds on
dnf_packages.py (same two-phase run flow over the real asyncssh
transport) and shows the first composition scenario: roles written in
isolation that each declare the same prerequisite.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds one host in the
"centos" group alongside hosts in other groups. The whole composed deploy
is wrapped with sub_deploy(..., when=is_centos), so the dnf steps are
eligible only on the CentOS hosts: the driver skips them on the Alpine and
Ubuntu hosts without materializing them, and neither the dnf commands nor
the DnfPackages fact behind them runs there. Because when composes by
conjunction and the wrapper prepends the same predicate tuple to every
request, the three baseline copies still share an effective identity and
dedupe collapses them exactly as before.
role_httpd and role_monitoring both re-declare role_baseline's
request -- each under its own name ("Install baseline tools", "Install
httpd prerequisites", "Install monitoring prerequisites"), the way
independently written roles naturally would. compose(..., dedupe=True)
still collapses them, by effective identity -- func, args, kwargs and
sudo, but not name -- to their first occurrence, which keeps
its name. Names are excluded from the key because they never reach the
host: they only label plan and log entries. So despite the three differing
labels (and the differing sub_deploy name prefixes on top), the plan
holds a single "baseline: Install baseline tools" entry -- first name wins.
Without dedupe the duplicates would remain as separate plan entries
(harmless: dnf.packages re-reads the DnfPackages fact at each
lockstep step and reports no change).
The determinism guard is unaffected: it still compares full request
values (name included), since both phases flatten the same composed
deploy. Dedupe itself is deterministic too -- the seen set is created
per invocation, so the prepare and apply phases collapse identically.
Run it with::
uv run python examples/demonstrations/composition/composed_baseline_dedupe.py
Layered environments from static configuration¶
composed_environments
¶
Example: layered environments assembled from static configuration.
This is a usage example, not part of the library. It builds on
dnf_packages.py (same two-phase run flow over the real asyncssh
transport) and shows the second composition scenario: staging and
production deploys differing only in which roles are assembled.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds hosts in the "centos"
group alongside hosts in other groups. Each environment's composed deploy is
wrapped with sub_deploy(..., when=is_centos), so the dnf steps are
eligible only on the CentOS hosts and the driver skips them -- without
reading the DnfPackages fact -- on the Alpine and Ubuntu hosts.
The deploy body must never branch on mutable facts -- the prepare and
apply phases would flatten differently and run_apply would raise
DeterminismError. The legitimate place to branch is assembly time:
build_deploy(env) consults a static environment table and returns one
composed callable that both phases then use unchanged, so determinism is
structural rather than something to be careful about.
Each role is wrapped with sub_deploy(name_prefix=...) so the plan
output shows which role produced which request. Prefixing is safe here
because the roles do not share value-identical requests (no dedupe is
requested); see composed_baseline_dedupe.py for the interaction
between prefixes and deduplication.
Run it with::
uv run python examples/demonstrations/composition/composed_environments.py
A privilege policy over a third-party role¶
composed_sudo_policy
¶
Example: a site-wide privilege policy over unprivileged third-party roles.
This is a usage example, not part of the library. It builds on
dnf_packages.py (same two-phase run flow over the real asyncssh
transport) and shows the third composition scenario: role functions written
elsewhere -- here, as if by a colleague targeting a root login -- that carry
no sudo flags at all, adopted under a site policy that connects as an
ordinary user and elevates everywhere.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds hosts in the "centos"
group alongside hosts in other groups. The same sub_deploy wrapper that
sets the elevation policy also carries when=is_centos, so the redis role
is eligible only on the CentOS hosts; the driver skips it -- without reading
the DnfPackages fact -- on the Alpine and Ubuntu hosts. Privilege and
eligibility are independent axes: sudo fills the unflagged requests
(inner-wins), while when restricts who may run them at all (conjunction).
sub_deploy(role_redis, sudo=True) retro-fits the policy without editing
the role: sudo=True is a forced-elevation policy that fills in every
request which has no opinion of its own. sudo/su are a tri-state
(reemote.core.Privilege: ON/OFF/UNSET), so the inheritance
rule is inner wins -- a request that already set sudo=True (ON)
passes through untouched, an unflagged one (UNSET) is elevated by the
policy, and a request that deliberately set sudo=False (OFF) opts out
and stays unprivileged. That opt-out is exactly what a plain bool could not
express: a bool cannot tell "run this one unprivileged" from "never flagged",
so a sudo=True wrapper elevated both. The wrapping is a pure request-stream
transform (dataclasses.replace over the frozen OperationRequest), so
prepare and apply flatten identically and the determinism guard holds.
The role below shows all three states: the install omits the flag (UNSET,
elevated by the policy), the boot link already asks for sudo=True (ON,
preserved), and a healthcheck marker deliberately sets sudo=False (OFF)
to stay unprivileged even under the site-wide policy -- it only touches a
world-writable path, so it needs no root. In the run log the elevated commands
render with their sudo prefix ($ sudo dnf install -y redis) and the
opt-out does not ($ touch /tmp/redis-managed), since format_log maps
descriptors through the same command_shell the real transport uses.
Run it with::
uv run python examples/demonstrations/composition/composed_sudo_policy.py
Ordering: repositories before packages¶
composed_repo_ordering
¶
Example: ordering guarantees across roles -- repositories before packages.
This is a usage example, not part of the library. It builds on
dnf_packages.py (same two-phase run flow over the real asyncssh
transport) and shows the fourth composition scenario: dnf install of an
EPEL-provided package fails unless epel-release is installed first, and
compose makes that ordering explicit and reusable.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds hosts in the "centos"
group alongside hosts in other groups. The composed deploy is wrapped with
sub_deploy(..., when=is_centos), so both roles are eligible only on the
CentOS hosts; the driver skips them -- without reading the DnfPackages
fact -- on the Alpine and Ubuntu hosts, where EPEL and dnf do not apply.
Composition order is execution order, and the driver executes it in
cross-host lockstep: every host runs the Nth request before any host pulls
request N+1. So composing the repository role first guarantees that both
web1 and web2 have epel-release installed before either host starts
installing htop from it -- an ordering property that would otherwise
have to be hand-maintained inside one flat generator.
The roles are wrapped with sub_deploy(name_prefix=...) so the plan
shows which layer each request came from. No dedupe is requested: the two
roles share no requests, and prefixing would rename any that they did
(name participates in request equality).
Run it with::
uv run python examples/demonstrations/composition/composed_repo_ordering.py
Per-host roles from inventory data¶
composed_per_host_roles
¶
Example: per-host variation keyed on static inventory data.
This is a usage example, not part of the library. It builds on
dnf_packages.py (same two-phase run flow over the real asyncssh
transport) and shows the fifth composition scenario: one deploy whose
request sequence differs per host -- extra canary tooling on web1 only --
without endangering the determinism guard.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds hosts in the "centos"
group alongside hosts in other groups. The whole deploy is wrapped with
sub_deploy(..., when=is_centos), so the dnf steps are eligible only on
the CentOS hosts and the driver skips them -- without reading the
DnfPackages fact -- on the Alpine and Ubuntu hosts. The per-host variation
below is a second, independent axis: within the eligible CentOS hosts the
canary branch still keys on host.name.
The rule: never branch on mutable facts inside a deploy body (the
prepare and apply phases would flatten differently and run_apply would
raise DeterminismError), but branching on static inputs is safe.
host.name comes from the inventory entry and reads identically in both
phases, so if host.name == "web1" produces the same sequence per host
every time the driver re-invokes deploy(host).
The per-host roles are delegated with plain yield from -- the simplest
composition, needing no helper because nothing is renamed or elevated at
the composition site. The canary role is still written as a standalone
sub-deploy so it stays independently testable and reusable.
Run it with::
uv run python examples/demonstrations/composition/composed_per_host_roles.py
A shared schedule with when guards¶
composed_when_guard
¶
Example: a shared schedule with per-host eligibility via when guards.
This is a usage example, not part of the library. It builds on
composed_per_host_roles.py (same two-phase run flow over the real
asyncssh transport) and shows the declarative alternative to that example's
if host.name == ... branching: a when guard on the request itself.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds one Alpine host, two
CentOS hosts (web1, web2) and one Ubuntu host. Two guards at two levels
select who runs what, and they compose by conjunction:
- a role-level guard
when=is_centoswraps the whole deploy at the composition site (sub_deploy(deploy, when=is_centos)), the analogue of Ansible's block-levelwhen. It makes every step centos-only, so the Alpine and Ubuntu hosts skip the entire schedule -- and because a skipped request is never materialized, theDnfPackagesfact behind thednfsteps is not read on them either; - a request-level guard
when=is_canaryrides on the canary tooling request alone, opting web1 (the canary, pinned by its static inventory name) into the extra step while web2 skips just that one.
The difference from an if is the schedule. An if in the deploy body
changes the request sequence per host. A when guard keeps every host's
sequence identical -- all hosts share one schedule, and the driver skips the
guarded request on ineligible hosts, rendering it as
[skipped: when guard is_centos] or [skipped: when guard is_canary] side
by side with the hosts that run it. The request still consumes its lockstep
slot, so the Nth operation on host A always pairs with the Nth operation on
host B. Because the guards compose by conjunction (both must pass) and a
wrapper can only restrict eligibility, never widen it, the skip reports the
outermost failing predicate: on the Alpine and Ubuntu hosts that is
is_centos even for the canary step, while on web2 it is is_canary.
when is a meta-parameter like sudo: captured on the request, never
forwarded to the operation. Two rules keep it deterministic:
- the predicate reads static inventory data only (here
host.groupandhost.name), never mutable facts, so prepare and apply reach the same skip decision; - the predicate is a stable function object defined at module level --
an inline
lambdain the deploy body would be a fresh object on each phase invocation and the determinism guard would (correctly) report the requests as divergent.
Run it with::
uv run python examples/demonstrations/composition/composed_when_guard.py
Group and tag roles from inventory data¶
composed_group_tag_roles
¶
Example: per-host roles selected by host.name, host.group and host.tags.
This is a usage example, not part of the library. It builds on
composed_per_host_roles.py -- one deploy whose request sequence differs
per host -- and shows the same idea driven by the inventory's static
classifiers: a single deploy generator that branches on host.name,
host.group and host.tags to select which roles each machine gets.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), the same file the other composition
examples use. Its four hosts carry the classifiers the deploy branches on:
web1 and web2 are in the "centos" group, ubuntu is in the
"ubuntu" group, and alpine is in the "alpine" group and carries the
apk_package_manager tag. Each branch selects the package manager that
matches its group -- dnf for the CentOS web role, apt for the Ubuntu
database role, apk for the tag-selected Alpine tooling -- so the example
also crosses module boundaries.
The rule that makes this safe is the one composed_per_host_roles.py
states: never branch on a mutable fact inside a deploy body (the prepare and
apply phases would flatten differently and run_apply would raise
DeterminismError), but branching on static inventory data is fine.
host.name, host.group and host.tags all come from the inventory
entry and read identically in both phases, so deploy(host) produces the
same per-host sequence every time the driver re-invokes it -- even though the
sequences differ in length across hosts (a finished host simply idles at the
lockstep barrier while the others run their extra steps). The is_centos
predicate used for the web branch is the same stable, static-data guard the
other examples pass as when; here it drives an if because the point of
this example is per-host sequences, not a shared schedule with skips.
The roles are delegated with plain yield from -- the simplest composition,
needing no helper because nothing is renamed or elevated at the composition
site -- and each stays a standalone sub-deploy so it is independently reusable.
Unlike the other demonstrations, this one runs entirely in memory and opens
no SSH connection: it injects the test framework's SimulatedHost through
run's host_factory argument, so the plan and the run log reflect a
simulated pre-deployment state rather than a real machine. That keeps the
example runnable anywhere, with nothing to point at your own hosts. SimulatedHost
is test-framework scaffolding (it lives in tests/, never in the library), so
the project root is added to sys.path to import it.
Run it with::
uv run python examples/demonstrations/composition/composed_group_tag_roles.py
Patch windows: one package list, two intents¶
composed_patch_window
¶
Example: patch-window deploys -- the same roles, different intent.
This is a usage example, not part of the library. It builds on
dnf_packages.py (same two-phase run flow over the real asyncssh
transport) and shows the sixth composition scenario: one package list
driving two different deploys -- a normal "ensure installed" run and a
patch-window run that upgrades the same packages.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds hosts in the "centos"
group alongside hosts in other groups. Both composed deploys carry
when=is_centos on their sub_deploy wrapper, so the dnf steps are
eligible only on the CentOS hosts; the driver skips them -- without reading
the DnfPackages fact -- on the Alpine and Ubuntu hosts. The guard lives at
the composition site, not inside package_role, so the factory stays a
pure package-list-to-deploy transform.
package_role is a factory: it takes a package set (as *packages,
which naturally yields the tuple OperationRequest kwargs must stay
hashable with) plus the latest flag, and returns a deploy-shaped
callable. The intent difference lives entirely in dnf.packages'
options: latest=False only installs missing packages, while
latest=True additionally upgrades every installed, unpinned package
through dnf update -y <pkgs>.
Because a factory produces a fresh callable per intent, patch Tuesday is a different composed deploy over the same package list -- no duplicated role code, and each composed deploy is a pure function of the host, so both satisfy the determinism guard.
This example prepares both deploys read-only (dry=True) so the two
plans can be compared, then really runs only the patch deploy: on hosts
missing the packages it installs them, and on hosts that have them it
upgrades them.
Run it with::
uv run python examples/demonstrations/composition/composed_patch_window.py
Splitting a fact-dependent deploy in two¶
A task the determinism rule forbids as a single deploy — "install nginx,
then configure it only if the install actually put it on the box" — made
legal by moving the mutable-fact read into the gap between two complete
prepare/apply cycles. Deploy 1 (install_nginx) is branch-free; a
gather_fact snapshot of dnf.DnfPackages taken after its apply-phase join
captures the real post-install state per host; deploy 2 is built by a
factory closing over that frozen dict, so its branch is a pure function of
static input and both phases yield the identical request sequence. The
second run's dry=True plan is an honest preview of the configure step,
computed against the state the install actually left behind. The config is
staged with files.put to /tmp (transfers are never elevated); the
trade-off — a frozen snapshot, no mid-run replanning — is spelled out in the
script's own docstring below.
two_deploy_split
¶
Example: splitting a fact-dependent deploy into two deterministic deploys.
This is a usage example, not part of the library. It demonstrates the
sanctioned workaround for a task the determinism rule otherwise forbids:
"install nginx, then configure it only if the install actually put it on
the box". Written as ONE deploy, that branch is illegal -- the deploy
generator runs twice (prepare against pre-deployment state, apply against
live state), so a generator body branching on dnf.DnfPackages after a
dnf.packages install yields a different request sequence per phase and
run_apply raises DeterminismError.
The fix is to move the mutable-fact read OUT of the generator and into the gap between two complete prepare/apply cycles:
install_nginx-- a branch-free deploy that only installs the package. Both phases trivially yield the same single request.- A fact SNAPSHOT taken between the runs:
gather_factfans theDnfPackagesread out across all hosts and returns a{host.name: list[str]}mapping of the real POST-install state (the fact's sorted name list). The apply-phase join of run 1 is the barrier: every host has finished installing before any host's snapshot is read. build_configure_nginx(installed)-- a factory closing over that frozen dict, returning the second deploy. Itsifconsults the snapshot, never a live fact, so the generator is a pure function of static input: prepare and apply resolve the branch identically and the determinism guard holds. The second run'sdry=Trueplan is therefore an honest preview computed against the state run 1 actually left behind.
The trade-off: the snapshot is frozen. If something external uninstalled nginx between the snapshot and run 2, the second deploy would not re-plan -- the same no-mid-run-replanning guarantee the determinism rule always makes, now with the boundary at a point the script controls.
The configure step stages the config with files.put to /tmp:
transfers ride the connection's scp channel as the login user and are never
elevated, so writing into root-owned /etc/nginx would be a follow-up
elevated shell operation (out of scope here). The put source is a
module-level io.StringIO -- one stable identity across both phases, as
OperationRequest compares a stream by identity -- and its
sha1-based idempotence means re-running the whole script transfers nothing
once the staged content matches.
run closes the hosts it owns, but aclose only resets the cached
connection and connect is lazy, so gather_fact on the returned hosts
transparently re-opens them; the script closes them again afterwards. Run 2
builds fresh hosts from the same inventory via the default factory.
The inventory is read from the project root's inventory.json (validated
up front with validate_inventory), which holds hosts in the "centos"
group alongside hosts in other groups. Both deploys carry when=is_centos
so the dnf/files steps run only on the CentOS hosts, and the fact
snapshot is guarded the same way: gather_fact takes no when parameter,
so the script filters the materialized host list to the centos group
before the fan-out -- the DnfPackages read would fail on the Alpine and
Ubuntu hosts, which have no dnf. The snapshot is therefore keyed by CentOS
host only, and deploy 2's lookup uses a default for the hosts it never covers.
Run it with::
uv run python examples/demonstrations/composition/two_deploy_split.py
Cleaning up untracked files: the conf.d recipe¶
The directory-listing facts' reason to exist: the DataDog Ansible role's
cleanup question — "delete every conf.d/*/conf.yaml that is NOT one of
the tracked checks" — where the names to remove are unknown to the deploy
author, so no static deploy can enumerate them. The same two-deploy split
as above, with an enumeration fact instead of a package fact: deploy 1
(ensure_tracked_checks) installs the tracked checks' configs with
files.install (branch-free); a gather_fact snapshot of files.FindFiles
(fname="conf.yaml", maxdepth=2) taken after its apply-phase join lists
what is really on each box; deploy 2 is built by a factory that subtracts
the tracked paths from the frozen snapshot and yields one files.file
present=False removal per stale path. The dry=True plan previews
exactly what the cleanup would remove, and re-running the whole script
converges to zero removals. The same shape answers the apt legacy-.list
cleanup — swap the directory, the filter and the tracked set.
conf_d_cleanup
¶
Example: the DataDog conf.d cleanup -- ensure tracked checks, remove the rest.
This is a usage example, not part of the library. It is the recipe the
directory-listing facts exist for: the DataDog Ansible role's cleanup
question, "delete every conf.d/*/conf.yaml that is NOT one of the
tracked checks" -- names the deploy author does not know, so no static
deploy can enumerate them. Written as ONE deploy it is doubly illegal:
the enumeration is a mutable-fact read, and branching on it after the
install steps would yield a different request sequence per phase and
trip DeterminismError. Like two_deploy_split.py, the fix moves
the read into the gap between two complete prepare/apply cycles:
ensure_tracked_checks-- a branch-free deploy that installs the tracked checks'conf.yamlfiles withfiles.install(the spool-and-install write: unprivileged scp to a/tmpspool, then an elevatedsh -cinstall, so the root-owned/etcdestination is one request). Both phases yield the same one request per check.- A fact SNAPSHOT taken between the runs:
gather_factfansFindFiles(fname="conf.yaml",maxdepth=2-- exactly theconf.d/<check>/conf.yamllayer) out across the CentOS hosts and returns a{host.name: list[str]}mapping of what is REALLY on each box, tracked and legacy alike. Run 1's apply-phase join is the barrier: every host finished installing before any snapshot is read. A host without the agent'sconf.dat all reads[](the find is|| true-guarded), not a failed read. build_cleanup_conf_d(found)-- a factory closing over that frozen dict, returning the second deploy. For each host it subtracts the tracked paths from the snapshot's list and yields onefiles.filepresent=Falseremoval per stale path. The subtraction consults the snapshot, never a live fact, so the generator is a pure function of static input: prepare and apply resolve it identically and the determinism guard holds. The second run'sdry=Trueplan is an honest preview of exactly what the cleanup would remove.
The trade-off is the same frozen-snapshot one as two_deploy_split.py:
a check config created by something external between the snapshot and
run 2 is not seen, the no-mid-run-replanning guarantee with the boundary
at a point the script controls. Re-running the whole script converges:
run 1 re-installs the tracked files (no-ops once content matches), the
fresh snapshot no longer lists the removed legacy paths, and deploy 2
yields nothing.
The same shape answers the apt legacy-.list cleanup (list
/etc/apt/sources.list.d with DirectoryEntries or FindFiles,
subtract the managed names) -- swap the directory, the filter and the
tracked set; the two-deploy skeleton is unchanged.
run closes the hosts it owns, but aclose only resets the cached
connection and connect is lazy, so gather_fact on the returned
hosts transparently re-opens them; the script closes them again
afterwards. Run 2 builds fresh hosts from the same inventory.
The inventory is read from the project root's inventory.json
(validated up front with validate_inventory). Both deploys carry
when=is_centos and the snapshot filters the host list the same way
(gather_fact takes no when parameter), so the work stays on the
CentOS hosts and deploy 2's lookup defaults to empty for the others.
Run it with::
uv run python examples/demonstrations/composition/conf_d_cleanup.py
Restarting a service only when a notifying operation changed¶
The Ansible notify:/handler pattern — "restart the agent only when a
config write actually changed something" — built from the apply report
instead of a fact snapshot. systemd.service(restarted=True) is
stateless: it restarts a running unit on every deploy that reaches it,
and neither when (static inventory data only) nor the deploy body (no
access to earlier outcomes) can condition it on a change. Deploy 1
(configure_nginx) installs nginx and writes a content-idempotent
conf.d drop-in with files.install, both requests named after the
script's NOTIFYING set; between the runs, notifying_hosts freezes the
hosts whose named entries report changed in run 1's ApplyReport;
deploy 2 is built by a factory closing over that frozen set and yields
systemd.service(restarted=True) only for those hosts. Deduplication and
end-of-run flushing come free — at most one restart per host, after every
config write landed — and a converged re-run queues no restart at all.
The guide's Change notification
section spells out why this is the sanctioned shape.
handler_restart
¶
Example: Ansible-style handlers -- restart a service only on change.
This is a usage example, not part of the library. It demonstrates the
sanctioned answer to a gap every Ansible role closes with notify: /
handlers: "restart nginx only when a package install or a config write
actually changed something". systemd.service(restarted=True) cannot
express that by itself -- restarted is deliberately stateless, so it
restarts a running unit on EVERY deploy that reaches it, and nothing feeds
an earlier operation's outcome back into a later request:
whenguards read static inventory data only, never run outcomes;ctx.changedflows one way (operation -> driver ->ApplyReport), so a deploy body cannot observe its own earlier operations' results;- branching on "did the config write change?" inside ONE deploy would
yield different request sequences per phase (prepare sees pre-state,
apply sees live state) and
run_applyraisesDeterminismError.
The fix is the two-deploy outcome split, a sibling of the fact split
in two_deploy_split.py: the outcome read moves into the gap between
two complete prepare/apply cycles.
configure_nginx-- a branch-free deploy that installs nginx and writes a comment-onlyconf.ddrop-in withfiles.install(the spool-and-install family, content-idempotent). Both requests are named; their names form the NOTIFYING set, the analogue of Ansible'snotify: restart datadog-agenton every config task.- The notification read:
notifying_hostsfilters run 1'sApplyReport.logfor entries that arechangedAND named in NOTIFYING, and freezes the host names into afrozenset. Achangedentry is trustworthy: the driver is fail-fast (a non-zero exit raisesCommandError), so it means the commands really ran. The apply-phase join of run 1 is the barrier -- every config write has landed before any restart is planned. build_restart_nginx(changed)-- a factory closing over that frozen set, returning the handler deploy. Itsifconsults the snapshot, never a live fact or report, so the generator is a pure function of static input and the determinism guard holds. Deduplication and end-of-run flushing come free: at most one restart request per host, after ALL notifying operations, exactly like an Ansible handler.
systemd.service semantics inside the handler: restarted=True only
restarts a unit that is already running; the default running=True
starts a stopped one. So the first pass (fresh install, unit stopped)
STARTS nginx, and a converged re-run of the whole script does nothing:
run 1 reports no change, the frozen set is empty, and the handler deploy
yields no request at all.
The trade-offs, mirroring Ansible: the restart happens in a second full
prepare/apply cycle, and a dry=True preview of run 1 cannot know
whether the handler will fire (Ansible check mode skips handlers for the
same reason). The dry preview of run 2 below IS honest -- it is computed
against the frozen outcome of run 1.
The inventory is read from the project root's inventory.json
(validated up front with validate_inventory); every request carries
when=is_centos so the dnf/systemd work runs only on the CentOS
hosts, and the run-2 factory builds fresh hosts from the same inventory
via the default factory.
Run it with::
uv run python examples/demonstrations/composition/handler_restart.py