rrun-cli 0.1.0__py3-none-any.whl
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/__init__.py +36 -0
- rrun/__main__.py +301 -0
- rrun/executor.py +362 -0
- rrun/registry.py +163 -0
- rrun/remote-requirements.txt +4 -0
- rrun/setup.py +276 -0
- rrun_cli-0.1.0.dist-info/METADATA +134 -0
- rrun_cli-0.1.0.dist-info/RECORD +12 -0
- rrun_cli-0.1.0.dist-info/WHEEL +5 -0
- rrun_cli-0.1.0.dist-info/entry_points.txt +3 -0
- rrun_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- rrun_cli-0.1.0.dist-info/top_level.txt +1 -0
rrun/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""rrun — 远程机器执行模块(Run Remote)。
|
|
3
|
+
|
|
4
|
+
约定:read/write/edit 等文件操作始终发生在本地;任何远程执行都是
|
|
5
|
+
「本地写脚本 → stdin 管道送远端解释器执行 → 收回 stdout/stderr/退出码」。
|
|
6
|
+
|
|
7
|
+
支持语言:bash(Mac/Linux)、powershell(Windows)、python(跨平台)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from importlib.metadata import version as _pkg_version
|
|
12
|
+
|
|
13
|
+
__version__ = _pkg_version("rrun-cli")
|
|
14
|
+
except Exception: # noqa: BLE001 - 未安装(源码直跑)时回退
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
from .executor import (
|
|
18
|
+
AUDIT_LOG,
|
|
19
|
+
EXIT_TIMEOUT,
|
|
20
|
+
EXIT_TRANSPORT_ERROR,
|
|
21
|
+
RRUN_HOME,
|
|
22
|
+
ExecResult,
|
|
23
|
+
build_ps_wrapper,
|
|
24
|
+
close_mux,
|
|
25
|
+
detect_remote_python,
|
|
26
|
+
run,
|
|
27
|
+
)
|
|
28
|
+
from .registry import Machine, SourceInfo, candidate_sources, load_machines, resolve_machine, scan_sources
|
|
29
|
+
from .setup import SetupResult, load_requirements, setup_machine
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
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",
|
|
35
|
+
"load_machines", "load_requirements", "resolve_machine", "run", "scan_sources", "setup_machine",
|
|
36
|
+
]
|
rrun/__main__.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""rrun — 远程机器执行器(Run Remote)。
|
|
3
|
+
|
|
4
|
+
核心约定:脚本内容(可含中文,UTF-8)经 **stdin 管道**送远端解释器执行,
|
|
5
|
+
全程不走命令行参数,规避 bash->ssh->cmd 多层转码/转义问题。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
rrun exec <host> <script.py|.ps1|.sh> [ascii_args...]
|
|
9
|
+
rrun exec <host> -c "Write-Output 中文" # lang 按 OS 推断
|
|
10
|
+
rrun exec pc_build temp/x.py --timeout 60
|
|
11
|
+
rrun exec mac_mini --lang python temp/x.py
|
|
12
|
+
rrun setup <host|--all> [--force] # 初始化统一 python 环境(3.12 venv + 阿里源)
|
|
13
|
+
rrun pip <host> -- list # 在统一 venv 中执行 pip
|
|
14
|
+
rrun machines # 列出可用机器(脱敏,含来源)
|
|
15
|
+
rrun config # 查看 machines.json 来源链解析
|
|
16
|
+
rrun close [<host>|--all] # 关闭 ssh 复用连接
|
|
17
|
+
|
|
18
|
+
约定:
|
|
19
|
+
- lang 推断:文件扩展名(.py/.ps1/.sh)优先,否则按机器 OS(Windows=powershell,其他=bash)。
|
|
20
|
+
- 命令行参数只允许 ASCII,透传远端(python=sys.argv;bash=$@;powershell=$args);
|
|
21
|
+
中文/特殊字符一律写进脚本内容或 JSON 文件。
|
|
22
|
+
- python 基线锁 3.12:探测命中统一 venv(优先)/standalone 基座/存量 3.12,
|
|
23
|
+
并校验版本号;全灭则报错提示先跑 setup(exec 热路径不做隐式安装)。
|
|
24
|
+
- 退出码:远端脚本退出码原样透传;255=ssh 传输层错误;124=本地超时。
|
|
25
|
+
- 内联 -c 内容自动落盘 ~/.rrun/drops/ 留档,可复跑(RRUN_HOME 可改根目录)。
|
|
26
|
+
- 审计:每次执行追加 ~/.rrun/log/remote-exec.jsonl。
|
|
27
|
+
|
|
28
|
+
退出码即远端退出码,可直接管道使用:远端 stdout→本机 stdout,stderr→stderr。
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
|
|
38
|
+
from .registry import load_machines, resolve_machine, scan_sources
|
|
39
|
+
from .setup import setup_machine, venv_python_or_die
|
|
40
|
+
|
|
41
|
+
INLINE_DROP_DIR = RRUN_HOME / "drops"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_env(pairs) -> dict:
|
|
45
|
+
env = {}
|
|
46
|
+
for p in pairs or []:
|
|
47
|
+
if "=" not in p:
|
|
48
|
+
raise SystemExit(f"[remote-exec] --env 格式应为 KEY=VAL: {p!r}")
|
|
49
|
+
k, v = p.split("=", 1)
|
|
50
|
+
env[k] = v
|
|
51
|
+
return env
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _drop_inline(host: str, lang: str, content: str):
|
|
55
|
+
"""内联内容落盘 ~/.rrun/drops/ 留档(可复跑/可 edit 后重跑)。"""
|
|
56
|
+
ext = {"python": ".py", "powershell": ".ps1", "bash": ".sh"}.get(lang, ".txt")
|
|
57
|
+
INLINE_DROP_DIR.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path = INLINE_DROP_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}_{host}{ext}"
|
|
59
|
+
path.write_text(content, encoding="utf-8")
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_exec(ns) -> int:
|
|
64
|
+
try:
|
|
65
|
+
machine = resolve_machine(ns.host)
|
|
66
|
+
except KeyError as e:
|
|
67
|
+
raise SystemExit(f"[remote-exec] {e}")
|
|
68
|
+
|
|
69
|
+
content = ns.content
|
|
70
|
+
file = ns.script or ""
|
|
71
|
+
lang = ns.lang or ""
|
|
72
|
+
if content and file:
|
|
73
|
+
raise SystemExit("[remote-exec] 脚本文件与 -c/--content 只能二选一")
|
|
74
|
+
if not content and not file:
|
|
75
|
+
raise SystemExit("[remote-exec] 缺少脚本:给文件路径或 -c/--content")
|
|
76
|
+
|
|
77
|
+
# content 模式先确定 lang 再落盘(扩展名需要 lang)
|
|
78
|
+
script_for_log = file
|
|
79
|
+
if content:
|
|
80
|
+
if not lang:
|
|
81
|
+
lang = machine.default_lang
|
|
82
|
+
dropped = _drop_inline(machine.name, lang, content)
|
|
83
|
+
script_for_log = str(dropped)
|
|
84
|
+
print(f"[remote-exec] 内联内容已落盘: {dropped}", file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
args = ns.args
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
result = run(
|
|
90
|
+
ns.host, lang=lang, file=file, content=content, args=args,
|
|
91
|
+
workdir=ns.workdir or "", env=_parse_env(ns.env),
|
|
92
|
+
timeout=ns.timeout, remote_python=(ns.python or ""), utf8=not ns.no_utf8,
|
|
93
|
+
mux=not ns.no_mux,
|
|
94
|
+
)
|
|
95
|
+
except (ValueError, RuntimeError) as e:
|
|
96
|
+
raise SystemExit(f"[remote-exec] {e}")
|
|
97
|
+
|
|
98
|
+
if not ns.quiet:
|
|
99
|
+
via = script_for_log or "<stdin>"
|
|
100
|
+
extra = f" python={result.remote_python}({result.remote_python_version})" if result.lang == "python" else ""
|
|
101
|
+
print(f"[remote-exec] host={result.host} ({result.ip}) lang={result.lang}{extra} "
|
|
102
|
+
f"script={via} sha1={result.content_sha1}", file=sys.stderr)
|
|
103
|
+
sys.stdout.buffer.write(result.stdout)
|
|
104
|
+
sys.stdout.buffer.flush()
|
|
105
|
+
sys.stderr.buffer.write(result.stderr)
|
|
106
|
+
sys.stderr.buffer.flush()
|
|
107
|
+
|
|
108
|
+
if result.timed_out:
|
|
109
|
+
print(f"[remote-exec] 超时({ns.timeout}s),本地已终止", file=sys.stderr)
|
|
110
|
+
elif result.transport_error:
|
|
111
|
+
print(f"[remote-exec] ssh 传输层错误(连接失败/认证失败/掉线),exit=255", file=sys.stderr)
|
|
112
|
+
if not ns.quiet:
|
|
113
|
+
print(f"[remote-exec] exit={result.exit_code} 耗时 {result.duration:.1f}s", file=sys.stderr)
|
|
114
|
+
return result.exit_code
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _short_source(path_str: str) -> str:
|
|
118
|
+
"""来源路径缩短显示:home → ~。"""
|
|
119
|
+
if not path_str:
|
|
120
|
+
return "-"
|
|
121
|
+
s = str(path_str)
|
|
122
|
+
home = str(Path.home())
|
|
123
|
+
if s.startswith(home):
|
|
124
|
+
return "~" + s[len(home):]
|
|
125
|
+
return s
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_machines(ns) -> int:
|
|
129
|
+
machines = load_machines()
|
|
130
|
+
if ns.json:
|
|
131
|
+
print(json.dumps([m.public_dict() for m in machines], ensure_ascii=False, indent=2))
|
|
132
|
+
return 0
|
|
133
|
+
for m in machines:
|
|
134
|
+
desc = f" # {m.description}" if m.description else ""
|
|
135
|
+
print(f"{m.name:<24} {m.ip:<16} {m.os:<8} {m.user:<12} {m.default_lang:<11} {_short_source(m.source)}{desc}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_config(ns) -> int:
|
|
140
|
+
infos = scan_sources()
|
|
141
|
+
print("machines.json 来源链(高 → 低优先级,同名机器高优先级覆盖):")
|
|
142
|
+
raw_total = 0
|
|
143
|
+
for i, info in enumerate(infos, 1):
|
|
144
|
+
if not info.exists:
|
|
145
|
+
state = "- 不存在"
|
|
146
|
+
elif info.error:
|
|
147
|
+
state = f"✗ 读取失败: {info.error}"
|
|
148
|
+
else:
|
|
149
|
+
state = f"✓ {info.machine_count} 台"
|
|
150
|
+
raw_total += info.machine_count
|
|
151
|
+
print(f" [{i}] {info.label:<22} {info.path} {state}")
|
|
152
|
+
merged = load_machines()
|
|
153
|
+
print(f"合计 {len(merged)} 台(各来源原始共 {raw_total} 台,同名覆盖后 {len(merged)} 台)")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_setup(ns) -> int:
|
|
158
|
+
if ns.all:
|
|
159
|
+
hosts = [m.name for m in load_machines()]
|
|
160
|
+
elif ns.host:
|
|
161
|
+
hosts = [ns.host]
|
|
162
|
+
else:
|
|
163
|
+
raise SystemExit("[setup] 需要指定 host 或 --all")
|
|
164
|
+
results = []
|
|
165
|
+
if len(hosts) == 1:
|
|
166
|
+
print(f"[setup] {hosts[0]} 初始化中...", file=sys.stderr)
|
|
167
|
+
results.append(setup_machine(hosts[0], force=ns.force))
|
|
168
|
+
else:
|
|
169
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
170
|
+
with ThreadPoolExecutor(max_workers=ns.jobs) as pool:
|
|
171
|
+
futs = {pool.submit(setup_machine, h, force=ns.force): h for h in hosts}
|
|
172
|
+
for f in as_completed(futs):
|
|
173
|
+
r = f.result()
|
|
174
|
+
results.append(r)
|
|
175
|
+
print(f"[setup] {r.host}: {'ok' if r.ok else 'FAIL'} ({r.duration:.0f}s)", file=sys.stderr)
|
|
176
|
+
print(f"\n{'host':<24} {'结果':<6} {'python':<10} {'venv':<46} 备注")
|
|
177
|
+
for r in sorted(results, key=lambda x: x.host):
|
|
178
|
+
if r.ok:
|
|
179
|
+
note = []
|
|
180
|
+
if r.installed_standalone:
|
|
181
|
+
note.append("新装standalone")
|
|
182
|
+
if r.created_venv:
|
|
183
|
+
note.append("新建venv")
|
|
184
|
+
if not note:
|
|
185
|
+
note.append("已存在,仅校验/补装依赖")
|
|
186
|
+
print(f"{r.host:<24} {'ok':<6} {r.version:<10} {r.venv_python:<46} {','.join(note)}")
|
|
187
|
+
else:
|
|
188
|
+
print(f"{r.host:<24} {'FAIL':<6} {'':<10} {'':<46} {r.message[:80]}")
|
|
189
|
+
return 0 if all(r.ok for r in results) else 1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_pip(ns) -> int:
|
|
193
|
+
try:
|
|
194
|
+
machine = resolve_machine(ns.host)
|
|
195
|
+
except KeyError as e:
|
|
196
|
+
raise SystemExit(f"[pip] {e}")
|
|
197
|
+
args = list(ns.pargs or []) + list(getattr(ns, "args", []) or [])
|
|
198
|
+
if not args:
|
|
199
|
+
raise SystemExit("[pip] 缺少 pip 参数,例:rrun pip <host> -- list")
|
|
200
|
+
try:
|
|
201
|
+
_check_ascii(args, "pip 参数")
|
|
202
|
+
py = venv_python_or_die(ns.host)
|
|
203
|
+
except (ValueError, RuntimeError) as e:
|
|
204
|
+
raise SystemExit(f"[pip] {e}")
|
|
205
|
+
joined = " ".join(args)
|
|
206
|
+
if machine.is_windows:
|
|
207
|
+
remote_cmd = f'"{py}" -m pip {joined}'
|
|
208
|
+
else:
|
|
209
|
+
remote_cmd = f"{py} -m pip {joined}"
|
|
210
|
+
t0 = time.time()
|
|
211
|
+
rc, out, err, timed_out = _ssh_run(machine, remote_cmd, b"", ns.timeout, True)
|
|
212
|
+
sys.stdout.buffer.write(out)
|
|
213
|
+
sys.stdout.buffer.flush()
|
|
214
|
+
sys.stderr.buffer.write(err)
|
|
215
|
+
sys.stderr.buffer.flush()
|
|
216
|
+
print(f"[pip] {machine.name} exit={rc} 耗时 {time.time() - t0:.1f}s", file=sys.stderr)
|
|
217
|
+
return rc
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_close(ns) -> int:
|
|
221
|
+
if ns.all:
|
|
222
|
+
hosts = [m.name for m in load_machines()]
|
|
223
|
+
elif ns.host:
|
|
224
|
+
hosts = [ns.host]
|
|
225
|
+
else:
|
|
226
|
+
raise SystemExit("[remote-exec] close 需要指定 host 或 --all")
|
|
227
|
+
rc = 0
|
|
228
|
+
for h in hosts:
|
|
229
|
+
try:
|
|
230
|
+
code, msg = close_mux(h)
|
|
231
|
+
except Exception as e: # noqa: BLE001 - close 尽量遍历完
|
|
232
|
+
code, msg = 1, str(e)
|
|
233
|
+
print(f"[remote-exec] close {h}: {'ok' if code == 0 else msg}")
|
|
234
|
+
rc = rc or code
|
|
235
|
+
return rc
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main() -> None:
|
|
239
|
+
ap = argparse.ArgumentParser(
|
|
240
|
+
prog="rrun",
|
|
241
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
242
|
+
sub = ap.add_subparsers(dest="subcmd", required=True)
|
|
243
|
+
|
|
244
|
+
ep = sub.add_parser("exec", help="在远端机器执行本地脚本/内联内容")
|
|
245
|
+
ep.add_argument("host", help="机器 name/ip/hostname(见 machines 子命令)")
|
|
246
|
+
ep.add_argument("script", nargs="?", help="本地脚本路径(.py/.ps1/.sh,UTF-8)")
|
|
247
|
+
ep.add_argument("--lang", choices=["bash", "powershell", "python"],
|
|
248
|
+
help="远端解释语言(缺省:按扩展名,否则按机器 OS)")
|
|
249
|
+
ep.add_argument("-c", "--content", help="内联脚本内容(自动落盘 ~/.rrun/drops/ 留档)")
|
|
250
|
+
ep.add_argument("args", nargs="*",
|
|
251
|
+
help="脚本参数(仅 ASCII;建议用 -- 与选项分隔,选项可放任意位置)")
|
|
252
|
+
ep.add_argument("--workdir", help="远端工作目录(bash/powershell 支持)")
|
|
253
|
+
ep.add_argument("--env", action="append", metavar="KEY=VAL",
|
|
254
|
+
help="远端环境变量(可多次;仅 ASCII)")
|
|
255
|
+
ep.add_argument("--timeout", type=float, help="本地超时秒数(超时 exit=124)")
|
|
256
|
+
ep.add_argument("--python", dest="python", help="远端 python 路径(跳过自动探测)")
|
|
257
|
+
ep.add_argument("--no-utf8", action="store_true", help="远端 python 不加 -X utf8")
|
|
258
|
+
ep.add_argument("--no-mux", action="store_true", help="禁用 ssh ControlMaster 复用")
|
|
259
|
+
ep.add_argument("-q", "--quiet", action="store_true", help="不打印 [remote-exec] 信息行")
|
|
260
|
+
ep.set_defaults(func=cmd_exec)
|
|
261
|
+
|
|
262
|
+
mp = sub.add_parser("machines", help="列出全部来源合并后的机器(脱敏,含来源)")
|
|
263
|
+
mp.add_argument("--json", action="store_true")
|
|
264
|
+
mp.set_defaults(func=cmd_machines)
|
|
265
|
+
|
|
266
|
+
cf = sub.add_parser("config", help="查看 machines.json 来源链解析(哪些文件生效、各贡献几台)")
|
|
267
|
+
cf.set_defaults(func=cmd_config)
|
|
268
|
+
|
|
269
|
+
sp = sub.add_parser("setup", help="初始化远端统一 python 环境(3.12 venv + 阿里云源)")
|
|
270
|
+
sp.add_argument("host", nargs="?", help="机器 name/ip;--all 表示全部")
|
|
271
|
+
sp.add_argument("--all", action="store_true", help="对 machines.json 所有机器执行")
|
|
272
|
+
sp.add_argument("--force", action="store_true", help="重建 venv(不动已装的 python 本体)")
|
|
273
|
+
sp.add_argument("--jobs", type=int, default=6, help="--all 时的并发数(默认 6)")
|
|
274
|
+
sp.set_defaults(func=cmd_setup)
|
|
275
|
+
|
|
276
|
+
pp = sub.add_parser("pip", help="在远端统一 venv 中执行 pip(ad-hoc 装包)")
|
|
277
|
+
pp.add_argument("host", help="机器 name/ip")
|
|
278
|
+
pp.add_argument("pargs", nargs="*", help="pip 参数;含 - 开头选项时放 -- 之后")
|
|
279
|
+
pp.add_argument("--timeout", type=float, default=300.0, help="本地超时秒数(默认 300)")
|
|
280
|
+
pp.set_defaults(func=cmd_pip)
|
|
281
|
+
|
|
282
|
+
cp = sub.add_parser("close", help="关闭 ssh ControlMaster 复用连接")
|
|
283
|
+
cp.add_argument("host", nargs="?", help="机器 name/ip;省略时需 --all")
|
|
284
|
+
cp.add_argument("--all", action="store_true", help="关闭所有机器的复用连接")
|
|
285
|
+
cp.set_defaults(func=cmd_close)
|
|
286
|
+
|
|
287
|
+
# argparse 对 -- 的处理与子解析器/位置参数组合有 quirk,手动切分更可靠:
|
|
288
|
+
# 第一个 -- 之后的全部内容原样作为脚本参数
|
|
289
|
+
argv = sys.argv[1:]
|
|
290
|
+
passthrough: "list[str] | None" = None
|
|
291
|
+
if "--" in argv:
|
|
292
|
+
i = argv.index("--")
|
|
293
|
+
argv, passthrough = argv[:i], argv[i + 1:]
|
|
294
|
+
ns = ap.parse_args(argv)
|
|
295
|
+
if passthrough is not None:
|
|
296
|
+
ns.args = list(getattr(ns, "args", []) or []) + passthrough
|
|
297
|
+
sys.exit(ns.func(ns))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
main()
|
rrun/executor.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""远程执行核心:本地脚本 → ssh stdin 管道 → 远端解释器执行。
|
|
3
|
+
|
|
4
|
+
设计要点(Phase 0 spike 实测验证):
|
|
5
|
+
- 全程 stdin 管道,脚本内容/中文不走命令行,规避多层转码转义问题。
|
|
6
|
+
- Windows PowerShell:-Command - 按行执行 stdin,故载荷编码为
|
|
7
|
+
**单行纯 ASCII wrapper**(内联 base64,UTF-8 解码后 ScriptBlock 执行),
|
|
8
|
+
支持 workdir / env / args($args) / 退出码透传 / terminating error -> 1。
|
|
9
|
+
- python:`python -X utf8 -`;bash:`bash -s --`。
|
|
10
|
+
- ControlMaster 连接复用:首连 ~0.5s,复用 ~0.02s。
|
|
11
|
+
- 退出码约定:远端脚本退出码原样透传;255 = ssh 传输层错误;124 = 本地超时。
|
|
12
|
+
- 本地状态目录:~/.rrun/(RRUN_HOME 环境变量可改根目录)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shlex
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .registry import Machine, resolve_machine
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rrun_home() -> Path:
|
|
31
|
+
"""本地状态根目录:$RRUN_HOME 或 ~/.rrun(放审计日志/内联落盘/用户配置)。"""
|
|
32
|
+
override = os.environ.get("RRUN_HOME", "").strip()
|
|
33
|
+
return Path(override).expanduser() if override else Path.home() / ".rrun"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
RRUN_HOME = _rrun_home()
|
|
37
|
+
AUDIT_LOG = RRUN_HOME / "log" / "remote-exec.jsonl"
|
|
38
|
+
|
|
39
|
+
LANGS = ("bash", "powershell", "python")
|
|
40
|
+
EXT_TO_LANG = {".py": "python", ".ps1": "powershell", ".sh": "bash"}
|
|
41
|
+
LANG_TO_EXT = {"python": ".py", "powershell": ".ps1", "bash": ".sh"}
|
|
42
|
+
|
|
43
|
+
SSH_BASE_OPTS = [
|
|
44
|
+
"-o", "StrictHostKeyChecking=no",
|
|
45
|
+
"-o", "LogLevel=ERROR",
|
|
46
|
+
"-o", "ConnectTimeout=10",
|
|
47
|
+
]
|
|
48
|
+
CONTROL_PATH_DIR = Path.home() / ".ssh"
|
|
49
|
+
CONTROL_PATH = str(CONTROL_PATH_DIR / "rrun-%C")
|
|
50
|
+
MUX_OPTS = [
|
|
51
|
+
"-o", "ControlMaster=auto",
|
|
52
|
+
"-o", f"ControlPath={CONTROL_PATH}",
|
|
53
|
+
"-o", "ControlPersist=600",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
EXIT_TRANSPORT_ERROR = 255 # ssh 自身失败(连不上/认证失败/掉线)
|
|
57
|
+
EXIT_TIMEOUT = 124 # 本地超时杀掉 ssh 进程
|
|
58
|
+
|
|
59
|
+
# 统一环境布局(setup 子命令初始化;基线版本锁 3.12):
|
|
60
|
+
# Windows: C:\tools\remote-machine\venv\Scripts\python.exe —— 统一 venv,常态命中
|
|
61
|
+
# C:\tools\remote-machine\python312\python.exe —— standalone 基座(venv 损坏时降级)
|
|
62
|
+
# Mac: ~/.remote-machine/venv/bin/python —— 统一 venv
|
|
63
|
+
# ~/.remote-machine/python312/bin/python3 —— standalone 基座(仅裸机安装)
|
|
64
|
+
# 候选按序探测,命中后校验版本 == 3.12.x(不符继续找);全灭 -> 报错提示跑 setup。
|
|
65
|
+
REQUIRED_PY_MAJOR_MINOR = (3, 12)
|
|
66
|
+
|
|
67
|
+
VENV_PY_WIN = r"C:\tools\remote-machine\venv\Scripts\python.exe"
|
|
68
|
+
BASE_PY_WIN = r"C:\tools\remote-machine\python312\python.exe"
|
|
69
|
+
VENV_PY_POSIX = "$HOME/.remote-machine/venv/bin/python"
|
|
70
|
+
BASE_PY_POSIX = "$HOME/.remote-machine/python312/bin/python3"
|
|
71
|
+
|
|
72
|
+
WINDOWS_PYTHON_CANDIDATES = [
|
|
73
|
+
VENV_PY_WIN,
|
|
74
|
+
BASE_PY_WIN,
|
|
75
|
+
r"C:\Python\Python312\python.exe", # 存量装机(降级,无第三方库)
|
|
76
|
+
]
|
|
77
|
+
# setup 创建 venv 时的基座候选(不含 venv 自身):机器已装的优先,standalone 兜底
|
|
78
|
+
WINDOWS_BASE_CANDIDATES = [r"C:\Python\Python312\python.exe", BASE_PY_WIN]
|
|
79
|
+
POSIX_PYTHON_CANDIDATES = [
|
|
80
|
+
VENV_PY_POSIX,
|
|
81
|
+
"python3.12", "/usr/local/bin/python3.12",
|
|
82
|
+
]
|
|
83
|
+
POSIX_BASE_CANDIDATES = ["python3.12", "/usr/local/bin/python3.12", BASE_PY_POSIX]
|
|
84
|
+
|
|
85
|
+
PS_REMOTE_CMD = "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command -"
|
|
86
|
+
|
|
87
|
+
_python_cache: "dict[tuple[str, str], tuple[str, str]]" = {} # (ip, override) -> (路径, 版本)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class ExecResult:
|
|
92
|
+
host: str
|
|
93
|
+
ip: str
|
|
94
|
+
lang: str
|
|
95
|
+
exit_code: int
|
|
96
|
+
duration: float
|
|
97
|
+
stdout: bytes = b""
|
|
98
|
+
stderr: bytes = b""
|
|
99
|
+
script_path: str = "" # 本地脚本路径(content 模式为落盘后的路径)
|
|
100
|
+
content_sha1: str = ""
|
|
101
|
+
remote_python: str = "" # 实际使用的远端 python(lang=python 时)
|
|
102
|
+
remote_python_version: str = "" # 远端 python 版本(如 3.12.14)
|
|
103
|
+
transport_error: bool = False # ssh 层失败(exit 255)
|
|
104
|
+
timed_out: bool = False
|
|
105
|
+
|
|
106
|
+
def stdout_text(self, errors: str = "replace") -> str:
|
|
107
|
+
return self.stdout.decode("utf-8", errors)
|
|
108
|
+
|
|
109
|
+
def stderr_text(self, errors: str = "replace") -> str:
|
|
110
|
+
return self.stderr.decode("utf-8", errors)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_ascii(values, what: str) -> None:
|
|
114
|
+
for v in values:
|
|
115
|
+
if not all(ord(c) < 128 for c in v):
|
|
116
|
+
raise ValueError(f"{what} 含非 ASCII:{v!r};中文/特殊字符请写进脚本内容或 JSON 文件,不要走命令行")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _ps_quote(s: str) -> str:
|
|
120
|
+
"""PowerShell 单引号字符串转义(入参已保证 ASCII)。"""
|
|
121
|
+
return "'" + s.replace("'", "''") + "'"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_ps_wrapper(code: str, args=(), workdir: str = "", env: "dict | None" = None) -> bytes:
|
|
125
|
+
"""把 PowerShell 脚本编码为单行纯 ASCII wrapper(-Command - 按行执行,禁止多行)。"""
|
|
126
|
+
_check_ascii(args, "参数")
|
|
127
|
+
parts = [
|
|
128
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
129
|
+
"$OutputEncoding=[Text.Encoding]::UTF8",
|
|
130
|
+
"$ProgressPreference='SilentlyContinue'",
|
|
131
|
+
]
|
|
132
|
+
if workdir:
|
|
133
|
+
parts.append(f"Set-Location {_ps_quote(workdir)}")
|
|
134
|
+
for k, v in (env or {}).items():
|
|
135
|
+
parts.append(f"$env:{k}={_ps_quote(v)}")
|
|
136
|
+
b64 = base64.b64encode(code.encode("utf-8")).decode("ascii")
|
|
137
|
+
parts.append(f"$__code=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{b64}'))")
|
|
138
|
+
parts.append("$__args=@(" + ",".join(_ps_quote(a) for a in args) + ")")
|
|
139
|
+
parts.append(
|
|
140
|
+
"try{& ([ScriptBlock]::Create($__code)) @__args}"
|
|
141
|
+
"catch{[Console]::Error.WriteLine($_.Exception.ToString());exit 1}"
|
|
142
|
+
)
|
|
143
|
+
parts.append("if($null -ne $LASTEXITCODE){exit $LASTEXITCODE}else{exit 0}")
|
|
144
|
+
line = ";".join(parts)
|
|
145
|
+
assert all(ord(c) < 128 for c in line), "powershell wrapper 必须纯 ASCII"
|
|
146
|
+
return (line + "\n").encode("ascii")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _posix_prefix(workdir: str = "", env: "dict | None" = None) -> str:
|
|
150
|
+
"""posix 远端命令前缀:cd + env(拼在实际命令前)。"""
|
|
151
|
+
prefix = ""
|
|
152
|
+
if workdir:
|
|
153
|
+
prefix += f"cd {shlex.quote(workdir)} && "
|
|
154
|
+
if env:
|
|
155
|
+
prefix += "env " + " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items()) + " "
|
|
156
|
+
return prefix
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_bash_command(args=(), workdir: str = "", env: "dict | None" = None) -> str:
|
|
160
|
+
_check_ascii(args, "参数")
|
|
161
|
+
cmd = _posix_prefix(workdir, env) + "bash -s"
|
|
162
|
+
if args:
|
|
163
|
+
cmd += " -- " + " ".join(shlex.quote(a) for a in args)
|
|
164
|
+
return cmd
|
|
165
|
+
|
|
166
|
+
|
|
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(sudo apt install sshpass)")
|
|
173
|
+
if mux:
|
|
174
|
+
CONTROL_PATH_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
175
|
+
args = [sshpass, "-p", machine.password, "ssh", *SSH_BASE_OPTS]
|
|
176
|
+
if mux:
|
|
177
|
+
args += MUX_OPTS
|
|
178
|
+
args += [machine.target, remote_cmd]
|
|
179
|
+
try:
|
|
180
|
+
p = subprocess.run(args, input=stdin, capture_output=True,
|
|
181
|
+
timeout=timeout if timeout and timeout > 0 else None)
|
|
182
|
+
return p.returncode, p.stdout, p.stderr, False
|
|
183
|
+
except subprocess.TimeoutExpired as e:
|
|
184
|
+
return EXIT_TIMEOUT, e.stdout or b"", (e.stderr or b"") + f"\n[remote-exec] 本地超时 {timeout}s\n".encode(), True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# 注意:代码内不得出现任何引号(bash 探测用单引号包裹此代码;chr(46)='.')
|
|
188
|
+
_PY_VER_PRINT_CODE = "import sys;print(*sys.version_info[:3],sep=chr(46))"
|
|
189
|
+
_PY_VER_CHECK_CODE = (
|
|
190
|
+
_PY_VER_PRINT_CODE + f";sys.exit(0 if sys.version_info[:2]=={REQUIRED_PY_MAJOR_MINOR} else 1)"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _ps_probe_script(candidates) -> str:
|
|
195
|
+
"""Windows 探测脚本:逐候选校验版本==3.12.x,命中输出 path|x.y.z,全灭 exit 1。"""
|
|
196
|
+
arr = ",".join(_ps_quote(c) for c in candidates)
|
|
197
|
+
return (
|
|
198
|
+
f"foreach($c in @({arr})){{"
|
|
199
|
+
"if(Test-Path $c){"
|
|
200
|
+
f"$v=& $c -X utf8 -c \"{_PY_VER_CHECK_CODE}\" 2>$null;"
|
|
201
|
+
"if($LASTEXITCODE -eq 0){Write-Output ($c+'|'+($v -join ''));exit 0}"
|
|
202
|
+
"}}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _bash_probe_script(candidates) -> str:
|
|
207
|
+
"""posix 探测脚本:同 Windows 语义。候选为可信常量($HOME 需保留展开,不可 shlex.quote)。"""
|
|
208
|
+
items = " ".join(f'"{c}"' for c in candidates)
|
|
209
|
+
return (
|
|
210
|
+
f"for p in {items}; do\n"
|
|
211
|
+
' command -v "$p" >/dev/null 2>&1 || continue\n'
|
|
212
|
+
f' v=$("$p" -X utf8 -c \'{_PY_VER_CHECK_CODE}\' 2>/dev/null) || continue\n'
|
|
213
|
+
' echo "$p|$v"\n'
|
|
214
|
+
" exit 0\n"
|
|
215
|
+
"done\n"
|
|
216
|
+
"exit 1\n"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def probe_python(machine: Machine, candidates, mux: bool = True) -> "tuple[str, str] | None":
|
|
221
|
+
"""在候选中找版本==3.12.x 的远端 python,单次往返;返回 (路径, 版本),全灭返回 None。"""
|
|
222
|
+
if machine.is_windows:
|
|
223
|
+
rc, out, _, _ = _ssh_run(machine, PS_REMOTE_CMD,
|
|
224
|
+
build_ps_wrapper(_ps_probe_script(candidates)), 60, mux)
|
|
225
|
+
else:
|
|
226
|
+
rc, out, _, _ = _ssh_run(machine, "bash -s",
|
|
227
|
+
_bash_probe_script(candidates).encode("utf-8"), 60, mux)
|
|
228
|
+
if rc != 0 or not out.strip():
|
|
229
|
+
return None
|
|
230
|
+
line = out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
|
|
231
|
+
path, sep, ver = line.partition("|")
|
|
232
|
+
return (path.strip(), ver.strip()) if sep and path.strip() else None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def probe_python_version(machine: Machine, python_path: str, mux: bool = True) -> str:
|
|
236
|
+
"""尽力探测指定 python 的版本字符串(失败返回空串)。posix 路径可含 $HOME(shell 展开)。"""
|
|
237
|
+
code = _PY_VER_PRINT_CODE
|
|
238
|
+
if machine.is_windows:
|
|
239
|
+
script = f"$v=& {_ps_quote(python_path)} -X utf8 -c \"{code}\" 2>$null;if($LASTEXITCODE -eq 0){{Write-Output ($v -join '')}}"
|
|
240
|
+
rc, out, _, _ = _ssh_run(machine, PS_REMOTE_CMD, build_ps_wrapper(script), 30, mux)
|
|
241
|
+
else:
|
|
242
|
+
rc, out, _, _ = _ssh_run(machine, "bash -s",
|
|
243
|
+
f'"{python_path}" -X utf8 -c \'{code}\' 2>/dev/null\n'.encode(), 30, mux)
|
|
244
|
+
if rc != 0 or not out.strip():
|
|
245
|
+
return ""
|
|
246
|
+
return out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def detect_remote_python(machine: Machine, override: str = "", mux: bool = True) -> "tuple[str, str]":
|
|
250
|
+
"""解析远端 python,返回 (路径, 版本)。基线锁 3.12;machines.json "python" 字段/参数可覆盖(跳过校验)。"""
|
|
251
|
+
key = (machine.ip, override or machine.python)
|
|
252
|
+
if key in _python_cache:
|
|
253
|
+
return _python_cache[key]
|
|
254
|
+
if override or machine.python:
|
|
255
|
+
path = override or machine.python
|
|
256
|
+
result = (path, probe_python_version(machine, path, mux))
|
|
257
|
+
else:
|
|
258
|
+
candidates = WINDOWS_PYTHON_CANDIDATES if machine.is_windows else POSIX_PYTHON_CANDIDATES
|
|
259
|
+
found = probe_python(machine, candidates, mux)
|
|
260
|
+
if not found:
|
|
261
|
+
raise RuntimeError(
|
|
262
|
+
f"[{machine.name}] 未找到 python {REQUIRED_PY_MAJOR_MINOR[0]}.{REQUIRED_PY_MAJOR_MINOR[1]}"
|
|
263
|
+
f"(基线版本已锁定)。请先初始化统一环境:rrun setup {machine.name}"
|
|
264
|
+
)
|
|
265
|
+
result = found
|
|
266
|
+
_python_cache[key] = result
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run(host: str, lang: str = "", file: str = "", content: str = "", args=(),
|
|
271
|
+
workdir: str = "", env: "dict | None" = None, timeout: "float | None" = None,
|
|
272
|
+
remote_python: str = "", utf8: bool = True, mux: bool = True,
|
|
273
|
+
audit: bool = True) -> ExecResult:
|
|
274
|
+
"""在远端机器执行本地脚本(或内联内容),返回 ExecResult。
|
|
275
|
+
|
|
276
|
+
host: 机器 name/ip/hostname;lang: bash|powershell|python(缺省按文件扩展名或机器 OS 推断)
|
|
277
|
+
file/content 二选一;args 仅允许 ASCII。
|
|
278
|
+
"""
|
|
279
|
+
machine = resolve_machine(host)
|
|
280
|
+
if file and content:
|
|
281
|
+
raise ValueError("file 与 content 只能二选一")
|
|
282
|
+
if not file and not content:
|
|
283
|
+
raise ValueError("必须提供 file 或 content")
|
|
284
|
+
|
|
285
|
+
if file:
|
|
286
|
+
script_path = str(Path(file).resolve())
|
|
287
|
+
raw = Path(file).read_bytes()
|
|
288
|
+
suffix = Path(file).suffix.lower()
|
|
289
|
+
else:
|
|
290
|
+
script_path = ""
|
|
291
|
+
raw = content.encode("utf-8")
|
|
292
|
+
suffix = ""
|
|
293
|
+
if not lang:
|
|
294
|
+
lang = EXT_TO_LANG.get(suffix, machine.default_lang)
|
|
295
|
+
if lang not in LANGS:
|
|
296
|
+
raise ValueError(f"不支持的语言: {lang}(可选 {LANGS})")
|
|
297
|
+
_check_ascii(args, "参数")
|
|
298
|
+
_check_ascii([x for kv in (env or {}).items() for x in kv], "env")
|
|
299
|
+
|
|
300
|
+
t0 = time.time()
|
|
301
|
+
remote_python_used = ""
|
|
302
|
+
remote_py_version = ""
|
|
303
|
+
if lang == "powershell":
|
|
304
|
+
remote_cmd = PS_REMOTE_CMD
|
|
305
|
+
stdin = build_ps_wrapper(raw.decode("utf-8"), args, workdir, env)
|
|
306
|
+
elif lang == "bash":
|
|
307
|
+
remote_cmd = build_bash_command(args, workdir, env)
|
|
308
|
+
stdin = raw
|
|
309
|
+
else: # python
|
|
310
|
+
remote_python_used, remote_py_version = detect_remote_python(machine, remote_python, mux)
|
|
311
|
+
py_cmd = remote_python_used + (" -X utf8" if utf8 else "") + " -"
|
|
312
|
+
if args:
|
|
313
|
+
py_cmd += " " + " ".join(shlex.quote(a) for a in args)
|
|
314
|
+
if env or workdir:
|
|
315
|
+
if machine.is_windows:
|
|
316
|
+
raise ValueError("Windows + python 暂不支持 workdir/env,请在脚本内 os.chdir/os.environ 处理")
|
|
317
|
+
remote_cmd = _posix_prefix(workdir, env) + py_cmd
|
|
318
|
+
else:
|
|
319
|
+
remote_cmd = py_cmd
|
|
320
|
+
stdin = raw
|
|
321
|
+
|
|
322
|
+
exit_code, stdout, stderr, timed_out = _ssh_run(machine, remote_cmd, stdin, timeout, mux)
|
|
323
|
+
result = ExecResult(
|
|
324
|
+
host=machine.name, ip=machine.ip, lang=lang, exit_code=exit_code,
|
|
325
|
+
duration=time.time() - t0, stdout=stdout, stderr=stderr,
|
|
326
|
+
script_path=script_path, content_sha1=hashlib.sha1(raw).hexdigest()[:12],
|
|
327
|
+
remote_python=remote_python_used, remote_python_version=remote_py_version,
|
|
328
|
+
transport_error=(exit_code == EXIT_TRANSPORT_ERROR), timed_out=timed_out,
|
|
329
|
+
)
|
|
330
|
+
if audit:
|
|
331
|
+
_write_audit(result, args, workdir, env, timeout)
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _write_audit(result: ExecResult, args, workdir: str, env: "dict | None",
|
|
336
|
+
timeout: "float | None") -> None:
|
|
337
|
+
try:
|
|
338
|
+
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
record = {
|
|
340
|
+
"ts": datetime.now().isoformat(timespec="seconds"),
|
|
341
|
+
"host": result.host, "ip": result.ip, "lang": result.lang,
|
|
342
|
+
"script": result.script_path, "sha1": result.content_sha1,
|
|
343
|
+
"args": list(args), "workdir": workdir,
|
|
344
|
+
"env_keys": sorted((env or {}).keys()), # 只记 key,防泄密
|
|
345
|
+
"timeout": timeout, "exit_code": result.exit_code,
|
|
346
|
+
"python": result.remote_python, "python_version": result.remote_python_version,
|
|
347
|
+
"duration_s": round(result.duration, 2),
|
|
348
|
+
"transport_error": result.transport_error, "timed_out": result.timed_out,
|
|
349
|
+
}
|
|
350
|
+
with open(AUDIT_LOG, "a", encoding="utf-8") as f:
|
|
351
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
352
|
+
except OSError:
|
|
353
|
+
pass # 审计失败不阻断执行
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def close_mux(host: str) -> "tuple[int, str]":
|
|
357
|
+
"""关闭某台机器的 ControlMaster 复用连接。"""
|
|
358
|
+
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)
|
|
362
|
+
return p.returncode, (p.stdout + p.stderr).strip()
|
rrun/registry.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""机器注册表:多来源加载 machines.json,按优先级 merge,解析 name/ip/hostname → Machine。
|
|
3
|
+
|
|
4
|
+
来源链(高 → 低优先级;同名机器高优先级覆盖;同名/ip 冲突静默处理,不刷警告):
|
|
5
|
+
1. $RRUN_CONFIG(os.pathsep 分隔,可多个文件)
|
|
6
|
+
2. $REMOTE_MACHINE_CONFIG(旧名,兼容)
|
|
7
|
+
3. ./machines.json(当前工作目录)
|
|
8
|
+
4. ~/.rrun/machines.json(用户级主清单)
|
|
9
|
+
5. ~/.rrun/machines.d/*.json(按文件名排序;推荐:多清单各放一个文件/软链)
|
|
10
|
+
6. 旧位置兼容:Windows C:\\tools\\remote-machine\\machines.json
|
|
11
|
+
POSIX ~/.remote-machine/machines.json
|
|
12
|
+
|
|
13
|
+
规则:
|
|
14
|
+
- 所有来源可选;不存在 / 读失败的来源静默跳过(诊断见 scan_sources / CLI config 子命令)。
|
|
15
|
+
- 每个文件先按 os 应用自己的 defaults 段,再按 name merge(先出现者胜)。
|
|
16
|
+
- 显式传 path 给 load_machines 则退化为单文件模式(测试 / 钉死用)。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
ENV_MACHINES_JSON = "RRUN_CONFIG"
|
|
25
|
+
ENV_MACHINES_JSON_LEGACY = "REMOTE_MACHINE_CONFIG"
|
|
26
|
+
|
|
27
|
+
_KNOWN_KEYS = {"name", "ip", "user", "password", "os", "hostname", "description", "python"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Machine:
|
|
32
|
+
name: str
|
|
33
|
+
ip: str
|
|
34
|
+
user: str
|
|
35
|
+
password: str = field(repr=False) # 凭据不进 repr/日志
|
|
36
|
+
os: str = "Windows" # 原样取值:Windows / Mac / Linux
|
|
37
|
+
hostname: str = ""
|
|
38
|
+
description: str = ""
|
|
39
|
+
python: str = "" # 可选:显式指定远端 python 路径,跳过自动探测
|
|
40
|
+
source: str = "" # 来源文件路径(多来源 merge 时记录出处)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_windows(self) -> bool:
|
|
44
|
+
return self.os.lower() == "windows"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def default_lang(self) -> str:
|
|
48
|
+
return "powershell" if self.is_windows else "bash"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def target(self) -> str:
|
|
52
|
+
return f"{self.user}@{self.ip}"
|
|
53
|
+
|
|
54
|
+
def public_dict(self) -> dict:
|
|
55
|
+
"""脱敏视图(不含密码),用于 CLI 展示 / prompt 注入。"""
|
|
56
|
+
return {
|
|
57
|
+
"name": self.name, "ip": self.ip, "os": self.os, "user": self.user,
|
|
58
|
+
"hostname": self.hostname, "description": self.description,
|
|
59
|
+
"default_lang": self.default_lang, "source": self.source,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SourceInfo:
|
|
65
|
+
"""单个来源的扫描诊断(CLI config 子命令用)。"""
|
|
66
|
+
label: str
|
|
67
|
+
path: Path
|
|
68
|
+
exists: bool = False
|
|
69
|
+
machine_count: int = 0
|
|
70
|
+
error: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def candidate_sources() -> "list[tuple[str, Path]]":
|
|
74
|
+
"""返回 (标签, 路径) 有序来源链(高→低优先级),按 resolved path 去重。"""
|
|
75
|
+
cands: "list[tuple[str, Path]]" = []
|
|
76
|
+
for env_name in (ENV_MACHINES_JSON, ENV_MACHINES_JSON_LEGACY):
|
|
77
|
+
env = os.environ.get(env_name, "")
|
|
78
|
+
env_paths = [x for x in env.split(os.pathsep) if x.strip()]
|
|
79
|
+
for i, p in enumerate(env_paths):
|
|
80
|
+
label = f"${env_name}" if i == 0 else f"${env_name}[{i}]"
|
|
81
|
+
cands.append((label, Path(p).expanduser()))
|
|
82
|
+
cands.append(("cwd", Path.cwd() / "machines.json"))
|
|
83
|
+
rrun_home = Path.home() / ".rrun"
|
|
84
|
+
cands.append(("user", rrun_home / "machines.json"))
|
|
85
|
+
machines_d = rrun_home / "machines.d"
|
|
86
|
+
if machines_d.is_dir():
|
|
87
|
+
for p in sorted(machines_d.glob("*.json")):
|
|
88
|
+
cands.append(("user.d", p))
|
|
89
|
+
if os.name == "nt":
|
|
90
|
+
cands.append(("legacy", Path(r"C:\tools\remote-machine\machines.json")))
|
|
91
|
+
else:
|
|
92
|
+
cands.append(("legacy", Path.home() / ".remote-machine" / "machines.json"))
|
|
93
|
+
seen: "set[str]" = set()
|
|
94
|
+
uniq: "list[tuple[str, Path]]" = []
|
|
95
|
+
for label, p in cands:
|
|
96
|
+
try:
|
|
97
|
+
key = os.path.normcase(str(p.resolve()))
|
|
98
|
+
except OSError:
|
|
99
|
+
key = os.path.normcase(str(p))
|
|
100
|
+
if key not in seen:
|
|
101
|
+
seen.add(key)
|
|
102
|
+
uniq.append((label, p))
|
|
103
|
+
return uniq
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_file(path: Path) -> "list[Machine]":
|
|
107
|
+
"""加载单个文件:先按 os 应用该文件自己的 defaults 段,再打 source 标签。"""
|
|
108
|
+
with open(path, encoding="utf-8") as f:
|
|
109
|
+
data = json.load(f)
|
|
110
|
+
defaults = data.get("defaults") or {}
|
|
111
|
+
result = []
|
|
112
|
+
for m in data.get("machines", []):
|
|
113
|
+
base = defaults.get(str(m.get("os", "")).lower()) or {}
|
|
114
|
+
merged = {**base, **m} # 机器自身字段优先于 defaults
|
|
115
|
+
kwargs = {k: v for k, v in merged.items() if k in _KNOWN_KEYS}
|
|
116
|
+
machine = Machine(**kwargs)
|
|
117
|
+
machine.source = str(path)
|
|
118
|
+
result.append(machine)
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_machines(path: "str | os.PathLike | None" = None) -> "list[Machine]":
|
|
123
|
+
"""加载机器清单。显式传 path = 单文件模式;否则按来源链 merge(同名高优先级胜)。"""
|
|
124
|
+
if path:
|
|
125
|
+
return _load_file(Path(path))
|
|
126
|
+
merged: "dict[str, Machine]" = {}
|
|
127
|
+
for _label, p in candidate_sources():
|
|
128
|
+
if not p.is_file():
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
machines = _load_file(p)
|
|
132
|
+
except Exception: # noqa: BLE001 - 坏来源静默跳过,诊断见 scan_sources()
|
|
133
|
+
continue
|
|
134
|
+
for m in machines:
|
|
135
|
+
if m.name and m.name not in merged:
|
|
136
|
+
merged[m.name] = m
|
|
137
|
+
return list(merged.values())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def scan_sources() -> "list[SourceInfo]":
|
|
141
|
+
"""扫描来源链,返回每个来源的诊断信息(存在性/机器数/错误)。"""
|
|
142
|
+
infos = []
|
|
143
|
+
for label, p in candidate_sources():
|
|
144
|
+
info = SourceInfo(label=label, path=p)
|
|
145
|
+
if p.is_file():
|
|
146
|
+
info.exists = True
|
|
147
|
+
try:
|
|
148
|
+
info.machine_count = len(_load_file(p))
|
|
149
|
+
except Exception as e: # noqa: BLE001 - 诊断场景需要原始错误
|
|
150
|
+
info.error = f"{type(e).__name__}: {e}"
|
|
151
|
+
infos.append(info)
|
|
152
|
+
return infos
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_machine(host: str, path: "str | os.PathLike | None" = None) -> Machine:
|
|
156
|
+
"""按 name / ip / hostname 解析机器;未命中时报错并列出可用机器。"""
|
|
157
|
+
machines = load_machines(path)
|
|
158
|
+
for m in machines:
|
|
159
|
+
if host and host in (m.name, m.ip, m.hostname):
|
|
160
|
+
return m
|
|
161
|
+
available = ", ".join(f"{m.name}({m.ip})" for m in machines) or "<空>"
|
|
162
|
+
sources = ", ".join(str(p) for _lbl, p in candidate_sources() if p.is_file()) or "<无可用来源>"
|
|
163
|
+
raise KeyError(f"机器 [{host}] 未找到。已扫描来源: {sources}。可用: {available}")
|
rrun/setup.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""setup:初始化远端统一 python 环境(基线 3.12 + 统一 venv + 阿里云 pip 源)。
|
|
3
|
+
|
|
4
|
+
布局(幂等,可重复执行):
|
|
5
|
+
Windows: C:\\tools\\remote-machine\\venv\\Scripts\\python.exe —— 统一 venv
|
|
6
|
+
C:\\tools\\remote-machine\\python312\\python.exe —— standalone 基座(仅裸机安装)
|
|
7
|
+
Mac: ~/.remote-machine/venv/bin/python
|
|
8
|
+
~/.remote-machine/python312/bin/python3
|
|
9
|
+
|
|
10
|
+
原则:
|
|
11
|
+
- 机器已有 3.12 则直接用作 venv 基座;没有才装 python-build-standalone
|
|
12
|
+
(免安装压缩包:零注册表、零 PATH、不改 python/py 指向,纯新增目录)。
|
|
13
|
+
- standalone 包先下载到本机缓存 ~/.cache/rrun/,再经 ssh stdin
|
|
14
|
+
推流到远端解压,远端无需访问 GitHub。
|
|
15
|
+
- pip 源(阿里云镜像)写在 venv 内 pip.ini/pip.conf,不污染机器全局配置。
|
|
16
|
+
- 标准依赖清单:默认用包内置 remote-requirements.txt;
|
|
17
|
+
~/.rrun/remote-requirements.txt 存在时覆盖(加依赖改文件后重跑 setup)。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .executor import (
|
|
30
|
+
AUDIT_LOG,
|
|
31
|
+
BASE_PY_POSIX,
|
|
32
|
+
BASE_PY_WIN,
|
|
33
|
+
POSIX_BASE_CANDIDATES,
|
|
34
|
+
PS_REMOTE_CMD,
|
|
35
|
+
RRUN_HOME,
|
|
36
|
+
VENV_PY_POSIX,
|
|
37
|
+
VENV_PY_WIN,
|
|
38
|
+
WINDOWS_BASE_CANDIDATES,
|
|
39
|
+
_check_ascii,
|
|
40
|
+
_ssh_run,
|
|
41
|
+
build_ps_wrapper,
|
|
42
|
+
probe_python,
|
|
43
|
+
probe_python_version,
|
|
44
|
+
)
|
|
45
|
+
from .registry import Machine, resolve_machine
|
|
46
|
+
|
|
47
|
+
# 标准依赖:用户级覆盖 > 包内置默认
|
|
48
|
+
REQUIREMENTS_OVERRIDE = RRUN_HOME / "remote-requirements.txt"
|
|
49
|
+
REQUIREMENTS_BUILTIN = Path(__file__).resolve().with_name("remote-requirements.txt")
|
|
50
|
+
|
|
51
|
+
# python-build-standalone(astral-sh)固定版本,免安装压缩包
|
|
52
|
+
PBS_TAG = "20260901"
|
|
53
|
+
PBS_VER = "3.12.14"
|
|
54
|
+
PBS_ASSETS = {
|
|
55
|
+
("Windows", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-pc-windows-msvc-install_only.tar.gz",
|
|
56
|
+
("Mac", "arm64"): f"cpython-{PBS_VER}+{PBS_TAG}-aarch64-apple-darwin-install_only.tar.gz",
|
|
57
|
+
("Mac", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-apple-darwin-install_only.tar.gz",
|
|
58
|
+
}
|
|
59
|
+
PBS_DOWNLOAD_BASE = f"https://github.com/astral-sh/python-build-standalone/releases/download/{PBS_TAG}/"
|
|
60
|
+
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "") or Path.home() / ".cache") / "rrun"
|
|
61
|
+
|
|
62
|
+
PIP_INDEX_URL = "https://mirrors.aliyun.com/pypi/simple/"
|
|
63
|
+
|
|
64
|
+
TOOLS_DIR_WIN = r"C:\tools\remote-machine"
|
|
65
|
+
VENV_DIR_WIN = TOOLS_DIR_WIN + r"\venv"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SetupResult:
|
|
70
|
+
host: str
|
|
71
|
+
ok: bool
|
|
72
|
+
venv_python: str = ""
|
|
73
|
+
version: str = ""
|
|
74
|
+
base_python: str = ""
|
|
75
|
+
created_venv: bool = False
|
|
76
|
+
installed_standalone: bool = False
|
|
77
|
+
packages: "list[str]" = field(default_factory=list)
|
|
78
|
+
message: str = ""
|
|
79
|
+
duration: float = 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_requirements() -> "list[str]":
|
|
83
|
+
"""读取标准依赖清单(过滤注释/空行)。~/.rrun/remote-requirements.txt 优先于包内置默认。"""
|
|
84
|
+
path = REQUIREMENTS_OVERRIDE if REQUIREMENTS_OVERRIDE.is_file() else REQUIREMENTS_BUILTIN
|
|
85
|
+
reqs = []
|
|
86
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
87
|
+
line = line.strip()
|
|
88
|
+
if line and not line.startswith("#"):
|
|
89
|
+
reqs.append(line)
|
|
90
|
+
return reqs
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _req_import_name(req: str) -> str:
|
|
94
|
+
"""requests>=2.31 -> requests(用于装后 import 自检)。"""
|
|
95
|
+
return re.split(r"[<>=!~\[ ]", req, maxsplit=1)[0].strip()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ensure_local_tarball(os_name: str, arch: str) -> Path:
|
|
99
|
+
"""standalone 包下载到本机缓存(远端不访问 GitHub)。"""
|
|
100
|
+
name = PBS_ASSETS[(os_name, arch)]
|
|
101
|
+
path = CACHE_DIR / name
|
|
102
|
+
if path.exists() and path.stat().st_size > 1024 * 1024:
|
|
103
|
+
return path
|
|
104
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
url = PBS_DOWNLOAD_BASE + name
|
|
106
|
+
print(f"[setup] 本机缓存缺失,下载 {url} ...", file=sys.stderr)
|
|
107
|
+
tmp = path.with_suffix(".part")
|
|
108
|
+
urllib.request.urlretrieve(url, tmp) # noqa: S310 - 固定官方源
|
|
109
|
+
tmp.rename(path)
|
|
110
|
+
return path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
|
|
114
|
+
"""推送 standalone python 到远端并解压,返回基座 python 路径。"""
|
|
115
|
+
if machine.is_windows:
|
|
116
|
+
os_name, arch = "Windows", "x86_64"
|
|
117
|
+
# 注意 cmd 陷阱:if exist 不带括号会把后续 & 链全吞进分支体,
|
|
118
|
+
# 条件为假时整行跳过且 exit=0(假成功),故条件放最后用 && 门控
|
|
119
|
+
extract_cmd = (
|
|
120
|
+
f'mkdir "{TOOLS_DIR_WIN}" 2>nul '
|
|
121
|
+
f'& rmdir /s /q "{TOOLS_DIR_WIN}\\python312" 2>nul '
|
|
122
|
+
f'& tar -xzf - -C "{TOOLS_DIR_WIN}" '
|
|
123
|
+
f'&& ren "{TOOLS_DIR_WIN}\\python" python312'
|
|
124
|
+
)
|
|
125
|
+
base_py = BASE_PY_WIN
|
|
126
|
+
else:
|
|
127
|
+
rc, out, _, _ = _ssh_run(machine, "uname -m", b"", 30, mux)
|
|
128
|
+
arch = out.decode("utf-8", "replace").strip()
|
|
129
|
+
os_name = "Mac"
|
|
130
|
+
if (os_name, arch) not in PBS_ASSETS:
|
|
131
|
+
raise RuntimeError(f"[{machine.name}] 暂不支持的架构: {arch}")
|
|
132
|
+
extract_cmd = (
|
|
133
|
+
'tools="$HOME/.remote-machine"; mkdir -p "$tools" '
|
|
134
|
+
'&& rm -rf "$tools/python312" '
|
|
135
|
+
'&& tar -xzf - -C "$tools" '
|
|
136
|
+
'&& mv "$tools/python" "$tools/python312"'
|
|
137
|
+
)
|
|
138
|
+
base_py = BASE_PY_POSIX
|
|
139
|
+
tarball = _ensure_local_tarball(os_name, arch)
|
|
140
|
+
print(f"[setup] [{machine.name}] 推送 standalone python ({tarball.name}, "
|
|
141
|
+
f"{tarball.stat().st_size // 1024 // 1024}MB) ...", file=sys.stderr)
|
|
142
|
+
rc, _, err, timed_out = _ssh_run(machine, extract_cmd, tarball.read_bytes(), timeout, mux)
|
|
143
|
+
if rc != 0:
|
|
144
|
+
raise RuntimeError(f"[{machine.name}] standalone 推送/解压失败(exit={rc}): "
|
|
145
|
+
f"{err.decode('utf-8', 'replace')[:300]}")
|
|
146
|
+
ver = probe_python_version(machine, base_py, mux)
|
|
147
|
+
if not ver:
|
|
148
|
+
raise RuntimeError(f"[{machine.name}] standalone 解压后验证失败: {base_py}")
|
|
149
|
+
return base_py
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_ps_setup(base_py: str, pkgs: "list[str]", force: bool) -> str:
|
|
153
|
+
"""Windows 一键脚本:建 venv(缺/force 时)→ pip.ini → 装依赖 → 自检。全 ASCII。"""
|
|
154
|
+
pkg_args = " ".join(pkgs)
|
|
155
|
+
imports = ";".join(f"import {_req_import_name(p)}" for p in pkgs) or "pass"
|
|
156
|
+
force_lit = "$true" if force else "$false"
|
|
157
|
+
return f"""
|
|
158
|
+
$tools={_ps_quote_const(TOOLS_DIR_WIN)}
|
|
159
|
+
$venv="$tools\\venv"
|
|
160
|
+
$venvPy="$venv\\Scripts\\python.exe"
|
|
161
|
+
New-Item -ItemType Directory -Force $tools | Out-Null
|
|
162
|
+
if({force_lit} -and (Test-Path $venv)){{Remove-Item -Recurse -Force $venv}}
|
|
163
|
+
if(-not (Test-Path $venvPy)){{
|
|
164
|
+
if('{base_py}' -eq ''){{Write-Error "no base python";exit 1}}
|
|
165
|
+
& '{base_py}' -m venv $venv
|
|
166
|
+
if($LASTEXITCODE -ne 0){{Write-Error "venv create failed";exit 1}}
|
|
167
|
+
}}
|
|
168
|
+
[System.IO.File]::WriteAllText("$venv\\pip.ini","[global]`nindex-url = {PIP_INDEX_URL}`n")
|
|
169
|
+
& $venvPy -m pip install --quiet --disable-pip-version-check {pkg_args}
|
|
170
|
+
if($LASTEXITCODE -ne 0){{Write-Error "pip install failed";exit 1}}
|
|
171
|
+
& $venvPy -X utf8 -c "import sys;{imports};print('verify ok', '.'.join(map(str,sys.version_info[:3])))"
|
|
172
|
+
if($LASTEXITCODE -ne 0){{Write-Error "verify failed";exit 1}}
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ps_quote_const(s: str) -> str:
|
|
177
|
+
return "'" + s.replace("'", "''") + "'"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_bash_setup(base_py: str, pkgs: "list[str]", force: bool) -> str:
|
|
181
|
+
pkg_args = " ".join(pkgs)
|
|
182
|
+
imports = ";".join(f"import {_req_import_name(p)}" for p in pkgs) or "pass"
|
|
183
|
+
return f"""set -e
|
|
184
|
+
tools="$HOME/.remote-machine"
|
|
185
|
+
venv="$tools/venv"
|
|
186
|
+
mkdir -p "$tools"
|
|
187
|
+
if [ "{'1' if force else '0'}" = "1" ]; then rm -rf "$venv"; fi
|
|
188
|
+
if [ ! -x "$venv/bin/python" ]; then
|
|
189
|
+
"{base_py}" -m venv "$venv"
|
|
190
|
+
fi
|
|
191
|
+
cat > "$venv/pip.conf" <<'EOF'
|
|
192
|
+
[global]
|
|
193
|
+
index-url = {PIP_INDEX_URL}
|
|
194
|
+
EOF
|
|
195
|
+
"$venv/bin/python" -m pip install --quiet --disable-pip-version-check {pkg_args}
|
|
196
|
+
"$venv/bin/python" -X utf8 -c "import sys;{imports};print('verify ok', '.'.join(map(str,sys.version_info[:3])))"
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def setup_machine(host: str, force: bool = False, mux: bool = True,
|
|
201
|
+
timeout: float = 600.0) -> SetupResult:
|
|
202
|
+
"""初始化单台机器的统一 python 环境。幂等;force 重建 venv(不动 python 本体)。"""
|
|
203
|
+
t0 = time.time()
|
|
204
|
+
result = SetupResult(host=host, ok=False)
|
|
205
|
+
try:
|
|
206
|
+
machine = resolve_machine(host)
|
|
207
|
+
reqs = load_requirements()
|
|
208
|
+
_check_ascii(reqs, "requirements")
|
|
209
|
+
venv_py = VENV_PY_WIN if machine.is_windows else VENV_PY_POSIX
|
|
210
|
+
base_cands = WINDOWS_BASE_CANDIDATES if machine.is_windows else POSIX_BASE_CANDIDATES
|
|
211
|
+
|
|
212
|
+
hit = probe_python(machine, [venv_py], mux)
|
|
213
|
+
need_create = force or not hit
|
|
214
|
+
base_py = ""
|
|
215
|
+
if need_create:
|
|
216
|
+
base_hit = probe_python(machine, base_cands, mux)
|
|
217
|
+
if base_hit:
|
|
218
|
+
base_py = base_hit[0]
|
|
219
|
+
else:
|
|
220
|
+
base_py = _install_standalone(machine, mux, timeout)
|
|
221
|
+
result.installed_standalone = True
|
|
222
|
+
result.created_venv = True
|
|
223
|
+
result.base_python = base_py
|
|
224
|
+
|
|
225
|
+
if machine.is_windows:
|
|
226
|
+
payload = build_ps_wrapper(_build_ps_setup(base_py, reqs, force))
|
|
227
|
+
rc, out, err, _ = _ssh_run(machine, PS_REMOTE_CMD, payload, timeout, mux)
|
|
228
|
+
else:
|
|
229
|
+
payload = _build_bash_setup(base_py, reqs, force).encode("utf-8")
|
|
230
|
+
rc, out, err, _ = _ssh_run(machine, "bash -s", payload, timeout, mux)
|
|
231
|
+
if rc != 0:
|
|
232
|
+
tail = (err or out).decode("utf-8", "replace").strip()[-400:]
|
|
233
|
+
raise RuntimeError(f"setup 脚本失败(exit={rc}): {tail}")
|
|
234
|
+
|
|
235
|
+
final = probe_python(machine, [venv_py], mux)
|
|
236
|
+
if not final:
|
|
237
|
+
raise RuntimeError("setup 完成但 venv python 探测失败")
|
|
238
|
+
result.venv_python, result.version = final
|
|
239
|
+
result.packages = reqs
|
|
240
|
+
result.ok = True
|
|
241
|
+
except Exception as e: # noqa: BLE001 - 汇总为 result,--all 继续遍历
|
|
242
|
+
result.message = str(e)
|
|
243
|
+
result.duration = time.time() - t0
|
|
244
|
+
_write_setup_audit(result)
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_setup_audit(result: SetupResult) -> None:
|
|
249
|
+
try:
|
|
250
|
+
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
record = {
|
|
252
|
+
"ts": datetime.now().isoformat(timespec="seconds"),
|
|
253
|
+
"host": result.host, "lang": "setup",
|
|
254
|
+
"exit_code": 0 if result.ok else 1,
|
|
255
|
+
"venv_python": result.venv_python, "python_version": result.version,
|
|
256
|
+
"base_python": result.base_python,
|
|
257
|
+
"created_venv": result.created_venv,
|
|
258
|
+
"installed_standalone": result.installed_standalone,
|
|
259
|
+
"packages": result.packages, "message": result.message,
|
|
260
|
+
"duration_s": round(result.duration, 2),
|
|
261
|
+
}
|
|
262
|
+
with open(AUDIT_LOG, "a", encoding="utf-8") as f:
|
|
263
|
+
import json
|
|
264
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def venv_python_or_die(host: str, mux: bool = True) -> str:
|
|
270
|
+
"""返回统一 venv 的 python 路径;未初始化则报错指路 setup。"""
|
|
271
|
+
machine = resolve_machine(host)
|
|
272
|
+
venv_py = VENV_PY_WIN if machine.is_windows else VENV_PY_POSIX
|
|
273
|
+
hit = probe_python(machine, [venv_py], mux)
|
|
274
|
+
if not hit:
|
|
275
|
+
raise RuntimeError(f"[{machine.name}] 统一 venv 未初始化,请先:rrun setup {machine.name}")
|
|
276
|
+
return hit[0]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rrun-cli
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author: waqiju
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/waqiju/rrun
|
|
8
|
+
Project-URL: Repository, https://github.com/waqiju/rrun
|
|
9
|
+
Project-URL: Issues, https://github.com/waqiju/rrun/issues
|
|
10
|
+
Keywords: ssh,remote-exec,powershell,windows,ops,automation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Topic :: System :: Systems Administration
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# rrun
|
|
26
|
+
|
|
27
|
+
Run local scripts on remote machines over SSH — **via stdin pipes, never via command-line arguments** — so quoting, escaping, and CJK/UTF-8 encoding survive the `bash → ssh → cmd/powershell` journey intact.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
rrun exec my-win-box ./deploy.ps1 # powershell (inferred from .ps1)
|
|
31
|
+
rrun exec my-mac ./build.sh # bash
|
|
32
|
+
rrun exec my-win-box ./report.py # python (remote 3.12, -X utf8)
|
|
33
|
+
rrun exec my-win-box -c "Get-Date" # inline content (archived to ~/.rrun/drops/)
|
|
34
|
+
rrun exec my-mac ./etl.py -- arg1 arg2 # ASCII args pass-through
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Remote stdout → your stdout, remote stderr → your stderr, and the **exit code is passed through** unchanged (255 = ssh transport failure, 124 = local `--timeout`). Pipes, `&&`, and CI integration just work.
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Running commands on remote Windows machines from a POSIX shell is a minefield: sshd lands you in `cmd` with a GBK codepage, quotes and `$` get eaten by one of three shells along the way, and any non-ASCII argument gets mojibake'd. rrun's rules:
|
|
42
|
+
|
|
43
|
+
- Script content (UTF-8, Chinese welcome) is always piped through **stdin** — never the command line.
|
|
44
|
+
- For PowerShell, the payload is base64-wrapped into a **single-line pure-ASCII wrapper** (`powershell -Command -` executes stdin line-by-line, multi-line fails silently), decoded and invoked as a ScriptBlock remotely.
|
|
45
|
+
- Command-line arguments are restricted to ASCII and passed through safely (`sys.argv` / `$@` / `$args`). Put anything fancier in the script or a JSON file.
|
|
46
|
+
|
|
47
|
+
See [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md) (中文) for the full set of hard-won conventions.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pipx install rrun-cli # recommended: isolated global CLI (provides the `rrun` command)
|
|
53
|
+
# or: pip install rrun-cli
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> The PyPI distribution is named `rrun-cli` (plain `rrun` is on PyPI's prohibited-name list
|
|
57
|
+
> as it's confusable with `run`), but the installed command is `rrun` — plus the legacy alias
|
|
58
|
+
> `remote-machine`.
|
|
59
|
+
|
|
60
|
+
Both `rrun` and the legacy alias `remote-machine` are installed.
|
|
61
|
+
|
|
62
|
+
**Control machine requirements:** `ssh` + `sshpass` (`sudo apt install sshpass`), Python ≥ 3.10. Zero third-party Python dependencies.
|
|
63
|
+
**Remote machines:** OpenSSH server. Windows remotes execute via PowerShell; Mac/Linux via bash.
|
|
64
|
+
|
|
65
|
+
## Configuration: machines.json
|
|
66
|
+
|
|
67
|
+
Credentials live in a local `machines.json` (never committed — it's a local file, `chmod 600` recommended). See [machines.template.json](machines.template.json):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"defaults": { "windows": { "os": "Windows" } },
|
|
72
|
+
"machines": [
|
|
73
|
+
{ "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
|
|
74
|
+
"user": "admin", "password": "secret" }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
|
|
80
|
+
|
|
81
|
+
1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
|
|
82
|
+
2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
|
|
83
|
+
3. `./machines.json` (current working directory)
|
|
84
|
+
4. `~/.rrun/machines.json`
|
|
85
|
+
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
|
|
86
|
+
6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
|
|
87
|
+
|
|
88
|
+
Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
|
|
89
|
+
|
|
90
|
+
## Subcommands
|
|
91
|
+
|
|
92
|
+
| Command | Purpose |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
|
|
95
|
+
| `rrun machines [--json]` | List merged machines (redacted, with source) |
|
|
96
|
+
| `rrun config` | Diagnose the machines.json source chain |
|
|
97
|
+
| `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
|
|
98
|
+
| `rrun pip <host> -- list` | Run pip inside the remote unified venv |
|
|
99
|
+
| `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
|
|
100
|
+
|
|
101
|
+
Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
|
|
102
|
+
|
|
103
|
+
## Remote Python: unified 3.12 environment
|
|
104
|
+
|
|
105
|
+
For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, probed in order:
|
|
106
|
+
|
|
107
|
+
1. Unified venv: `C:\tools\remote-machine\venv\Scripts\python.exe` / `~/.remote-machine/venv/bin/python`
|
|
108
|
+
2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
|
|
109
|
+
3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
|
|
110
|
+
|
|
111
|
+
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 an Alibaba Cloud pip mirror in its own `pip.ini`/`pip.conf` (global config untouched) plus the standard packages from `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
112
|
+
|
|
113
|
+
## Auditing & state
|
|
114
|
+
|
|
115
|
+
- Every execution appends a JSON line to `~/.rrun/log/remote-exec.jsonl` (host, lang, script sha1, exit code, duration — never passwords; env vars recorded as keys only).
|
|
116
|
+
- Inline `-c` payloads are archived under `~/.rrun/drops/` for replay.
|
|
117
|
+
- Root directory overridable via `$RRUN_HOME`.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 中文简介
|
|
122
|
+
|
|
123
|
+
rrun 解决「从 POSIX shell 在远程机器(尤其 Windows)执行脚本」的转码/转义地狱:
|
|
124
|
+
脚本内容一律走 **stdin 管道**(UTF-8,中文随便用),命令行参数只放行 ASCII;
|
|
125
|
+
PowerShell 载荷编码为单行纯 ASCII wrapper,退出码原样透传。
|
|
126
|
+
|
|
127
|
+
- 安装:`pipx install rrun-cli`(控制端需 `ssh` + `sshpass`;装好后命令是 `rrun`)
|
|
128
|
+
- 机器清单 `machines.json` 来源链:`$RRUN_CONFIG` → `./machines.json` → `~/.rrun/machines.json` → `~/.rrun/machines.d/*.json` → 旧位置兼容
|
|
129
|
+
- 远程 python 基线锁 3.12,`rrun setup <host>` 一键幂等初始化统一 venv(standalone 基座经 ssh 推流安装,远端无需访问 GitHub,pip 走阿里云镜像)
|
|
130
|
+
- 更多踩坑约定见 [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md)
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
rrun/__init__.py,sha256=C4Ur90ge4OBnk3hX38tFDuuCM528mqjyVHSmka8bUJI,1259
|
|
2
|
+
rrun/__main__.py,sha256=UaSliJmYrpnp-R-CtnKRFdWqdu4IOrYJN4Q5qpWcowM,12967
|
|
3
|
+
rrun/executor.py,sha256=E9kpzHO12p7ctSO414hv0itlJn8FOLPi-zhV5AzDwY8,15639
|
|
4
|
+
rrun/registry.py,sha256=GJ0t9M6oIm6QxaIn2A2VDiSOi6Od7YeMyPuEpHt2DLc,6507
|
|
5
|
+
rrun/remote-requirements.txt,sha256=ycll-CCbCZf2DwFzd5Uxa3ew81syJ9U_X25oqSs4GUE,218
|
|
6
|
+
rrun/setup.py,sha256=C9clIXFx2geBzLKm2VdyM7c1A-XUArYQue-U1VYcHuc,11318
|
|
7
|
+
rrun_cli-0.1.0.dist-info/licenses/LICENSE,sha256=u41jhONFUIF6jMQ2_7smPk0EO7D5lpA1SCvO3cNIhnU,1063
|
|
8
|
+
rrun_cli-0.1.0.dist-info/METADATA,sha256=-M9zMRzC0Y6iOmoYHeblowJjFciqt0MgNPY5VFnH6Co,7261
|
|
9
|
+
rrun_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
rrun_cli-0.1.0.dist-info/entry_points.txt,sha256=QA2kQPFQxiDG3QcazF2mCDosr7xE9p6bVFp3TfKkH8Q,80
|
|
11
|
+
rrun_cli-0.1.0.dist-info/top_level.txt,sha256=q7vXzinYW5Xp1_paTQImU_kQqzowXRYP7YsT2q1SsOQ,5
|
|
12
|
+
rrun_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 waqiju
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rrun
|