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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner.
5
5
  Author: waqiju
6
6
  License-Expression: MIT
@@ -21,6 +21,9 @@ Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
27
  Dynamic: license-file
25
28
 
26
29
  # rrun
@@ -100,6 +103,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
100
103
  | `rrun config` | Diagnose the machines.json source chain |
101
104
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
102
105
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
106
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
103
107
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
104
108
 
105
109
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -121,11 +125,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
121
125
  "defaults": { "windows": { "os": "Windows" } },
122
126
  "machines": [
123
127
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
124
- "user": "admin", "password": "secret" }
128
+ "user": "admin", "password": "secret" },
129
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
130
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
125
131
  ]
126
132
  }
127
133
  ```
128
134
 
135
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
136
+
129
137
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
130
138
 
131
139
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -149,8 +157,8 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
149
157
 
150
158
  ## Security
151
159
 
152
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
153
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
160
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
161
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
154
162
  - The audit log never records passwords, and `--env` values are logged as keys only.
155
163
 
156
164
  ## Auditing & state
@@ -168,7 +176,7 @@ pipx uninstall rrun-cli
168
176
 
169
177
  ## Contributing
170
178
 
171
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
179
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
172
180
 
173
181
  ## License
174
182
 
@@ -75,6 +75,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
75
75
  | `rrun config` | Diagnose the machines.json source chain |
76
76
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
77
77
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
78
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
78
79
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
79
80
 
80
81
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -96,11 +97,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
96
97
  "defaults": { "windows": { "os": "Windows" } },
97
98
  "machines": [
98
99
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
99
- "user": "admin", "password": "secret" }
100
+ "user": "admin", "password": "secret" },
101
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
102
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
100
103
  ]
101
104
  }
102
105
  ```
103
106
 
107
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
108
+
104
109
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
105
110
 
106
111
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -124,8 +129,8 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
124
129
 
125
130
  ## Security
126
131
 
127
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
128
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
132
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
133
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
129
134
  - The audit log never records passwords, and `--env` values are logged as keys only.
130
135
 
131
136
  ## Auditing & state
@@ -143,7 +148,7 @@ pipx uninstall rrun-cli
143
148
 
144
149
  ## Contributing
145
150
 
146
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
151
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
147
152
 
148
153
  ## License
149
154
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rrun-cli"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,6 +24,9 @@ classifiers = [
24
24
  # 纯标准库实现,零第三方依赖。控制端仅需本机有 ssh + sshpass。
25
25
  dependencies = []
26
26
 
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "ruff>=0.6"]
29
+
27
30
  [project.urls]
28
31
  Homepage = "https://github.com/waqiju/rrun"
29
32
  Repository = "https://github.com/waqiju/rrun"
@@ -43,3 +46,16 @@ where = ["src"]
43
46
 
44
47
  [tool.setuptools.package-data]
45
48
  rrun = ["remote-requirements.txt"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+
53
+ [tool.ruff]
54
+ target-version = "py310"
55
+ line-length = 120
56
+ src = ["src"]
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "I", "UP", "B", "BLE"]
60
+ # E501 交给格式化宽限:项目内大量中文注释 + 单行 wrapper 模板,硬限 120 收益低
61
+ ignore = ["E501"]
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  """rrun — 远程机器执行模块(Run Remote)。
3
2
 
4
3
  约定:read/write/edit 等文件操作始终发生在本地;任何远程执行都是
@@ -12,8 +11,9 @@ try:
12
11
 
13
12
  __version__ = _pkg_version("rrun-cli")
14
13
  except Exception: # noqa: BLE001 - 未安装(源码直跑)时回退
15
- __version__ = "0.1.2"
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(f"[remote-exec] ssh transport error (unreachable / auth failure / dropped), exit=255", file=sys.stderr)
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: "list[str] | None" = None
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 _ssh_run(machine: Machine, remote_cmd: str, stdin: bytes, timeout: "float | None",
168
- mux: bool) -> "tuple[int, bytes, bytes, bool]":
169
- """返回 (exit_code, stdout, stderr, timed_out)。exit 255 即传输层错误。"""
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)")
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 += [machine.target, remote_cmd]
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
- p = subprocess.run(
360
- ["ssh", "-O", "exit", "-o", f"ControlPath={CONTROL_PATH}", machine.target],
361
- capture_output=True, text=True, timeout=15)
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: "list[tuple[str, Path]]" = []
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: "set[str]" = set()
94
- uniq: "list[tuple[str, Path]]" = []
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: "dict[str, Machine]" = {}
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
  布局(幂等,可重复执行):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner.
5
5
  Author: waqiju
6
6
  License-Expression: MIT
@@ -21,6 +21,9 @@ Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
27
  Dynamic: license-file
25
28
 
26
29
  # rrun
@@ -100,6 +103,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
100
103
  | `rrun config` | Diagnose the machines.json source chain |
101
104
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
102
105
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
106
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
103
107
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
104
108
 
105
109
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -121,11 +125,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
121
125
  "defaults": { "windows": { "os": "Windows" } },
122
126
  "machines": [
123
127
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
124
- "user": "admin", "password": "secret" }
128
+ "user": "admin", "password": "secret" },
129
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
130
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
125
131
  ]
126
132
  }
127
133
  ```
128
134
 
135
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
136
+
129
137
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
130
138
 
131
139
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -149,8 +157,8 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
149
157
 
150
158
  ## Security
151
159
 
152
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
153
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
160
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
161
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
154
162
  - The audit log never records passwords, and `--env` values are logged as keys only.
155
163
 
156
164
  ## Auditing & state
@@ -168,7 +176,7 @@ pipx uninstall rrun-cli
168
176
 
169
177
  ## Contributing
170
178
 
171
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
179
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
172
180
 
173
181
  ## License
174
182
 
@@ -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/top_level.txt
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,4 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ ruff>=0.6
@@ -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é", "a​b"])
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