runon

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.

View on GitHub runon on PyPI

Plays the six stages below in order, about 65 seconds. Scroll or press Esc to stop.

Language
Python 3.10 or newer
Tests
422 passing, none needs a server or a key
Transport
The system ssh and scp, so your keys and ~/.ssh/config apply
Dependencies
None on Python 3.11 and up; tomli on 3.10
Published
PyPI, version 0.14.0
License
MIT
runon -C examples group --group production run-program migrate < /dev/null Migrate the database — Applies pending schema migrations DESTRUCTIVE: Locks tables while it runs and cannot be rolled back. runon: migrate is marked destructive and there is no terminal to confirm on. Pass --yes, or set RUNON_ASSUME_YES=1, if you mean it.

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.

1Keep each procedure as a directory

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.

  • Optional files sit beside 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.
  • Shared helpers live in functions/ and are copied with every program, because a script that sources a helper is broken without it.
  • There is one workspace, ~/.runon/workspace, made on first use. -C points somewhere else for a single command.
  • A program learns where it is running from environment variables, so ./main.sh still runs by hand.

The examples/ workspace in the repo

  • examples/
  • programs/
  • disk-report/
  • main.sh # Reports the fullest filesystem, and fails if it is above a threshold.
  • hello-world/
  • main.sh # Prints a greeting from each host.
  • functions/
  • say.sh # Prints a message with a consistent prefix.
  • layouts/
  • split.sh
  • inventory.toml # hosts and groups
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

  • RUNON_HOSTdb-1
  • RUNON_ADDRESS10.0.0.9
  • RUNON_PROGRAMdisk-report
  • RUNON_FUNCTIONS"$HOME/.runon"/functions
  • RUNON_VAR_ROLEdatabase
The tree and its comment lines are the repo's 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.

2Resolve every target from one file

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.

  • Each address is handed to ssh untouched, so a Host alias from ~/.ssh/config works.
  • A group that names an unknown host stops every command when the file loads, before a rollout can half-finish on the hosts it could find.
  • An unknown bare name is refused as a typo. Anything shaped like an address, such as 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

    The inventory is the repo's 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.

    3Check the program before it travels

    The program is checked on your machine as well, because the target is the worst place to learn that a script cannot run.

    • Names may use letters, digits, dot, dash and underscore, so ../../etc is refused rather than resolved.
    • A 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.
    • An empty --program, usually an unset shell variable, is refused instead of falling through to a menu.
    • With nobody at a terminal, a missing program, host or group is refused and the choices are named, even when there is only one.

    Five commands, four that stop here

    Each line runs runon -C examples host --host web-1 followed by:

      Real runs of runon 0.14.0 on the examples workspace. winbox is an Illustrative input: a program written with CRLF line endings, the same input CI uses. None of these runs started ssh.

      4Stop for a plan or a yes

      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.

      • With nobody at a terminal, a destructive program is refused unless the command says --yes or the environment says RUNON_ASSUME_YES=1.
      • The warning prints even when --yes skips the question, so the log records what was agreed to.
      • Answering no exits 130, so runon … && notify "deployed" can't announce a rollout that never happened.
      • Values a program asks for in 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

      1. Targetsweb-1, web-2, db-1
      2. Programmigrate, marked destructive
      3. ConsentNot asked yet

      Group production

      • web-1deploy@web-1.example.com
      • web-2deploy@web-2.example.com
      • db-1deploy@10.0.0.9

      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.

      5Ship the program with its helpers

      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.

      • Copies land in the parent directory, because scp -r nests a second copy inside the first otherwise.
      • The run is wrapped as env … /bin/sh -c '…', so an account whose login shell is tcsh or fish still gets every variable.
      • With no password in play, ssh runs with BatchMode=yes, so a missing key fails at once instead of leaving twenty connections waiting for input.
      • A password goes through a private askpass helper that answers password prompts only, never appears in argv, and is deleted when the run ends.

      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.

        Arguments recorded by replacing ssh and scp with a script that logs what it is given; nothing connected. The control socket path is shown as it would be for a home of /home/you; paths replaced. The password run was recorded on a run-program call; the copy steps take their options from the same builder.

        6Report every host, and fail if one did

        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.

        • Failures always show their output. Successes show theirs only with -v.
        • A group run ends with a count, such as 2/3 ok, and exits 1 if any host failed.
        • A failed copy skips the run on that host, so it never runs whatever version was already there.
        • -j N runs N hosts at once, and the report keeps the inventory's order.

        One group run of disk-report, across production

        HostmkdirProgramFunctionsRunResult
        The first two runs drive runon 0.14.0 through its own FakeTransport, so nothing connected; db-1's program output and its scp error are Illustrative input, and the report around them is runon's. The CI runs are real failures against the runner's own sshd, shown without the timeout and log redirection CI wraps them in.

        Measured, not assumed

        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.

        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

        Dry run6
        Program checks3
        Inventory, targets2
        No terminal2
        Consent2
        Flag values2
        Password file 06441
        All 18 commands
        CommandStopped byExit

        What crossing costs

        Calls the recorder logged once a run cleared the line.

        copy-run, 1 host4
        run, 3 hosts3

        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.

        What it installs

        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.

        runon==0.14.0, and nothing else72,863-byte wheeltomli only on Python 3.10

        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.

        422 tests, no server needed

        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.

          Weighted toward what fails quietly

          • The command and the remote path: refusals with nobody at a terminal, a failed copy skipping the run, quoting so a value can't smuggle a command, variables surviving a login shell that isn't Bourne.
          • Credentials and connections: a password never in argv or the inventory, a 0600 file created with its mode already set, a helper that never answers a host-key question, socket paths that always fit.
          • Programs and the inventory: destructive consent, declining exits 130, prompts in order, CRLF, a group naming an unknown host, IPv6 addresses.
          • The terminal: shell completion in bash, zsh and fish, the picker, 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.

          Deliberately missing

          Rollback and desired state
          It runs your script. For convergence, the README points to Ansible or Chef, and says to use them.
          Inventory discovery
          No cloud APIs and no dynamic inventory. You write the file.
          Live output in the collected path
          Results arrive when a host finishes. --watch opens a tmux pane per host when you need to see it happen.
          An agent on the hosts
          Groups run over ssh only, with no plan to add one.
          A private channel for prompt answers
          A secret prompt is hidden on screen, but on a remote run its value is visible in ps on both machines while the program runs. The README says to fetch such credentials on the target instead.

          Run it

          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.