rrun-cli 0.1.1__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner.
5
5
  Author: waqiju
6
6
  License-Expression: MIT
@@ -21,6 +21,9 @@ Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
27
  Dynamic: license-file
25
28
 
26
29
  # rrun
@@ -100,6 +103,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
100
103
  | `rrun config` | Diagnose the machines.json source chain |
101
104
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
102
105
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
106
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
103
107
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
104
108
 
105
109
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -121,11 +125,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
121
125
  "defaults": { "windows": { "os": "Windows" } },
122
126
  "machines": [
123
127
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
124
- "user": "admin", "password": "secret" }
128
+ "user": "admin", "password": "secret" },
129
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
130
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
125
131
  ]
126
132
  }
127
133
  ```
128
134
 
135
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
136
+
129
137
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
130
138
 
131
139
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -149,8 +157,8 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
149
157
 
150
158
  ## Security
151
159
 
152
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
153
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
160
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
161
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
154
162
  - The audit log never records passwords, and `--env` values are logged as keys only.
155
163
 
156
164
  ## Auditing & state
@@ -168,7 +176,7 @@ pipx uninstall rrun-cli
168
176
 
169
177
  ## Contributing
170
178
 
171
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
179
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
172
180
 
173
181
  ## License
174
182
 
@@ -75,6 +75,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
75
75
  | `rrun config` | Diagnose the machines.json source chain |
76
76
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
77
77
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
78
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
78
79
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
79
80
 
80
81
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -96,11 +97,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
96
97
  "defaults": { "windows": { "os": "Windows" } },
97
98
  "machines": [
98
99
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
99
- "user": "admin", "password": "secret" }
100
+ "user": "admin", "password": "secret" },
101
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
102
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
100
103
  ]
101
104
  }
102
105
  ```
103
106
 
107
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
108
+
104
109
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
105
110
 
106
111
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -124,8 +129,8 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
124
129
 
125
130
  ## Security
126
131
 
127
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
128
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
132
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
133
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
129
134
  - The audit log never records passwords, and `--env` values are logged as keys only.
130
135
 
131
136
  ## Auditing & state
@@ -143,7 +148,7 @@ pipx uninstall rrun-cli
143
148
 
144
149
  ## Contributing
145
150
 
146
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
151
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
147
152
 
148
153
  ## License
149
154
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rrun-cli"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,6 +24,9 @@ classifiers = [
24
24
  # 纯标准库实现,零第三方依赖。控制端仅需本机有 ssh + sshpass。
25
25
  dependencies = []
26
26
 
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "ruff>=0.6"]
29
+
27
30
  [project.urls]
28
31
  Homepage = "https://github.com/waqiju/rrun"
29
32
  Repository = "https://github.com/waqiju/rrun"
@@ -43,3 +46,16 @@ where = ["src"]
43
46
 
44
47
  [tool.setuptools.package-data]
