rrun-cli 0.1.2__tar.gz → 0.2.1__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.1}/PKG-INFO +14 -6
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/README.md +10 -5
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/pyproject.toml +17 -1
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun/__init__.py +4 -4
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun/__main__.py +45 -7
- rrun_cli-0.2.1/src/rrun/doctor.py +109 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun/executor.py +65 -22
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun/registry.py +11 -7
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun/setup.py +34 -8
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun_cli.egg-info/PKG-INFO +14 -6
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun_cli.egg-info/SOURCES.txt +7 -1
- rrun_cli-0.2.1/src/rrun_cli.egg-info/requires.txt +4 -0
- rrun_cli-0.2.1/tests/test_cli.py +29 -0
- rrun_cli-0.2.1/tests/test_executor.py +156 -0
- rrun_cli-0.2.1/tests/test_registry.py +131 -0
- rrun_cli-0.2.1/tests/test_setup.py +79 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/LICENSE +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/setup.cfg +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun/remote-requirements.txt +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun_cli.egg-info/dependency_links.txt +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/src/rrun_cli.egg-info/entry_points.txt +0 -0
- {rrun_cli-0.1.2 → rrun_cli-0.2.1}/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.1
|
|
3
|
+
Version: 0.2.1
|
|
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)
|
|
@@ -145,12 +153,12 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
|
|
|
145
153
|
2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
|
|
146
154
|
3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
|
|
147
155
|
|
|
148
|
-
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
156
|
+
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
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)
|
|
@@ -120,12 +125,12 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
|
|
|
120
125
|
2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
|
|
121
126
|
3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
|
|
122
127
|
|
|
123
|
-
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
128
|
+
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
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.1
|
|
7
|
+
version = "0.2.1"
|
|
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
|
|
14
|
+
__version__ = "0.2.1"
|
|
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,109 @@
|
|
|
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_BASE_CANDIDATES,
|
|
12
|
+
POSIX_PYTHON_CANDIDATES,
|
|
13
|
+
PS_REMOTE_CMD,
|
|
14
|
+
VENV_PY_POSIX,
|
|
15
|
+
VENV_PY_WIN,
|
|
16
|
+
WINDOWS_BASE_CANDIDATES,
|
|
17
|
+
WINDOWS_PYTHON_CANDIDATES,
|
|
18
|
+
_ssh_run,
|
|
19
|
+
build_ps_wrapper,
|
|
20
|
+
check_remote_pip,
|
|
21
|
+
probe_python,
|
|
22
|
+
)
|
|
23
|
+
from .registry import Machine, resolve_machine
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DoctorResult:
|
|
28
|
+
host: str
|
|
29
|
+
ok: bool = False
|
|
30
|
+
ssh_ok: bool = False
|
|
31
|
+
latency_s: float = 0.0
|
|
32
|
+
python_path: str = ""
|
|
33
|
+
python_version: str = ""
|
|
34
|
+
is_unified_venv: bool = False
|
|
35
|
+
checks: "list[str]" = field(default_factory=list) # 已通过的检查项描述
|
|
36
|
+
message: str = "" # 失败原因 / 建议
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _venv_missing_guidance(machine: Machine, found_version: str, mux: bool) -> str:
|
|
40
|
+
"""venv 缺失时的指引:审查基座资格(ensurepip),说明 setup 将会走哪条路。"""
|
|
41
|
+
base_cands = WINDOWS_BASE_CANDIDATES if machine.is_windows else POSIX_BASE_CANDIDATES
|
|
42
|
+
base = probe_python(machine, base_cands, mux, require_ensurepip=True)
|
|
43
|
+
if base:
|
|
44
|
+
head = "unified venv missing" if found_version else "no python 3.12 found"
|
|
45
|
+
return f"{head}; run: rrun setup {machine.name}"
|
|
46
|
+
hint = " (Debian/Ubuntu: sudo apt install python3.12-venv)" if not machine.is_windows else ""
|
|
47
|
+
if found_version:
|
|
48
|
+
return (f"unified venv missing; base python {found_version} lacks ensurepip{hint}; "
|
|
49
|
+
f"rrun setup {machine.name} will install a standalone python")
|
|
50
|
+
return f"no python 3.12 found; rrun setup {machine.name} will install a standalone python"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_machine(host: str, mux: bool = True, timeout: float = 30.0) -> DoctorResult:
|
|
54
|
+
"""检查单台机器:ssh 回显 → 远端 python 探测。不抛异常,结果全部汇入 DoctorResult。"""
|
|
55
|
+
try:
|
|
56
|
+
machine = resolve_machine(host)
|
|
57
|
+
except KeyError as e:
|
|
58
|
+
return DoctorResult(host=host, message=str(e))
|
|
59
|
+
r = DoctorResult(host=machine.name)
|
|
60
|
+
|
|
61
|
+
# 1) ssh 连通 + 认证(一个最小回显脚本)
|
|
62
|
+
t0 = time.time()
|
|
63
|
+
try:
|
|
64
|
+
if machine.is_windows:
|
|
65
|
+
rc, out, err, _ = _ssh_run(machine, PS_REMOTE_CMD,
|
|
66
|
+
build_ps_wrapper("Write-Output rrun-ok"), timeout, mux)
|
|
67
|
+
else:
|
|
68
|
+
rc, out, err, _ = _ssh_run(machine, "bash -s", b"echo rrun-ok\n", timeout, mux)
|
|
69
|
+
except RuntimeError as e:
|
|
70
|
+
r.message = str(e)
|
|
71
|
+
return r
|
|
72
|
+
r.latency_s = time.time() - t0
|
|
73
|
+
if rc != 0 or b"rrun-ok" not in out:
|
|
74
|
+
detail = err.decode("utf-8", "replace").strip()[:200]
|
|
75
|
+
r.message = (f"ssh check failed (exit={rc})"
|
|
76
|
+
+ (f": {detail}" if detail else ""))
|
|
77
|
+
return r
|
|
78
|
+
r.ssh_ok = True
|
|
79
|
+
r.checks.append(f"ssh ok ({r.latency_s:.2f}s)")
|
|
80
|
+
|
|
81
|
+
# 2) 远端 python 探测(exec 热路径同款候选链;不命中不算 ssh 失败)
|
|
82
|
+
venv_py = VENV_PY_WIN if machine.is_windows else VENV_PY_POSIX
|
|
83
|
+
candidates = WINDOWS_PYTHON_CANDIDATES if machine.is_windows else POSIX_PYTHON_CANDIDATES
|
|
84
|
+
try:
|
|
85
|
+
hit = probe_python(machine, candidates, mux)
|
|
86
|
+
except RuntimeError as e:
|
|
87
|
+
r.message = str(e)
|
|
88
|
+
return r
|
|
89
|
+
if hit:
|
|
90
|
+
r.python_path, r.python_version = hit
|
|
91
|
+
# POSIX 候选含 $HOME,远端展开后回显的是绝对路径,不能与常量直接等值比较;
|
|
92
|
+
# 用后缀判定(Windows 候选是绝对路径常量,等值即可)。
|
|
93
|
+
if machine.is_windows:
|
|
94
|
+
r.is_unified_venv = r.python_path.lower() == venv_py.lower()
|
|
95
|
+
else:
|
|
96
|
+
r.is_unified_venv = r.python_path == venv_py or r.python_path.endswith(
|
|
97
|
+
"/.remote-machine/venv/bin/python")
|
|
98
|
+
tag = "unified venv" if r.is_unified_venv else "fallback (not the unified venv)"
|
|
99
|
+
r.checks.append(f"python {r.python_version} at {r.python_path} [{tag}]")
|
|
100
|
+
if r.is_unified_venv:
|
|
101
|
+
if not check_remote_pip(machine, r.python_path, mux):
|
|
102
|
+
r.message = f"unified venv is broken (pip missing); repair: rrun setup {machine.name}"
|
|
103
|
+
return r
|
|
104
|
+
else:
|
|
105
|
+
r.message = _venv_missing_guidance(machine, r.python_version, mux)
|
|
106
|
+
else:
|
|
107
|
+
r.message = _venv_missing_guidance(machine, "", mux)
|
|
108
|
+
r.ok = r.ssh_ok and bool(hit) and r.is_unified_venv
|
|
109
|
+
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)
|
|
@@ -189,27 +205,33 @@ _PY_VER_PRINT_CODE = "import sys;print(*sys.version_info[:3],sep=chr(46))"
|
|
|
189
205
|
_PY_VER_CHECK_CODE = (
|
|
190
206
|
_PY_VER_PRINT_CODE + f";sys.exit(0 if sys.version_info[:2]=={REQUIRED_PY_MAJOR_MINOR} else 1)"
|
|
191
207
|
)
|
|
208
|
+
# 基座资格加码:ensurepip 必须可 import(Debian/Ubuntu 把 ensurepip 整体拆进 python3.x-venv
|
|
209
|
+
# 包,缺失时系统 python 版本对却建不了 venv——不算合格基座)。import 失败即非零退出,候选跳过。
|
|
210
|
+
_PY_BASE_CHECK_CODE = "import ensurepip;" + _PY_VER_CHECK_CODE
|
|
192
211
|
|
|
193
212
|
|
|
194
|
-
def _ps_probe_script(candidates) -> str:
|
|
195
|
-
"""Windows
|
|
213
|
+
def _ps_probe_script(candidates, require_ensurepip: bool = False) -> str:
|
|
214
|
+
"""Windows 探测脚本:逐候选校验(版本==3.12.x,require_ensurepip 时加 ensurepip 资格),
|
|
215
|
+
命中输出 path|x.y.z,全灭 exit 1。"""
|
|
216
|
+
check = _PY_BASE_CHECK_CODE if require_ensurepip else _PY_VER_CHECK_CODE
|
|
196
217
|
arr = ",".join(_ps_quote(c) for c in candidates)
|
|
197
218
|
return (
|
|
198
219
|
f"foreach($c in @({arr})){{"
|
|
199
220
|
"if(Test-Path $c){"
|
|
200
|
-
f"$v=& $c -X utf8 -c \"{
|
|
221
|
+
f"$v=& $c -X utf8 -c \"{check}\" 2>$null;"
|
|
201
222
|
"if($LASTEXITCODE -eq 0){Write-Output ($c+'|'+($v -join ''));exit 0}"
|
|
202
223
|
"}}"
|
|
203
224
|
)
|
|
204
225
|
|
|
205
226
|
|
|
206
|
-
def _bash_probe_script(candidates) -> str:
|
|
227
|
+
def _bash_probe_script(candidates, require_ensurepip: bool = False) -> str:
|
|
207
228
|
"""posix 探测脚本:同 Windows 语义。候选为可信常量($HOME 需保留展开,不可 shlex.quote)。"""
|
|
229
|
+
check = _PY_BASE_CHECK_CODE if require_ensurepip else _PY_VER_CHECK_CODE
|
|
208
230
|
items = " ".join(f'"{c}"' for c in candidates)
|
|
209
231
|
return (
|
|
210
232
|
f"for p in {items}; do\n"
|
|
211
233
|
' command -v "$p" >/dev/null 2>&1 || continue\n'
|
|
212
|
-
f' v=$("$p" -X utf8 -c \'{
|
|
234
|
+
f' v=$("$p" -X utf8 -c \'{check}\' 2>/dev/null) || continue\n'
|
|
213
235
|
' echo "$p|$v"\n'
|
|
214
236
|
" exit 0\n"
|
|
215
237
|
"done\n"
|
|
@@ -217,14 +239,19 @@ def _bash_probe_script(candidates) -> str:
|
|
|
217
239
|
)
|
|
218
240
|
|
|
219
241
|
|
|
220
|
-
def probe_python(machine: Machine, candidates, mux: bool = True
|
|
221
|
-
|
|
242
|
+
def probe_python(machine: Machine, candidates, mux: bool = True,
|
|
243
|
+
require_ensurepip: bool = False) -> "tuple[str, str] | None":
|
|
244
|
+
"""在候选中找版本==3.12.x 的远端 python,单次往返;返回 (路径, 版本),全灭返回 None。
|
|
245
|
+
|
|
246
|
+
require_ensurepip=True 用于 setup/doctor 的基座资格审查:无 ensurepip 的 python
|
|
247
|
+
建不了 venv(Debian/Ubuntu 拆包 python3.x-venv 的典型场景),跳过。
|
|
248
|
+
"""
|
|
222
249
|
if machine.is_windows:
|
|
223
250
|
rc, out, _, _ = _ssh_run(machine, PS_REMOTE_CMD,
|
|
224
|
-
build_ps_wrapper(_ps_probe_script(candidates)), 60, mux)
|
|
251
|
+
build_ps_wrapper(_ps_probe_script(candidates, require_ensurepip)), 60, mux)
|
|
225
252
|
else:
|
|
226
253
|
rc, out, _, _ = _ssh_run(machine, "bash -s",
|
|
227
|
-
_bash_probe_script(candidates).encode("utf-8"), 60, mux)
|
|
254
|
+
_bash_probe_script(candidates, require_ensurepip).encode("utf-8"), 60, mux)
|
|
228
255
|
if rc != 0 or not out.strip():
|
|
229
256
|
return None
|
|
230
257
|
line = out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
|
|
@@ -246,6 +273,18 @@ def probe_python_version(machine: Machine, python_path: str, mux: bool = True) -
|
|
|
246
273
|
return out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
|
|
247
274
|
|
|
248
275
|
|
|
276
|
+
def check_remote_pip(machine: Machine, python_path: str, mux: bool = True) -> bool:
|
|
277
|
+
"""检查指定远端 python 是否带 pip(半拉子 venv 检测:venv python 能跑,
|
|
278
|
+
但若创建时 ensurepip 失败过,venv 里就没有 pip)。"""
|
|
279
|
+
if machine.is_windows:
|
|
280
|
+
payload = build_ps_wrapper(f"& {_ps_quote(python_path)} -m pip --version *>$null")
|
|
281
|
+
rc, _, _, _ = _ssh_run(machine, PS_REMOTE_CMD, payload, 30, mux)
|
|
282
|
+
else:
|
|
283
|
+
rc, _, _, _ = _ssh_run(machine, "bash -s",
|
|
284
|
+
f'"{python_path}" -m pip --version >/dev/null 2>&1\n'.encode(), 30, mux)
|
|
285
|
+
return rc == 0
|
|
286
|
+
|
|
287
|
+
|
|
249
288
|
def detect_remote_python(machine: Machine, override: str = "", mux: bool = True) -> "tuple[str, str]":
|
|
250
289
|
"""解析远端 python,返回 (路径, 版本)。基线锁 3.12;machines.json "python" 字段/参数可覆盖(跳过校验)。"""
|
|
251
290
|
key = (machine.ip, override or machine.python)
|
|
@@ -356,7 +395,11 @@ def _write_audit(result: ExecResult, args, workdir: str, env: "dict | None",
|
|
|
356
395
|
def close_mux(host: str) -> "tuple[int, str]":
|
|
357
396
|
"""关闭某台机器的 ControlMaster 复用连接。"""
|
|
358
397
|
machine = resolve_machine(host)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
398
|
+
args = ["ssh", "-O", "exit", "-o", f"ControlPath={CONTROL_PATH}"]
|
|
399
|
+
if machine.port != 22:
|
|
400
|
+
args += ["-p", str(machine.port)]
|
|
401
|
+
if machine.identity_file:
|
|
402
|
+
args += ["-i", str(Path(machine.identity_file).expanduser())]
|
|
403
|
+
args.append(machine.target)
|
|
404
|
+
p = subprocess.run(args, capture_output=True, text=True, timeout=15)
|
|
362
405
|
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,4 +1,3 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
1
|
"""setup:初始化远端统一 python 环境(基线 3.12 + 统一 venv + 阿里云 pip 源)。
|
|
3
2
|
|
|
4
3
|
布局(幂等,可重复执行):
|
|
@@ -39,6 +38,7 @@ from .executor import (
|
|
|
39
38
|
_check_ascii,
|
|
40
39
|
_ssh_run,
|
|
41
40
|
build_ps_wrapper,
|
|
41
|
+
check_remote_pip,
|
|
42
42
|
probe_python,
|
|
43
43
|
probe_python_version,
|
|
44
44
|
)
|
|
@@ -55,6 +55,8 @@ PBS_ASSETS = {
|
|
|
55
55
|
("Windows", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-pc-windows-msvc-install_only.tar.gz",
|
|
56
56
|
("Mac", "arm64"): f"cpython-{PBS_VER}+{PBS_TAG}-aarch64-apple-darwin-install_only.tar.gz",
|
|
57
57
|
("Mac", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-apple-darwin-install_only.tar.gz",
|
|
58
|
+
("Linux", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-unknown-linux-gnu-install_only.tar.gz",
|
|
59
|
+
("Linux", "aarch64"): f"cpython-{PBS_VER}+{PBS_TAG}-aarch64-unknown-linux-gnu-install_only.tar.gz",
|
|
58
60
|
}
|
|
59
61
|
PBS_DOWNLOAD_BASE = f"https://github.com/astral-sh/python-build-standalone/releases/download/{PBS_TAG}/"
|
|
60
62
|
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "") or Path.home() / ".cache") / "rrun"
|
|
@@ -110,6 +112,13 @@ def _ensure_local_tarball(os_name: str, arch: str) -> Path:
|
|
|
110
112
|
return path
|
|
111
113
|
|
|
112
114
|
|
|
115
|
+
def _pbs_platform(uname_sm: str) -> "tuple[str, str]":
|
|
116
|
+
"""`uname -sm` 输出 -> PBS_ASSETS 键('Linux x86_64'->('Linux','x86_64'),'Darwin arm64'->('Mac','arm64'))。"""
|
|
117
|
+
parts = uname_sm.split()
|
|
118
|
+
sys_name, arch = (parts + ["", ""])[:2]
|
|
119
|
+
return {"Darwin": "Mac", "Linux": "Linux"}.get(sys_name, sys_name), arch
|
|
120
|
+
|
|
121
|
+
|
|
113
122
|
def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
|
|
114
123
|
"""推送 standalone python 到远端并解压,返回基座 python 路径。"""
|
|
115
124
|
if machine.is_windows:
|
|
@@ -124,11 +133,10 @@ def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
|
|
|
124
133
|
)
|
|
125
134
|
base_py = BASE_PY_WIN
|
|
126
135
|
else:
|
|
127
|
-
rc, out, _, _ = _ssh_run(machine, "uname -
|
|
128
|
-
arch = out.decode("utf-8", "replace")
|
|
129
|
-
os_name = "Mac"
|
|
136
|
+
rc, out, _, _ = _ssh_run(machine, "uname -sm", b"", 30, mux)
|
|
137
|
+
os_name, arch = _pbs_platform(out.decode("utf-8", "replace"))
|
|
130
138
|
if (os_name, arch) not in PBS_ASSETS:
|
|
131
|
-
raise RuntimeError(f"[{machine.name}]
|
|
139
|
+
raise RuntimeError(f"[{machine.name}] no standalone python build for platform: {os_name} {arch}")
|
|
132
140
|
extract_cmd = (
|
|
133
141
|
'tools="$HOME/.remote-machine"; mkdir -p "$tools" '
|
|
134
142
|
'&& rm -rf "$tools/python312" '
|
|
@@ -150,7 +158,11 @@ def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
|
|
|
150
158
|
|
|
151
159
|
|
|
152
160
|
def _build_ps_setup(base_py: str, pkgs: "list[str]", force: bool) -> str:
|
|
153
|
-
"""Windows 一键脚本:建 venv
|
|
161
|
+
"""Windows 一键脚本:建 venv(缺/坏/force 时)→ pip.ini → 装依赖 → 自检。全 ASCII。
|
|
162
|
+
|
|
163
|
+
“坏” = bin/python 在但 pip 不在:上次 venv 创建中途失败(如 ensurepip 缺失)
|
|
164
|
+
留下的半拉子 venv,直接重建(幂等自愈)。
|
|
165
|
+
"""
|
|
154
166
|
pkg_args = " ".join(pkgs)
|
|
155
167
|
imports = ";".join(f"import {_req_import_name(p)}" for p in pkgs) or "pass"
|
|
156
168
|
force_lit = "$true" if force else "$false"
|
|
@@ -160,6 +172,10 @@ $venv="$tools\\venv"
|
|
|
160
172
|
$venvPy="$venv\\Scripts\\python.exe"
|
|
161
173
|
New-Item -ItemType Directory -Force $tools | Out-Null
|
|
162
174
|
if({force_lit} -and (Test-Path $venv)){{Remove-Item -Recurse -Force $venv}}
|
|
175
|
+
if(Test-Path $venvPy){{
|
|
176
|
+
& $venvPy -m pip --version *>$null
|
|
177
|
+
if($LASTEXITCODE -ne 0){{Remove-Item -Recurse -Force $venv}}
|
|
178
|
+
}}
|
|
163
179
|
if(-not (Test-Path $venvPy)){{
|
|
164
180
|
if('{base_py}' -eq ''){{Write-Error "no base python";exit 1}}
|
|
165
181
|
& '{base_py}' -m venv $venv
|
|
@@ -185,6 +201,9 @@ tools="$HOME/.remote-machine"
|
|
|
185
201
|
venv="$tools/venv"
|
|
186
202
|
mkdir -p "$tools"
|
|
187
203
|
if [ "{'1' if force else '0'}" = "1" ]; then rm -rf "$venv"; fi
|
|
204
|
+
if [ -x "$venv/bin/python" ] && ! "$venv/bin/python" -m pip --version >/dev/null 2>&1; then
|
|
205
|
+
rm -rf "$venv"
|
|
206
|
+
fi
|
|
188
207
|
if [ ! -x "$venv/bin/python" ]; then
|
|
189
208
|
"{base_py}" -m venv "$venv"
|
|
190
209
|
fi
|
|
@@ -210,13 +229,20 @@ def setup_machine(host: str, force: bool = False, mux: bool = True,
|
|
|
210
229
|
base_cands = WINDOWS_BASE_CANDIDATES if machine.is_windows else POSIX_BASE_CANDIDATES
|
|
211
230
|
|
|
212
231
|
hit = probe_python(machine, [venv_py], mux)
|
|
213
|
-
|
|
232
|
+
# 半拉子 venv(python 能跑但 pip 不在——上次创建中途失败的残留)也视为待重建;
|
|
233
|
+
# 否则远端脚本删掉坏 venv 后会拿空 base_py 去重建,必然失败
|
|
234
|
+
venv_broken = bool(hit) and not check_remote_pip(machine, hit[0], mux)
|
|
235
|
+
need_create = force or not hit or venv_broken
|
|
214
236
|
base_py = ""
|
|
215
237
|
if need_create:
|
|
216
|
-
|
|
238
|
+
# 基座资格:版本 3.12 且带 ensurepip(缺 ensurepip 的系统 python 建不了 venv,
|
|
239
|
+
# Debian/Ubuntu 未装 python3.x-venv 的典型场景)——不合格则装 standalone 基座
|
|
240
|
+
base_hit = probe_python(machine, base_cands, mux, require_ensurepip=True)
|
|
217
241
|
if base_hit:
|
|
218
242
|
base_py = base_hit[0]
|
|
219
243
|
else:
|
|
244
|
+
print(f"[setup] [{machine.name}] no venv-capable python 3.12 base found; "
|
|
245
|
+
f"installing standalone python ...", file=sys.stderr)
|
|
220
246
|
base_py = _install_standalone(machine, mux, timeout)
|
|
221
247
|
result.installed_standalone = True
|
|
222
248
|
result.created_venv = True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rrun-cli
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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)
|
|
@@ -145,12 +153,12 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
|
|
|
145
153
|
2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
|
|
146
154
|
3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
|
|
147
155
|
|
|
148
|
-
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
156
|
+
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
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,156 @@
|
|
|
1
|
+
"""executor 纯函数测试:PS wrapper、bash 命令、ASCII 校验、ssh 参数组装。"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from rrun.executor import (
|
|
8
|
+
_PY_BASE_CHECK_CODE,
|
|
9
|
+
_PY_VER_CHECK_CODE,
|
|
10
|
+
_bash_probe_script,
|
|
11
|
+
_check_ascii,
|
|
12
|
+
_posix_prefix,
|
|
13
|
+
_ps_probe_script,
|
|
14
|
+
_ps_quote,
|
|
15
|
+
_ssh_args,
|
|
16
|
+
build_bash_command,
|
|
17
|
+
build_ps_wrapper,
|
|
18
|
+
)
|
|
19
|
+
from rrun.registry import Machine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _win(**kw):
|
|
23
|
+
kw.setdefault("os", "Windows")
|
|
24
|
+
return Machine(name="w", ip="1.1.1.1", user="u", **kw)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestCheckAscii:
|
|
28
|
+
def test_ascii_ok(self):
|
|
29
|
+
_check_ascii(["abc", "x=1"], "args")
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("bad", ["中文", "café", "ab"])
|
|
32
|
+
def test_non_ascii_rejected(self, bad):
|
|
33
|
+
with pytest.raises(ValueError, match="ASCII"):
|
|
34
|
+
_check_ascii([bad], "args")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestPsWrapper:
|
|
38
|
+
def test_pure_ascii_single_line(self):
|
|
39
|
+
payload = build_ps_wrapper('Write-Output "你好,世界"')
|
|
40
|
+
assert payload.count(b"\n") == 1
|
|
41
|
+
payload.decode("ascii") # 必须纯 ASCII
|
|
42
|
+
|
|
43
|
+
def test_script_content_roundtrips_via_base64(self):
|
|
44
|
+
code = 'Write-Output "你好 $HOME `n"'
|
|
45
|
+
line = build_ps_wrapper(code).decode("ascii")
|
|
46
|
+
b64 = line.split("FromBase64String('")[1].split("'")[0]
|
|
47
|
+
assert base64.b64decode(b64).decode("utf-8") == code
|
|
48
|
+
|
|
49
|
+
def test_args_workdir_env(self):
|
|
50
|
+
line = build_ps_wrapper("x", args=["a", "b'c"], workdir="C:\\work",
|
|
51
|
+
env={"K": "v"}).decode("ascii")
|
|
52
|
+
assert "Set-Location 'C:\\work'" in line
|
|
53
|
+
assert "$env:K='v'" in line
|
|
54
|
+
assert "'b''c'" in line # PS 单引号转义
|
|
55
|
+
assert "$__args=@('a','b''c')" in line
|
|
56
|
+
|
|
57
|
+
def test_non_ascii_args_rejected(self):
|
|
58
|
+
with pytest.raises(ValueError, match="ASCII"):
|
|
59
|
+
build_ps_wrapper("x", args=["中文"])
|
|
60
|
+
|
|
61
|
+
def test_exit_code_passthrough_tail(self):
|
|
62
|
+
line = build_ps_wrapper("x").decode("ascii")
|
|
63
|
+
assert "exit $LASTEXITCODE" in line
|
|
64
|
+
assert "ScriptBlock" in line
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestBashCommand:
|
|
68
|
+
def test_plain(self):
|
|
69
|
+
assert build_bash_command() == "bash -s"
|
|
70
|
+
|
|
71
|
+
def test_args_workdir_env(self):
|
|
72
|
+
cmd = build_bash_command(args=["a b", "c"], workdir="/tmp/x y", env={"K": "v v"})
|
|
73
|
+
assert cmd.startswith("cd '/tmp/x y' && env K='v v' bash -s -- ")
|
|
74
|
+
assert "'a b'" in cmd and cmd.endswith(" c")
|
|
75
|
+
|
|
76
|
+
def test_non_ascii_args_rejected(self):
|
|
77
|
+
with pytest.raises(ValueError, match="ASCII"):
|
|
78
|
+
build_bash_command(args=["中文"])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestProbeScripts:
|
|
82
|
+
def test_bash_probe_default_is_version_only(self):
|
|
83
|
+
s = _bash_probe_script(["python3.12"])
|
|
84
|
+
assert "sys.version_info" in s
|
|
85
|
+
assert "ensurepip" not in s # exec 热路径不做基座资格加码
|
|
86
|
+
|
|
87
|
+
def test_bash_probe_require_ensurepip(self):
|
|
88
|
+
s = _bash_probe_script(["python3.12", "/usr/local/bin/python3.12"], require_ensurepip=True)
|
|
89
|
+
assert "import ensurepip" in s
|
|
90
|
+
|
|
91
|
+
def test_ps_probe_require_ensurepip(self):
|
|
92
|
+
s = _ps_probe_script([r"C:\Python\Python312\python.exe"], require_ensurepip=True)
|
|
93
|
+
assert "import ensurepip" in s
|
|
94
|
+
|
|
95
|
+
def test_ps_probe_default_is_version_only(self):
|
|
96
|
+
assert "ensurepip" not in _ps_probe_script([r"C:\py\python.exe"])
|
|
97
|
+
|
|
98
|
+
def test_check_codes_contain_no_quotes(self):
|
|
99
|
+
# bash 探测用单引号包裹检查代码,代码内出现引号会破坏组装
|
|
100
|
+
for code in (_PY_VER_CHECK_CODE, _PY_BASE_CHECK_CODE):
|
|
101
|
+
assert "'" not in code and '"' not in code
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestPosixPrefix:
|
|
105
|
+
def test_empty(self):
|
|
106
|
+
assert _posix_prefix() == ""
|
|
107
|
+
|
|
108
|
+
def test_quoting(self):
|
|
109
|
+
assert _posix_prefix(workdir="a b") == "cd 'a b' && "
|
|
110
|
+
assert _posix_prefix(env={"K": "v v"}) == "env K='v v' "
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestPsQuote:
|
|
114
|
+
def test_escape(self):
|
|
115
|
+
assert _ps_quote("a'b") == "'a''b'"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestSshArgs:
|
|
119
|
+
def test_password_uses_sshpass(self, monkeypatch):
|
|
120
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass" if name == "sshpass" else None)
|
|
121
|
+
args = _ssh_args(_win(password="pw"), mux=False)
|
|
122
|
+
assert args[:3] == ["/usr/bin/sshpass", "-p", "pw"]
|
|
123
|
+
assert "NumberOfPasswordPrompts=1" in args
|
|
124
|
+
assert "BatchMode=yes" not in args
|
|
125
|
+
|
|
126
|
+
def test_password_without_sshpass_raises(self, monkeypatch):
|
|
127
|
+
monkeypatch.setattr("shutil.which", lambda name: None)
|
|
128
|
+
with pytest.raises(RuntimeError, match="sshpass not found"):
|
|
129
|
+
_ssh_args(_win(password="pw"), mux=False)
|
|
130
|
+
|
|
131
|
+
def test_key_auth_needs_no_sshpass(self, monkeypatch):
|
|
132
|
+
monkeypatch.setattr("shutil.which", lambda name: None) # 无 sshpass 也能走 key
|
|
133
|
+
args = _ssh_args(_win(password="", identity_file="~/.ssh/id_ed25519"), mux=False)
|
|
134
|
+
assert args[0] == "ssh"
|
|
135
|
+
assert "BatchMode=yes" in args
|
|
136
|
+
i = args.index("-i")
|
|
137
|
+
assert args[i + 1].endswith(".ssh/id_ed25519")
|
|
138
|
+
|
|
139
|
+
def test_custom_port(self, monkeypatch):
|
|
140
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
|
|
141
|
+
args = _ssh_args(_win(password="pw", port=2222), mux=False)
|
|
142
|
+
i = args.index("-p", 2) # 跳过 sshpass -p
|
|
143
|
+
assert args[i + 1] == "2222"
|
|
144
|
+
|
|
145
|
+
def test_default_port_no_flag(self, monkeypatch):
|
|
146
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
|
|
147
|
+
args = _ssh_args(_win(password="pw"), mux=False)
|
|
148
|
+
assert "-p" not in args[3:] # sshpass 的 -p 之后不再有 -p
|
|
149
|
+
|
|
150
|
+
def test_mux_adds_control_opts(self, monkeypatch, tmp_path):
|
|
151
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
|
|
152
|
+
import rrun.executor as ex
|
|
153
|
+
monkeypatch.setattr(ex, "CONTROL_PATH_DIR", tmp_path / ".ssh")
|
|
154
|
+
args = _ssh_args(_win(password="pw"), mux=True)
|
|
155
|
+
assert "ControlMaster=auto" in " ".join(args)
|
|
156
|
+
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,79 @@
|
|
|
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 TestPbsAssets:
|
|
34
|
+
def test_linux_assets_present(self):
|
|
35
|
+
assert ("Linux", "x86_64") in st.PBS_ASSETS
|
|
36
|
+
assert ("Linux", "aarch64") in st.PBS_ASSETS
|
|
37
|
+
|
|
38
|
+
def test_pbs_platform_mapping(self):
|
|
39
|
+
assert st._pbs_platform("Linux x86_64") == ("Linux", "x86_64")
|
|
40
|
+
assert st._pbs_platform("Linux aarch64") == ("Linux", "aarch64")
|
|
41
|
+
assert st._pbs_platform("Darwin arm64") == ("Mac", "arm64")
|
|
42
|
+
assert st._pbs_platform("Darwin x86_64") == ("Mac", "x86_64")
|
|
43
|
+
|
|
44
|
+
def test_pbs_platform_edge(self):
|
|
45
|
+
assert st._pbs_platform("") == ("", "")
|
|
46
|
+
assert st._pbs_platform("FreeBSD") == ("FreeBSD", "")
|
|
47
|
+
|
|
48
|
+
def test_all_mapped_posix_platforms_have_assets(self):
|
|
49
|
+
for uname in ("Linux x86_64", "Linux aarch64", "Darwin arm64", "Darwin x86_64"):
|
|
50
|
+
assert st._pbs_platform(uname) in st.PBS_ASSETS
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestSetupScripts:
|
|
54
|
+
def test_bash_setup(self):
|
|
55
|
+
s = _build_bash_setup("/usr/bin/python3.12", ["requests>=2", "foo"], force=False)
|
|
56
|
+
assert '"/usr/bin/python3.12" -m venv "$venv"' in s
|
|
57
|
+
assert "pip install" in s and "requests>=2 foo" in s
|
|
58
|
+
assert "import requests;import foo" in s
|
|
59
|
+
assert "[ \"0\" = \"1\" ]" in s # force off
|
|
60
|
+
|
|
61
|
+
def test_bash_setup_force(self):
|
|
62
|
+
assert "[ \"1\" = \"1\" ]" in _build_bash_setup("p", ["requests"], force=True)
|
|
63
|
+
|
|
64
|
+
def test_bash_setup_heals_half_broken_venv(self):
|
|
65
|
+
# 上次 venv 创建中途失败(ensurepip 缺失等)留下的半拉子 venv:检测无 pip 并重建
|
|
66
|
+
s = _build_bash_setup("/usr/bin/python3.12", ["requests"], force=False)
|
|
67
|
+
assert '"$venv/bin/python" -m pip --version' in s
|
|
68
|
+
assert 'rm -rf "$venv"' in s
|
|
69
|
+
|
|
70
|
+
def test_ps_setup_ascii_and_content(self):
|
|
71
|
+
s = _build_ps_setup("C:\\py\\python.exe", ["requests"], force=False)
|
|
72
|
+
assert "$true" not in s and "$false" in s
|
|
73
|
+
assert "pip.ini" in s and "requests" in s and "import requests" in s
|
|
74
|
+
|
|
75
|
+
def test_ps_setup_force(self):
|
|
76
|
+
assert "if($true" in _build_ps_setup("C:\\py\\python.exe", ["requests"], force=True)
|
|
77
|
+
|
|
78
|
+
def test_ps_setup_heals_half_broken_venv(self):
|
|
79
|
+
assert "-m pip --version" in _build_ps_setup("C:\\py\\python.exe", ["requests"], force=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|