runon 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runon-0.1.0/LICENSE +21 -0
- runon-0.1.0/PKG-INFO +269 -0
- runon-0.1.0/README.md +244 -0
- runon-0.1.0/pyproject.toml +52 -0
- runon-0.1.0/setup.cfg +4 -0
- runon-0.1.0/src/runon/__init__.py +35 -0
- runon-0.1.0/src/runon/cli.py +267 -0
- runon-0.1.0/src/runon/errors.py +33 -0
- runon-0.1.0/src/runon/inventory.py +142 -0
- runon-0.1.0/src/runon/picker.py +39 -0
- runon-0.1.0/src/runon/program.py +127 -0
- runon-0.1.0/src/runon/report.py +42 -0
- runon-0.1.0/src/runon/runner.py +133 -0
- runon-0.1.0/src/runon/scaffold.py +110 -0
- runon-0.1.0/src/runon/transport.py +178 -0
- runon-0.1.0/src/runon.egg-info/PKG-INFO +269 -0
- runon-0.1.0/src/runon.egg-info/SOURCES.txt +23 -0
- runon-0.1.0/src/runon.egg-info/dependency_links.txt +1 -0
- runon-0.1.0/src/runon.egg-info/entry_points.txt +2 -0
- runon-0.1.0/src/runon.egg-info/requires.txt +4 -0
- runon-0.1.0/src/runon.egg-info/top_level.txt +1 -0
- runon-0.1.0/tests/test_cli.py +216 -0
- runon-0.1.0/tests/test_inventory.py +71 -0
- runon-0.1.0/tests/test_runner.py +124 -0
- runon-0.1.0/tests/test_transport.py +101 -0
runon-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ahmed Hashim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
runon-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keep your operational procedures as plain shell scripts, and run any of them identically on your laptop, one server, or a named group of servers.
|
|
5
|
+
Author: Ahmed Hashim
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ahmed-hashim-pro/runon
|
|
8
|
+
Project-URL: Repository, https://github.com/ahmed-hashim-pro/runon
|
|
9
|
+
Keywords: cli,ssh,ops,runbook,automation,fleet,shell
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: System :: Systems Administration
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest==9.1.1; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff==0.16.6; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# runon
|
|
27
|
+
|
|
28
|
+
[](https://github.com/ahmed-hashim-pro/runon/actions/workflows/ci.yml) [](LICENSE)
|
|
29
|
+
|
|
30
|
+
Keep your operational procedures as plain shell scripts, and run any of them
|
|
31
|
+
identically on your laptop, on one server, or across a named group of servers.
|
|
32
|
+
|
|
33
|
+
Most teams end up with a `scripts/` folder nobody trusts and a wiki page that
|
|
34
|
+
went stale months ago. The alternatives are heavy: Ansible wants YAML, modules
|
|
35
|
+
and a mental model, which is a lot to adopt when the thing you actually have is
|
|
36
|
+
six shell scripts that work.
|
|
37
|
+
|
|
38
|
+
`runon` keeps the shell scripts. It adds the part that is genuinely annoying
|
|
39
|
+
to write yourself — getting a script and its helpers onto twenty machines,
|
|
40
|
+
running them, and telling you which ones failed.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
$ runon group --group production copy-run-program --program disk-report
|
|
44
|
+
web-1 ok
|
|
45
|
+
web-2 ok
|
|
46
|
+
db-1 FAILED (1)
|
|
47
|
+
[db-1] highest usage: 94% on /var
|
|
48
|
+
[db-1] OVER THRESHOLD
|
|
49
|
+
|
|
50
|
+
2/3 ok
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Exit code is non-zero, because a rollout that worked on two of three machines
|
|
54
|
+
has not worked.
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
Python 3.11+. No runtime dependencies.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pipx install git+https://github.com/ahmed-hashim-pro/runon.git
|
|
62
|
+
# or, into a virtualenv you manage:
|
|
63
|
+
# pip install git+https://github.com/ahmed-hashim-pro/runon.git
|
|
64
|
+
|
|
65
|
+
mkdir my-ops && cd my-ops
|
|
66
|
+
runon init # scaffolds programs/, functions/, layouts/, inventory.toml
|
|
67
|
+
runon list programs
|
|
68
|
+
runon local run-program --program hello-world --verbose
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> Not on PyPI yet, so install from the repository for now. Nothing else is
|
|
72
|
+
> needed — `runon` has no runtime dependencies, and the only external programs
|
|
73
|
+
> it uses are the `ssh` and `scp` you already have.
|
|
74
|
+
|
|
75
|
+
<details>
|
|
76
|
+
<summary>Releasing (for maintainers)</summary>
|
|
77
|
+
|
|
78
|
+
Publishing uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/):
|
|
79
|
+
GitHub Actions mints a short-lived OIDC token, so there is no API token stored
|
|
80
|
+
anywhere to leak.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# rehearse against TestPyPI first
|
|
84
|
+
gh workflow run release.yml -f target=testpypi
|
|
85
|
+
|
|
86
|
+
# then release for real
|
|
87
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The workflow runs the suite, builds an sdist and a wheel, checks the metadata,
|
|
91
|
+
and refuses to publish if the tag does not match the version in `pyproject.toml`.
|
|
92
|
+
|
|
93
|
+
</details>
|
|
94
|
+
|
|
95
|
+
That last command works immediately, with no servers and no configuration.
|
|
96
|
+
|
|
97
|
+
## The idea
|
|
98
|
+
|
|
99
|
+
A **program** is a directory with a `main.sh`:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
programs/
|
|
103
|
+
disk-report/
|
|
104
|
+
main.sh <- entry point
|
|
105
|
+
functions/
|
|
106
|
+
say.sh <- shared helpers, sourced by programs
|
|
107
|
+
layouts/
|
|
108
|
+
split.sh <- terminal layouts for the local machine
|
|
109
|
+
inventory.toml <- your machines
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Adding a capability means adding a directory. **The tool never changes.** That
|
|
113
|
+
is the whole design: `runon` knows how to *reach* machines, shell knows what
|
|
114
|
+
to *do* on them, and neither has to learn the other's job.
|
|
115
|
+
|
|
116
|
+
Programs get their context from the environment, so they stay runnable by hand:
|
|
117
|
+
|
|
118
|
+
| Variable | Is |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| `RUNON_HOST` | the host's name from the inventory |
|
|
121
|
+
| `RUNON_ADDRESS` | what ssh was given |
|
|
122
|
+
| `RUNON_PROGRAM` | the program's own name |
|
|
123
|
+
| `RUNON_FUNCTIONS` | where the helpers are — locally, your workspace; remotely, the copied cache |
|
|
124
|
+
| `RUNON_VAR_*` | anything you put in that host's `vars` |
|
|
125
|
+
|
|
126
|
+
Export those and `./main.sh` behaves exactly as `runon` would run it. That
|
|
127
|
+
matters at 3am.
|
|
128
|
+
|
|
129
|
+
## Three scopes, the same verbs
|
|
130
|
+
|
|
131
|
+
Where the work happens and what the work is are separate questions, so they are
|
|
132
|
+
separate parts of the command line:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
runon local run-program --program disk-report
|
|
136
|
+
runon host --host web-1 run-program --program disk-report
|
|
137
|
+
runon group --group production run-program --program disk-report -j 8
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The remote scopes share four verbs:
|
|
141
|
+
|
|
142
|
+
| Verb | Does |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| `copy` | copy a local file or directory (`--local-dir`, `--remote-dir`) |
|
|
145
|
+
| `copy-program` | copy a program **and the functions library** to the target(s) |
|
|
146
|
+
| `run-program` | run an already-copied program |
|
|
147
|
+
| `copy-run-program` | both, in one step |
|
|
148
|
+
|
|
149
|
+
`copy-program` ships the functions library alongside the program deliberately: a
|
|
150
|
+
program that sources a helper is broken without it, and the target is the worst
|
|
151
|
+
place to discover that.
|
|
152
|
+
|
|
153
|
+
Omit `--program` and you get a picker. Add `--dry-run` to see which hosts would
|
|
154
|
+
be touched without touching them.
|
|
155
|
+
|
|
156
|
+
## Inventory
|
|
157
|
+
|
|
158
|
+
One file, so you can read the whole thing in one screen and diff it in review:
|
|
159
|
+
|
|
160
|
+
```toml
|
|
161
|
+
[hosts.web-1]
|
|
162
|
+
address = "web-1.example.com"
|
|
163
|
+
user = "deploy"
|
|
164
|
+
vars = { role = "web" }
|
|
165
|
+
|
|
166
|
+
[hosts.db-1]
|
|
167
|
+
address = "10.0.0.9"
|
|
168
|
+
port = 2222
|
|
169
|
+
|
|
170
|
+
[groups.production]
|
|
171
|
+
hosts = ["web-1", "db-1"]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`address` is handed to ssh untouched, so a `Host` alias from your `~/.ssh/config`
|
|
175
|
+
works here. And `--host` falls back to treating an unknown name as an address,
|
|
176
|
+
so `--host root@10.0.0.4` needs no inventory entry at all.
|
|
177
|
+
|
|
178
|
+
A group naming a host that does not exist fails when the inventory loads —
|
|
179
|
+
before a rollout has half-finished on the hosts it could resolve.
|
|
180
|
+
|
|
181
|
+
## It uses your ssh, on purpose
|
|
182
|
+
|
|
183
|
+
`runon` shells out to the system `ssh` and `scp`. It does **not** embed an SSH
|
|
184
|
+
library.
|
|
185
|
+
|
|
186
|
+
That means your `~/.ssh/config`, your agent, your keys, your `ProxyJump` and
|
|
187
|
+
your `known_hosts` all work exactly as they already do, and `runon` never has
|
|
188
|
+
to grow its own half-version of any of it. Connections run with `BatchMode=yes`,
|
|
189
|
+
so a missing key fails fast instead of hanging on a password prompt — which
|
|
190
|
+
across a group would otherwise mean twenty stuck connections.
|
|
191
|
+
|
|
192
|
+
One thing worth knowing: OpenSSH 9 moved `scp` onto the SFTP subsystem, so a
|
|
193
|
+
target with SFTP disabled fails a copy with an error that does not say so.
|
|
194
|
+
`runon` detects that and tells you the fix.
|
|
195
|
+
|
|
196
|
+
## Testing your programs without servers
|
|
197
|
+
|
|
198
|
+
Everything that touches another machine goes through one `Transport` interface,
|
|
199
|
+
and the fake one is **public API** rather than a test fixture — the hard part of
|
|
200
|
+
adopting a tool like this is proving your programs do the right thing *before*
|
|
201
|
+
you point them at production:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from runon import FakeTransport, Host, Workspace
|
|
205
|
+
from runon import runner
|
|
206
|
+
|
|
207
|
+
fake = FakeTransport()
|
|
208
|
+
runner.run_program(fake, Host("web-1", "web-1.example.com"), workspace, program)
|
|
209
|
+
|
|
210
|
+
fake.calls # [("web-1", "cd ~/.runon/programs/... && ./main.sh")]
|
|
211
|
+
fake.copies # what would have been shipped
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Writing programs
|
|
215
|
+
|
|
216
|
+
Conventions that keep this pleasant, learned the hard way:
|
|
217
|
+
|
|
218
|
+
- **One job per function file.** `clone.sh` and `build.sh`, not `do_everything.sh`.
|
|
219
|
+
- **Functions do not call functions.** A call stack you have to unpick over ssh
|
|
220
|
+
at 3am is a call stack too deep. Keep the depth at one.
|
|
221
|
+
- **The first comment line is the description.** `runon list programs` shows
|
|
222
|
+
it, so it cannot drift out of date the way a separate metadata file would.
|
|
223
|
+
- **Take arguments, don't hardcode.** Arguments after the program name are
|
|
224
|
+
passed through, quoted: `run-program --program disk-report 80`.
|
|
225
|
+
|
|
226
|
+
## Commands
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
runon init scaffold a workspace here
|
|
230
|
+
runon new-program <name> create one from the template
|
|
231
|
+
runon list programs|hosts|groups|layouts
|
|
232
|
+
|
|
233
|
+
runon local run-program [--program P] [args...]
|
|
234
|
+
runon local run-layout [--layout L]
|
|
235
|
+
|
|
236
|
+
runon host --host H <verb> [--program P] [args...]
|
|
237
|
+
runon group --group G <verb> [--program P] [-j N] [args...]
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## What this does not do
|
|
241
|
+
|
|
242
|
+
- **No rollback, no idempotency, no desired-state model.** It runs your script.
|
|
243
|
+
If you need convergence, you need Ansible or Chef, and you should use them.
|
|
244
|
+
- **No secrets management.** Put credentials in your own vault and have the
|
|
245
|
+
program fetch them; `runon` never asks for or stores one.
|
|
246
|
+
- **No inventory discovery.** No cloud APIs, no dynamic inventory — you write
|
|
247
|
+
the file.
|
|
248
|
+
- **No output streaming.** Results arrive when a host finishes, not as it goes.
|
|
249
|
+
For a long program, watch it on one host first.
|
|
250
|
+
- **Groups run over ssh only.** There is no agent to install, and no plan to add
|
|
251
|
+
one.
|
|
252
|
+
|
|
253
|
+
## Tests
|
|
254
|
+
|
|
255
|
+
60 tests. No servers, no SSH keys, no network.
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
pip install -e ".[dev]"
|
|
259
|
+
pytest
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
CI runs them on Linux and macOS across Python 3.11–3.13, then executes the
|
|
263
|
+
quickstart above from an empty directory — so `init` producing something that
|
|
264
|
+
actually runs is checked on every commit, rather than being discovered by the
|
|
265
|
+
first user.
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT — see [LICENSE](LICENSE).
|
runon-0.1.0/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# runon
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ahmed-hashim-pro/runon/actions/workflows/ci.yml) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
Keep your operational procedures as plain shell scripts, and run any of them
|
|
6
|
+
identically on your laptop, on one server, or across a named group of servers.
|
|
7
|
+
|
|
8
|
+
Most teams end up with a `scripts/` folder nobody trusts and a wiki page that
|
|
9
|
+
went stale months ago. The alternatives are heavy: Ansible wants YAML, modules
|
|
10
|
+
and a mental model, which is a lot to adopt when the thing you actually have is
|
|
11
|
+
six shell scripts that work.
|
|
12
|
+
|
|
13
|
+
`runon` keeps the shell scripts. It adds the part that is genuinely annoying
|
|
14
|
+
to write yourself — getting a script and its helpers onto twenty machines,
|
|
15
|
+
running them, and telling you which ones failed.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
$ runon group --group production copy-run-program --program disk-report
|
|
19
|
+
web-1 ok
|
|
20
|
+
web-2 ok
|
|
21
|
+
db-1 FAILED (1)
|
|
22
|
+
[db-1] highest usage: 94% on /var
|
|
23
|
+
[db-1] OVER THRESHOLD
|
|
24
|
+
|
|
25
|
+
2/3 ok
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exit code is non-zero, because a rollout that worked on two of three machines
|
|
29
|
+
has not worked.
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
Python 3.11+. No runtime dependencies.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pipx install git+https://github.com/ahmed-hashim-pro/runon.git
|
|
37
|
+
# or, into a virtualenv you manage:
|
|
38
|
+
# pip install git+https://github.com/ahmed-hashim-pro/runon.git
|
|
39
|
+
|
|
40
|
+
mkdir my-ops && cd my-ops
|
|
41
|
+
runon init # scaffolds programs/, functions/, layouts/, inventory.toml
|
|
42
|
+
runon list programs
|
|
43
|
+
runon local run-program --program hello-world --verbose
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> Not on PyPI yet, so install from the repository for now. Nothing else is
|
|
47
|
+
> needed — `runon` has no runtime dependencies, and the only external programs
|
|
48
|
+
> it uses are the `ssh` and `scp` you already have.
|
|
49
|
+
|
|
50
|
+
<details>
|
|
51
|
+
<summary>Releasing (for maintainers)</summary>
|
|
52
|
+
|
|
53
|
+
Publishing uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/):
|
|
54
|
+
GitHub Actions mints a short-lived OIDC token, so there is no API token stored
|
|
55
|
+
anywhere to leak.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# rehearse against TestPyPI first
|
|
59
|
+
gh workflow run release.yml -f target=testpypi
|
|
60
|
+
|
|
61
|
+
# then release for real
|
|
62
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The workflow runs the suite, builds an sdist and a wheel, checks the metadata,
|
|
66
|
+
and refuses to publish if the tag does not match the version in `pyproject.toml`.
|
|
67
|
+
|
|
68
|
+
</details>
|
|
69
|
+
|
|
70
|
+
That last command works immediately, with no servers and no configuration.
|
|
71
|
+
|
|
72
|
+
## The idea
|
|
73
|
+
|
|
74
|
+
A **program** is a directory with a `main.sh`:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
programs/
|
|
78
|
+
disk-report/
|
|
79
|
+
main.sh <- entry point
|
|
80
|
+
functions/
|
|
81
|
+
say.sh <- shared helpers, sourced by programs
|
|
82
|
+
layouts/
|
|
83
|
+
split.sh <- terminal layouts for the local machine
|
|
84
|
+
inventory.toml <- your machines
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Adding a capability means adding a directory. **The tool never changes.** That
|
|
88
|
+
is the whole design: `runon` knows how to *reach* machines, shell knows what
|
|
89
|
+
to *do* on them, and neither has to learn the other's job.
|
|
90
|
+
|
|
91
|
+
Programs get their context from the environment, so they stay runnable by hand:
|
|
92
|
+
|
|
93
|
+
| Variable | Is |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `RUNON_HOST` | the host's name from the inventory |
|
|
96
|
+
| `RUNON_ADDRESS` | what ssh was given |
|
|
97
|
+
| `RUNON_PROGRAM` | the program's own name |
|
|
98
|
+
| `RUNON_FUNCTIONS` | where the helpers are — locally, your workspace; remotely, the copied cache |
|
|
99
|
+
| `RUNON_VAR_*` | anything you put in that host's `vars` |
|
|
100
|
+
|
|
101
|
+
Export those and `./main.sh` behaves exactly as `runon` would run it. That
|
|
102
|
+
matters at 3am.
|
|
103
|
+
|
|
104
|
+
## Three scopes, the same verbs
|
|
105
|
+
|
|
106
|
+
Where the work happens and what the work is are separate questions, so they are
|
|
107
|
+
separate parts of the command line:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
runon local run-program --program disk-report
|
|
111
|
+
runon host --host web-1 run-program --program disk-report
|
|
112
|
+
runon group --group production run-program --program disk-report -j 8
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The remote scopes share four verbs:
|
|
116
|
+
|
|
117
|
+
| Verb | Does |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `copy` | copy a local file or directory (`--local-dir`, `--remote-dir`) |
|
|
120
|
+
| `copy-program` | copy a program **and the functions library** to the target(s) |
|
|
121
|
+
| `run-program` | run an already-copied program |
|
|
122
|
+
| `copy-run-program` | both, in one step |
|
|
123
|
+
|
|
124
|
+
`copy-program` ships the functions library alongside the program deliberately: a
|
|
125
|
+
program that sources a helper is broken without it, and the target is the worst
|
|
126
|
+
place to discover that.
|
|
127
|
+
|
|
128
|
+
Omit `--program` and you get a picker. Add `--dry-run` to see which hosts would
|
|
129
|
+
be touched without touching them.
|
|
130
|
+
|
|
131
|
+
## Inventory
|
|
132
|
+
|
|
133
|
+
One file, so you can read the whole thing in one screen and diff it in review:
|
|
134
|
+
|
|
135
|
+
```toml
|
|
136
|
+
[hosts.web-1]
|
|
137
|
+
address = "web-1.example.com"
|
|
138
|
+
user = "deploy"
|
|
139
|
+
vars = { role = "web" }
|
|
140
|
+
|
|
141
|
+
[hosts.db-1]
|
|
142
|
+
address = "10.0.0.9"
|
|
143
|
+
port = 2222
|
|
144
|
+
|
|
145
|
+
[groups.production]
|
|
146
|
+
hosts = ["web-1", "db-1"]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`address` is handed to ssh untouched, so a `Host` alias from your `~/.ssh/config`
|
|
150
|
+
works here. And `--host` falls back to treating an unknown name as an address,
|
|
151
|
+
so `--host root@10.0.0.4` needs no inventory entry at all.
|
|
152
|
+
|
|
153
|
+
A group naming a host that does not exist fails when the inventory loads —
|
|
154
|
+
before a rollout has half-finished on the hosts it could resolve.
|
|
155
|
+
|
|
156
|
+
## It uses your ssh, on purpose
|
|
157
|
+
|
|
158
|
+
`runon` shells out to the system `ssh` and `scp`. It does **not** embed an SSH
|
|
159
|
+
library.
|
|
160
|
+
|
|
161
|
+
That means your `~/.ssh/config`, your agent, your keys, your `ProxyJump` and
|
|
162
|
+
your `known_hosts` all work exactly as they already do, and `runon` never has
|
|
163
|
+
to grow its own half-version of any of it. Connections run with `BatchMode=yes`,
|
|
164
|
+
so a missing key fails fast instead of hanging on a password prompt — which
|
|
165
|
+
across a group would otherwise mean twenty stuck connections.
|
|
166
|
+
|
|
167
|
+
One thing worth knowing: OpenSSH 9 moved `scp` onto the SFTP subsystem, so a
|
|
168
|
+
target with SFTP disabled fails a copy with an error that does not say so.
|
|
169
|
+
`runon` detects that and tells you the fix.
|
|
170
|
+
|
|
171
|
+
## Testing your programs without servers
|
|
172
|
+
|
|
173
|
+
Everything that touches another machine goes through one `Transport` interface,
|
|
174
|
+
and the fake one is **public API** rather than a test fixture — the hard part of
|
|
175
|
+
adopting a tool like this is proving your programs do the right thing *before*
|
|
176
|
+
you point them at production:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from runon import FakeTransport, Host, Workspace
|
|
180
|
+
from runon import runner
|
|
181
|
+
|
|
182
|
+
fake = FakeTransport()
|
|
183
|
+
runner.run_program(fake, Host("web-1", "web-1.example.com"), workspace, program)
|
|
184
|
+
|
|
185
|
+
fake.calls # [("web-1", "cd ~/.runon/programs/... && ./main.sh")]
|
|
186
|
+
fake.copies # what would have been shipped
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Writing programs
|
|
190
|
+
|
|
191
|
+
Conventions that keep this pleasant, learned the hard way:
|
|
192
|
+
|
|
193
|
+
- **One job per function file.** `clone.sh` and `build.sh`, not `do_everything.sh`.
|
|
194
|
+
- **Functions do not call functions.** A call stack you have to unpick over ssh
|
|
195
|
+
at 3am is a call stack too deep. Keep the depth at one.
|
|
196
|
+
- **The first comment line is the description.** `runon list programs` shows
|
|
197
|
+
it, so it cannot drift out of date the way a separate metadata file would.
|
|
198
|
+
- **Take arguments, don't hardcode.** Arguments after the program name are
|
|
199
|
+
passed through, quoted: `run-program --program disk-report 80`.
|
|
200
|
+
|
|
201
|
+
## Commands
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
runon init scaffold a workspace here
|
|
205
|
+
runon new-program <name> create one from the template
|
|
206
|
+
runon list programs|hosts|groups|layouts
|
|
207
|
+
|
|
208
|
+
runon local run-program [--program P] [args...]
|
|
209
|
+
runon local run-layout [--layout L]
|
|
210
|
+
|
|
211
|
+
runon host --host H <verb> [--program P] [args...]
|
|
212
|
+
runon group --group G <verb> [--program P] [-j N] [args...]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## What this does not do
|
|
216
|
+
|
|
217
|
+
- **No rollback, no idempotency, no desired-state model.** It runs your script.
|
|
218
|
+
If you need convergence, you need Ansible or Chef, and you should use them.
|
|
219
|
+
- **No secrets management.** Put credentials in your own vault and have the
|
|
220
|
+
program fetch them; `runon` never asks for or stores one.
|
|
221
|
+
- **No inventory discovery.** No cloud APIs, no dynamic inventory — you write
|
|
222
|
+
the file.
|
|
223
|
+
- **No output streaming.** Results arrive when a host finishes, not as it goes.
|
|
224
|
+
For a long program, watch it on one host first.
|
|
225
|
+
- **Groups run over ssh only.** There is no agent to install, and no plan to add
|
|
226
|
+
one.
|
|
227
|
+
|
|
228
|
+
## Tests
|
|
229
|
+
|
|
230
|
+
60 tests. No servers, no SSH keys, no network.
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
pip install -e ".[dev]"
|
|
234
|
+
pytest
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
CI runs them on Linux and macOS across Python 3.11–3.13, then executes the
|
|
238
|
+
quickstart above from an empty directory — so `init` producing something that
|
|
239
|
+
actually runs is checked on every commit, rather than being discovered by the
|
|
240
|
+
first user.
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Keep your operational procedures as plain shell scripts, and run any of them identically on your laptop, one server, or a named group of servers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Ahmed Hashim" }]
|
|
13
|
+
keywords = ["cli", "ssh", "ops", "runbook", "automation", "fleet", "shell"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: System :: Systems Administration",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# No runtime dependencies. Everything it needs is in the standard library, and
|
|
26
|
+
# transport is the system's own ssh/scp — which is the point: your ~/.ssh/config,
|
|
27
|
+
# your agent, your jump hosts and your keys already work.
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest==9.1.1", "ruff==0.16.6"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/ahmed-hashim-pro/runon"
|
|
35
|
+
Repository = "https://github.com/ahmed-hashim-pro/runon"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
runon = "runon.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
addopts = "-q"
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py311"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "C4"]
|
runon-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""runon — run shell programs locally, on one host, or across a group."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .errors import (
|
|
6
|
+
ConfigError,
|
|
7
|
+
ProgramInvalid,
|
|
8
|
+
RunonError,
|
|
9
|
+
UnknownGroup,
|
|
10
|
+
UnknownHost,
|
|
11
|
+
UnknownProgram,
|
|
12
|
+
)
|
|
13
|
+
from .inventory import Group, Host, Inventory
|
|
14
|
+
from .program import Program, Workspace
|
|
15
|
+
from .transport import FakeTransport, LocalTransport, Result, SSHTransport, Transport
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ConfigError",
|
|
19
|
+
"FakeTransport",
|
|
20
|
+
"Group",
|
|
21
|
+
"Host",
|
|
22
|
+
"Inventory",
|
|
23
|
+
"LocalTransport",
|
|
24
|
+
"Program",
|
|
25
|
+
"ProgramInvalid",
|
|
26
|
+
"Result",
|
|
27
|
+
"RunonError",
|
|
28
|
+
"SSHTransport",
|
|
29
|
+
"Transport",
|
|
30
|
+
"UnknownGroup",
|
|
31
|
+
"UnknownHost",
|
|
32
|
+
"UnknownProgram",
|
|
33
|
+
"Workspace",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|