Ad-hoc runs and the unreachable-tolerant outcome
The ad-hoc command and ansible verbs under the same governance as a play, and tolerating an unreachable roaming host instead of paging.
playbook run is the batch verb. The two verbs here are the everyday ones an
operator reaches for between plays — a one-off command across a host pattern, and
any single module — plus the outcome that keeps a scheduled converge from paging
when a roaming machine is asleep.
exocomp command — the everyday verb
exocomp command --host <pattern> [--shell] [--user-session] [--become] -- <cmd...>
Runs a bare command on every host a pattern matches, through the ansible
command module (no shell), the ad-hoc ansible binary rather than
ansible-playbook:
exocomp command --host web -- systemctl is-active nginx
exocomp command --host 'fedora:!nas' --become -- dnf -y upgrade
exocomp command --source homelab --host db -- pg_isready
--host takes any ansible pattern: a host, a group, or an expression like
fedora:!nas. --limit is an accepted alias. Everything after -- is the
command; it is joined into the module's single argument and never spread onto
ansible's own flags. A command that needs a pipe or a redirect uses --shell,
which switches to the shell module:
exocomp command --host web --shell -- 'journalctl -u nginx | tail -50'
Commands targeting user-level services (systemctl --user, dconf, D-Bus desktop
services) use --user-session to automatically resolve and inject the active user's
$XDG_RUNTIME_DIR (/run/user/<uid>) and $DBUS_SESSION_BUS_ADDRESS:
exocomp command --host workstation --user-session -- systemctl --user status podman
For scripts and agents, --output json emits a structured, machine-parsable per-host
dictionary (command.result) rather than raw streaming terminal output:
exocomp command --host 'web*' --output json -- hostname -f
The command module runs no shell, so a pipe is an explicit --shell, never an
accident. Local execution without an explicit --source resolves relative to the
operator's home directory. Privilege escalation on a local target with --become
is refused up front and requires --privileged (unsandboxed execution); targeting the
host over SSH instead runs the escalation on the far side, outside the sandbox.
exocomp ansible — any module
exocomp ansible --host <pattern> -m <module> [-a "<args>"] [--become]
The power form for any module — service, copy, package, ping, setup, …
command is the ergonomic shorthand for -m command.
exocomp ansible --host web -m ansible.builtin.service -a "name=nginx state=restarted" --become
exocomp ansible --host all -m ansible.builtin.ping
exocomp ansible --host db -m ansible.builtin.setup -a "filter=ansible_distribution*"
Same governance as a play
Both verbs run through the one queue with the same credential and isolation model
playbook run uses:
- From a source checkout with
--source <name>, so the repo'sansible.cfgand inventory apply.--inventorynames a registered inventory or a path; left empty, ansible discovers its own inventory (the checkout'sansible.cfg, then its default hosts file) — no localhost is pinned. - Sandboxed and redacted, the vault-id from
--vault-project, the run-secret set from--run-secrets, resolved from the envelope at run time. --ssh-identity <path>grants read access to one ssh identity key file — never its directory — oncommandandansible, so a one-off against a key-only host does not need--privileged. A sandboxed run whose ssh key read is denied appends a hint naming this flag (see playbook-run.md).- Secrets never on argv. The vault password and run-secret / dynamic-inventory credentials keep their out-of-argv paths (a private-dir file, named env). What a run does is public and rides the job row; what it authenticates with does not.
--require-approvalgates a destructive one: the job lands inawaiting_approvalwhere no worker can claim it, an approval is created, andexocomp approval link <id>prints its decision link. It reuses the same gate a policy-gated tofu apply does.
Injection posture
The host pattern, the module name, the module args, and the command are guarded
before the run starts. A pattern or command that starts with a dash (it would
be read as a flag) or carries a control character (it would split or truncate
the single argv token) is refused; a module name must be the plain
namespace.collection.module shape. A flag's value is one argv token after its
flag and cannot introduce a second flag — the guard is against a value that would
truncate or split the argument, the same posture the passthrough flags carry.
The unreachable-tolerant outcome
A static fleet with a roaming laptop in the daily target is the motivating
case: a scheduled site.yml fails and pages every time the laptop sleeps, because
an unreachable host is a non-zero exit and correctness is decided by exit code
(AGENTS.md invariant 3). ansible-playbook exits 4 when a host is unreachable —
the same failure as any other non-zero exit.
--tolerate-unreachable is a per-run (and per-schedule) opt-in that keeps the
strict default for everyone who does not ask:
exocomp playbook run site.yml --inventory prod --tolerate-unreachable
exocomp command --host all --tolerate-unreachable -- uptime
When it is set, the run is classified by its record — a playbook's PLAY RECAP,
or an ad-hoc run's host | STATUS lines (ad-hoc mode prints no recap):
- Only unreachable hosts were the non-success (
failed=0on every host the run reached, and at least one host WAS reached) → the run settles in thedegradedterminal state: the run completed, every reachable target succeeded, the offline ones were skipped because the launch tolerated it. It does not page, and the CLI exits 0 for it (a tolerated offline host is not a caller failure). The reachable hosts converged; the offline ones are recorded and named in a job event. Thepartialcolumn is the legacy marker for this outcome, still written beside the state for readers that predate it. - Every addressed host unreachable (the whole inventory, or the whole
--limitset, was offline) → a hard failure, not degraded: with nothing reached, nothing converged, and the tolerance flag must not turn a full miss into a tolerated success. - Any reachable host's task failed → a hard failure, exactly as without the flag. The tolerance never hides a real failure.
The job's true exit code is still recorded (a degraded job keeps its exit 4);
--tolerate-unreachable only changes how the queue classifies it, not the number.
A run that errors before any host record is not called degraded — with nothing
to prove failed=0, a tolerated degradation must be proven, never assumed.
The degraded job carries a warning-level event and the structured outcome under
ansible_partial in its raw_json:
ansible: degraded — 1 host(s) unreachable, tolerated: laptop (reachable hosts converged)
{"ansible_partial": {"operation": "run", "unreachable": ["laptop"]}}
executor.AnsiblePartialHosts(rawJSON) reads it back out, the twin of
AnsibleCheckChanges for the config-drift outcome — a reader turns a degraded
fire into the list of offline hosts.
The homelab example — a converge that does not page at night
Register the recurring converge over a standing inventory with the tolerance on:
exocomp schedule add nightly-converge --kind play --tolerate-unreachable \
--target tf:homelab --cron "0 0 3 * * *" -- site.yml
Every fire converges the online fleet. When only the laptop is offline the fire
settles in the degraded state — the online hosts converged, the laptop
named — instead of failing and paging; the degraded state is still surfaced, so
a machine that is always offline is visible rather than silently skipped. The
fail-closed rule for play schedules still holds: a missing, stale, or hostless
standing inventory fires nothing rather than reporting a clean sweep against
nobody. --tolerate-unreachable is rejected on any kind but play.
A genuine breakage — a task that fails on a host that is online — still fails the fire and pages, tolerance or not. The flag narrows exactly one thing: an offline host is no longer a failure.
Related: Playbook run flags and config-drift · Sources and credentials · Provision → onboard → converge · Reacting to alerts.