rrun-cli 0.1.2__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.2 → rrun_cli-0.2.0}/PKG-INFO +13 -5
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/README.md +9 -4
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/pyproject.toml +17 -1
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun/__init__.py +4 -4
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun/__main__.py +45 -7
- rrun_cli-0.2.0/src/rrun/doctor.py +88 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun/executor.py +33 -13
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun/registry.py +11 -7
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun/setup.py +0 -1
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/PKG-INFO +13 -5
- {rrun_cli-0.1.2 → 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.2 → rrun_cli-0.2.0}/LICENSE +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/setup.cfg +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun/remote-requirements.txt +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/dependency_links.txt +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.0}/src/rrun_cli.egg-info/entry_points.txt +0 -0
- {rrun_cli-0.1.2 → 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
|
]
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
1
|
"""rrun CLI — run local scripts on remote machines over SSH stdin pipes.
|
|
3
2
|
|
|
4
3
|
Core contract: script content (UTF-8, CJK welcome) is piped to the remote
|
|
@@ -12,6 +11,7 @@ Usage:
|
|
|
12
11
|
rrun exec mac_mini --lang python temp/x.py
|
|
13
12
|
rrun setup <host|--all> [--force] # provision the unified python env (3.12 venv)
|
|
14
13
|
rrun pip <host> -- list # run pip in the unified venv
|
|
14
|
+
rrun doctor <host|--all> # health-check ssh + auth + remote python
|
|
15
15
|
rrun machines # list machines (redacted, with source)
|
|
16
16
|
rrun config # diagnose the machines.json source chain
|
|
17
17
|
rrun close [<host>|--all] # close ssh multiplexed connections
|
|
@@ -29,6 +29,8 @@ Conventions:
|
|
|
29
29
|
255=ssh transport error; 124=local timeout.
|
|
30
30
|
- Inline -c content is archived under ~/.rrun/drops/ for replay.
|
|
31
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.
|
|
32
34
|
|
|
33
35
|
Remote stdout -> local stdout, stderr -> stderr; the exit code is the remote one.
|
|
34
36
|
"""
|
|
@@ -39,6 +41,7 @@ import sys
|
|
|
39
41
|
import time
|
|
40
42
|
from pathlib import Path
|
|
41
43
|
|
|
44
|
+
from .doctor import check_machine
|
|
42
45
|
from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
|
|
43
46
|
from .registry import load_machines, resolve_machine, scan_sources
|
|
44
47
|
from .setup import setup_machine, venv_python_or_die
|
|
@@ -69,7 +72,7 @@ def cmd_exec(ns) -> int:
|
|
|
69
72
|
try:
|
|
70
73
|
machine = resolve_machine(ns.host)
|
|
71
74
|
except KeyError as e:
|
|
72
|
-
raise SystemExit(f"[remote-exec] {e}")
|
|
75
|
+
raise SystemExit(f"[remote-exec] {e}") from None
|
|
73
76
|
|
|
74
77
|
content = ns.content
|
|
75
78
|
file = ns.script or ""
|
|
@@ -98,7 +101,7 @@ def cmd_exec(ns) -> int:
|
|
|
98
101
|
mux=not ns.no_mux,
|
|
99
102
|
)
|
|
100
103
|
except (ValueError, RuntimeError) as e:
|
|
101
|
-
raise SystemExit(f"[remote-exec] {e}")
|
|
104
|
+
raise SystemExit(f"[remote-exec] {e}") from None
|
|
102
105
|
|
|
103
106
|
if not ns.quiet:
|
|
104
107
|
via = script_for_log or "<stdin>"
|
|
@@ -113,7 +116,7 @@ def cmd_exec(ns) -> int:
|
|
|
113
116
|
if result.timed_out:
|
|
114
117
|
print(f"[remote-exec] local timeout ({ns.timeout}s), ssh client killed", file=sys.stderr)
|
|
115
118
|
elif result.transport_error:
|
|
116
|
-
print(
|
|
119
|
+
print("[remote-exec] ssh transport error (unreachable / auth failure / dropped), exit=255", file=sys.stderr)
|
|
117
120
|
if not ns.quiet:
|
|
118
121
|
print(f"[remote-exec] exit={result.exit_code} took {result.duration:.1f}s", file=sys.stderr)
|
|
119
122
|
return result.exit_code
|
|
@@ -199,7 +202,7 @@ def cmd_pip(ns) -> int:
|
|
|
199
202
|
try:
|
|
200
203
|
machine = resolve_machine(ns.host)
|
|
201
204
|
except KeyError as e:
|
|
202
|
-
raise SystemExit(f"[pip] {e}")
|
|
205
|
+
raise SystemExit(f"[pip] {e}") from None
|
|
203
206
|
args = list(ns.pargs or []) + list(getattr(ns, "args", []) or [])
|
|
204
207
|
if not args:
|
|
205
208
|
raise SystemExit("[pip] missing pip args, e.g.: rrun pip <host> -- list")
|
|
@@ -207,7 +210,7 @@ def cmd_pip(ns) -> int:
|
|
|
207
210
|
_check_ascii(args, "pip args")
|
|
208
211
|
py = venv_python_or_die(ns.host)
|
|
209
212
|
except (ValueError, RuntimeError) as e:
|
|
210
|
-
raise SystemExit(f"[pip] {e}")
|
|
213
|
+
raise SystemExit(f"[pip] {e}") from None
|
|
211
214
|
joined = " ".join(args)
|
|
212
215
|
if machine.is_windows:
|
|
213
216
|
remote_cmd = f'"{py}" -m pip {joined}'
|
|
@@ -223,6 +226,35 @@ def cmd_pip(ns) -> int:
|
|
|
223
226
|
return rc
|
|
224
227
|
|
|
225
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
|
+
|
|
226
258
|
def cmd_close(ns) -> int:
|
|
227
259
|
if ns.all:
|
|
228
260
|
hosts = [m.name for m in load_machines()]
|
|
@@ -285,6 +317,12 @@ def main() -> None:
|
|
|
285
317
|
pp.add_argument("--timeout", type=float, default=300.0, help="local timeout in seconds (default 300)")
|
|
286
318
|
pp.set_defaults(func=cmd_pip)
|
|
287
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
|
+
|
|
288
326
|
cp = sub.add_parser("close", help="close ssh ControlMaster multiplexed connections")
|
|
289
327
|
cp.add_argument("host", nargs="?", help="machine name/ip; omit with --all")
|
|
290
328
|
cp.add_argument("--all", action="store_true", help="close connections for all machines")
|
|
@@ -293,7 +331,7 @@ def main() -> None:
|
|
|
293
331
|
# argparse 对 -- 的处理与子解析器/位置参数组合有 quirk,手动切分更可靠:
|
|
294
332
|
# 第一个 -- 之后的全部内容原样作为脚本参数
|
|
295
333
|
argv = sys.argv[1:]
|
|
296
|
-
passthrough:
|
|
334
|
+
passthrough: list[str] | None = None
|
|
297
335
|
if "--" in argv:
|
|
298
336
|
i = argv.index("--")
|
|
299
337
|
argv, passthrough = argv[:i], argv[i + 1:]
|
|
@@ -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
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
1
|
"""远程执行核心:本地脚本 → ssh stdin 管道 → 远端解释器执行。
|
|
3
2
|
|
|
4
3
|
设计要点(Phase 0 spike 实测验证):
|
|
@@ -164,18 +163,35 @@ def build_bash_command(args=(), workdir: str = "", env: "dict | None" = None) ->
|
|
|
164
163
|
return cmd
|
|
165
164
|
|
|
166
165
|
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
def _ssh_args(machine: Machine, mux: bool) -> "list[str]":
|
|
167
|
+
"""组装 ssh/sshpass 参数:认证(密码走 sshpass,否则 key 链 + BatchMode)、端口、mux。"""
|
|
168
|
+
args: list[str] = []
|
|
169
|
+
if machine.password:
|
|
170
|
+
sshpass = shutil.which("sshpass")
|
|
171
|
+
if not sshpass:
|
|
172
|
+
raise RuntimeError("sshpass not found on this machine (install: sudo apt install sshpass / brew install hudochenkov/sshpass/sshpass)")
|
|
173
|
+
args += [sshpass, "-p", machine.password]
|
|
174
|
+
args.append("ssh")
|
|
175
|
+
if machine.password:
|
|
176
|
+
args += ["-o", "NumberOfPasswordPrompts=1"] # 密码错误快速失败,不反复提示
|
|
177
|
+
else:
|
|
178
|
+
# key 认证:禁交互提示(防密码 prompt 把 stdin 脚本吃掉/挂起)
|
|
179
|
+
args += ["-o", "BatchMode=yes"]
|
|
180
|
+
if machine.identity_file:
|
|
181
|
+
args += ["-i", str(Path(machine.identity_file).expanduser())]
|
|
182
|
+
args += SSH_BASE_OPTS
|
|
183
|
+
if machine.port != 22:
|
|
184
|
+
args += ["-p", str(machine.port)]
|
|
173
185
|
if mux:
|
|
174
186
|
CONTROL_PATH_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
175
|
-
args = [sshpass, "-p", machine.password, "ssh", *SSH_BASE_OPTS]
|
|
176
|
-
if mux:
|
|
177
187
|
args += MUX_OPTS
|
|
178
|
-
args
|
|
188
|
+
return args
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _ssh_run(machine: Machine, remote_cmd: str, stdin: bytes, timeout: "float | None",
|
|
192
|
+
mux: bool) -> "tuple[int, bytes, bytes, bool]":
|
|
193
|
+
"""返回 (exit_code, stdout, stderr, timed_out)。exit 255 即传输层错误。"""
|
|
194
|
+
args = [*_ssh_args(machine, mux), machine.target, remote_cmd]
|
|
179
195
|
try:
|
|
180
196
|
p = subprocess.run(args, input=stdin, capture_output=True,
|
|
181
197
|
timeout=timeout if timeout and timeout > 0 else None)
|
|
@@ -356,7 +372,11 @@ def _write_audit(result: ExecResult, args, workdir: str, env: "dict | None",
|
|
|
356
372
|
def close_mux(host: str) -> "tuple[int, str]":
|
|
357
373
|
"""关闭某台机器的 ControlMaster 复用连接。"""
|
|
358
374
|
machine = resolve_machine(host)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
375
|
+
args = ["ssh", "-O", "exit", "-o", f"ControlPath={CONTROL_PATH}"]
|
|
376
|
+
if machine.port != 22:
|
|
377
|
+
args += ["-p", str(machine.port)]
|
|
378
|
+
if machine.identity_file:
|
|
379
|
+
args += ["-i", str(Path(machine.identity_file).expanduser())]
|
|
380
|
+
args.append(machine.target)
|
|
381
|
+
p = subprocess.run(args, capture_output=True, text=True, timeout=15)
|
|
362
382
|
return p.returncode, (p.stdout + p.stderr).strip()
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
1
|
"""机器注册表:多来源加载 machines.json,按优先级 merge,解析 name/ip/hostname → Machine。
|
|
3
2
|
|
|
4
3
|
来源链(高 → 低优先级;同名机器高优先级覆盖;同名/ip 冲突静默处理,不刷警告):
|
|
@@ -24,7 +23,7 @@ from pathlib import Path
|
|
|
24
23
|
ENV_MACHINES_JSON = "RRUN_CONFIG"
|
|
25
24
|
ENV_MACHINES_JSON_LEGACY = "REMOTE_MACHINE_CONFIG"
|
|
26
25
|
|
|
27
|
-
_KNOWN_KEYS = {"name", "ip", "user", "password", "os", "hostname", "description", "python"}
|
|
26
|
+
_KNOWN_KEYS = {"name", "ip", "user", "password", "os", "hostname", "description", "python", "port", "identity_file"}
|
|
28
27
|
|
|
29
28
|
|
|
30
29
|
@dataclass
|
|
@@ -32,11 +31,13 @@ class Machine:
|
|
|
32
31
|
name: str
|
|
33
32
|
ip: str
|
|
34
33
|
user: str
|
|
35
|
-
password: str = field(repr=False) # 凭据不进 repr
|
|
34
|
+
password: str = field(repr=False, default="") # 凭据不进 repr/日志;留空 = 走 key 认证
|
|
36
35
|
os: str = "Windows" # 原样取值:Windows / Mac / Linux
|
|
37
36
|
hostname: str = ""
|
|
38
37
|
description: str = ""
|
|
39
38
|
python: str = "" # 可选:显式指定远端 python 路径,跳过自动探测
|
|
39
|
+
port: int = 22 # SSH 端口
|
|
40
|
+
identity_file: str = "" # 可选:私钥路径(password 为空时走默认 key 链/agent)
|
|
40
41
|
source: str = "" # 来源文件路径(多来源 merge 时记录出处)
|
|
41
42
|
|
|
42
43
|
@property
|
|
@@ -56,6 +57,7 @@ class Machine:
|
|
|
56
57
|
return {
|
|
57
58
|
"name": self.name, "ip": self.ip, "os": self.os, "user": self.user,
|
|
58
59
|
"hostname": self.hostname, "description": self.description,
|
|
60
|
+
"port": self.port, "auth": "password" if self.password else "key",
|
|
59
61
|
"default_lang": self.default_lang, "source": self.source,
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -72,7 +74,7 @@ class SourceInfo:
|
|
|
72
74
|
|
|
73
75
|
def candidate_sources() -> "list[tuple[str, Path]]":
|
|
74
76
|
"""返回 (标签, 路径) 有序来源链(高→低优先级),按 resolved path 去重。"""
|
|
75
|
-
cands:
|
|
77
|
+
cands: list[tuple[str, Path]] = []
|
|
76
78
|
for env_name in (ENV_MACHINES_JSON, ENV_MACHINES_JSON_LEGACY):
|
|
77
79
|
env = os.environ.get(env_name, "")
|
|
78
80
|
env_paths = [x for x in env.split(os.pathsep) if x.strip()]
|
|
@@ -90,8 +92,8 @@ def candidate_sources() -> "list[tuple[str, Path]]":
|
|
|
90
92
|
cands.append(("legacy", Path(r"C:\tools\remote-machine\machines.json")))
|
|
91
93
|
else:
|
|
92
94
|
cands.append(("legacy", Path.home() / ".remote-machine" / "machines.json"))
|
|
93
|
-
seen:
|
|
94
|
-
uniq:
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
uniq: list[tuple[str, Path]] = []
|
|
95
97
|
for label, p in cands:
|
|
96
98
|
try:
|
|
97
99
|
key = os.path.normcase(str(p.resolve()))
|
|
@@ -113,6 +115,8 @@ def _load_file(path: Path) -> "list[Machine]":
|
|
|
113
115
|
base = defaults.get(str(m.get("os", "")).lower()) or {}
|
|
114
116
|
merged = {**base, **m} # 机器自身字段优先于 defaults
|
|
115
117
|
kwargs = {k: v for k, v in merged.items() if k in _KNOWN_KEYS}
|
|
118
|
+
if "port" in kwargs:
|
|
119
|
+
kwargs["port"] = int(kwargs["port"])
|
|
116
120
|
machine = Machine(**kwargs)
|
|
117
121
|
machine.source = str(path)
|
|
118
122
|
result.append(machine)
|
|
@@ -123,7 +127,7 @@ def load_machines(path: "str | os.PathLike | None" = None) -> "list[Machine]":
|
|
|
123
127
|
"""加载机器清单。显式传 path = 单文件模式;否则按来源链 merge(同名高优先级胜)。"""
|
|
124
128
|
if path:
|
|
125
129
|
return _load_file(Path(path))
|
|
126
|
-
merged:
|
|
130
|
+
merged: dict[str, Machine] = {}
|
|
127
131
|
for _label, p in candidate_sources():
|
|
128
132
|
if not p.is_file():
|
|
129
133
|
continue
|
|
@@ -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
|
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/rrun/__init__.py
|
|
5
5
|
src/rrun/__main__.py
|
|
6
|
+
src/rrun/doctor.py
|
|
6
7
|
src/rrun/executor.py
|
|
7
8
|
src/rrun/registry.py
|
|
8
9
|
src/rrun/remote-requirements.txt
|
|
@@ -11,4 +12,9 @@ src/rrun_cli.egg-info/PKG-INFO
|
|
|
11
12
|
src/rrun_cli.egg-info/SOURCES.txt
|
|
12
13
|
src/rrun_cli.egg-info/dependency_links.txt
|
|
13
14
|
src/rrun_cli.egg-info/entry_points.txt
|
|
14
|
-
src/rrun_cli.egg-info/
|
|
15
|
+
src/rrun_cli.egg-info/requires.txt
|
|
16
|
+
src/rrun_cli.egg-info/top_level.txt
|
|
17
|
+
tests/test_cli.py
|
|
18
|
+
tests/test_executor.py
|
|
19
|
+
tests/test_registry.py
|
|
20
|
+
tests/test_setup.py
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""CLI 辅助函数测试:--env 解析、inline 落盘。"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import rrun.__main__ as cli
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestParseEnv:
|
|
9
|
+
def test_pairs(self):
|
|
10
|
+
assert cli._parse_env(["A=1", "B=x=y"]) == {"A": "1", "B": "x=y"}
|
|
11
|
+
|
|
12
|
+
def test_none(self):
|
|
13
|
+
assert cli._parse_env(None) == {}
|
|
14
|
+
|
|
15
|
+
def test_bad_pair(self):
|
|
16
|
+
with pytest.raises(SystemExit):
|
|
17
|
+
cli._parse_env(["NO_EQUALS"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestDropInline:
|
|
21
|
+
def test_drop_by_lang(self, tmp_path, monkeypatch):
|
|
22
|
+
monkeypatch.setattr(cli, "INLINE_DROP_DIR", tmp_path / "drops")
|
|
23
|
+
p = cli._drop_inline("mac1", "python", "print('你好')\n")
|
|
24
|
+
assert p.suffix == ".py" and "mac1" in p.name
|
|
25
|
+
assert p.read_text(encoding="utf-8") == "print('你好')\n"
|
|
26
|
+
|
|
27
|
+
def test_unknown_lang_txt(self, tmp_path, monkeypatch):
|
|
28
|
+
monkeypatch.setattr(cli, "INLINE_DROP_DIR", tmp_path / "drops")
|
|
29
|
+
assert cli._drop_inline("h", "weird", "x").suffix == ".txt"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""executor 纯函数测试:PS wrapper、bash 命令、ASCII 校验、ssh 参数组装。"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from rrun.executor import (
|
|
8
|
+
_check_ascii,
|
|
9
|
+
_posix_prefix,
|
|
10
|
+
_ps_quote,
|
|
11
|
+
_ssh_args,
|
|
12
|
+
build_bash_command,
|
|
13
|
+
build_ps_wrapper,
|
|
14
|
+
)
|
|
15
|
+
from rrun.registry import Machine
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _win(**kw):
|
|
19
|
+
kw.setdefault("os", "Windows")
|
|
20
|
+
return Machine(name="w", ip="1.1.1.1", user="u", **kw)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestCheckAscii:
|
|
24
|
+
def test_ascii_ok(self):
|
|
25
|
+
_check_ascii(["abc", "x=1"], "args")
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("bad", ["中文", "café", "ab"])
|
|
28
|
+
def test_non_ascii_rejected(self, bad):
|
|
29
|
+
with pytest.raises(ValueError, match="ASCII"):
|
|
30
|
+
_check_ascii([bad], "args")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestPsWrapper:
|
|
34
|
+
def test_pure_ascii_single_line(self):
|
|
35
|
+
payload = build_ps_wrapper('Write-Output "你好,世界"')
|
|
36
|
+
assert payload.count(b"\n") == 1
|
|
37
|
+
payload.decode("ascii") # 必须纯 ASCII
|
|
38
|
+
|
|
39
|
+
def test_script_content_roundtrips_via_base64(self):
|
|
40
|
+
code = 'Write-Output "你好 $HOME `n"'
|
|
41
|
+
line = build_ps_wrapper(code).decode("ascii")
|
|
42
|
+
b64 = line.split("FromBase64String('")[1].split("'")[0]
|
|
43
|
+
assert base64.b64decode(b64).decode("utf-8") == code
|
|
44
|
+
|
|
45
|
+
def test_args_workdir_env(self):
|
|
46
|
+
line = build_ps_wrapper("x", args=["a", "b'c"], workdir="C:\\work",
|
|
47
|
+
env={"K": "v"}).decode("ascii")
|
|
48
|
+
assert "Set-Location 'C:\\work'" in line
|
|
49
|
+
assert "$env:K='v'" in line
|
|
50
|
+
assert "'b''c'" in line # PS 单引号转义
|
|
51
|
+
assert "$__args=@('a','b''c')" in line
|
|
52
|
+
|
|
53
|
+
def test_non_ascii_args_rejected(self):
|
|
54
|
+
with pytest.raises(ValueError, match="ASCII"):
|
|
55
|
+
build_ps_wrapper("x", args=["中文"])
|
|
56
|
+
|
|
57
|
+
def test_exit_code_passthrough_tail(self):
|
|
58
|
+
line = build_ps_wrapper("x").decode("ascii")
|
|
59
|
+
assert "exit $LASTEXITCODE" in line
|
|
60
|
+
assert "ScriptBlock" in line
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestBashCommand:
|
|
64
|
+
def test_plain(self):
|
|
65
|
+
assert build_bash_command() == "bash -s"
|
|
66
|
+
|
|
67
|
+
def test_args_workdir_env(self):
|
|
68
|
+
cmd = build_bash_command(args=["a b", "c"], workdir="/tmp/x y", env={"K": "v v"})
|
|
69
|
+
assert cmd.startswith("cd '/tmp/x y' && env K='v v' bash -s -- ")
|
|
70
|
+
assert "'a b'" in cmd and cmd.endswith(" c")
|
|
71
|
+
|
|
72
|
+
def test_non_ascii_args_rejected(self):
|
|
73
|
+
with pytest.raises(ValueError, match="ASCII"):
|
|
74
|
+
build_bash_command(args=["中文"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestPosixPrefix:
|
|
78
|
+
def test_empty(self):
|
|
79
|
+
assert _posix_prefix() == ""
|
|
80
|
+
|
|
81
|
+
def test_quoting(self):
|
|
82
|
+
assert _posix_prefix(workdir="a b") == "cd 'a b' && "
|
|
83
|
+
assert _posix_prefix(env={"K": "v v"}) == "env K='v v' "
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestPsQuote:
|
|
87
|
+
def test_escape(self):
|
|
88
|
+
assert _ps_quote("a'b") == "'a''b'"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestSshArgs:
|
|
92
|
+
def test_password_uses_sshpass(self, monkeypatch):
|
|
93
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass" if name == "sshpass" else None)
|
|
94
|
+
args = _ssh_args(_win(password="pw"), mux=False)
|
|
95
|
+
assert args[:3] == ["/usr/bin/sshpass", "-p", "pw"]
|
|
96
|
+
assert "NumberOfPasswordPrompts=1" in args
|
|
97
|
+
assert "BatchMode=yes" not in args
|
|
98
|
+
|
|
99
|
+
def test_password_without_sshpass_raises(self, monkeypatch):
|
|
100
|
+
monkeypatch.setattr("shutil.which", lambda name: None)
|
|
101
|
+
with pytest.raises(RuntimeError, match="sshpass not found"):
|
|
102
|
+
_ssh_args(_win(password="pw"), mux=False)
|
|
103
|
+
|
|
104
|
+
def test_key_auth_needs_no_sshpass(self, monkeypatch):
|
|
105
|
+
monkeypatch.setattr("shutil.which", lambda name: None) # 无 sshpass 也能走 key
|
|
106
|
+
args = _ssh_args(_win(password="", identity_file="~/.ssh/id_ed25519"), mux=False)
|
|
107
|
+
assert args[0] == "ssh"
|
|
108
|
+
assert "BatchMode=yes" in args
|
|
109
|
+
i = args.index("-i")
|
|
110
|
+
assert args[i + 1].endswith(".ssh/id_ed25519")
|
|
111
|
+
|
|
112
|
+
def test_custom_port(self, monkeypatch):
|
|
113
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
|
|
114
|
+
args = _ssh_args(_win(password="pw", port=2222), mux=False)
|
|
115
|
+
i = args.index("-p", 2) # 跳过 sshpass -p
|
|
116
|
+
assert args[i + 1] == "2222"
|
|
117
|
+
|
|
118
|
+
def test_default_port_no_flag(self, monkeypatch):
|
|
119
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
|
|
120
|
+
args = _ssh_args(_win(password="pw"), mux=False)
|
|
121
|
+
assert "-p" not in args[3:] # sshpass 的 -p 之后不再有 -p
|
|
122
|
+
|
|
123
|
+
def test_mux_adds_control_opts(self, monkeypatch, tmp_path):
|
|
124
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
|
|
125
|
+
import rrun.executor as ex
|
|
126
|
+
monkeypatch.setattr(ex, "CONTROL_PATH_DIR", tmp_path / ".ssh")
|
|
127
|
+
args = _ssh_args(_win(password="pw"), mux=True)
|
|
128
|
+
assert "ControlMaster=auto" in " ".join(args)
|
|
129
|
+
assert (tmp_path / ".ssh").is_dir()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""registry 纯函数测试:来源链、merge 优先级、defaults、端口/key 字段。"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from rrun import registry
|
|
9
|
+
from rrun.registry import Machine, _load_file, load_machines, resolve_machine
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _write(path, machines, defaults=None):
|
|
13
|
+
data = {"machines": machines}
|
|
14
|
+
if defaults:
|
|
15
|
+
data["defaults"] = defaults
|
|
16
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture()
|
|
21
|
+
def isolated(monkeypatch, tmp_path):
|
|
22
|
+
"""隔离全部来源:HOME 指向临时目录、cwd 切换、两个 env var 清空。"""
|
|
23
|
+
monkeypatch.setenv("RRUN_CONFIG", "")
|
|
24
|
+
monkeypatch.setenv("REMOTE_MACHINE_CONFIG", "")
|
|
25
|
+
home = tmp_path / "home"
|
|
26
|
+
(home / ".rrun").mkdir(parents=True)
|
|
27
|
+
monkeypatch.setenv("HOME", str(home)) # POSIX 下 Path.home() 读 HOME
|
|
28
|
+
cwd = tmp_path / "cwd"
|
|
29
|
+
cwd.mkdir()
|
|
30
|
+
monkeypatch.chdir(cwd)
|
|
31
|
+
return tmp_path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestMachine:
|
|
35
|
+
def test_defaults(self):
|
|
36
|
+
m = Machine(name="a", ip="1.1.1.1", user="u")
|
|
37
|
+
assert m.port == 22 and m.identity_file == "" and m.password == ""
|
|
38
|
+
assert m.os == "Windows" and m.is_windows and m.default_lang == "powershell"
|
|
39
|
+
|
|
40
|
+
def test_public_dict_redacts_password(self):
|
|
41
|
+
m = Machine(name="a", ip="1.1.1.1", user="u", password="secret")
|
|
42
|
+
d = m.public_dict()
|
|
43
|
+
assert "secret" not in json.dumps(d)
|
|
44
|
+
assert d["auth"] == "password"
|
|
45
|
+
m2 = Machine(name="b", ip="2.2.2.2", user="u")
|
|
46
|
+
assert m2.public_dict()["auth"] == "key"
|
|
47
|
+
|
|
48
|
+
def test_password_not_in_repr(self):
|
|
49
|
+
assert "secret" not in repr(Machine(name="a", ip="1.1.1.1", user="u", password="secret"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestLoadFile:
|
|
53
|
+
def test_defaults_applied_per_os(self, tmp_path):
|
|
54
|
+
p = _write(
|
|
55
|
+
tmp_path / "m.json",
|
|
56
|
+
[{"name": "w1", "ip": "1.1.1.1", "os": "Windows"},
|
|
57
|
+
{"name": "m1", "ip": "2.2.2.2", "os": "Mac", "user": "macuser"}],
|
|
58
|
+
defaults={"windows": {"user": "winuser", "password": "wpw"},
|
|
59
|
+
"mac": {"password": "mpw"}},
|
|
60
|
+
)
|
|
61
|
+
machines = {m.name: m for m in _load_file(p)}
|
|
62
|
+
assert machines["w1"].user == "winuser" and machines["w1"].password == "wpw"
|
|
63
|
+
assert machines["m1"].user == "macuser" and machines["m1"].password == "mpw"
|
|
64
|
+
|
|
65
|
+
def test_port_and_identity_file(self, tmp_path):
|
|
66
|
+
p = _write(tmp_path / "m.json",
|
|
67
|
+
[{"name": "a", "ip": "1.1.1.1", "user": "u", "port": 2222,
|
|
68
|
+
"identity_file": "~/.ssh/id_ed25519"}])
|
|
69
|
+
(m,) = _load_file(p)
|
|
70
|
+
assert m.port == 2222 and m.identity_file == "~/.ssh/id_ed25519"
|
|
71
|
+
|
|
72
|
+
def test_port_string_cast_to_int(self, tmp_path):
|
|
73
|
+
p = _write(tmp_path / "m.json", [{"name": "a", "ip": "1.1.1.1", "user": "u", "port": "2222"}])
|
|
74
|
+
assert _load_file(p)[0].port == 2222
|
|
75
|
+
|
|
76
|
+
def test_unknown_keys_ignored(self, tmp_path):
|
|
77
|
+
p = _write(tmp_path / "m.json", [{"name": "a", "ip": "1.1.1.1", "user": "u", "zzz": 1}])
|
|
78
|
+
assert _load_file(p)[0].name == "a"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestSourceChain:
|
|
82
|
+
def test_cwd_beats_user(self, isolated):
|
|
83
|
+
_write(isolated / "cwd" / "machines.json",
|
|
84
|
+
[{"name": "a", "ip": "1.1.1.1", "user": "cwd_user"}])
|
|
85
|
+
_write(isolated / "home" / ".rrun" / "machines.json",
|
|
86
|
+
[{"name": "a", "ip": "1.1.1.1", "user": "home_user"}])
|
|
87
|
+
(m,) = load_machines()
|
|
88
|
+
assert m.user == "cwd_user"
|
|
89
|
+
|
|
90
|
+
def test_env_beats_cwd_and_multi_path(self, isolated, monkeypatch):
|
|
91
|
+
f1 = _write(isolated / "env1.json", [{"name": "a", "ip": "1.1.1.1", "user": "env_user"}])
|
|
92
|
+
f2 = _write(isolated / "env2.json", [{"name": "b", "ip": "2.2.2.2", "user": "u2"}])
|
|
93
|
+
_write(isolated / "cwd" / "machines.json", [{"name": "a", "ip": "1.1.1.1", "user": "cwd"}])
|
|
94
|
+
monkeypatch.setenv("RRUN_CONFIG", os.pathsep.join([str(f1), str(f2)]))
|
|
95
|
+
machines = {m.name: m for m in load_machines()}
|
|
96
|
+
assert machines["a"].user == "env_user" and "b" in machines
|
|
97
|
+
|
|
98
|
+
def test_machines_d_sorted_and_below_user(self, isolated):
|
|
99
|
+
rrun_home = isolated / "home" / ".rrun"
|
|
100
|
+
(rrun_home / "machines.d").mkdir(parents=True)
|
|
101
|
+
_write(rrun_home / "machines.json", [{"name": "a", "ip": "1.1.1.1", "user": "user_main"}])
|
|
102
|
+
_write(rrun_home / "machines.d" / "10-x.json",
|
|
103
|
+
[{"name": "a", "ip": "1.1.1.1", "user": "from_d"}, {"name": "b", "ip": "2.2.2.2", "user": "u"}])
|
|
104
|
+
machines = {m.name: m for m in load_machines()}
|
|
105
|
+
assert machines["a"].user == "user_main" and machines["b"].user == "u"
|
|
106
|
+
|
|
107
|
+
def test_broken_source_skipped(self, isolated):
|
|
108
|
+
(isolated / "cwd" / "machines.json").write_text("{not json", encoding="utf-8")
|
|
109
|
+
_write(isolated / "home" / ".rrun" / "machines.json",
|
|
110
|
+
[{"name": "a", "ip": "1.1.1.1", "user": "u"}])
|
|
111
|
+
assert [m.name for m in load_machines()] == ["a"]
|
|
112
|
+
errors = [i for i in registry.scan_sources() if i.error]
|
|
113
|
+
assert len(errors) == 1
|
|
114
|
+
|
|
115
|
+
def test_explicit_path_single_file_mode(self, isolated, tmp_path):
|
|
116
|
+
_write(isolated / "cwd" / "machines.json", [{"name": "cwd", "ip": "1.1.1.1", "user": "u"}])
|
|
117
|
+
p = _write(tmp_path / "pinned.json", [{"name": "pinned", "ip": "2.2.2.2", "user": "u"}])
|
|
118
|
+
assert [m.name for m in load_machines(p)] == ["pinned"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestResolve:
|
|
122
|
+
def test_by_name_ip_hostname(self, tmp_path):
|
|
123
|
+
p = _write(tmp_path / "m.json",
|
|
124
|
+
[{"name": "a", "ip": "1.1.1.1", "user": "u", "hostname": "host-a"}])
|
|
125
|
+
for key in ("a", "1.1.1.1", "host-a"):
|
|
126
|
+
assert resolve_machine(key, p).name == "a"
|
|
127
|
+
|
|
128
|
+
def test_not_found_lists_available(self, tmp_path):
|
|
129
|
+
p = _write(tmp_path / "m.json", [{"name": "a", "ip": "1.1.1.1", "user": "u"}])
|
|
130
|
+
with pytest.raises(KeyError, match="a\\(1.1.1.1\\)"):
|
|
131
|
+
resolve_machine("nope", p)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""setup 纯函数测试:依赖解析、装机脚本生成。"""
|
|
2
|
+
|
|
3
|
+
import rrun.setup as st
|
|
4
|
+
from rrun.setup import _build_bash_setup, _build_ps_setup, _req_import_name, load_requirements
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestReqImportName:
|
|
8
|
+
def test_plain(self):
|
|
9
|
+
assert _req_import_name("requests") == "requests"
|
|
10
|
+
|
|
11
|
+
def test_version_specs(self):
|
|
12
|
+
assert _req_import_name("requests>=2.31") == "requests"
|
|
13
|
+
assert _req_import_name("urllib3<2") == "urllib3"
|
|
14
|
+
assert _req_import_name("pip==24.0") == "pip"
|
|
15
|
+
|
|
16
|
+
def test_extras(self):
|
|
17
|
+
assert _req_import_name("requests[socks]>=2") == "requests"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestLoadRequirements:
|
|
21
|
+
def test_builtin_default(self, tmp_path, monkeypatch):
|
|
22
|
+
monkeypatch.setattr(st, "REQUIREMENTS_OVERRIDE", tmp_path / "nope.txt")
|
|
23
|
+
reqs = load_requirements()
|
|
24
|
+
assert reqs and all(not r.startswith("#") for r in reqs)
|
|
25
|
+
|
|
26
|
+
def test_user_override_wins(self, tmp_path, monkeypatch):
|
|
27
|
+
p = tmp_path / "reqs.txt"
|
|
28
|
+
p.write_text("# comment\nfoo>=1\n\nbar\n", encoding="utf-8")
|
|
29
|
+
monkeypatch.setattr(st, "REQUIREMENTS_OVERRIDE", p)
|
|
30
|
+
assert load_requirements() == ["foo>=1", "bar"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestSetupScripts:
|
|
34
|
+
def test_bash_setup(self):
|
|
35
|
+
s = _build_bash_setup("/usr/bin/python3.12", ["requests>=2", "foo"], force=False)
|
|
36
|
+
assert '"/usr/bin/python3.12" -m venv "$venv"' in s
|
|
37
|
+
assert "pip install" in s and "requests>=2 foo" in s
|
|
38
|
+
assert "import requests;import foo" in s
|
|
39
|
+
assert "[ \"0\" = \"1\" ]" in s # force off
|
|
40
|
+
|
|
41
|
+
def test_bash_setup_force(self):
|
|
42
|
+
assert "[ \"1\" = \"1\" ]" in _build_bash_setup("p", ["requests"], force=True)
|
|
43
|
+
|
|
44
|
+
def test_ps_setup_ascii_and_content(self):
|
|
45
|
+
s = _build_ps_setup("C:\\py\\python.exe", ["requests"], force=False)
|
|
46
|
+
assert "$true" not in s and "$false" in s
|
|
47
|
+
assert "pip.ini" in s and "requests" in s and "import requests" in s
|
|
48
|
+
|
|
49
|
+
def test_ps_setup_force(self):
|
|
50
|
+
assert "if($true" in _build_ps_setup("C:\\py\\python.exe", ["requests"], force=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|