Skip to content

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_centos wraps the whole deploy at the composition site (sub_deploy(deploy, when=is_centos)), the analogue of Ansible's block-level when. It makes every step centos-only, so the Alpine and Ubuntu hosts skip the entire schedule -- and because a skipped request is never materialized, the DnfPackages fact behind the dnf steps is not read on them either;
  • a request-level guard when=is_canary rides 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.group and host.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 lambda in 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:

  1. install_nginx -- a branch-free deploy that only installs the package. Both phases trivially yield the same single request.
  2. A fact SNAPSHOT taken between the runs: gather_fact fans the DnfPackages read 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.
  3. build_configure_nginx(installed) -- a factory closing over that frozen dict, returning the second deploy. Its if consults 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's dry=True plan 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:

  1. ensure_tracked_checks -- a branch-free deploy that installs the tracked checks' conf.yaml files with files.install (the spool-and-install write: unprivileged scp to a /tmp spool, then an elevated sh -c install, so the root-owned /etc destination is one request). Both phases yield the same one request per check.
  2. A fact SNAPSHOT taken between the runs: gather_fact fans FindFiles (fname="conf.yaml", maxdepth=2 -- exactly the conf.d/<check>/conf.yaml layer) 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's conf.d at all reads [] (the find is || true-guarded), not a failed read.
  3. 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 one files.file present=False removal 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's dry=True plan 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:

  • when guards read static inventory data only, never run outcomes;
  • ctx.changed flows 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_apply raises DeterminismError.

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.

  1. configure_nginx -- a branch-free deploy that installs nginx and writes a comment-only conf.d drop-in with files.install (the spool-and-install family, content-idempotent). Both requests are named; their names form the NOTIFYING set, the analogue of Ansible's notify: restart datadog-agent on every config task.
  2. The notification read: notifying_hosts filters run 1's ApplyReport.log for entries that are changed AND named in NOTIFYING, and freezes the host names into a frozenset. A changed entry is trustworthy: the driver is fail-fast (a non-zero exit raises CommandError), 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.
  3. build_restart_nginx(changed) -- a factory closing over that frozen set, returning the handler deploy. Its if consults 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