45
48
  rrun = ["remote-requirements.txt"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+
53
+ [tool.ruff]
54
+ target-version = "py310"
55
+ line-length = 120
56
+ src = ["src"]
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "I", "UP", "B", "BLE"]
60
+ # E501 交给格式化宽限:项目内大量中文注释 + 单行 wrapper 模板,硬限 120 收益低
61
+ ignore = ["E501"]
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  """rrun — 远程机器执行模块(Run Remote)。
3
2
 
4
3
  约定:read/write/edit 等文件操作始终发生在本地;任何远程执行都是
@@ -12,8 +11,9 @@ try:
12
11
 
13
12
  __version__ = _pkg_version("rrun-cli")
14
13
  except Exception: # noqa: BLE001 - 未安装(源码直跑)时回退
15
- __version__ = "0.1.1"
14
+ __version__ = "0.2.0"
16
15
 
16
+ from .doctor import DoctorResult, check_machine
17
17
  from .executor import (
18
18
  AUDIT_LOG,
19
19
  EXIT_TIMEOUT,
@@ -30,7 +30,7 @@ from .setup import SetupResult, load_requirements, setup_machine
30
30
 
31
31
  __all__ = [
32
32
  "AUDIT_LOG", "EXIT_TIMEOUT", "EXIT_TRANSPORT_ERROR", "RRUN_HOME",
33
- "ExecResult", "Machine", "SetupResult", "SourceInfo",
34
- "build_ps_wrapper", "candidate_sources", "close_mux", "detect_remote_python",
33
+ "DoctorResult", "ExecResult", "Machine", "SetupResult", "SourceInfo",
34
+ "build_ps_wrapper", "candidate_sources", "check_machine", "close_mux", "detect_remote_python",
35
35
  "load_machines", "load_requirements", "resolve_machine", "run", "scan_sources", "setup_machine",
36
36
  ]
@@ -0,0 +1,345 @@
1
+ """rrun CLI — run local scripts on remote machines over SSH stdin pipes.
2
+
3
+ Core contract: script content (UTF-8, CJK welcome) is piped to the remote
4
+ interpreter via stdin, never via command-line arguments — sidestepping the
5
+ bash -> ssh -> cmd quoting/encoding gauntlet.
6
+
7
+ Usage:
8
+ rrun exec <host> <script.py|.ps1|.sh> [ascii_args...]
9
+ rrun exec <host> -c "Write-Output hello" # lang inferred from remote OS
10
+ rrun exec pc_build temp/x.py --timeout 60
11
+ rrun exec mac_mini --lang python temp/x.py
12
+ rrun setup <host|--all> [--force] # provision the unified python env (3.12 venv)
13
+ rrun pip <host> -- list # run pip in the unified venv
14
+ rrun doctor <host|--all> # health-check ssh + auth + remote python
15
+ rrun machines # list machines (redacted, with source)
16
+ rrun config # diagnose the machines.json source chain
17
+ rrun close [<host>|--all] # close ssh multiplexed connections
18
+
19
+ Conventions:
20
+ - Language inference: file extension (.py/.ps1/.sh) first, then remote OS
21
+ (Windows=powershell, others=bash).
22
+ - Command-line args are ASCII-only, passed through remotely
23
+ (python=sys.argv; bash=$@; powershell=$args); put CJK/special characters
24
+ in the script content or a JSON file instead.
25
+ - Remote python is pinned to 3.12: probes the unified venv (preferred),
26
+ the standalone base, then existing installs; all missing -> run `rrun setup`
27
+ first (exec never installs implicitly on the hot path).
28
+ - Exit codes: the remote exit code passes through unchanged;
29
+ 255=ssh transport error; 124=local timeout.
30
+ - Inline -c content is archived under ~/.rrun/drops/ for replay.
31
+ - Audit: every execution appends to ~/.rrun/log/remote-exec.jsonl.
32
+ - Auth: machines.json "password" => sshpass; leave it empty for key-based
33
+ ssh ("identity_file" optional); "port" overrides the default ssh port 22.
34
+
35
+ Remote stdout -> local stdout, stderr -> stderr; the exit code is the remote one.
36
+ """
37
+
38
+ import argparse
39
+ import json
40
+ import sys
41
+ import time
42
+ from pathlib import Path
43
+
44
+ from .doctor import check_machine
45
+ from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
46
+ from .registry import load_machines, resolve_machine, scan_sources
47
+ from .setup import setup_machine, venv_python_or_die
48
+
49
+ INLINE_DROP_DIR = RRUN_HOME / "drops"
50
+
51
+
52
+ def _parse_env(pairs) -> dict:
53
+ env = {}
54
+ for p in pairs or []:
55
+ if "=" not in p:
56
+ raise SystemExit(f"[remote-exec] --env expects KEY=VAL, got: {p!r}")
57
+ k, v = p.split("=", 1)
58
+ env[k] = v
59
+ return env
60
+
61
+
62
+ def _drop_inline(host: str, lang: str, content: str):
63
+ """内联内容落盘 ~/.rrun/drops/ 留档(可复跑/可 edit 后重跑)。"""
64
+ ext = {"python": ".py", "powershell": ".ps1", "bash": ".sh"}.get(lang, ".txt")
65
+ INLINE_DROP_DIR.mkdir(parents=True, exist_ok=True)
66
+ path = INLINE_DROP_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}_{host}{ext}"
67
+ path.write_text(content, encoding="utf-8")
68
+ return path
69
+
70
+
71
+ def cmd_exec(ns) -> int:
72
+ try:
73
+ machine = resolve_machine(ns.host)
74
+ except KeyError as e:
75
+ raise SystemExit(f"[remote-exec] {e}") from None
76
+
77
+ content = ns.content
78
+ file = ns.script or ""
79
+ lang = ns.lang or ""
80
+ if content and file:
81
+ raise SystemExit("[remote-exec] give either a script file or -c/--content, not both")
82
+ if not content and not file:
83
+ raise SystemExit("[remote-exec] nothing to run: pass a script file or -c/--content")
84
+
85
+ # content 模式先确定 lang 再落盘(扩展名需要 lang)
86
+ script_for_log = file
87
+ if content:
88
+ if not lang:
89
+ lang = machine.default_lang
90
+ dropped = _drop_inline(machine.name, lang, content)
91
+ script_for_log = str(dropped)
92
+ print(f"[remote-exec] inline content archived to {dropped}", file=sys.stderr)
93
+
94
+ args = ns.args
95
+
96
+ try:
97
+ result = run(
98
+ ns.host, lang=lang, file=file, content=content, args=args,
99
+ workdir=ns.workdir or "", env=_parse_env(ns.env),
100
+ timeout=ns.timeout, remote_python=(ns.python or ""), utf8=not ns.no_utf8,
101
+ mux=not ns.no_mux,
102
+ )
103
+ except (ValueError, RuntimeError) as e:
104
+ raise SystemExit(f"[remote-exec] {e}") from None
105
+
106
+ if not ns.quiet:
107
+ via = script_for_log or "<stdin>"
108
+ extra = f" python={result.remote_python}({result.remote_python_version})" if result.lang == "python" else ""
109
+ print(f"[remote-exec] host={result.host} ({result.ip}) lang={result.lang}{extra} "
110
+ f"script={via} sha1={result.content_sha1}", file=sys.stderr)
111
+ sys.stdout.buffer.write(result.stdout)
112
+ sys.stdout.buffer.flush()
113
+ sys.stderr.buffer.write(result.stderr)
114
+ sys.stderr.buffer.flush()
115
+
116
+ if result.timed_out:
117
+ print(f"[remote-exec] local timeout ({ns.timeout}s), ssh client killed", file=sys.stderr)
118
+ elif result.transport_error:
119
+ print("[remote-exec] ssh transport error (unreachable / auth failure / dropped), exit=255", file=sys.stderr)
120
+ if not ns.quiet:
121
+ print(f"[remote-exec] exit={result.exit_code} took {result.duration:.1f}s", file=sys.stderr)
122
+ return result.exit_code
123
+
124
+
125
+ def _short_source(path_str: str) -> str:
126
+ """来源路径缩短显示:home → ~。"""
127
+ if not path_str:
128
+ return "-"
129
+ s = str(path_str)
130
+ home = str(Path.home())
131
+ if s.startswith(home):
132
+ return "~" + s[len(home):]
133
+ return s
134
+
135
+
136
+ def cmd_machines(ns) -> int:
137
+ machines = load_machines()
138
+ if ns.json:
139
+ print(json.dumps([m.public_dict() for m in machines], ensure_ascii=False, indent=2))
140
+ return 0
141
+ for m in machines:
142
+ desc = f" # {m.description}" if m.description else ""
143
+ print(f"{m.name:<24} {m.ip:<16} {m.os:<8} {m.user:<12} {m.default_lang:<11} {_short_source(m.source)}{desc}")
144
+ return 0
145
+
146
+
147
+ def cmd_config(ns) -> int:
148
+ infos = scan_sources()
149
+ print("machines.json source chain (high -> low priority; same-name machines are")
150
+ print("overridden by higher-priority sources):")
151
+ raw_total = 0
152
+ for i, info in enumerate(infos, 1):
153
+ if not info.exists:
154
+ state = "- missing"
155
+ elif info.error:
156
+ state = f"x read failed: {info.error}"
157
+ else:
158
+ state = f"ok, {info.machine_count} machines"
159
+ raw_total += info.machine_count
160
+ print(f" [{i}] {info.label:<22} {info.path} {state}")
161
+ merged = load_machines()
162
+ print(f"{len(merged)} machines total ({raw_total} across all sources before same-name overrides)")
163
+ return 0
164
+
165
+
166
+ def cmd_setup(ns) -> int:
167
+ if ns.all:
168
+ hosts = [m.name for m in load_machines()]
169
+ elif ns.host:
170
+ hosts = [ns.host]
171
+ else:
172
+ raise SystemExit("[setup] specify a host or --all")
173
+ results = []
174
+ if len(hosts) == 1:
175
+ print(f"[setup] provisioning {hosts[0]} ...", file=sys.stderr)
176
+ results.append(setup_machine(hosts[0], force=ns.force))
177
+ else:
178
+ from concurrent.futures import ThreadPoolExecutor, as_completed
179
+ with ThreadPoolExecutor(max_workers=ns.jobs) as pool:
180
+ futs = {pool.submit(setup_machine, h, force=ns.force): h for h in hosts}
181
+ for f in as_completed(futs):
182
+ r = f.result()
183
+ results.append(r)
184
+ print(f"[setup] {r.host}: {'ok' if r.ok else 'FAIL'} ({r.duration:.0f}s)", file=sys.stderr)
185
+ print(f"\n{'host':<24} {'result':<6} {'python':<10} {'venv':<46} note")
186
+ for r in sorted(results, key=lambda x: x.host):
187
+ if r.ok:
188
+ note = []
189
+ if r.installed_standalone:
190
+ note.append("standalone installed")
191
+ if r.created_venv:
192
+ note.append("venv created")
193
+ if not note:
194
+ note.append("already present, verified/topped-up deps")
195
+ print(f"{r.host:<24} {'ok':<6} {r.version:<10} {r.venv_python:<46} {', '.join(note)}")
196
+ else:
197
+ print(f"{r.host:<24} {'FAIL':<6} {'':<10} {'':<46} {r.message[:80]}")
198
+ return 0 if all(r.ok for r in results) else 1
199
+
200
+
201
+ def cmd_pip(ns) -> int:
202
+ try:
203
+ machine = resolve_machine(ns.host)
204
+ except KeyError as e:
205
+ raise SystemExit(f"[pip] {e}") from None
206
+ args = list(ns.pargs or []) + list(getattr(ns, "args", []) or [])
207
+ if not args:
208
+ raise SystemExit("[pip] missing pip args, e.g.: rrun pip <host> -- list")
209
+ try:
210
+ _check_ascii(args, "pip args")
211
+ py = venv_python_or_die(ns.host)
212
+ except (ValueError, RuntimeError) as e:
213
+ raise SystemExit(f"[pip] {e}") from None
214
+ joined = " ".join(args)
215
+ if machine.is_windows:
216
+ remote_cmd = f'"{py}" -m pip {joined}'
217
+ else:
218
+ remote_cmd = f"{py} -m pip {joined}"
219
+ t0 = time.time()
220
+ rc, out, err, timed_out = _ssh_run(machine, remote_cmd, b"", ns.timeout, True)
221
+ sys.stdout.buffer.write(out)
222
+ sys.stdout.buffer.flush()
223
+ sys.stderr.buffer.write(err)
224
+ sys.stderr.buffer.flush()
225
+ print(f"[pip] {machine.name} exit={rc} took {time.time() - t0:.1f}s", file=sys.stderr)
226
+ return rc
227
+
228
+
229
+ def cmd_doctor(ns) -> int:
230
+ if ns.all:
231
+ hosts = [m.name for m in load_machines()]
232
+ elif ns.host:
233
+ hosts = [ns.host]
234
+ else:
235
+ raise SystemExit("[doctor] specify a host, or --all to check every machine")
236
+ results = []
237
+ if len(hosts) == 1:
238
+ print(f"[doctor] checking {hosts[0]} ...", file=sys.stderr)
239
+ results.append(check_machine(hosts[0]))
240
+ else:
241
+ from concurrent.futures import ThreadPoolExecutor, as_completed
242
+ with ThreadPoolExecutor(max_workers=ns.jobs) as pool:
243
+ futs = {pool.submit(check_machine, h): h for h in hosts}
244
+ for f in as_completed(futs):
245
+ r = f.result()
246
+ results.append(r)
247
+ print(f"[doctor] {r.host}: {'ok' if r.ok else 'FAIL'} ({r.latency_s:.1f}s)", file=sys.stderr)
248
+ print(f"\n{'host':<24} {'result':<6} {'ssh':<5} {'python':<10} {'via':<14} note")
249
+ for r in sorted(results, key=lambda x: x.host):
250
+ ssh = "ok" if r.ssh_ok else "FAIL"
251
+ ver = r.python_version or "-"
252
+ via = ("unified venv" if r.is_unified_venv else ("fallback" if r.python_path else "-"))
253
+ note = "; ".join(r.checks) if r.ok else r.message
254
+ print(f"{r.host:<24} {'ok' if r.ok else 'FAIL':<6} {ssh:<5} {ver:<10} {via:<14} {note[:90]}")
255
+ return 0 if all(r.ok for r in results) else 1
256
+
257
+
258
+ def cmd_close(ns) -> int:
259
+ if ns.all:
260
+ hosts = [m.name for m in load_machines()]
261
+ elif ns.host:
262
+ hosts = [ns.host]
263
+ else:
264
+ raise SystemExit("[remote-exec] close needs a host or --all")
265
+ rc = 0
266
+ for h in hosts:
267
+ try:
268
+ code, msg = close_mux(h)
269
+ except Exception as e: # noqa: BLE001 - close 尽量遍历完
270
+ code, msg = 1, str(e)
271
+ print(f"[remote-exec] close {h}: {'ok' if code == 0 else msg}")
272
+ rc = rc or code
273
+ return rc
274
+
275
+
276
+ def main() -> None:
277
+ ap = argparse.ArgumentParser(
278
+ prog="rrun",
279
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
280
+ sub = ap.add_subparsers(dest="subcmd", required=True)
281
+
282
+ ep = sub.add_parser("exec", help="execute a local script / inline content on a remote machine")
283
+ ep.add_argument("host", help="machine name/ip/hostname (see the machines subcommand)")
284
+ ep.add_argument("script", nargs="?", help="local script path (.py/.ps1/.sh, UTF-8)")
285
+ ep.add_argument("--lang", choices=["bash", "powershell", "python"],
286
+ help="remote interpreter language (default: by extension, else by remote OS)")
287
+ ep.add_argument("-c", "--content", help="inline script content (archived to ~/.rrun/drops/)")
288
+ ep.add_argument("args", nargs="*",
289
+ help="script arguments (ASCII only; use -- to separate from options)")
290
+ ep.add_argument("--workdir", help="remote working directory (bash/powershell only)")
291
+ ep.add_argument("--env", action="append", metavar="KEY=VAL",
292
+ help="remote environment variable (repeatable; ASCII only)")
293
+ ep.add_argument("--timeout", type=float, help="local timeout in seconds (exit=124 on expiry)")
294
+ ep.add_argument("--python", dest="python", help="remote python path (skips auto-detection)")
295
+ ep.add_argument("--no-utf8", action="store_true", help="do not pass -X utf8 to remote python")
296
+ ep.add_argument("--no-mux", action="store_true", help="disable ssh ControlMaster multiplexing")
297
+ ep.add_argument("-q", "--quiet", action="store_true", help="suppress [remote-exec] info lines")
298
+ ep.set_defaults(func=cmd_exec)
299
+
300
+ mp = sub.add_parser("machines", help="list machines merged from all sources (redacted, with source)")
301
+ mp.add_argument("--json", action="store_true")
302
+ mp.set_defaults(func=cmd_machines)
303
+
304
+ cf = sub.add_parser("config", help="show the machines.json source chain (which files apply, how many machines each)")
305
+ cf.set_defaults(func=cmd_config)
306
+
307
+ sp = sub.add_parser("setup", help="provision the unified remote python environment (3.12 venv)")
308
+ sp.add_argument("host", nargs="?", help="machine name/ip; use --all for every machine")
309
+ sp.add_argument("--all", action="store_true", help="run against all machines in machines.json")
310
+ sp.add_argument("--force", action="store_true", help="recreate the venv (keeps the python base)")
311
+ sp.add_argument("--jobs", type=int, default=6, help="concurrency for --all (default 6)")
312
+ sp.set_defaults(func=cmd_setup)
313
+
314
+ pp = sub.add_parser("pip", help="run pip inside the remote unified venv (ad-hoc installs)")
315
+ pp.add_argument("host", help="machine name/ip")
316
+ pp.add_argument("pargs", nargs="*", help="pip arguments; put options starting with - after --")
317
+ pp.add_argument("--timeout", type=float, default=300.0, help="local timeout in seconds (default 300)")
318
+ pp.set_defaults(func=cmd_pip)
319
+
320
+ dp = sub.add_parser("doctor", help="health-check a machine (ssh + auth + remote python)")
321
+ dp.add_argument("host", nargs="?", help="machine name/ip; omit with --all")
322
+ dp.add_argument("--all", action="store_true", help="check every machine (opens real ssh connections)")
323
+ dp.add_argument("--jobs", type=int, default=6, help="concurrency for --all (default 6)")
324
+ dp.set_defaults(func=cmd_doctor)
325
+
326
+ cp = sub.add_parser("close", help="close ssh ControlMaster multiplexed connections")
327
+ cp.add_argument("host", nargs="?", help="machine name/ip; omit with --all")
328
+ cp.add_argument("--all", action="store_true", help="close connections for all machines")
329
+ cp.set_defaults(func=cmd_close)
330
+
331
+ # argparse 对 -- 的处理与子解析器/位置参数组合有 quirk,手动切分更可靠:
332
+ # 第一个 -- 之后的全部内容原样作为脚本参数
333
+ argv = sys.argv[1:]
334
+ passthrough: list[str] | None = None
335
+ if "--" in argv:
336
+ i = argv.index("--")
337
+ argv, passthrough = argv[:i], argv[i + 1:]
338
+ ns = ap.parse_args(argv)
339
+ if passthrough is not None:
340
+ ns.args = list(getattr(ns, "args", []) or []) + passthrough
341
+ sys.exit(ns.func(ns))
342
+
343
+
344
+ if __name__ == "__main__":
345
+ main()
@@ -0,0 +1,88 @@
1
+ """doctor:单机健康检查(ssh 连通性 + 认证 + 远端 python 环境)。
2
+
3
+ 默认只查显式指定的一台机器——检查会产生真实 ssh 连接(副作用),
4
+ 连全部机器必须显式 --all。
5
+ """
6
+
7
+ import time
8
+ from dataclasses import dataclass, field
9
+
10
+ from .executor import (
11
+ POSIX_PYTHON_CANDIDATES,
12
+ PS_REMOTE_CMD,
13
+ VENV_PY_POSIX,
14
+ VENV_PY_WIN,
15
+ WINDOWS_PYTHON_CANDIDATES,
16
+ _ssh_run,
17
+ build_ps_wrapper,
18
+ probe_python,
19
+ )
20
+ from .registry import resolve_machine
21
+
22
+
23
+ @dataclass
24
+ class DoctorResult:
25
+ host: str
26
+ ok: bool = False
27
+ ssh_ok: bool = False
28
+ latency_s: float = 0.0
29
+ python_path: str = ""
30
+ python_version: str = ""
31
+ is_unified_venv: bool = False
32
+ checks: "list[str]" = field(default_factory=list) # 已通过的检查项描述
33
+ message: str = "" # 失败原因 / 建议
34
+
35
+
36
+ def check_machine(host: str, mux: bool = True, timeout: float = 30.0) -> DoctorResult:
37
+ """检查单台机器:ssh 回显 → 远端 python 探测。不抛异常,结果全部汇入 DoctorResult。"""
38
+ try:
39
+ machine = resolve_machine(host)
40
+ except KeyError as e:
41
+ return DoctorResult(host=host, message=str(e))
42
+ r = DoctorResult(host=machine.name)
43
+
44
+ # 1) ssh 连通 + 认证(一个最小回显脚本)
45
+ t0 = time.time()
46
+ try:
47
+ if machine.is_windows:
48
+ rc, out, err, _ = _ssh_run(machine, PS_REMOTE_CMD,
49
+ build_ps_wrapper("Write-Output rrun-ok"), timeout, mux)
50
+ else:
51
+ rc, out, err, _ = _ssh_run(machine, "bash -s", b"echo rrun-ok\n", timeout, mux)
52
+ except RuntimeError as e:
53
+ r.message = str(e)
54
+ return r
55
+ r.latency_s = time.time() - t0
56
+ if rc != 0 or b"rrun-ok" not in out:
57
+ detail = err.decode("utf-8", "replace").strip()[:200]
58
+ r.message = (f"ssh check failed (exit={rc})"
59
+ + (f": {detail}" if detail else ""))
60
+ return r
61
+ r.ssh_ok = True
62
+ r.checks.append(f"ssh ok ({r.latency_s:.2f}s)")
63
+
64
+ # 2) 远端 python 探测(exec 热路径同款候选链;不命中不算 ssh 失败)
65
+ venv_py = VENV_PY_WIN if machine.is_windows else VENV_PY_POSIX
66
+ candidates = WINDOWS_PYTHON_CANDIDATES if machine.is_windows else POSIX_PYTHON_CANDIDATES
67
+ try:
68
+ hit = probe_python(machine, candidates, mux)
69
+ except RuntimeError as e:
70
+ r.message = str(e)
71
+ return r
72
+ if hit:
73
+ r.python_path, r.python_version = hit
74
+ # POSIX 候选含 $HOME,远端展开后回显的是绝对路径,不能与常量直接等值比较;
75
+ # 用后缀判定(Windows 候选是绝对路径常量,等值即可)。
76
+ if machine.is_windows:
77
+ r.is_unified_venv = r.python_path.lower() == venv_py.lower()
78
+ else:
79
+ r.is_unified_venv = r.python_path == venv_py or r.python_path.endswith(
80
+ "/.remote-machine/venv/bin/python")
81
+ tag = "unified venv" if r.is_unified_venv else "fallback (not the unified venv)"
82
+ r.checks.append(f"python {r.python_version} at {r.python_path} [{tag}]")
83
+ if not r.is_unified_venv:
84
+ r.message = f"unified venv missing; run: rrun setup {machine.name}"
85
+ else:
86
+ r.message = f"no python 3.12 found; run: rrun setup {machine.name}"
87
+ r.ok = r.ssh_ok and bool(hit) and r.is_unified_venv
88
+ return r