Stopped on this machine
18 remote commands that runon refused or only planned, run while a recorder stood in for ssh and scp.
0ssh or scp processes started
All 18 commands
| Command | Stopped by | Exit |
|---|
A command-line tool that runs plain shell-script programs on your machine, on one server, or across a named group of servers, over the ssh you already use. It is published on PyPI as runon.
Most teams keep their procedures in a scripts folder nobody trusts, and Ansible is a lot to adopt for six scripts that work. runon keeps the scripts and does the annoying part: getting a script and its helpers onto many machines, running them, and saying which ones failed. Before anything reaches a server, it settles where the run goes, what it runs, and whether you meant it.
Plays the six stages below in order, about 65 seconds. Scroll or press Esc to stop.
Real output. Refuse and Plan were captured from runon 0.14.0 on the repo's examples workspace, with an illustrative destructive program, migrate, added to it. Run is the quickstart step from CI. Nothing on this page connected to a server.
A program is a directory with a main.sh in it. Adding a capability means adding a directory, and the tool itself never changes: runon knows how to reach machines, and shell knows what to do on them.
main.sh: meta.toml says what it is, params.toml holds settings that travel with it, and prompts.toml lists what it asks for at run time.functions/ and are copied with every program, because a script that sources a helper is broken without it.~/.runon/workspace, made on first use. -C points somewhere else for a single command../main.sh still runs by hand.The examples/ workspace in the repo
runon -C examples list programs disk-report Reports the fullest filesystem, and fails if it is above a threshold. hello-world Prints a greeting from each host.
What disk-report is handed on db-1
examples/ directory. The listing is a real run of runon 0.14.0: with no meta.toml, a program's description is the first comment line of its main.sh. The variables are what runon's own environment builder produced for db-1, recorded through its FakeTransport.Hosts and groups live in one inventory.toml, short enough to read on one screen and diff in review. It is loaded and checked before runon touches anything.
address is handed to ssh untouched, so a Host alias from ~/.ssh/config works.root@10.0.0.4, is used as given: there is no allowlist.password = "..." in the file is an error. The inventory gets committed, so it may only say where a password lives.Five ways to name a target
examples/inventory.toml
What runon resolved
examples/inventory.toml with its comments trimmed. Lines marked Illustrative input are edits made for this page and read by runon's real parser. Every output is a real run of runon 0.14.0, and none of these runs started ssh.The program is checked on your machine as well, because the target is the worst place to learn that a script cannot run.
../../etc is refused rather than resolved.main.sh saved with Windows line endings is refused, with the sed that fixes it. On the target it would fail as "not found", which reads like a missing program.--program, usually an unset shell variable, is refused instead of falling through to a menu.Five commands, four that stop here
Each line runs runon -C examples host --host web-1 followed by:
winbox is an Illustrative input: a program written with CRLF line endings, the same input CI uses. None of these runs started ssh.This is the last step before the first connection, and it runs once per command, not once per host. --dry-run stops just before it and prints the plan. A program that declares destructive = true in its meta.toml has to be agreed to here.
--yes or the environment says RUNON_ASSUME_YES=1.--yes skips the question, so the log records what was agreed to.runon … && notify "deployed" can't announce a rollout that never happened.prompts.toml are collected here too, once for the whole group: the environment first, then the terminal, then the declared default.The line only holds what declares itself. A program without destructive = true goes straight through once its targets and name check out, copy-program skips the question, and the plain copy verb has no consent step at all. A test pins the refusal: it asserts exit status 2 and an empty list of transport calls.
One destructive program, sent to production four ways
runon -C examples group --group production run-program migrate < /dev/null
On this machine
Group production
0ssh processes started
migrate is an Illustrative input: a one-line program with destructive = true and its own warning text. Every line of output is runon 0.14.0's. ssh and scp were replaced by a recorder that logs its arguments and connects to nothing: it logged nothing for the first three runs, and one ssh call per host for the --yes run, whose host results are not shown because the recorder answered them.ClearedEverything above happens on your machine and starts no ssh process. Below this line, runon hands the run to ssh and scp.
Past the line, each host gets the same steps: make the directory, copy the program, copy the shared functions, run it. Everything goes through the system ssh and scp, so your keys, agent, ~/.ssh/config and known_hosts apply unchanged.
scp -r nests a second copy inside the first otherwise.env … /bin/sh -c '…', so an account whose login shell is tcsh or fish still gets every variable.BatchMode=yes, so a missing key fails at once instead of leaving twenty connections waiting for input.Connections are kept open for 60 seconds by default with ssh's own ControlMaster, so the several connections one command makes can share a login.
copy-run-program migrate to web-1, as ssh and scp received it
From CI, against a real sshd, on an account whose login shell is tcsh
runon host --host cshbox copy-run-program shellcheck --verbose < /dev/null cshbox ok H=[cshbox] P=[42] F=[yes]
The CI program prints its host name, a parameter and whether the functions library arrived. Before the env … /bin/sh -c wrapper, all three arrived empty on tcsh and the run still reported ok.
/home/you; paths replaced. The password run was recorded on a run-program call; the copy steps take their options from the same builder.Each host runs inside its own guard, so one unreachable machine becomes one failed line and the others carry on. A rollout that worked on two machines out of three has not worked, and the exit status says so.
-v.2/3 ok, and exits 1 if any host failed.-j N runs N hosts at once, and the report keeps the inventory's order.One group run of disk-report, across production
A password host whose key ssh has never seen
runon host --host unknown run-program hello-world --verbose < /dev/null unknown FAILED (255) Host key verification failed.
It fails at once. The askpass helper now declines the "are you sure" question. When it answered that with the password, ssh asked again and again until CI timed out.
--watch, pointed at an address that doesn't answer
runon -C /tmp/ops host --host 192.0.2.1 --watch run-program hello-world < /dev/null could not log in, so these get no pane: 192.0.2.1 FAILED (255) ssh: connect to host 192.0.2.1 port 22: Connection timed out
The run exits non-zero and opens no pane. Before 0.14.0 it printed a tmux session name and exited 0 with every pane dead.
Captured for this page with runon 0.14.0. Wherever ssh or scp would have run, a recorder stood in for them and logged every call it received.
18 remote commands that runon refused or only planned, run while a recorder stood in for ssh and scp.
0ssh or scp processes started
| Command | Stopped by | Exit |
|---|
Calls the recorder logged once a run cleared the line.
One host costs two ssh calls and two scp calls: make the directory, copy the program, copy the functions, run. A group run of an already-copied program is one ssh call per host.
Every call carries ControlPersist=60s, so those calls can share one login. That sharing needs a real sshd and was not measured here.
On Python 3.11 and up, runon installs nothing but itself. CI checks it on every commit by installing the wheel into a clean environment.
19 modules and 3,987 lines of Python. The full-screen program picker draws on the terminal directly rather than pull in a library every install would need.
Everything that would reach another machine goes through one Transport interface, and the suite drives the fake one, which ships as public API so you can test your own programs the same way. An empty list of recorded calls is how a test proves a refusal never reached a host.
doctor, --watch, and a check that the README's command reference matches the parser.CI runs ruff and the suite on Ubuntu and macOS across Python 3.10 to 3.13. All 422 pass on macOS; on Ubuntu 421 pass and one is skipped, a test that needs csh. A separate job drives a real sshd on localhost: key and password logins, an unknown host key, a tcsh account, a group of one.
--watch opens a tmux pane per host when you need to see it happen.ps on both machines while the program runs. The README says to fetch such credentials on the target instead.pip install runon # or: pipx install runon # Nothing to set up first. runon list programs runon local run-program hello-world -v # A real machine, over your own ssh. runon add-host web-1 --address 10.0.0.1 --user deploy runon host --host web-1 copy-run-program hello-world -v # Who a group run would touch, without touching them. runon group --group production run-program hello-world --dry-run
Python 3.10 or newer. On 3.11 and up it installs nothing but itself; on 3.10 pip also fetches tomli. The suite runs from a clone with pip install -e ".[dev]" and pytest.