runon 0.1.0__py3-none-any.whl
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/__init__.py +35 -0
- runon/cli.py +267 -0
- runon/errors.py +33 -0
- runon/inventory.py +142 -0
- runon/picker.py +39 -0
- runon/program.py +127 -0
- runon/report.py +42 -0
- runon/runner.py +133 -0
- runon/scaffold.py +110 -0
- runon/transport.py +178 -0
- runon-0.1.0.dist-info/METADATA +269 -0
- runon-0.1.0.dist-info/RECORD +16 -0
- runon-0.1.0.dist-info/WHEEL +5 -0
- runon-0.1.0.dist-info/entry_points.txt +2 -0
- runon-0.1.0.dist-info/licenses/LICENSE +21 -0
- runon-0.1.0.dist-info/top_level.txt +1 -0
runon/__init__.py
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
|
+
]
|
runon/cli.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""The command surface.
|
|
2
|
+
|
|
3
|
+
Three scopes — local, host, group — sharing the same verbs, because where the
|
|
4
|
+
work happens and what the work is are separate questions and the CLI should not
|
|
5
|
+
tangle them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__, inventory, runner
|
|
15
|
+
from . import program as program_mod
|
|
16
|
+
from .errors import RunonError
|
|
17
|
+
from .picker import choose
|
|
18
|
+
from .program import Workspace
|
|
19
|
+
from .report import emit
|
|
20
|
+
from .transport import LocalTransport, Result, SSHTransport
|
|
21
|
+
|
|
22
|
+
REMOTE_VERBS = ("copy", "copy-program", "run-program", "copy-run-program")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="runon",
|
|
28
|
+
description="Run shell programs on your machine, one server, or a named group.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"runon {__version__}")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"-C", "--directory", type=Path, help="workspace to use (default: search up)"
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--inventory", type=Path, help="inventory file (default: search up)")
|
|
35
|
+
sub = parser.add_subparsers(dest="scope", required=True)
|
|
36
|
+
|
|
37
|
+
# -- local ---------------------------------------------------------------
|
|
38
|
+
local = sub.add_parser("local", help="run on this machine")
|
|
39
|
+
local_sub = local.add_subparsers(dest="verb", required=True)
|
|
40
|
+
run_local = local_sub.add_parser("run-program", help="run a program here")
|
|
41
|
+
_add_program_args(run_local)
|
|
42
|
+
layout = local_sub.add_parser("run-layout", help="open a predefined terminal layout")
|
|
43
|
+
layout.add_argument("--layout", "-l", help="layout name (prompts if omitted)")
|
|
44
|
+
|
|
45
|
+
# -- host ----------------------------------------------------------------
|
|
46
|
+
host = sub.add_parser("host", help="run on one machine")
|
|
47
|
+
host.add_argument("--host", "-H", required=True, help="inventory name, or user@address")
|
|
48
|
+
_add_remote_verbs(host)
|
|
49
|
+
|
|
50
|
+
# -- group ---------------------------------------------------------------
|
|
51
|
+
group = sub.add_parser("group", help="run on every machine in a group")
|
|
52
|
+
group.add_argument("--group", "-g", required=True, help="group name from the inventory")
|
|
53
|
+
group.add_argument("--parallel", "-j", type=int, default=1, help="hosts at once (default 1)")
|
|
54
|
+
_add_remote_verbs(group)
|
|
55
|
+
|
|
56
|
+
# -- list / init / new ---------------------------------------------------
|
|
57
|
+
listing = sub.add_parser("list", help="show what is available")
|
|
58
|
+
listing.add_argument(
|
|
59
|
+
"what", choices=["programs", "hosts", "groups", "layouts"], nargs="?", default="programs"
|
|
60
|
+
)
|
|
61
|
+
init = sub.add_parser("init", help="scaffold a workspace here")
|
|
62
|
+
init.add_argument("--force", action="store_true", help="write into a non-empty directory")
|
|
63
|
+
new = sub.add_parser("new-program", help="create a program from the template")
|
|
64
|
+
new.add_argument("name")
|
|
65
|
+
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _add_program_args(parser: argparse.ArgumentParser) -> None:
|
|
70
|
+
parser.add_argument("--program", "-p", help="program name (prompts if omitted)")
|
|
71
|
+
parser.add_argument("--verbose", "-v", action="store_true", help="show output from successes")
|
|
72
|
+
parser.add_argument("--dry-run", action="store_true", help="print what would happen")
|
|
73
|
+
parser.add_argument("args", nargs="*", help="arguments passed through to main.sh")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _add_remote_verbs(parser: argparse.ArgumentParser) -> None:
|
|
77
|
+
verbs = parser.add_subparsers(dest="verb", required=True)
|
|
78
|
+
for verb in REMOTE_VERBS:
|
|
79
|
+
sub = verbs.add_parser(verb, help=_verb_help(verb))
|
|
80
|
+
if verb == "copy":
|
|
81
|
+
sub.add_argument("--local-dir", type=Path, required=True)
|
|
82
|
+
sub.add_argument("--remote-dir", required=True)
|
|
83
|
+
sub.add_argument("--verbose", "-v", action="store_true")
|
|
84
|
+
sub.add_argument("--dry-run", action="store_true")
|
|
85
|
+
else:
|
|
86
|
+
_add_program_args(sub)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _verb_help(verb: str) -> str:
|
|
90
|
+
return {
|
|
91
|
+
"copy": "copy a local file or directory to the target(s)",
|
|
92
|
+
"copy-program": "copy a program (and the functions library) to the target(s)",
|
|
93
|
+
"run-program": "run an already-copied program on the target(s)",
|
|
94
|
+
"copy-run-program": "copy and run in one step",
|
|
95
|
+
}[verb]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main(argv: list[str] | None = None) -> int:
|
|
99
|
+
parser = build_parser()
|
|
100
|
+
args = parser.parse_args(argv)
|
|
101
|
+
try:
|
|
102
|
+
return _dispatch(args)
|
|
103
|
+
except RunonError as exc:
|
|
104
|
+
# Expected failures print what went wrong, not where in our code it did.
|
|
105
|
+
print(f"runon: {exc}", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
except KeyboardInterrupt:
|
|
108
|
+
print("\ninterrupted", file=sys.stderr)
|
|
109
|
+
return 130
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
113
|
+
start = args.directory or Path.cwd()
|
|
114
|
+
workspace = program_mod.find_workspace(start) or Workspace(root=start)
|
|
115
|
+
inv = inventory.load(args.inventory, start=start)
|
|
116
|
+
|
|
117
|
+
if args.scope == "init":
|
|
118
|
+
return _init(start, force=args.force)
|
|
119
|
+
if args.scope == "new-program":
|
|
120
|
+
return _new_program(workspace, args.name)
|
|
121
|
+
if args.scope == "list":
|
|
122
|
+
return _list(workspace, inv, args.what)
|
|
123
|
+
if args.scope == "local":
|
|
124
|
+
return _local(workspace, args)
|
|
125
|
+
return _remote(workspace, inv, args)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_program(workspace: Workspace, name: str | None):
|
|
129
|
+
# An explicitly empty --program is almost always an unset shell variable.
|
|
130
|
+
# Falling through to the picker would make a scripted run do nothing and
|
|
131
|
+
# still report success, so it is refused instead.
|
|
132
|
+
if name is not None and not name.strip():
|
|
133
|
+
raise RunonError("--program was given an empty value")
|
|
134
|
+
|
|
135
|
+
programs = workspace.programs()
|
|
136
|
+
# Checked before the name is looked up: someone who has not set up a
|
|
137
|
+
# workspace yet needs to be told that, not told their program is missing
|
|
138
|
+
# from an empty list.
|
|
139
|
+
if not programs:
|
|
140
|
+
raise RunonError(
|
|
141
|
+
f"no programs found under {workspace.programs_path}\n\n"
|
|
142
|
+
"Run 'runon init' here to scaffold a workspace, or use -C to point at one."
|
|
143
|
+
)
|
|
144
|
+
if name:
|
|
145
|
+
return workspace.program(program_mod.validate_name(name))
|
|
146
|
+
return choose(programs)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _local(workspace: Workspace, args) -> int:
|
|
150
|
+
host = inventory.Host(name="local", address="localhost")
|
|
151
|
+
transport = LocalTransport()
|
|
152
|
+
|
|
153
|
+
if args.verb == "run-layout":
|
|
154
|
+
layouts = workspace.layouts()
|
|
155
|
+
if not layouts:
|
|
156
|
+
raise RunonError(f"no layouts found under {workspace.layouts_path}")
|
|
157
|
+
if args.layout:
|
|
158
|
+
chosen = next((lay for lay in layouts if lay.name == args.layout), None)
|
|
159
|
+
if chosen is None:
|
|
160
|
+
raise RunonError(f"no layout named {args.layout!r}")
|
|
161
|
+
else:
|
|
162
|
+
chosen = choose(layouts)
|
|
163
|
+
if chosen is None:
|
|
164
|
+
return 0
|
|
165
|
+
script = workspace.layouts_path / f"{chosen.name}.sh"
|
|
166
|
+
result = transport.run(host, f"sh {script}")
|
|
167
|
+
return emit([result], verbose=True)
|
|
168
|
+
|
|
169
|
+
program = _resolve_program(workspace, args.program)
|
|
170
|
+
if program is None:
|
|
171
|
+
return 0
|
|
172
|
+
if args.dry_run:
|
|
173
|
+
print(f"would run {program.name} on the local machine")
|
|
174
|
+
return 0
|
|
175
|
+
result = runner.run_program(transport, host, workspace, program, args=args.args, remote=False)
|
|
176
|
+
return emit([result], verbose=args.verbose)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _remote(workspace: Workspace, inv: inventory.Inventory, args) -> int:
|
|
180
|
+
if args.scope == "host":
|
|
181
|
+
hosts = [inv.host(args.host)]
|
|
182
|
+
parallel = 1
|
|
183
|
+
else:
|
|
184
|
+
hosts = inv.group(args.group)
|
|
185
|
+
parallel = max(1, args.parallel)
|
|
186
|
+
if not hosts:
|
|
187
|
+
raise RunonError("no hosts selected")
|
|
188
|
+
|
|
189
|
+
transport = SSHTransport()
|
|
190
|
+
|
|
191
|
+
if args.verb == "copy":
|
|
192
|
+
if args.dry_run:
|
|
193
|
+
_print_plan(hosts, f"copy {args.local_dir} -> {args.remote_dir}")
|
|
194
|
+
return 0
|
|
195
|
+
results = runner.fan_out(
|
|
196
|
+
hosts, lambda h: transport.copy(h, args.local_dir, args.remote_dir), parallel=parallel
|
|
197
|
+
)
|
|
198
|
+
return emit(results, verbose=args.verbose)
|
|
199
|
+
|
|
200
|
+
program = _resolve_program(workspace, args.program)
|
|
201
|
+
if program is None:
|
|
202
|
+
return 0
|
|
203
|
+
if args.dry_run:
|
|
204
|
+
_print_plan(hosts, f"{args.verb} {program.name}")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
def work(host) -> Result | list[Result]:
|
|
208
|
+
if args.verb == "copy-program":
|
|
209
|
+
return runner.copy_program(transport, host, workspace, program)
|
|
210
|
+
if args.verb == "run-program":
|
|
211
|
+
return runner.run_program(transport, host, workspace, program, args=args.args)
|
|
212
|
+
copied = runner.copy_program(transport, host, workspace, program)
|
|
213
|
+
failed = [r for r in copied if not r.ok]
|
|
214
|
+
if failed:
|
|
215
|
+
return failed[0]
|
|
216
|
+
return runner.run_program(transport, host, workspace, program, args=args.args)
|
|
217
|
+
|
|
218
|
+
results = runner.fan_out(hosts, work, parallel=parallel)
|
|
219
|
+
return emit(results, verbose=args.verbose)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _print_plan(hosts, action: str) -> None:
|
|
223
|
+
print(f"would {action} on:")
|
|
224
|
+
for host in hosts:
|
|
225
|
+
print(f" {host.name} ({host.ssh_target})")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _list(workspace: Workspace, inv: inventory.Inventory, what: str) -> int:
|
|
229
|
+
if what == "programs":
|
|
230
|
+
items = workspace.programs()
|
|
231
|
+
if not items:
|
|
232
|
+
print(f"no programs under {workspace.programs_path}")
|
|
233
|
+
print("Run 'runon init' here to scaffold a workspace.")
|
|
234
|
+
return 0
|
|
235
|
+
width = max(len(p.name) for p in items)
|
|
236
|
+
for p in items:
|
|
237
|
+
print(f" {p.name:<{width}} {p.description}".rstrip())
|
|
238
|
+
elif what == "layouts":
|
|
239
|
+
for lay in workspace.layouts():
|
|
240
|
+
print(f" {lay.name}")
|
|
241
|
+
elif what == "hosts":
|
|
242
|
+
for name, host in sorted(inv.hosts.items()):
|
|
243
|
+
print(f" {name:<20} {host.ssh_target}")
|
|
244
|
+
else:
|
|
245
|
+
for name, grp in sorted(inv.groups.items()):
|
|
246
|
+
print(f" {name:<20} {', '.join(grp.hosts)}")
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _init(root: Path, *, force: bool) -> int:
|
|
251
|
+
from .scaffold import write_workspace
|
|
252
|
+
|
|
253
|
+
created = write_workspace(root, force=force)
|
|
254
|
+
for path in created:
|
|
255
|
+
print(f" created {path.relative_to(root)}")
|
|
256
|
+
print("\nTry: runon list programs")
|
|
257
|
+
print(" runon local run-program --program hello-world")
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _new_program(workspace: Workspace, name: str) -> int:
|
|
262
|
+
from .scaffold import write_program
|
|
263
|
+
|
|
264
|
+
program_mod.validate_name(name)
|
|
265
|
+
path = write_program(workspace.programs_path, name)
|
|
266
|
+
print(f" created {path}")
|
|
267
|
+
return 0
|
runon/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Errors a user can act on.
|
|
2
|
+
|
|
3
|
+
Every one of these is printed without a traceback, because a stack trace tells
|
|
4
|
+
an operator nothing they can use about a typo'd host name.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RunonError(Exception):
|
|
9
|
+
"""Base for anything we expect and can explain."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(RunonError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownHost(RunonError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnknownGroup(RunonError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnknownProgram(RunonError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProgramInvalid(RunonError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RemoteFailure(RunonError):
|
|
33
|
+
pass
|
runon/inventory.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Who the hosts are, and which groups they belong to.
|
|
2
|
+
|
|
3
|
+
One TOML file rather than a directory tree of JSON: an operator should be able
|
|
4
|
+
to read the whole inventory in one screen and diff it in a review.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigError, UnknownGroup, UnknownHost
|
|
14
|
+
|
|
15
|
+
DEFAULT_FILENAME = "inventory.toml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Host:
|
|
20
|
+
"""A machine runon can reach.
|
|
21
|
+
|
|
22
|
+
``address`` is handed to ssh untouched, so anything ssh understands works:
|
|
23
|
+
a bare hostname, user@host, or a Host alias out of ~/.ssh/config.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
address: str
|
|
28
|
+
port: int | None = None
|
|
29
|
+
user: str | None = None
|
|
30
|
+
#: Free-form, exported to programs as RUNON_VAR_<KEY>.
|
|
31
|
+
vars: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def ssh_target(self) -> str:
|
|
35
|
+
return f"{self.user}@{self.address}" if self.user else self.address
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Group:
|
|
40
|
+
name: str
|
|
41
|
+
hosts: tuple[str, ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Inventory:
|
|
46
|
+
hosts: dict[str, Host]
|
|
47
|
+
groups: dict[str, Group]
|
|
48
|
+
source: Path | None = None
|
|
49
|
+
|
|
50
|
+
def host(self, name: str) -> Host:
|
|
51
|
+
"""Resolves a name, falling back to treating it as a literal address.
|
|
52
|
+
|
|
53
|
+
This is what collapses the original tool's separate "named instance" and
|
|
54
|
+
"ad-hoc target" commands into one: `--host web-1` uses the inventory,
|
|
55
|
+
`--host root@10.0.0.4` just works, and neither needs its own verb.
|
|
56
|
+
"""
|
|
57
|
+
if name in self.hosts:
|
|
58
|
+
return self.hosts[name]
|
|
59
|
+
if _looks_like_address(name):
|
|
60
|
+
return Host(name=name, address=name)
|
|
61
|
+
raise UnknownHost(
|
|
62
|
+
f"no host named {name!r} in the inventory, and it does not look like an address.\n"
|
|
63
|
+
f"Known hosts: {', '.join(sorted(self.hosts)) or '(none)'}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def group(self, name: str) -> list[Host]:
|
|
67
|
+
if name not in self.groups:
|
|
68
|
+
raise UnknownGroup(
|
|
69
|
+
f"no group named {name!r}.\n"
|
|
70
|
+
f"Known groups: {', '.join(sorted(self.groups)) or '(none)'}"
|
|
71
|
+
)
|
|
72
|
+
return [self.host(h) for h in self.groups[name].hosts]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _looks_like_address(value: str) -> bool:
|
|
76
|
+
"""True for things ssh would accept but an inventory lookup missed."""
|
|
77
|
+
return "@" in value or "." in value or value == "localhost"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(path: Path | None = None, *, start: Path | None = None) -> Inventory:
|
|
81
|
+
"""Reads an inventory, or returns an empty one when there is no file.
|
|
82
|
+
|
|
83
|
+
An absent inventory is not an error: running programs on the local machine
|
|
84
|
+
needs no hosts at all, and requiring a config file to do that would be
|
|
85
|
+
friction for no reason.
|
|
86
|
+
"""
|
|
87
|
+
if path is None:
|
|
88
|
+
path = find(start or Path.cwd())
|
|
89
|
+
if path is None:
|
|
90
|
+
return Inventory(hosts={}, groups={})
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
raise ConfigError(f"inventory file not found: {path}")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
96
|
+
except tomllib.TOMLDecodeError as exc:
|
|
97
|
+
raise ConfigError(f"{path} is not valid TOML: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
return _parse(raw, path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def find(start: Path) -> Path | None:
|
|
103
|
+
"""Walks up from `start` looking for an inventory, like git finds .git."""
|
|
104
|
+
for directory in [start, *start.parents]:
|
|
105
|
+
candidate = directory / DEFAULT_FILENAME
|
|
106
|
+
if candidate.is_file():
|
|
107
|
+
return candidate
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse(raw: dict, source: Path) -> Inventory:
|
|
112
|
+
hosts: dict[str, Host] = {}
|
|
113
|
+
for name, spec in (raw.get("hosts") or {}).items():
|
|
114
|
+
if not isinstance(spec, dict):
|
|
115
|
+
raise ConfigError(f"{source}: host {name!r} must be a table")
|
|
116
|
+
address = spec.get("address", name)
|
|
117
|
+
port = spec.get("port")
|
|
118
|
+
if port is not None and not isinstance(port, int):
|
|
119
|
+
raise ConfigError(f"{source}: host {name!r} has a non-integer port")
|
|
120
|
+
hosts[name] = Host(
|
|
121
|
+
name=name,
|
|
122
|
+
address=str(address),
|
|
123
|
+
port=port,
|
|
124
|
+
user=spec.get("user"),
|
|
125
|
+
vars={str(k): str(v) for k, v in (spec.get("vars") or {}).items()},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
groups: dict[str, Group] = {}
|
|
129
|
+
for name, spec in (raw.get("groups") or {}).items():
|
|
130
|
+
members = spec.get("hosts") if isinstance(spec, dict) else spec
|
|
131
|
+
if not isinstance(members, list) or not all(isinstance(m, str) for m in members):
|
|
132
|
+
raise ConfigError(f"{source}: group {name!r} must list host names")
|
|
133
|
+
# Catching this here means a typo in a group fails before runon has
|
|
134
|
+
# half-finished a rollout across the hosts it could resolve.
|
|
135
|
+
unknown = [m for m in members if m not in hosts and not _looks_like_address(m)]
|
|
136
|
+
if unknown:
|
|
137
|
+
raise ConfigError(
|
|
138
|
+
f"{source}: group {name!r} refers to unknown hosts: {', '.join(unknown)}"
|
|
139
|
+
)
|
|
140
|
+
groups[name] = Group(name=name, hosts=tuple(members))
|
|
141
|
+
|
|
142
|
+
return Inventory(hosts=hosts, groups=groups, source=source)
|
runon/picker.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Choosing a program when you did not name one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .program import Program
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def choose(programs: list[Program], *, stream=None, prompt_stream=None) -> Program | None:
|
|
11
|
+
"""Numbered menu on stderr, so stdout stays pipeable.
|
|
12
|
+
|
|
13
|
+
Returns None if the user aborts, which the caller treats as a clean exit
|
|
14
|
+
rather than an error — changing your mind is not a failure.
|
|
15
|
+
"""
|
|
16
|
+
stream = stream or sys.stdin
|
|
17
|
+
out = prompt_stream or sys.stderr
|
|
18
|
+
|
|
19
|
+
if not programs:
|
|
20
|
+
return None
|
|
21
|
+
if len(programs) == 1:
|
|
22
|
+
return programs[0]
|
|
23
|
+
|
|
24
|
+
for index, program in enumerate(programs, 1):
|
|
25
|
+
suffix = f" — {program.description}" if program.description else ""
|
|
26
|
+
print(f" {index:>2}. {program.name}{suffix}", file=out)
|
|
27
|
+
print("", file=out)
|
|
28
|
+
|
|
29
|
+
while True:
|
|
30
|
+
print(f"Select 1-{len(programs)} (or blank to cancel): ", end="", file=out, flush=True)
|
|
31
|
+
raw = stream.readline()
|
|
32
|
+
if not raw:
|
|
33
|
+
return None
|
|
34
|
+
raw = raw.strip()
|
|
35
|
+
if raw == "":
|
|
36
|
+
return None
|
|
37
|
+
if raw.isdigit() and 1 <= int(raw) <= len(programs):
|
|
38
|
+
return programs[int(raw) - 1]
|
|
39
|
+
print(f" not a choice: {raw!r}", file=out)
|
runon/program.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Programs and functions: the unit of work, and the reason the tool is small.
|
|
2
|
+
|
|
3
|
+
A program is a directory with a ``main.sh``. Adding a capability means adding a
|
|
4
|
+
directory — the tool itself never changes. That is the whole design: the CLI
|
|
5
|
+
knows how to *reach* machines, and shell knows what to *do* on them, and neither
|
|
6
|
+
has to learn the other's job.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .errors import ProgramInvalid, UnknownProgram
|
|
16
|
+
|
|
17
|
+
ENTRY_POINT = "main.sh"
|
|
18
|
+
PROGRAMS_DIR = "programs"
|
|
19
|
+
FUNCTIONS_DIR = "functions"
|
|
20
|
+
LAYOUTS_DIR = "layouts"
|
|
21
|
+
|
|
22
|
+
_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Program:
|
|
27
|
+
name: str
|
|
28
|
+
path: Path
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def entry_point(self) -> Path:
|
|
32
|
+
return self.path / ENTRY_POINT
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def description(self) -> str:
|
|
36
|
+
"""The first ``# comment`` line of main.sh, if there is one.
|
|
37
|
+
|
|
38
|
+
Cheap convention, no metadata file: a program that describes itself in a
|
|
39
|
+
comment is a program whose description cannot drift out of date.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
for line in self.entry_point.read_text(encoding="utf-8").splitlines()[:10]:
|
|
43
|
+
stripped = line.strip()
|
|
44
|
+
if stripped.startswith("#!"):
|
|
45
|
+
continue
|
|
46
|
+
if stripped.startswith("#"):
|
|
47
|
+
text = stripped.lstrip("#").strip()
|
|
48
|
+
if text:
|
|
49
|
+
return text
|
|
50
|
+
elif stripped:
|
|
51
|
+
break
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Workspace:
|
|
59
|
+
"""The directory holding programs/, functions/ and layouts/."""
|
|
60
|
+
|
|
61
|
+
root: Path
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def programs_path(self) -> Path:
|
|
65
|
+
return self.root / PROGRAMS_DIR
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def functions_path(self) -> Path:
|
|
69
|
+
return self.root / FUNCTIONS_DIR
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def layouts_path(self) -> Path:
|
|
73
|
+
return self.root / LAYOUTS_DIR
|
|
74
|
+
|
|
75
|
+
def programs(self) -> list[Program]:
|
|
76
|
+
if not self.programs_path.is_dir():
|
|
77
|
+
return []
|
|
78
|
+
found = [
|
|
79
|
+
Program(name=child.name, path=child)
|
|
80
|
+
for child in sorted(self.programs_path.iterdir())
|
|
81
|
+
if child.is_dir() and (child / ENTRY_POINT).is_file()
|
|
82
|
+
]
|
|
83
|
+
return found
|
|
84
|
+
|
|
85
|
+
def program(self, name: str) -> Program:
|
|
86
|
+
for program in self.programs():
|
|
87
|
+
if program.name == name:
|
|
88
|
+
return program
|
|
89
|
+
available = ", ".join(p.name for p in self.programs()) or "(none)"
|
|
90
|
+
raise UnknownProgram(f"no program named {name!r}.\nAvailable: {available}")
|
|
91
|
+
|
|
92
|
+
def functions(self) -> list[Path]:
|
|
93
|
+
if not self.functions_path.is_dir():
|
|
94
|
+
return []
|
|
95
|
+
return sorted(p for p in self.functions_path.iterdir() if p.suffix == ".sh")
|
|
96
|
+
|
|
97
|
+
def layouts(self) -> list[Program]:
|
|
98
|
+
"""Layouts are programs too — a layout is just a script that opens panes."""
|
|
99
|
+
if not self.layouts_path.is_dir():
|
|
100
|
+
return []
|
|
101
|
+
return [
|
|
102
|
+
Program(name=child.stem, path=self.layouts_path)
|
|
103
|
+
for child in sorted(self.layouts_path.iterdir())
|
|
104
|
+
if child.suffix == ".sh"
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_workspace(start: Path) -> Workspace | None:
|
|
109
|
+
"""Walks up looking for a directory containing programs/."""
|
|
110
|
+
for directory in [start, *start.parents]:
|
|
111
|
+
if (directory / PROGRAMS_DIR).is_dir():
|
|
112
|
+
return Workspace(root=directory)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_name(name: str) -> str:
|
|
117
|
+
"""Program names become path components and shell arguments.
|
|
118
|
+
|
|
119
|
+
Rejecting the awkward ones here means nothing downstream has to quote
|
|
120
|
+
defensively, and `--program ../../etc` is refused rather than resolved.
|
|
121
|
+
"""
|
|
122
|
+
if not _NAME.match(name):
|
|
123
|
+
raise ProgramInvalid(
|
|
124
|
+
f"{name!r} is not a valid program name. "
|
|
125
|
+
"Use letters, digits, dot, dash or underscore, starting with a letter or digit."
|
|
126
|
+
)
|
|
127
|
+
return name
|
runon/report.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Printing results in a way an operator can scan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from .transport import Result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit(results: Iterable[Result], *, verbose: bool = False, stream=None) -> int:
|
|
12
|
+
"""Prints one line per host and returns the exit code for the whole run.
|
|
13
|
+
|
|
14
|
+
Non-zero if any host failed, because a rollout that worked on nine of ten
|
|
15
|
+
machines has not worked.
|
|
16
|
+
"""
|
|
17
|
+
stream = stream or sys.stdout
|
|
18
|
+
results = list(results)
|
|
19
|
+
failures = 0
|
|
20
|
+
|
|
21
|
+
for result in results:
|
|
22
|
+
status = "ok" if result.ok else f"FAILED ({result.exit_code})"
|
|
23
|
+
print(f"{result.host:<24} {status}", file=stream)
|
|
24
|
+
if result.ok and verbose and result.stdout.strip():
|
|
25
|
+
_indent(result.stdout, stream)
|
|
26
|
+
if not result.ok:
|
|
27
|
+
failures += 1
|
|
28
|
+
# Always show why a failure failed. Needing a flag to see the error
|
|
29
|
+
# means running it twice.
|
|
30
|
+
if result.stderr.strip():
|
|
31
|
+
_indent(result.stderr, stream)
|
|
32
|
+
elif result.stdout.strip():
|
|
33
|
+
_indent(result.stdout, stream)
|
|
34
|
+
|
|
35
|
+
if len(results) > 1:
|
|
36
|
+
print(f"\n{len(results) - failures}/{len(results)} ok", file=stream)
|
|
37
|
+
return 1 if failures else 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _indent(text: str, stream) -> None:
|
|
41
|
+
for line in text.rstrip().splitlines():
|
|
42
|
+
print(f" {line}", file=stream)
|