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.
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/PKG-INFO +13 -5
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/README.md +9 -4
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/pyproject.toml +17 -1
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun/__init__.py +4 -4
- rrun_cli-0.2.0/src/rrun/__main__.py +345 -0
- rrun_cli-0.2.0/src/rrun/doctor.py +88 -0
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun/executor.py +44 -24
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun/registry.py +14 -10
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun/setup.py +8 -9
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/PKG-INFO +13 -5
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/SOURCES.txt +7 -1
- rrun_cli-0.2.0/src/rrun_cli.egg-info/requires.txt +4 -0
- rrun_cli-0.2.0/tests/test_cli.py +29 -0
- rrun_cli-0.2.0/tests/test_executor.py +129 -0
- rrun_cli-0.2.0/tests/test_registry.py +131 -0
- rrun_cli-0.2.0/tests/test_setup.py +50 -0
- rrun_cli-0.1.1/src/rrun/__main__.py +0 -301
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/LICENSE +0 -0
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/setup.cfg +0 -0
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun/remote-requirements.txt +0 -0
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/dependency_links.txt +0 -0
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/entry_points.txt +0 -0
- {rrun_cli-0.1.1 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rrun-cli
|
|
3
|
-
Version: 0.
|
|
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
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|