dsh-scnet 0.4.2 → 0.5.0
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.
- package/README.md +20 -1
- package/README_CN.md +20 -1
- package/docs/installation.md +10 -0
- package/docs/installation.zh-CN.md +13 -1
- package/docs/testing.md +11 -0
- package/docs/testing.zh-CN.md +10 -0
- package/index.mjs +184 -0
- package/package.json +4 -2
- package/skills/scnet-hpc/SKILL.md +63 -105
- package/skills/scnet-hpc/VERSION +1 -0
- package/skills/scnet-hpc/clusters/_template.conf +10 -4
- package/skills/scnet-hpc/references/adding-cluster.md +6 -1
- package/skills/scnet-hpc/references/backends.md +177 -0
- package/skills/scnet-hpc/references/notebook.md +60 -0
- package/skills/scnet-hpc/references/openapi.md +140 -0
- package/skills/scnet-hpc/references/quickstart-en.md +22 -0
- package/skills/scnet-hpc/references/setup.md +4 -0
- package/skills/scnet-hpc/scripts/__pycache__/compute-probe.cpython-312.pyc +0 -0
- package/skills/scnet-hpc/scripts/__pycache__/scnet.cpython-312.pyc +0 -0
- package/skills/scnet-hpc/scripts/__pycache__/scnet_config.cpython-312.pyc +0 -0
- package/skills/scnet-hpc/scripts/__pycache__/scnet_credentials.cpython-312.pyc +0 -0
- package/skills/scnet-hpc/scripts/__pycache__/scnet_version.cpython-312.pyc +0 -0
- package/skills/scnet-hpc/scripts/scnet.py +1573 -0
- package/skills/scnet-hpc/scripts/scnet_backends/__init__.py +53 -0
- package/skills/scnet-hpc/scripts/scnet_backends/base.py +64 -0
- package/skills/scnet-hpc/scripts/scnet_backends/external.py +92 -0
- package/skills/scnet-hpc/scripts/scnet_backends/openapi.py +771 -0
- package/skills/scnet-hpc/scripts/scnet_backends/profile.py +78 -0
- package/skills/scnet-hpc/scripts/scnet_backends/ssh.py +258 -0
- package/skills/scnet-hpc/scripts/scnet_config.py +175 -0
- package/skills/scnet-hpc/scripts/scnet_credentials.py +160 -0
- package/skills/scnet-hpc/scripts/scnet_sdk/__init__.py +11 -0
- package/skills/scnet-hpc/scripts/scnet_sdk/cli.py +394 -0
- package/skills/scnet-hpc/scripts/scnet_sdk/client.py +240 -0
- package/skills/scnet-hpc/scripts/scnet_sdk/notebook.py +379 -0
- package/skills/scnet-hpc/scripts/scnet_version.py +20 -0
- package/skills/scnet-hpc/scripts/setup-ssh.sh +33 -1
- package/skills/scnet-hpc/scripts/setup.sh +471 -0
|
@@ -0,0 +1,1573 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Backend-neutral SCNet command-line entrypoint."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import getpass
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import select
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Mapping
|
|
20
|
+
|
|
21
|
+
from scnet_backends import BackendContext, BackendError, backend_catalog, create_backend
|
|
22
|
+
from scnet_backends.profile import list_profiles, load_profile
|
|
23
|
+
from scnet_config import (
|
|
24
|
+
config_path,
|
|
25
|
+
load_user_config,
|
|
26
|
+
redacted_config,
|
|
27
|
+
reset_all_metadata,
|
|
28
|
+
reset_openapi_metadata,
|
|
29
|
+
reset_ssh_metadata,
|
|
30
|
+
save_user_config,
|
|
31
|
+
)
|
|
32
|
+
from scnet_credentials import (
|
|
33
|
+
CredentialError,
|
|
34
|
+
delete_openapi_credentials,
|
|
35
|
+
load_openapi_credentials,
|
|
36
|
+
secure_store_name,
|
|
37
|
+
store_openapi_credentials,
|
|
38
|
+
)
|
|
39
|
+
from scnet_sdk.cli import add_notebook_parser, execute_notebook
|
|
40
|
+
from scnet_version import VERSION
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
44
|
+
MUTATING_OPERATIONS = frozenset(
|
|
45
|
+
{"submit", "cancel", "mkdir", "upload", "download", "exec"}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
description="Operate SCNet through SSH, OpenAPI, or an external backend adapter."
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--version",
|
|
55
|
+
action="version",
|
|
56
|
+
version=f"scnet-hpc {VERSION}",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--backend",
|
|
60
|
+
help=(
|
|
61
|
+
"backend name; defaults to SCNET_HPC_BACKEND, saved setup choice, "
|
|
62
|
+
"profile DEFAULT_BACKEND, then ssh"
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("--cluster", help="local cluster profile name")
|
|
66
|
+
parser.add_argument("--region", help="OpenAPI region ID or name")
|
|
67
|
+
parser.add_argument("--scheduler-id", help="OpenAPI scheduler ID")
|
|
68
|
+
parser.add_argument("--username", help="OpenAPI region username override")
|
|
69
|
+
parser.add_argument("--timeout", type=int, default=30)
|
|
70
|
+
parser.add_argument("--json", action="store_true", help="emit a JSON envelope")
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--dry-run",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="preview a mutation without contacting the selected backend",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--raw",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="request backend-specific raw fields when supported",
|
|
80
|
+
)
|
|
81
|
+
subparsers = parser.add_subparsers(dest="operation", required=True)
|
|
82
|
+
|
|
83
|
+
subparsers.add_parser("backends", help="list built-in and discovered backends")
|
|
84
|
+
subparsers.add_parser(
|
|
85
|
+
"capabilities", help="show capabilities for the selected backend"
|
|
86
|
+
)
|
|
87
|
+
subparsers.add_parser("clusters", help="list local profiles or OpenAPI regions")
|
|
88
|
+
subparsers.add_parser("queues", help="list queues/partitions")
|
|
89
|
+
subparsers.add_parser("config", help="show saved non-secret configuration")
|
|
90
|
+
setup = subparsers.add_parser(
|
|
91
|
+
"setup", help="create, modify, inspect, or reset local configuration"
|
|
92
|
+
)
|
|
93
|
+
setup.add_argument(
|
|
94
|
+
"setup_action",
|
|
95
|
+
nargs="?",
|
|
96
|
+
choices=("auto", "new", "modify", "status", "reset"),
|
|
97
|
+
default="auto",
|
|
98
|
+
help="configuration lifecycle action; default chooses interactively",
|
|
99
|
+
)
|
|
100
|
+
setup.add_argument(
|
|
101
|
+
"--skip-connect",
|
|
102
|
+
action="store_true",
|
|
103
|
+
help="save non-secret choices without testing or changing remote access",
|
|
104
|
+
)
|
|
105
|
+
setup.add_argument(
|
|
106
|
+
"--mode",
|
|
107
|
+
choices=("all", "ssh", "openapi"),
|
|
108
|
+
default="all",
|
|
109
|
+
help="limit the configuration panel to one backend",
|
|
110
|
+
)
|
|
111
|
+
doctor = subparsers.add_parser(
|
|
112
|
+
"doctor", help="check first-use configuration without changing it"
|
|
113
|
+
)
|
|
114
|
+
doctor.add_argument(
|
|
115
|
+
"--no-network",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="check local configuration only",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
limits = subparsers.add_parser("limits", help="show scheduler or user resource limits")
|
|
121
|
+
limits.add_argument("--partition")
|
|
122
|
+
|
|
123
|
+
job = subparsers.add_parser("job", help="show one job")
|
|
124
|
+
job.add_argument("job_id")
|
|
125
|
+
|
|
126
|
+
logs = subparsers.add_parser("logs", help="read a log file")
|
|
127
|
+
logs.add_argument("--path", required=True)
|
|
128
|
+
logs.add_argument("--lines", type=int, default=200)
|
|
129
|
+
logs.add_argument("--direction", choices=("head", "tail"), default="tail")
|
|
130
|
+
logs.add_argument("--page", type=int, default=1)
|
|
131
|
+
logs.add_argument("--host-name")
|
|
132
|
+
|
|
133
|
+
submit = subparsers.add_parser("submit", help="submit a job")
|
|
134
|
+
submit.add_argument("--remote-path", help="SSH: existing remote Slurm script")
|
|
135
|
+
submit.add_argument("--name", help="OpenAPI: job name")
|
|
136
|
+
submit.add_argument("--command", help="OpenAPI: command or multiline script body")
|
|
137
|
+
submit.add_argument("--work-dir", help="OpenAPI: absolute work directory")
|
|
138
|
+
submit.add_argument("--queue", help="OpenAPI: queue name")
|
|
139
|
+
submit.add_argument("--nodes", type=int, default=1)
|
|
140
|
+
submit.add_argument("--cpus", type=int, default=1)
|
|
141
|
+
submit.add_argument("--gpus", type=int, default=0)
|
|
142
|
+
submit.add_argument("--dcus", type=int, default=0)
|
|
143
|
+
submit.add_argument("--memory")
|
|
144
|
+
submit.add_argument("--walltime", default="24:00:00")
|
|
145
|
+
submit.add_argument("--stdout")
|
|
146
|
+
submit.add_argument("--stderr")
|
|
147
|
+
submit.add_argument("--exclusive", action="store_true")
|
|
148
|
+
submit.add_argument(
|
|
149
|
+
"--scheduler-option",
|
|
150
|
+
dest="scheduler_options",
|
|
151
|
+
action="append",
|
|
152
|
+
default=[],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
cancel = subparsers.add_parser("cancel", help="cancel/delete a job")
|
|
156
|
+
cancel.add_argument("job_id")
|
|
157
|
+
|
|
158
|
+
files = subparsers.add_parser("files", help="list remote files (OpenAPI)")
|
|
159
|
+
files.add_argument("--path")
|
|
160
|
+
files.add_argument("--limit", type=int, default=100)
|
|
161
|
+
files.add_argument("--start", type=int, default=0)
|
|
162
|
+
files.add_argument("--order", choices=("asc", "desc"), default="asc")
|
|
163
|
+
files.add_argument(
|
|
164
|
+
"--order-by",
|
|
165
|
+
choices=("name", "size", "lastModifiedTime"),
|
|
166
|
+
default="name",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
upload = subparsers.add_parser("upload", help="upload one file")
|
|
170
|
+
upload.add_argument("local_path")
|
|
171
|
+
upload.add_argument("remote_path")
|
|
172
|
+
upload.add_argument("--cover", action="store_true")
|
|
173
|
+
upload.add_argument(
|
|
174
|
+
"--chunk-size",
|
|
175
|
+
type=int,
|
|
176
|
+
default=8 * 1024 * 1024,
|
|
177
|
+
help="chunked OpenAPI upload threshold and chunk size in bytes; 0 disables",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
mkdir = subparsers.add_parser("mkdir", help="create a remote directory (OpenAPI)")
|
|
181
|
+
mkdir.add_argument("path")
|
|
182
|
+
mkdir.add_argument(
|
|
183
|
+
"--parents",
|
|
184
|
+
action="store_true",
|
|
185
|
+
help="create missing parent directories",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
download = subparsers.add_parser("download", help="download one file or folder")
|
|
189
|
+
download.add_argument("remote_path")
|
|
190
|
+
download.add_argument("local_path")
|
|
191
|
+
download.add_argument("--cover", action="store_true")
|
|
192
|
+
|
|
193
|
+
execute = subparsers.add_parser("exec", help="run an SSH command")
|
|
194
|
+
execute.add_argument("command", nargs=argparse.REMAINDER)
|
|
195
|
+
add_notebook_parser(subparsers)
|
|
196
|
+
return parser
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def choose_backend(
|
|
200
|
+
explicit: str | None, profile: dict[str, str], user_config: dict[str, Any]
|
|
201
|
+
) -> str:
|
|
202
|
+
return (
|
|
203
|
+
explicit
|
|
204
|
+
or os.environ.get("SCNET_HPC_BACKEND")
|
|
205
|
+
or str(user_config.get("default_backend") or "")
|
|
206
|
+
or profile.get("DEFAULT_BACKEND")
|
|
207
|
+
or "ssh"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def compact_output(operation: str, data: Any) -> str:
|
|
212
|
+
if operation == "logs" and isinstance(data, dict):
|
|
213
|
+
header = (
|
|
214
|
+
f"# {data.get('path')} "
|
|
215
|
+
f"({data.get('direction')}, {len(data.get('lines') or [])} lines)"
|
|
216
|
+
)
|
|
217
|
+
return header + "\n" + "\n".join(data.get("lines") or [])
|
|
218
|
+
if operation == "exec" and isinstance(data, dict):
|
|
219
|
+
return str(data.get("stdout") or "")
|
|
220
|
+
return json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ask(prompt: str, default: str = "") -> str:
|
|
224
|
+
suffix = f" [{default}]" if default else ""
|
|
225
|
+
answer = input(f"{prompt}{suffix}: ").strip()
|
|
226
|
+
return answer or default
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _ask_secret(prompt: str) -> str:
|
|
230
|
+
return getpass.getpass(f"{prompt}: ").strip()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _ask_yes_no(prompt: str, default: bool = True) -> bool:
|
|
234
|
+
hint = "Y/n" if default else "y/N"
|
|
235
|
+
answer = input(f"{prompt} [{hint}]: ").strip().lower()
|
|
236
|
+
if not answer:
|
|
237
|
+
return default
|
|
238
|
+
return answer in {"y", "yes"}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _choose(prompt: str, choices: list[str], default: int = 1) -> str:
|
|
242
|
+
if (
|
|
243
|
+
sys.stdin.isatty()
|
|
244
|
+
and sys.stdout.isatty()
|
|
245
|
+
and os.name == "posix"
|
|
246
|
+
and os.environ.get("TERM", "") != "dumb"
|
|
247
|
+
):
|
|
248
|
+
return _choose_interactive(prompt, choices, default)
|
|
249
|
+
|
|
250
|
+
print(prompt)
|
|
251
|
+
for index, choice in enumerate(choices, 1):
|
|
252
|
+
marker = "*" if index == default else " "
|
|
253
|
+
print(f" {marker} {index}. {choice}")
|
|
254
|
+
print(f"请输入 1-{len(choices)} 的数字并按 Enter。")
|
|
255
|
+
print(f"直接按 Enter 使用带 * 的默认项 {default}。")
|
|
256
|
+
answer = _ask("选择", str(default))
|
|
257
|
+
try:
|
|
258
|
+
selected = int(answer)
|
|
259
|
+
except ValueError as exc:
|
|
260
|
+
raise BackendError("请输入列表中的数字") from exc
|
|
261
|
+
if selected < 1 or selected > len(choices):
|
|
262
|
+
raise BackendError("选择超出范围")
|
|
263
|
+
return choices[selected - 1]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _choose_interactive(
|
|
267
|
+
prompt: str, choices: list[str], default: int
|
|
268
|
+
) -> str:
|
|
269
|
+
import termios
|
|
270
|
+
import tty
|
|
271
|
+
|
|
272
|
+
selected = max(0, min(default - 1, len(choices) - 1))
|
|
273
|
+
number = ""
|
|
274
|
+
line_count = len(choices) + 1
|
|
275
|
+
|
|
276
|
+
print(prompt)
|
|
277
|
+
print("使用 ↑/↓ 移动,Enter 确认;也可输入编号后按 Enter;q 取消。")
|
|
278
|
+
|
|
279
|
+
def render(first: bool = False) -> None:
|
|
280
|
+
if not first:
|
|
281
|
+
sys.stdout.write(f"\033[{line_count}A")
|
|
282
|
+
for index, choice in enumerate(choices):
|
|
283
|
+
pointer = "▶" if index == selected else " "
|
|
284
|
+
sys.stdout.write(
|
|
285
|
+
f"\r\033[2K {pointer} {index + 1}. {choice}\n"
|
|
286
|
+
)
|
|
287
|
+
typed = f"编号:{number}" if number else "编号:(可直接按 Enter)"
|
|
288
|
+
sys.stdout.write(f"\r\033[2K {typed}\n")
|
|
289
|
+
sys.stdout.flush()
|
|
290
|
+
|
|
291
|
+
old_settings = termios.tcgetattr(sys.stdin.fileno())
|
|
292
|
+
input_fd = sys.stdin.fileno()
|
|
293
|
+
render(first=True)
|
|
294
|
+
try:
|
|
295
|
+
tty.setcbreak(input_fd)
|
|
296
|
+
while True:
|
|
297
|
+
char = os.read(input_fd, 1).decode("utf-8", "ignore")
|
|
298
|
+
if char in {"\r", "\n"}:
|
|
299
|
+
if number:
|
|
300
|
+
value = int(number)
|
|
301
|
+
if 1 <= value <= len(choices):
|
|
302
|
+
selected = value - 1
|
|
303
|
+
break
|
|
304
|
+
number = ""
|
|
305
|
+
render()
|
|
306
|
+
continue
|
|
307
|
+
break
|
|
308
|
+
if char == "\x1b":
|
|
309
|
+
ready, _, _ = select.select([input_fd], [], [], 0.1)
|
|
310
|
+
sequence = (
|
|
311
|
+
os.read(input_fd, 2).decode("utf-8", "ignore")
|
|
312
|
+
if ready
|
|
313
|
+
else ""
|
|
314
|
+
)
|
|
315
|
+
if sequence == "[A":
|
|
316
|
+
selected = (selected - 1) % len(choices)
|
|
317
|
+
number = ""
|
|
318
|
+
render()
|
|
319
|
+
elif sequence == "[B":
|
|
320
|
+
selected = (selected + 1) % len(choices)
|
|
321
|
+
number = ""
|
|
322
|
+
render()
|
|
323
|
+
continue
|
|
324
|
+
if char in {"q", "Q", "\x03"}:
|
|
325
|
+
raise BackendError("用户取消配置")
|
|
326
|
+
if char in {"\x7f", "\b"}:
|
|
327
|
+
number = number[:-1]
|
|
328
|
+
render()
|
|
329
|
+
continue
|
|
330
|
+
if char.isdigit():
|
|
331
|
+
number += char
|
|
332
|
+
render()
|
|
333
|
+
finally:
|
|
334
|
+
termios.tcsetattr(
|
|
335
|
+
input_fd, termios.TCSADRAIN, old_settings
|
|
336
|
+
)
|
|
337
|
+
print(f"已选择:{selected + 1}. {choices[selected]}")
|
|
338
|
+
return choices[selected]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _parse_multi_numbers(value: str, count: int) -> set[int]:
|
|
342
|
+
selected: set[int] = set()
|
|
343
|
+
for part in value.replace(" ", "").split(","):
|
|
344
|
+
if not part:
|
|
345
|
+
continue
|
|
346
|
+
if "-" in part:
|
|
347
|
+
start_text, end_text = part.split("-", 1)
|
|
348
|
+
start, end = int(start_text), int(end_text)
|
|
349
|
+
if start > end:
|
|
350
|
+
start, end = end, start
|
|
351
|
+
selected.update(range(start - 1, end))
|
|
352
|
+
else:
|
|
353
|
+
selected.add(int(part) - 1)
|
|
354
|
+
if not selected or any(index < 0 or index >= count for index in selected):
|
|
355
|
+
raise ValueError("selection out of range")
|
|
356
|
+
return selected
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _multi_choose(
|
|
360
|
+
prompt: str,
|
|
361
|
+
choices: list[str],
|
|
362
|
+
defaults: set[int] | None = None,
|
|
363
|
+
) -> list[str]:
|
|
364
|
+
selected = set(defaults or set())
|
|
365
|
+
if (
|
|
366
|
+
sys.stdin.isatty()
|
|
367
|
+
and sys.stdout.isatty()
|
|
368
|
+
and os.name == "posix"
|
|
369
|
+
and os.environ.get("TERM", "") != "dumb"
|
|
370
|
+
):
|
|
371
|
+
return _multi_choose_interactive(prompt, choices, selected)
|
|
372
|
+
|
|
373
|
+
print(prompt)
|
|
374
|
+
for index, choice in enumerate(choices, 1):
|
|
375
|
+
marker = "x" if index - 1 in selected else " "
|
|
376
|
+
print(f" [{marker}] {index}. {choice}")
|
|
377
|
+
default_text = ",".join(str(index + 1) for index in sorted(selected))
|
|
378
|
+
print("输入编号列表,如 1,3,5-8;直接按 Enter 保留当前勾选。")
|
|
379
|
+
answer = _ask("选择", default_text)
|
|
380
|
+
try:
|
|
381
|
+
indices = _parse_multi_numbers(answer, len(choices))
|
|
382
|
+
except (ValueError, TypeError) as exc:
|
|
383
|
+
raise BackendError("多选格式无效,请输入如 1,3,5-8") from exc
|
|
384
|
+
return [choice for index, choice in enumerate(choices) if index in indices]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _multi_choose_interactive(
|
|
388
|
+
prompt: str,
|
|
389
|
+
choices: list[str],
|
|
390
|
+
selected: set[int],
|
|
391
|
+
) -> list[str]:
|
|
392
|
+
import termios
|
|
393
|
+
import tty
|
|
394
|
+
|
|
395
|
+
cursor = min(selected) if selected else 0
|
|
396
|
+
digits = ""
|
|
397
|
+
line_count = len(choices) + 1
|
|
398
|
+
print(prompt)
|
|
399
|
+
print(
|
|
400
|
+
"使用 ↑/↓ 移动,Space 勾选,a 全选,n 清空,"
|
|
401
|
+
"Enter 保存,q 取消。"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def render(first: bool = False) -> None:
|
|
405
|
+
if not first:
|
|
406
|
+
sys.stdout.write(f"\033[{line_count}A")
|
|
407
|
+
for index, choice in enumerate(choices):
|
|
408
|
+
pointer = "▶" if index == cursor else " "
|
|
409
|
+
checked = "x" if index in selected else " "
|
|
410
|
+
sys.stdout.write(
|
|
411
|
+
f"\r\033[2K {pointer} [{checked}] {index + 1}. {choice}\n"
|
|
412
|
+
)
|
|
413
|
+
hint = (
|
|
414
|
+
f"编号定位:{digits}"
|
|
415
|
+
if digits
|
|
416
|
+
else f"已选择 {len(selected)}/{len(choices)}"
|
|
417
|
+
)
|
|
418
|
+
sys.stdout.write(f"\r\033[2K {hint}\n")
|
|
419
|
+
sys.stdout.flush()
|
|
420
|
+
|
|
421
|
+
input_fd = sys.stdin.fileno()
|
|
422
|
+
old_settings = termios.tcgetattr(input_fd)
|
|
423
|
+
render(first=True)
|
|
424
|
+
try:
|
|
425
|
+
tty.setcbreak(input_fd)
|
|
426
|
+
while True:
|
|
427
|
+
char = os.read(input_fd, 1).decode("utf-8", "ignore")
|
|
428
|
+
if char in {"\r", "\n"}:
|
|
429
|
+
if digits:
|
|
430
|
+
value = int(digits)
|
|
431
|
+
if 1 <= value <= len(choices):
|
|
432
|
+
cursor = value - 1
|
|
433
|
+
digits = ""
|
|
434
|
+
render()
|
|
435
|
+
continue
|
|
436
|
+
if selected:
|
|
437
|
+
break
|
|
438
|
+
render()
|
|
439
|
+
continue
|
|
440
|
+
if char == "\x1b":
|
|
441
|
+
ready, _, _ = select.select([input_fd], [], [], 0.1)
|
|
442
|
+
sequence = (
|
|
443
|
+
os.read(input_fd, 2).decode("utf-8", "ignore")
|
|
444
|
+
if ready
|
|
445
|
+
else ""
|
|
446
|
+
)
|
|
447
|
+
if sequence == "[A":
|
|
448
|
+
cursor = (cursor - 1) % len(choices)
|
|
449
|
+
digits = ""
|
|
450
|
+
render()
|
|
451
|
+
elif sequence == "[B":
|
|
452
|
+
cursor = (cursor + 1) % len(choices)
|
|
453
|
+
digits = ""
|
|
454
|
+
render()
|
|
455
|
+
continue
|
|
456
|
+
if char == " ":
|
|
457
|
+
if cursor in selected:
|
|
458
|
+
selected.remove(cursor)
|
|
459
|
+
else:
|
|
460
|
+
selected.add(cursor)
|
|
461
|
+
digits = ""
|
|
462
|
+
render()
|
|
463
|
+
continue
|
|
464
|
+
if char in {"a", "A"}:
|
|
465
|
+
selected = set(range(len(choices)))
|
|
466
|
+
digits = ""
|
|
467
|
+
render()
|
|
468
|
+
continue
|
|
469
|
+
if char in {"n", "N"}:
|
|
470
|
+
selected.clear()
|
|
471
|
+
digits = ""
|
|
472
|
+
render()
|
|
473
|
+
continue
|
|
474
|
+
if char in {"q", "Q", "\x03"}:
|
|
475
|
+
raise BackendError("用户取消配置")
|
|
476
|
+
if char in {"\x7f", "\b"}:
|
|
477
|
+
digits = digits[:-1]
|
|
478
|
+
render()
|
|
479
|
+
continue
|
|
480
|
+
if char.isdigit():
|
|
481
|
+
digits += char
|
|
482
|
+
value = int(digits)
|
|
483
|
+
if 1 <= value <= len(choices):
|
|
484
|
+
cursor = value - 1
|
|
485
|
+
render()
|
|
486
|
+
finally:
|
|
487
|
+
termios.tcsetattr(input_fd, termios.TCSADRAIN, old_settings)
|
|
488
|
+
print(f"已启用 {len(selected)} 个区域。")
|
|
489
|
+
return [
|
|
490
|
+
choice for index, choice in enumerate(choices) if index in selected
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _print_terminal_help() -> None:
|
|
495
|
+
system = platform.system()
|
|
496
|
+
if system == "Darwin":
|
|
497
|
+
print(
|
|
498
|
+
"macOS:在 Terminal 或 iTerm2 中可用 ↑/↓ 和 Enter,"
|
|
499
|
+
"也可输入编号;Ctrl+C 可取消。敏感输入不会回显。"
|
|
500
|
+
)
|
|
501
|
+
print("凭据可保存到系统自带的 macOS Keychain。")
|
|
502
|
+
elif system == "Linux":
|
|
503
|
+
distro = ""
|
|
504
|
+
try:
|
|
505
|
+
for line in Path("/etc/os-release").read_text().splitlines():
|
|
506
|
+
if line.startswith("ID="):
|
|
507
|
+
distro = line.split("=", 1)[1].strip().strip('"')
|
|
508
|
+
break
|
|
509
|
+
except OSError:
|
|
510
|
+
pass
|
|
511
|
+
if distro in {"ubuntu", "debian"}:
|
|
512
|
+
print(
|
|
513
|
+
"Ubuntu/Debian:在 Terminal 中可用 ↑/↓ 和 Enter,"
|
|
514
|
+
"也可输入编号;Ctrl+C 可取消。敏感输入不会回显。"
|
|
515
|
+
)
|
|
516
|
+
print(
|
|
517
|
+
"如需安全保存 AK/SK,可安装 Secret Service 工具:"
|
|
518
|
+
"sudo apt install libsecret-tools"
|
|
519
|
+
)
|
|
520
|
+
else:
|
|
521
|
+
print(
|
|
522
|
+
"Linux:使用 ↑/↓ 和 Enter,也可输入编号;"
|
|
523
|
+
"Ctrl+C 可取消。敏感输入不会回显。"
|
|
524
|
+
)
|
|
525
|
+
print()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _run_setup_ssh(cluster: str, key_path: str, username: str) -> None:
|
|
529
|
+
script = REPO_ROOT / "scripts" / "setup-ssh.sh"
|
|
530
|
+
command = [str(script), "--cluster", cluster, key_path, username]
|
|
531
|
+
try:
|
|
532
|
+
completed = subprocess.run(command, check=False)
|
|
533
|
+
except OSError as exc:
|
|
534
|
+
raise BackendError(f"无法执行 SSH 配置脚本: {exc}") from exc
|
|
535
|
+
if completed.returncode != 0:
|
|
536
|
+
raise BackendError("SSH 配置失败,请根据上面的输出修复后重新运行 setup")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _with_openapi_credentials(
|
|
540
|
+
profile: dict[str, str],
|
|
541
|
+
*,
|
|
542
|
+
user: str = "",
|
|
543
|
+
access_key: str = "",
|
|
544
|
+
secret_key: str = "",
|
|
545
|
+
token: str = "",
|
|
546
|
+
region_id: str = "",
|
|
547
|
+
) -> tuple[Any, dict[str, str | None]]:
|
|
548
|
+
previous = {
|
|
549
|
+
key: os.environ.get(key)
|
|
550
|
+
for key in (
|
|
551
|
+
"SCNET_OPENAPI_USER",
|
|
552
|
+
"SCNET_OPENAPI_ACCESS_KEY",
|
|
553
|
+
"SCNET_OPENAPI_SECRET_KEY",
|
|
554
|
+
"SCNET_OPENAPI_TOKEN",
|
|
555
|
+
"SCNET_OPENAPI_REGION_ID",
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
try:
|
|
559
|
+
for key, value in (
|
|
560
|
+
("SCNET_OPENAPI_USER", user),
|
|
561
|
+
("SCNET_OPENAPI_ACCESS_KEY", access_key),
|
|
562
|
+
("SCNET_OPENAPI_SECRET_KEY", secret_key),
|
|
563
|
+
("SCNET_OPENAPI_TOKEN", token),
|
|
564
|
+
("SCNET_OPENAPI_REGION_ID", region_id),
|
|
565
|
+
):
|
|
566
|
+
if value:
|
|
567
|
+
os.environ[key] = value
|
|
568
|
+
else:
|
|
569
|
+
os.environ.pop(key, None)
|
|
570
|
+
context = BackendContext(
|
|
571
|
+
repo_root=REPO_ROOT,
|
|
572
|
+
profile_name=None,
|
|
573
|
+
profile=profile,
|
|
574
|
+
timeout=30,
|
|
575
|
+
)
|
|
576
|
+
backend = create_backend("openapi", context)
|
|
577
|
+
return backend, previous
|
|
578
|
+
except Exception:
|
|
579
|
+
for key, value in previous.items():
|
|
580
|
+
if value is None:
|
|
581
|
+
os.environ.pop(key, None)
|
|
582
|
+
else:
|
|
583
|
+
os.environ[key] = value
|
|
584
|
+
raise
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _restore_environment(previous: dict[str, str | None]) -> None:
|
|
588
|
+
for key, value in previous.items():
|
|
589
|
+
if value is None:
|
|
590
|
+
os.environ.pop(key, None)
|
|
591
|
+
else:
|
|
592
|
+
os.environ[key] = value
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _openapi_probe(
|
|
596
|
+
backend: Any, region: str
|
|
597
|
+
) -> dict[str, Any]:
|
|
598
|
+
return backend.discover_region_context(region)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _key_expiry_from_name(path: str) -> str:
|
|
602
|
+
match = re.search(
|
|
603
|
+
r"RsaKeyExpireTime[_-](\d{4}-\d{2}-\d{2})",
|
|
604
|
+
Path(path).name,
|
|
605
|
+
)
|
|
606
|
+
return match.group(1) if match else ""
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _detect_ssh_username(
|
|
610
|
+
cluster: str,
|
|
611
|
+
profile: Mapping[str, str],
|
|
612
|
+
key_path: str,
|
|
613
|
+
existing: Mapping[str, Any],
|
|
614
|
+
) -> str:
|
|
615
|
+
username = str(existing.get("username") or "")
|
|
616
|
+
if username:
|
|
617
|
+
return username
|
|
618
|
+
marker = profile.get("KEY_NAME_MARKER", "")
|
|
619
|
+
filename = Path(key_path).name if key_path else ""
|
|
620
|
+
if marker and "<" not in marker and marker in filename:
|
|
621
|
+
candidate = filename.split(marker, 1)[0]
|
|
622
|
+
if candidate:
|
|
623
|
+
return candidate
|
|
624
|
+
host_marker = f"_{profile.get('SSH_HOST', '')}_"
|
|
625
|
+
if profile.get("SSH_HOST") and host_marker in filename:
|
|
626
|
+
candidate = filename.split(host_marker, 1)[0]
|
|
627
|
+
if candidate:
|
|
628
|
+
return candidate
|
|
629
|
+
if shutil.which("ssh"):
|
|
630
|
+
completed = subprocess.run(
|
|
631
|
+
["ssh", "-G", cluster],
|
|
632
|
+
text=True,
|
|
633
|
+
stdout=subprocess.PIPE,
|
|
634
|
+
stderr=subprocess.DEVNULL,
|
|
635
|
+
check=False,
|
|
636
|
+
)
|
|
637
|
+
resolved: dict[str, str] = {}
|
|
638
|
+
for line in completed.stdout.splitlines():
|
|
639
|
+
key, _, value = line.partition(" ")
|
|
640
|
+
if key in {"hostname", "user"} and value:
|
|
641
|
+
resolved[key] = value
|
|
642
|
+
if (
|
|
643
|
+
completed.returncode == 0
|
|
644
|
+
and resolved.get("hostname") == profile.get("SSH_HOST")
|
|
645
|
+
):
|
|
646
|
+
return resolved.get("user", "")
|
|
647
|
+
return ""
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _resolved_ssh_connection(
|
|
651
|
+
cluster: str, profile: Mapping[str, str]
|
|
652
|
+
) -> dict[str, Any]:
|
|
653
|
+
if not shutil.which("ssh"):
|
|
654
|
+
return {}
|
|
655
|
+
completed = subprocess.run(
|
|
656
|
+
["ssh", "-G", cluster],
|
|
657
|
+
text=True,
|
|
658
|
+
stdout=subprocess.PIPE,
|
|
659
|
+
stderr=subprocess.DEVNULL,
|
|
660
|
+
check=False,
|
|
661
|
+
)
|
|
662
|
+
if completed.returncode != 0:
|
|
663
|
+
return {}
|
|
664
|
+
values: dict[str, list[str]] = {}
|
|
665
|
+
for line in completed.stdout.splitlines():
|
|
666
|
+
key, _, value = line.partition(" ")
|
|
667
|
+
if value:
|
|
668
|
+
values.setdefault(key, []).append(value)
|
|
669
|
+
host = (values.get("hostname") or [""])[0]
|
|
670
|
+
port = (values.get("port") or [""])[0]
|
|
671
|
+
if host != profile.get("SSH_HOST"):
|
|
672
|
+
return {}
|
|
673
|
+
expected_port = str(profile.get("SSH_PORT") or "22")
|
|
674
|
+
if port and port != expected_port:
|
|
675
|
+
return {}
|
|
676
|
+
identity_files = [
|
|
677
|
+
str(Path(value).expanduser())
|
|
678
|
+
for value in values.get("identityfile", [])
|
|
679
|
+
]
|
|
680
|
+
existing_key = next(
|
|
681
|
+
(value for value in identity_files if Path(value).is_file()), ""
|
|
682
|
+
)
|
|
683
|
+
return {
|
|
684
|
+
"username": (values.get("user") or [""])[0],
|
|
685
|
+
"hostname": host,
|
|
686
|
+
"port": port or expected_port,
|
|
687
|
+
"key_path": existing_key,
|
|
688
|
+
"detected": bool(existing_key),
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _ssh_key_metadata(cluster: str, source: str, username: str) -> dict[str, Any]:
|
|
693
|
+
installed = Path.home() / ".ssh" / f"id_rsa_{cluster}"
|
|
694
|
+
metadata: dict[str, Any] = {
|
|
695
|
+
"username": username,
|
|
696
|
+
"key_path": str(installed),
|
|
697
|
+
"key_source_name": Path(source).name,
|
|
698
|
+
"updated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
699
|
+
}
|
|
700
|
+
expiry = _key_expiry_from_name(source)
|
|
701
|
+
if expiry:
|
|
702
|
+
metadata["key_expires_at"] = expiry
|
|
703
|
+
if installed.is_file() and shutil.which("ssh-keygen"):
|
|
704
|
+
completed = subprocess.run(
|
|
705
|
+
["ssh-keygen", "-lf", str(installed)],
|
|
706
|
+
text=True,
|
|
707
|
+
stdout=subprocess.PIPE,
|
|
708
|
+
stderr=subprocess.DEVNULL,
|
|
709
|
+
check=False,
|
|
710
|
+
)
|
|
711
|
+
if completed.returncode == 0:
|
|
712
|
+
fields = completed.stdout.split()
|
|
713
|
+
if len(fields) >= 2:
|
|
714
|
+
metadata["key_fingerprint"] = fields[1]
|
|
715
|
+
return metadata
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _has_configuration(config: Mapping[str, Any]) -> bool:
|
|
719
|
+
if config.get("default_backend") or config.get("cluster"):
|
|
720
|
+
return True
|
|
721
|
+
ssh = config.get("ssh")
|
|
722
|
+
if isinstance(ssh, dict) and ssh.get("clusters"):
|
|
723
|
+
return True
|
|
724
|
+
return isinstance(config.get("openapi"), dict) and bool(config["openapi"])
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _print_setup_status(config: Mapping[str, Any]) -> None:
|
|
728
|
+
print("\nSCNet HPC 配置状态")
|
|
729
|
+
print("===================")
|
|
730
|
+
print(f"版本:{VERSION}")
|
|
731
|
+
print(f"配置目录:{config_path().parent}")
|
|
732
|
+
print(f"默认 backend:{config.get('default_backend') or '未设置'}")
|
|
733
|
+
print(f"默认 SSH profile:{config.get('cluster') or '未设置'}")
|
|
734
|
+
ssh = config.get("ssh")
|
|
735
|
+
clusters = ssh.get("clusters", {}) if isinstance(ssh, dict) else {}
|
|
736
|
+
print(f"SSH profiles:{len(clusters) if isinstance(clusters, dict) else 0}")
|
|
737
|
+
if isinstance(clusters, dict):
|
|
738
|
+
for name, profile in sorted(clusters.items()):
|
|
739
|
+
if not isinstance(profile, dict):
|
|
740
|
+
continue
|
|
741
|
+
expiry = profile.get("key_expires_at") or "未知"
|
|
742
|
+
print(
|
|
743
|
+
f" - {name}: user={profile.get('username') or '未知'}, "
|
|
744
|
+
f"key_expires={expiry}"
|
|
745
|
+
)
|
|
746
|
+
configured_names = set(clusters) if isinstance(clusters, dict) else set()
|
|
747
|
+
for item in list_profiles(REPO_ROOT):
|
|
748
|
+
name = item["name"]
|
|
749
|
+
if name in configured_names:
|
|
750
|
+
continue
|
|
751
|
+
_, profile = load_profile(REPO_ROOT, name)
|
|
752
|
+
detected = _resolved_ssh_connection(name, profile)
|
|
753
|
+
if detected:
|
|
754
|
+
print(
|
|
755
|
+
f" - {name}: 已检测到本机 SSH 连接,"
|
|
756
|
+
f"user={detected.get('username') or '未知'}, "
|
|
757
|
+
f"key={detected.get('key_path') or '未知'} "
|
|
758
|
+
"(尚未纳入元数据)"
|
|
759
|
+
)
|
|
760
|
+
openapi = config.get("openapi")
|
|
761
|
+
if isinstance(openapi, dict) and openapi:
|
|
762
|
+
regions = openapi.get("regions")
|
|
763
|
+
regions = regions if isinstance(regions, dict) else {}
|
|
764
|
+
enabled = openapi.get("enabled_region_ids")
|
|
765
|
+
enabled = enabled if isinstance(enabled, list) else list(regions)
|
|
766
|
+
default_region = str(
|
|
767
|
+
openapi.get("default_region_id")
|
|
768
|
+
or openapi.get("region_id")
|
|
769
|
+
or ""
|
|
770
|
+
)
|
|
771
|
+
print(
|
|
772
|
+
f"OpenAPI:user={openapi.get('platform_user') or '未知'}, "
|
|
773
|
+
f"provider={openapi.get('credential_provider') or '环境变量'}"
|
|
774
|
+
)
|
|
775
|
+
print(f" 已发现区域:{len(regions)},已启用区域:{len(enabled)}")
|
|
776
|
+
for region_id in enabled:
|
|
777
|
+
region = regions.get(str(region_id), {})
|
|
778
|
+
marker = "*" if str(region_id) == default_region else " "
|
|
779
|
+
print(
|
|
780
|
+
f" {marker} {region.get('name') or region_id} ({region_id})"
|
|
781
|
+
)
|
|
782
|
+
else:
|
|
783
|
+
print("OpenAPI:未配置")
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _setup_reset_panel(config: dict[str, Any]) -> dict[str, Any]:
|
|
787
|
+
choices = [
|
|
788
|
+
"OpenAPI 区域缓存和默认项",
|
|
789
|
+
"OpenAPI 安全凭据(Keychain/Secret Service)",
|
|
790
|
+
"一个 SSH profile 的本地元数据",
|
|
791
|
+
"全部 SCNet HPC 本地元数据(不删除真实 SSH key/config)",
|
|
792
|
+
]
|
|
793
|
+
selected = _choose("选择重置范围:", choices, 1)
|
|
794
|
+
if not _ask_yes_no(f"确认重置“{selected}”?", default=False):
|
|
795
|
+
raise BackendError("用户取消重置")
|
|
796
|
+
if selected.startswith("OpenAPI 区域"):
|
|
797
|
+
reset_openapi_metadata()
|
|
798
|
+
config.pop("openapi", None)
|
|
799
|
+
if config.get("default_backend") == "openapi":
|
|
800
|
+
config["default_backend"] = "ssh"
|
|
801
|
+
save_user_config(config)
|
|
802
|
+
elif selected.startswith("OpenAPI 安全"):
|
|
803
|
+
try:
|
|
804
|
+
deleted, provider = delete_openapi_credentials()
|
|
805
|
+
except CredentialError as exc:
|
|
806
|
+
raise BackendError(str(exc)) from exc
|
|
807
|
+
print(
|
|
808
|
+
f"{provider or '安全凭据库'}:"
|
|
809
|
+
f"{'已删除' if deleted else '没有可删除的凭据'}"
|
|
810
|
+
)
|
|
811
|
+
elif selected.startswith("一个 SSH"):
|
|
812
|
+
ssh = config.get("ssh")
|
|
813
|
+
clusters = ssh.get("clusters", {}) if isinstance(ssh, dict) else {}
|
|
814
|
+
if not isinstance(clusters, dict) or not clusters:
|
|
815
|
+
raise BackendError("没有已保存的 SSH profile 元数据")
|
|
816
|
+
names = sorted(clusters)
|
|
817
|
+
name = _choose("选择 SSH profile:", names, 1)
|
|
818
|
+
reset_ssh_metadata(name)
|
|
819
|
+
clusters.pop(name, None)
|
|
820
|
+
if config.get("cluster") == name:
|
|
821
|
+
config["cluster"] = next(iter(clusters), "")
|
|
822
|
+
save_user_config(config)
|
|
823
|
+
print("仅删除本地元数据;~/.ssh 中的 key 和 config 保持不变。")
|
|
824
|
+
else:
|
|
825
|
+
removed = reset_all_metadata()
|
|
826
|
+
print(f"已删除 {len(removed)} 个本地配置文件。")
|
|
827
|
+
return load_user_config()
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def setup_lifecycle(
|
|
831
|
+
action: str, mode: str, skip_connect: bool
|
|
832
|
+
) -> dict[str, Any]:
|
|
833
|
+
current = load_user_config()
|
|
834
|
+
exists = _has_configuration(current)
|
|
835
|
+
if action == "auto":
|
|
836
|
+
labels = [
|
|
837
|
+
"查看当前状态(status)",
|
|
838
|
+
"新增连接(new)",
|
|
839
|
+
"修改现有配置(modify)",
|
|
840
|
+
"重置配置(reset)",
|
|
841
|
+
]
|
|
842
|
+
default = 3 if exists else 2
|
|
843
|
+
selected = _choose("选择配置操作:", labels, default)
|
|
844
|
+
action = selected.split("(", 1)[1].split(")", 1)[0]
|
|
845
|
+
if action == "status":
|
|
846
|
+
_print_setup_status(current)
|
|
847
|
+
return current
|
|
848
|
+
if action == "reset":
|
|
849
|
+
return _setup_reset_panel(current)
|
|
850
|
+
if action == "modify" and not exists:
|
|
851
|
+
raise BackendError("尚无配置,请使用 `setup new`")
|
|
852
|
+
setup_panel._skip_connect = skip_connect
|
|
853
|
+
return setup_panel(mode, action)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def setup_panel(
|
|
857
|
+
mode: str = "all", action: str = "modify"
|
|
858
|
+
) -> dict[str, Any]:
|
|
859
|
+
profiles = list_profiles(REPO_ROOT)
|
|
860
|
+
if not profiles:
|
|
861
|
+
raise BackendError("clusters/ 下没有 profile")
|
|
862
|
+
current = load_user_config()
|
|
863
|
+
print("\nSCNet HPC 配置/维护面板")
|
|
864
|
+
print("=======================")
|
|
865
|
+
print("自动探测区域、调度器和 SSH 元数据;只要求输入必要凭据。\n")
|
|
866
|
+
_print_terminal_help()
|
|
867
|
+
|
|
868
|
+
current_default = str(current.get("default_backend") or "ssh")
|
|
869
|
+
if mode == "all":
|
|
870
|
+
backend = _choose(
|
|
871
|
+
"选择默认 backend:",
|
|
872
|
+
["SSH(推荐,支持环境和深度诊断)", "OpenAPI(结构化控制面)"],
|
|
873
|
+
1 if current_default == "ssh" else 2,
|
|
874
|
+
)
|
|
875
|
+
default_backend = "ssh" if backend.startswith("SSH") else "openapi"
|
|
876
|
+
configure_ssh = _ask_yes_no(
|
|
877
|
+
"是否添加或更新一个 SSH 区域?", default=default_backend == "ssh"
|
|
878
|
+
)
|
|
879
|
+
configure_openapi = _ask_yes_no(
|
|
880
|
+
"是否配置或刷新 OpenAPI?", default=default_backend == "openapi"
|
|
881
|
+
)
|
|
882
|
+
else:
|
|
883
|
+
default_backend = mode
|
|
884
|
+
configure_ssh = mode == "ssh"
|
|
885
|
+
configure_openapi = mode == "openapi"
|
|
886
|
+
|
|
887
|
+
result: dict[str, Any] = dict(current)
|
|
888
|
+
result.update({"version": 1, "default_backend": default_backend})
|
|
889
|
+
skip_connect = bool(getattr(setup_panel, "_skip_connect", False))
|
|
890
|
+
selected_cluster = str(current.get("cluster") or "")
|
|
891
|
+
|
|
892
|
+
if configure_ssh:
|
|
893
|
+
existing_ssh = current.get("ssh", {})
|
|
894
|
+
if not isinstance(existing_ssh, dict):
|
|
895
|
+
existing_ssh = {}
|
|
896
|
+
existing_clusters = existing_ssh.get("clusters", {})
|
|
897
|
+
if not isinstance(existing_clusters, dict):
|
|
898
|
+
existing_clusters = {}
|
|
899
|
+
all_profile_names = [item["name"] for item in profiles]
|
|
900
|
+
detected_connections = {
|
|
901
|
+
name: _resolved_ssh_connection(name, load_profile(REPO_ROOT, name)[1])
|
|
902
|
+
for name in all_profile_names
|
|
903
|
+
}
|
|
904
|
+
detected_connections = {
|
|
905
|
+
name: value for name, value in detected_connections.items() if value
|
|
906
|
+
}
|
|
907
|
+
if action == "new":
|
|
908
|
+
profile_names = [
|
|
909
|
+
name
|
|
910
|
+
for name in all_profile_names
|
|
911
|
+
if name not in existing_clusters and name not in detected_connections
|
|
912
|
+
]
|
|
913
|
+
if not profile_names:
|
|
914
|
+
raise BackendError(
|
|
915
|
+
"所有 SSH profiles 已配置或已被检测到;请使用 `setup modify`"
|
|
916
|
+
)
|
|
917
|
+
else:
|
|
918
|
+
profile_names = [
|
|
919
|
+
name
|
|
920
|
+
for name in all_profile_names
|
|
921
|
+
if name in existing_clusters or name in detected_connections
|
|
922
|
+
]
|
|
923
|
+
if not profile_names:
|
|
924
|
+
raise BackendError(
|
|
925
|
+
"没有已配置的 SSH profile;请使用 `setup new`"
|
|
926
|
+
)
|
|
927
|
+
default_cluster = (
|
|
928
|
+
profile_names.index(selected_cluster) + 1
|
|
929
|
+
if selected_cluster in profile_names
|
|
930
|
+
else 1
|
|
931
|
+
)
|
|
932
|
+
cluster = _choose("选择要配置或更新的 SSH 区域:", profile_names, default_cluster)
|
|
933
|
+
selected_cluster = cluster
|
|
934
|
+
result["cluster"] = cluster
|
|
935
|
+
_, profile = load_profile(REPO_ROOT, cluster)
|
|
936
|
+
existing_profile = existing_clusters.get(cluster, {})
|
|
937
|
+
if not isinstance(existing_profile, dict):
|
|
938
|
+
existing_profile = {}
|
|
939
|
+
detected_connection = detected_connections.get(cluster, {})
|
|
940
|
+
if not existing_profile and detected_connection:
|
|
941
|
+
print(
|
|
942
|
+
"\n检测到已有 SSH 连接,将接管其本地元数据:\n"
|
|
943
|
+
f" 用户:{detected_connection.get('username') or '未知'}\n"
|
|
944
|
+
f" 主机:{detected_connection.get('hostname')}:"
|
|
945
|
+
f"{detected_connection.get('port')}\n"
|
|
946
|
+
f" 私钥:{detected_connection.get('key_path') or '未找到'}"
|
|
947
|
+
)
|
|
948
|
+
existing_profile = dict(detected_connection)
|
|
949
|
+
key_path = ""
|
|
950
|
+
if not skip_connect:
|
|
951
|
+
detected_key = str(existing_profile.get("key_path") or "")
|
|
952
|
+
if action == "modify" and detected_key:
|
|
953
|
+
rotate = _ask_yes_no(
|
|
954
|
+
"是否轮换为新的 SSH 私钥?", default=False
|
|
955
|
+
)
|
|
956
|
+
key_path = (
|
|
957
|
+
str(Path(_ask("新私钥文件路径")).expanduser())
|
|
958
|
+
if rotate
|
|
959
|
+
else detected_key
|
|
960
|
+
)
|
|
961
|
+
else:
|
|
962
|
+
key_path = str(Path(_ask("私钥文件路径")).expanduser())
|
|
963
|
+
if not key_path or not Path(key_path).is_file():
|
|
964
|
+
raise BackendError(f"找不到私钥文件: {key_path}")
|
|
965
|
+
detected_user = _detect_ssh_username(
|
|
966
|
+
cluster, profile, key_path, existing_profile
|
|
967
|
+
)
|
|
968
|
+
username = _ask(
|
|
969
|
+
"远端用户名(已自动探测,可直接回车)",
|
|
970
|
+
detected_user,
|
|
971
|
+
)
|
|
972
|
+
if not username:
|
|
973
|
+
raise BackendError("无法自动探测 SSH 用户名,请手工输入")
|
|
974
|
+
clusters = dict(existing_clusters)
|
|
975
|
+
clusters[cluster] = {**existing_profile, "username": username}
|
|
976
|
+
result["ssh"] = {"clusters": clusters}
|
|
977
|
+
if skip_connect:
|
|
978
|
+
print("已按 --skip-connect 跳过私钥安装和 SSH 测试。")
|
|
979
|
+
else:
|
|
980
|
+
adopted = (
|
|
981
|
+
action == "modify"
|
|
982
|
+
and bool(detected_connection)
|
|
983
|
+
and key_path == detected_connection.get("key_path")
|
|
984
|
+
)
|
|
985
|
+
prompt = (
|
|
986
|
+
"确认接管已有 SSH 连接并测试?"
|
|
987
|
+
if adopted
|
|
988
|
+
else "确认安装/轮换私钥并测试连接?"
|
|
989
|
+
)
|
|
990
|
+
if not _ask_yes_no(prompt, default=True):
|
|
991
|
+
print("已跳过 SSH 写入;可稍后单独运行 setup-ssh.sh。")
|
|
992
|
+
elif adopted:
|
|
993
|
+
completed = subprocess.run(
|
|
994
|
+
[
|
|
995
|
+
"ssh",
|
|
996
|
+
"-o",
|
|
997
|
+
"BatchMode=yes",
|
|
998
|
+
"-o",
|
|
999
|
+
"ConnectTimeout=20",
|
|
1000
|
+
cluster,
|
|
1001
|
+
"printf SCNET_OK",
|
|
1002
|
+
],
|
|
1003
|
+
text=True,
|
|
1004
|
+
stdout=subprocess.PIPE,
|
|
1005
|
+
stderr=subprocess.PIPE,
|
|
1006
|
+
check=False,
|
|
1007
|
+
)
|
|
1008
|
+
if completed.returncode != 0:
|
|
1009
|
+
raise BackendError(
|
|
1010
|
+
completed.stderr.strip() or "已有 SSH 连接测试失败"
|
|
1011
|
+
)
|
|
1012
|
+
clusters[cluster] = {
|
|
1013
|
+
**existing_profile,
|
|
1014
|
+
**_ssh_key_metadata(cluster, key_path, username),
|
|
1015
|
+
"adopted": True,
|
|
1016
|
+
}
|
|
1017
|
+
else:
|
|
1018
|
+
_run_setup_ssh(cluster, key_path, username)
|
|
1019
|
+
clusters[cluster] = _ssh_key_metadata(
|
|
1020
|
+
cluster, key_path, username
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if configure_openapi:
|
|
1024
|
+
existing_openapi = current.get("openapi", {})
|
|
1025
|
+
if not isinstance(existing_openapi, dict):
|
|
1026
|
+
existing_openapi = {}
|
|
1027
|
+
if action == "new" and existing_openapi:
|
|
1028
|
+
raise BackendError(
|
|
1029
|
+
"OpenAPI 已配置;请使用 `setup modify --mode openapi`"
|
|
1030
|
+
)
|
|
1031
|
+
if action == "modify" and not existing_openapi:
|
|
1032
|
+
raise BackendError(
|
|
1033
|
+
"OpenAPI 尚未配置;请使用 `setup new --mode openapi`"
|
|
1034
|
+
)
|
|
1035
|
+
if skip_connect:
|
|
1036
|
+
print("已按 --skip-connect 跳过 OpenAPI 认证和区域发现。")
|
|
1037
|
+
else:
|
|
1038
|
+
print(
|
|
1039
|
+
"\n当前配置的是 HPC/Slurm 作业 OpenAPI,"
|
|
1040
|
+
"不是 Notebook、容器或模型服务 API。"
|
|
1041
|
+
)
|
|
1042
|
+
print(
|
|
1043
|
+
"AK/SK 获取:登录 SCNet → 个人中心 → 访问控制 → "
|
|
1044
|
+
"生成并下载授权码。"
|
|
1045
|
+
)
|
|
1046
|
+
print(
|
|
1047
|
+
"官方说明:https://www.scnet.cn/ac/openapi/doc/2.0/"
|
|
1048
|
+
"api/safecertification/get-user-tokens-aksk.html"
|
|
1049
|
+
)
|
|
1050
|
+
print("SecretKey 输入时不会显示字符或星号,这是正常现象。\n")
|
|
1051
|
+
saved_credentials, saved_provider = load_openapi_credentials()
|
|
1052
|
+
use_saved = bool(saved_credentials) and _ask_yes_no(
|
|
1053
|
+
f"检测到 {saved_provider} 中的 OpenAPI 凭据,是否使用?",
|
|
1054
|
+
default=True,
|
|
1055
|
+
)
|
|
1056
|
+
entered_credentials = not use_saved
|
|
1057
|
+
if use_saved and saved_credentials:
|
|
1058
|
+
api_user = saved_credentials["user"]
|
|
1059
|
+
access_key = saved_credentials["access_key"]
|
|
1060
|
+
secret_key = saved_credentials["secret_key"]
|
|
1061
|
+
else:
|
|
1062
|
+
api_user = _ask(
|
|
1063
|
+
"平台用户名",
|
|
1064
|
+
str(existing_openapi.get("platform_user") or ""),
|
|
1065
|
+
)
|
|
1066
|
+
access_key = _ask_secret("AccessKey")
|
|
1067
|
+
secret_key = _ask_secret("SecretKey")
|
|
1068
|
+
if not api_user or not access_key or not secret_key:
|
|
1069
|
+
raise BackendError("AK/SK 配置不完整")
|
|
1070
|
+
backend_instance, previous = _with_openapi_credentials(
|
|
1071
|
+
{},
|
|
1072
|
+
user=api_user,
|
|
1073
|
+
access_key=access_key,
|
|
1074
|
+
secret_key=secret_key,
|
|
1075
|
+
)
|
|
1076
|
+
try:
|
|
1077
|
+
contexts = backend_instance.discover_all_region_contexts()
|
|
1078
|
+
if not contexts:
|
|
1079
|
+
raise BackendError("账号没有可用的 OpenAPI 计算区域")
|
|
1080
|
+
print("\n已自动发现支持 HPC/Slurm 作业 API 的授权区域:")
|
|
1081
|
+
for item in contexts:
|
|
1082
|
+
scheduler_names = ", ".join(
|
|
1083
|
+
str(scheduler.get("name") or scheduler.get("id"))
|
|
1084
|
+
for scheduler in item["schedulers"]
|
|
1085
|
+
)
|
|
1086
|
+
print(
|
|
1087
|
+
f" - {item.get('name')} ({item['region_id']})"
|
|
1088
|
+
f" / user={item.get('username')}"
|
|
1089
|
+
f" / scheduler={scheduler_names or 'none'}"
|
|
1090
|
+
)
|
|
1091
|
+
region_labels = [
|
|
1092
|
+
f"{item.get('name')} ({item['region_id']})"
|
|
1093
|
+
for item in contexts
|
|
1094
|
+
]
|
|
1095
|
+
existing_enabled = existing_openapi.get("enabled_region_ids")
|
|
1096
|
+
if isinstance(existing_enabled, list):
|
|
1097
|
+
enabled_ids = {str(item) for item in existing_enabled}
|
|
1098
|
+
elif action == "new":
|
|
1099
|
+
enabled_ids = {item["region_id"] for item in contexts}
|
|
1100
|
+
else:
|
|
1101
|
+
enabled_ids = {
|
|
1102
|
+
str(item)
|
|
1103
|
+
for item in (existing_openapi.get("regions") or {})
|
|
1104
|
+
}
|
|
1105
|
+
default_enabled = {
|
|
1106
|
+
index
|
|
1107
|
+
for index, item in enumerate(contexts)
|
|
1108
|
+
if item["region_id"] in enabled_ids
|
|
1109
|
+
}
|
|
1110
|
+
selected_labels = _multi_choose(
|
|
1111
|
+
"选择在本机启用的 HPC 区域(可多选):",
|
|
1112
|
+
region_labels,
|
|
1113
|
+
default_enabled,
|
|
1114
|
+
)
|
|
1115
|
+
selected_label_set = set(selected_labels)
|
|
1116
|
+
enabled_contexts = [
|
|
1117
|
+
item
|
|
1118
|
+
for item, label in zip(contexts, region_labels)
|
|
1119
|
+
if label in selected_label_set
|
|
1120
|
+
]
|
|
1121
|
+
existing_region = str(
|
|
1122
|
+
existing_openapi.get("default_region_id")
|
|
1123
|
+
or existing_openapi.get("region_id")
|
|
1124
|
+
or ""
|
|
1125
|
+
)
|
|
1126
|
+
default_region = next(
|
|
1127
|
+
(
|
|
1128
|
+
index
|
|
1129
|
+
for index, item in enumerate(enabled_contexts, 1)
|
|
1130
|
+
if item["region_id"] == existing_region
|
|
1131
|
+
),
|
|
1132
|
+
1,
|
|
1133
|
+
)
|
|
1134
|
+
enabled_labels = [
|
|
1135
|
+
f"{item.get('name')} ({item['region_id']})"
|
|
1136
|
+
for item in enabled_contexts
|
|
1137
|
+
]
|
|
1138
|
+
selected_label = _choose(
|
|
1139
|
+
"从已启用区域中选择默认 HPC 作业区域(单选):",
|
|
1140
|
+
enabled_labels,
|
|
1141
|
+
default_region,
|
|
1142
|
+
)
|
|
1143
|
+
selected_index = enabled_labels.index(selected_label)
|
|
1144
|
+
selected = enabled_contexts[selected_index]
|
|
1145
|
+
finally:
|
|
1146
|
+
_restore_environment(previous)
|
|
1147
|
+
stored_provider = saved_provider
|
|
1148
|
+
if entered_credentials and secure_store_name():
|
|
1149
|
+
if _ask_yes_no(
|
|
1150
|
+
f"是否将 AK/SK 保存到 {secure_store_name()}?",
|
|
1151
|
+
default=True,
|
|
1152
|
+
):
|
|
1153
|
+
try:
|
|
1154
|
+
provider = store_openapi_credentials(
|
|
1155
|
+
api_user, access_key, secret_key
|
|
1156
|
+
)
|
|
1157
|
+
stored_provider = provider
|
|
1158
|
+
print(f"OpenAPI 凭据已保存到 {provider}。")
|
|
1159
|
+
except CredentialError as exc:
|
|
1160
|
+
print(f"无法保存到安全凭据库:{exc}")
|
|
1161
|
+
selected_schedulers = selected.get("schedulers") or []
|
|
1162
|
+
default_scheduler = (
|
|
1163
|
+
selected_schedulers[0].get("id")
|
|
1164
|
+
if len(selected_schedulers) == 1
|
|
1165
|
+
else ""
|
|
1166
|
+
)
|
|
1167
|
+
result["openapi"] = {
|
|
1168
|
+
"platform_user": api_user,
|
|
1169
|
+
"credential_provider": stored_provider,
|
|
1170
|
+
"default_region_id": selected["region_id"],
|
|
1171
|
+
"enabled_region_ids": [
|
|
1172
|
+
item["region_id"] for item in enabled_contexts
|
|
1173
|
+
],
|
|
1174
|
+
"region_id": selected["region_id"],
|
|
1175
|
+
"region_name": selected.get("name"),
|
|
1176
|
+
"scheduler_id": default_scheduler,
|
|
1177
|
+
"username": selected.get("username"),
|
|
1178
|
+
"regions": {
|
|
1179
|
+
item["region_id"]: {
|
|
1180
|
+
"name": item.get("name"),
|
|
1181
|
+
"available": True,
|
|
1182
|
+
"username": item.get("username"),
|
|
1183
|
+
"home_path": item.get("home_path"),
|
|
1184
|
+
"schedulers": item.get("schedulers"),
|
|
1185
|
+
}
|
|
1186
|
+
for item in contexts
|
|
1187
|
+
},
|
|
1188
|
+
}
|
|
1189
|
+
print(
|
|
1190
|
+
"\nHPC OpenAPI 配置完成:\n"
|
|
1191
|
+
f" 默认区域:{selected.get('name') or selected['region_id']}"
|
|
1192
|
+
f" ({selected['region_id']})\n"
|
|
1193
|
+
f" 区域用户:{selected.get('username')}\n"
|
|
1194
|
+
f" Home:{selected.get('home_path')}\n"
|
|
1195
|
+
f" Scheduler:"
|
|
1196
|
+
f"{', '.join(str(item.get('name') or item.get('id')) for item in selected_schedulers) or '未发现'}\n"
|
|
1197
|
+
" API 类型:HPC/Slurm 作业 API(非 Notebook API)"
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
if selected_cluster:
|
|
1201
|
+
result["cluster"] = selected_cluster
|
|
1202
|
+
path = save_user_config(result)
|
|
1203
|
+
print(f"\n已保存非敏感配置:{path}")
|
|
1204
|
+
print("后续可用 `python3 scripts/scnet.py config` 查看。")
|
|
1205
|
+
if configure_openapi and not secure_store_name():
|
|
1206
|
+
print("系统没有可用安全凭据库;请通过环境变量注入 AK/SK。")
|
|
1207
|
+
doctor_cluster = selected_cluster or str(current.get("cluster") or "")
|
|
1208
|
+
suffix = f" --cluster {shlex.quote(doctor_cluster)}" if doctor_cluster else ""
|
|
1209
|
+
print(f"下一步:python3 scripts/scnet.py{suffix} doctor")
|
|
1210
|
+
return result
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def doctor_report(
|
|
1214
|
+
backend_name: str,
|
|
1215
|
+
profile_name: str | None,
|
|
1216
|
+
profile: dict[str, str],
|
|
1217
|
+
user_config: dict[str, Any],
|
|
1218
|
+
*,
|
|
1219
|
+
no_network: bool,
|
|
1220
|
+
timeout: int,
|
|
1221
|
+
) -> dict[str, Any]:
|
|
1222
|
+
checks: list[dict[str, Any]] = []
|
|
1223
|
+
|
|
1224
|
+
def add(name: str, ok: bool, detail: str) -> None:
|
|
1225
|
+
checks.append({"name": name, "ok": ok, "detail": detail})
|
|
1226
|
+
|
|
1227
|
+
try:
|
|
1228
|
+
path = config_path()
|
|
1229
|
+
if path.is_file():
|
|
1230
|
+
mode = path.stat().st_mode & 0o777
|
|
1231
|
+
add(
|
|
1232
|
+
"user_config",
|
|
1233
|
+
mode & 0o077 == 0,
|
|
1234
|
+
f"{path} mode={mode:03o}",
|
|
1235
|
+
)
|
|
1236
|
+
else:
|
|
1237
|
+
add("user_config", False, f"{path} 不存在;先运行 setup")
|
|
1238
|
+
except OSError as exc:
|
|
1239
|
+
add("user_config", False, str(exc))
|
|
1240
|
+
|
|
1241
|
+
profile_required = backend_name == "ssh"
|
|
1242
|
+
add(
|
|
1243
|
+
"profile",
|
|
1244
|
+
bool(profile_name and profile.get("CLUSTER_ID"))
|
|
1245
|
+
if profile_required
|
|
1246
|
+
else True,
|
|
1247
|
+
(profile_name or "未选择集群 profile")
|
|
1248
|
+
if profile_required
|
|
1249
|
+
else "OpenAPI 不需要 SSH profile",
|
|
1250
|
+
)
|
|
1251
|
+
add(
|
|
1252
|
+
"backend",
|
|
1253
|
+
backend_name in {item["name"] for item in backend_catalog()},
|
|
1254
|
+
backend_name,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
if backend_name == "ssh":
|
|
1258
|
+
for command in ("ssh", "scp"):
|
|
1259
|
+
executable = shutil.which(command)
|
|
1260
|
+
add(command, bool(executable), executable or f"找不到 {command}")
|
|
1261
|
+
alias = profile.get("CLUSTER_ID")
|
|
1262
|
+
if alias and shutil.which("ssh"):
|
|
1263
|
+
completed = subprocess.run(
|
|
1264
|
+
["ssh", "-G", alias],
|
|
1265
|
+
stdout=subprocess.DEVNULL,
|
|
1266
|
+
stderr=subprocess.PIPE,
|
|
1267
|
+
text=True,
|
|
1268
|
+
check=False,
|
|
1269
|
+
)
|
|
1270
|
+
add(
|
|
1271
|
+
"ssh_config",
|
|
1272
|
+
completed.returncode == 0,
|
|
1273
|
+
f"alias={alias}"
|
|
1274
|
+
if completed.returncode == 0
|
|
1275
|
+
else completed.stderr.strip(),
|
|
1276
|
+
)
|
|
1277
|
+
if not no_network:
|
|
1278
|
+
completed = subprocess.run(
|
|
1279
|
+
[
|
|
1280
|
+
"ssh",
|
|
1281
|
+
"-o",
|
|
1282
|
+
"BatchMode=yes",
|
|
1283
|
+
"-o",
|
|
1284
|
+
"StrictHostKeyChecking=yes",
|
|
1285
|
+
"-o",
|
|
1286
|
+
f"ConnectTimeout={min(timeout, 20)}",
|
|
1287
|
+
alias,
|
|
1288
|
+
"printf SCNET_OK",
|
|
1289
|
+
],
|
|
1290
|
+
stdout=subprocess.PIPE,
|
|
1291
|
+
stderr=subprocess.PIPE,
|
|
1292
|
+
text=True,
|
|
1293
|
+
check=False,
|
|
1294
|
+
)
|
|
1295
|
+
add(
|
|
1296
|
+
"ssh_connection",
|
|
1297
|
+
completed.returncode == 0
|
|
1298
|
+
and completed.stdout.strip() == "SCNET_OK",
|
|
1299
|
+
"连接成功"
|
|
1300
|
+
if completed.returncode == 0
|
|
1301
|
+
else (completed.stderr.strip() or "连接失败"),
|
|
1302
|
+
)
|
|
1303
|
+
ssh_config = user_config.get("ssh", {})
|
|
1304
|
+
ssh_clusters = (
|
|
1305
|
+
ssh_config.get("clusters", {})
|
|
1306
|
+
if isinstance(ssh_config, dict)
|
|
1307
|
+
else {}
|
|
1308
|
+
)
|
|
1309
|
+
metadata = (
|
|
1310
|
+
ssh_clusters.get(profile_name, {})
|
|
1311
|
+
if isinstance(ssh_clusters, dict) and profile_name
|
|
1312
|
+
else {}
|
|
1313
|
+
)
|
|
1314
|
+
if isinstance(metadata, dict):
|
|
1315
|
+
key_path = metadata.get("key_path")
|
|
1316
|
+
if key_path:
|
|
1317
|
+
expanded = Path(str(key_path)).expanduser()
|
|
1318
|
+
add(
|
|
1319
|
+
"ssh_key",
|
|
1320
|
+
expanded.is_file(),
|
|
1321
|
+
str(expanded) if expanded.is_file() else f"找不到 {expanded}",
|
|
1322
|
+
)
|
|
1323
|
+
expiry = metadata.get("key_expires_at")
|
|
1324
|
+
if expiry:
|
|
1325
|
+
try:
|
|
1326
|
+
expiry_date = dt.date.fromisoformat(str(expiry))
|
|
1327
|
+
days = (expiry_date - dt.date.today()).days
|
|
1328
|
+
add(
|
|
1329
|
+
"ssh_key_expiry",
|
|
1330
|
+
days >= 0,
|
|
1331
|
+
f"{expiry}(剩余 {days} 天)"
|
|
1332
|
+
if days >= 0
|
|
1333
|
+
else f"{expiry}(已过期 {abs(days)} 天)",
|
|
1334
|
+
)
|
|
1335
|
+
except ValueError:
|
|
1336
|
+
add("ssh_key_expiry", False, f"无法解析日期: {expiry}")
|
|
1337
|
+
elif not no_network:
|
|
1338
|
+
add("ssh_connection", False, "缺少 profile 或 ssh 命令")
|
|
1339
|
+
|
|
1340
|
+
elif backend_name == "openapi":
|
|
1341
|
+
has_token = bool(os.environ.get("SCNET_OPENAPI_TOKEN"))
|
|
1342
|
+
credentials, credential_provider = load_openapi_credentials()
|
|
1343
|
+
has_aksk = bool(credentials)
|
|
1344
|
+
add(
|
|
1345
|
+
"openapi_credentials",
|
|
1346
|
+
has_token or has_aksk,
|
|
1347
|
+
"检测到区域 token"
|
|
1348
|
+
if has_token
|
|
1349
|
+
else (
|
|
1350
|
+
f"检测到 AK/SK({credential_provider})"
|
|
1351
|
+
if has_aksk
|
|
1352
|
+
else "未检测到 OpenAPI 凭据"
|
|
1353
|
+
),
|
|
1354
|
+
)
|
|
1355
|
+
openapi_config = user_config.get("openapi", {})
|
|
1356
|
+
region = (
|
|
1357
|
+
(
|
|
1358
|
+
openapi_config.get("default_region_id")
|
|
1359
|
+
or openapi_config.get("region_id")
|
|
1360
|
+
)
|
|
1361
|
+
if isinstance(openapi_config, dict)
|
|
1362
|
+
else None
|
|
1363
|
+
)
|
|
1364
|
+
add("openapi_region", bool(region), str(region or "未配置区域"))
|
|
1365
|
+
cached_regions = (
|
|
1366
|
+
openapi_config.get("regions")
|
|
1367
|
+
if isinstance(openapi_config, dict)
|
|
1368
|
+
else None
|
|
1369
|
+
)
|
|
1370
|
+
if isinstance(cached_regions, dict):
|
|
1371
|
+
add(
|
|
1372
|
+
"openapi_region_cache",
|
|
1373
|
+
bool(cached_regions),
|
|
1374
|
+
f"已缓存区域数={len(cached_regions)}",
|
|
1375
|
+
)
|
|
1376
|
+
if not no_network and (has_token or has_aksk):
|
|
1377
|
+
context = BackendContext(
|
|
1378
|
+
repo_root=REPO_ROOT,
|
|
1379
|
+
profile_name=profile_name,
|
|
1380
|
+
profile=profile,
|
|
1381
|
+
timeout=timeout,
|
|
1382
|
+
)
|
|
1383
|
+
try:
|
|
1384
|
+
backend = create_backend("openapi", context)
|
|
1385
|
+
regions = backend.execute("clusters", {})
|
|
1386
|
+
add("openapi_connection", True, f"可访问区域数={len(regions)}")
|
|
1387
|
+
if region:
|
|
1388
|
+
options = {
|
|
1389
|
+
"region": region,
|
|
1390
|
+
"scheduler_id": openapi_config.get("scheduler_id"),
|
|
1391
|
+
"username": openapi_config.get("username"),
|
|
1392
|
+
}
|
|
1393
|
+
queues = backend.execute("queues", options)
|
|
1394
|
+
add("openapi_queues", True, f"可访问队列数={len(queues)}")
|
|
1395
|
+
except BackendError as exc:
|
|
1396
|
+
add("openapi_connection", False, str(exc))
|
|
1397
|
+
|
|
1398
|
+
else:
|
|
1399
|
+
try:
|
|
1400
|
+
context = BackendContext(
|
|
1401
|
+
repo_root=REPO_ROOT,
|
|
1402
|
+
profile_name=profile_name,
|
|
1403
|
+
profile=profile,
|
|
1404
|
+
timeout=timeout,
|
|
1405
|
+
)
|
|
1406
|
+
backend = create_backend(backend_name, context)
|
|
1407
|
+
add(
|
|
1408
|
+
"external_backend",
|
|
1409
|
+
True,
|
|
1410
|
+
", ".join(sorted(backend.capabilities)),
|
|
1411
|
+
)
|
|
1412
|
+
except BackendError as exc:
|
|
1413
|
+
add("external_backend", False, str(exc))
|
|
1414
|
+
|
|
1415
|
+
return {
|
|
1416
|
+
"ok": all(item["ok"] for item in checks),
|
|
1417
|
+
"backend": backend_name,
|
|
1418
|
+
"cluster": profile_name,
|
|
1419
|
+
"checks": checks,
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def apply_openapi_defaults(
|
|
1424
|
+
options: dict[str, Any], openapi_config: Mapping[str, Any]
|
|
1425
|
+
) -> dict[str, Any]:
|
|
1426
|
+
region = (
|
|
1427
|
+
options.get("region")
|
|
1428
|
+
or openapi_config.get("default_region_id")
|
|
1429
|
+
or openapi_config.get("region_id")
|
|
1430
|
+
)
|
|
1431
|
+
options["region"] = region
|
|
1432
|
+
region_config: Mapping[str, Any] = {}
|
|
1433
|
+
regions = openapi_config.get("regions")
|
|
1434
|
+
if isinstance(regions, dict) and region:
|
|
1435
|
+
candidate = regions.get(str(region))
|
|
1436
|
+
if isinstance(candidate, dict):
|
|
1437
|
+
region_config = candidate
|
|
1438
|
+
default_region = str(
|
|
1439
|
+
openapi_config.get("default_region_id")
|
|
1440
|
+
or openapi_config.get("region_id")
|
|
1441
|
+
or ""
|
|
1442
|
+
)
|
|
1443
|
+
scheduler_id = options.get("scheduler_id")
|
|
1444
|
+
if not scheduler_id and str(region or "") == default_region:
|
|
1445
|
+
scheduler_id = openapi_config.get("scheduler_id")
|
|
1446
|
+
schedulers = region_config.get("schedulers")
|
|
1447
|
+
if not scheduler_id and isinstance(schedulers, list):
|
|
1448
|
+
available = [
|
|
1449
|
+
item
|
|
1450
|
+
for item in schedulers
|
|
1451
|
+
if isinstance(item, dict) and item.get("id")
|
|
1452
|
+
]
|
|
1453
|
+
if len(available) == 1:
|
|
1454
|
+
scheduler_id = available[0]["id"]
|
|
1455
|
+
options["scheduler_id"] = scheduler_id
|
|
1456
|
+
options["username"] = (
|
|
1457
|
+
options.get("username")
|
|
1458
|
+
or region_config.get("username")
|
|
1459
|
+
or (
|
|
1460
|
+
openapi_config.get("username")
|
|
1461
|
+
if str(region or "") == default_region
|
|
1462
|
+
else None
|
|
1463
|
+
)
|
|
1464
|
+
)
|
|
1465
|
+
return options
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1469
|
+
parser = build_parser()
|
|
1470
|
+
args = parser.parse_args(argv)
|
|
1471
|
+
if args.timeout < 1 or args.timeout > 3600:
|
|
1472
|
+
parser.error("--timeout must be between 1 and 3600 seconds")
|
|
1473
|
+
|
|
1474
|
+
try:
|
|
1475
|
+
user_config = load_user_config()
|
|
1476
|
+
selected_cluster = args.cluster or str(user_config.get("cluster") or "") or None
|
|
1477
|
+
if args.operation == "backends":
|
|
1478
|
+
data = backend_catalog()
|
|
1479
|
+
backend_name = None
|
|
1480
|
+
elif args.operation == "setup":
|
|
1481
|
+
data = setup_lifecycle(
|
|
1482
|
+
args.setup_action,
|
|
1483
|
+
args.mode,
|
|
1484
|
+
args.skip_connect,
|
|
1485
|
+
)
|
|
1486
|
+
backend_name = data.get("default_backend")
|
|
1487
|
+
elif args.operation == "config":
|
|
1488
|
+
data = redacted_config(user_config)
|
|
1489
|
+
backend_name = None
|
|
1490
|
+
else:
|
|
1491
|
+
profile_name, profile = load_profile(REPO_ROOT, selected_cluster)
|
|
1492
|
+
backend_name = choose_backend(args.backend, profile, user_config)
|
|
1493
|
+
context = BackendContext(
|
|
1494
|
+
repo_root=REPO_ROOT,
|
|
1495
|
+
profile_name=profile_name,
|
|
1496
|
+
profile=profile,
|
|
1497
|
+
timeout=args.timeout,
|
|
1498
|
+
)
|
|
1499
|
+
backend = create_backend(backend_name, context)
|
|
1500
|
+
options = vars(args).copy()
|
|
1501
|
+
openapi_config = user_config.get("openapi", {})
|
|
1502
|
+
if isinstance(openapi_config, dict):
|
|
1503
|
+
apply_openapi_defaults(options, openapi_config)
|
|
1504
|
+
if args.operation == "notebook":
|
|
1505
|
+
if backend_name != "openapi":
|
|
1506
|
+
raise BackendError(
|
|
1507
|
+
"Notebook management requires the OpenAPI backend"
|
|
1508
|
+
)
|
|
1509
|
+
data = execute_notebook(args, context, user_config)
|
|
1510
|
+
elif args.dry_run:
|
|
1511
|
+
if args.operation not in MUTATING_OPERATIONS:
|
|
1512
|
+
raise BackendError(
|
|
1513
|
+
"--dry-run 仅适用于 submit/cancel/mkdir/upload/download/exec"
|
|
1514
|
+
)
|
|
1515
|
+
data = backend.preview(args.operation, options)
|
|
1516
|
+
elif args.operation == "doctor":
|
|
1517
|
+
data = doctor_report(
|
|
1518
|
+
backend_name,
|
|
1519
|
+
profile_name,
|
|
1520
|
+
profile,
|
|
1521
|
+
user_config,
|
|
1522
|
+
no_network=args.no_network,
|
|
1523
|
+
timeout=args.timeout,
|
|
1524
|
+
)
|
|
1525
|
+
elif args.operation == "capabilities":
|
|
1526
|
+
data = {
|
|
1527
|
+
"name": backend.name,
|
|
1528
|
+
"capabilities": sorted(backend.capabilities),
|
|
1529
|
+
}
|
|
1530
|
+
elif args.operation == "exec":
|
|
1531
|
+
options["command"] = " ".join(args.command).strip()
|
|
1532
|
+
data = backend.execute(args.operation, options)
|
|
1533
|
+
else:
|
|
1534
|
+
data = backend.execute(args.operation, options)
|
|
1535
|
+
if args.json:
|
|
1536
|
+
print(
|
|
1537
|
+
json.dumps(
|
|
1538
|
+
{
|
|
1539
|
+
"ok": True,
|
|
1540
|
+
"version": VERSION,
|
|
1541
|
+
"backend": backend_name,
|
|
1542
|
+
"operation": args.operation,
|
|
1543
|
+
"data": data,
|
|
1544
|
+
},
|
|
1545
|
+
ensure_ascii=False,
|
|
1546
|
+
indent=2,
|
|
1547
|
+
sort_keys=True,
|
|
1548
|
+
)
|
|
1549
|
+
)
|
|
1550
|
+
elif args.operation != "setup":
|
|
1551
|
+
print(compact_output(args.operation, data))
|
|
1552
|
+
if args.operation == "doctor" and not data.get("ok", False):
|
|
1553
|
+
return 1
|
|
1554
|
+
return 0
|
|
1555
|
+
except BackendError as exc:
|
|
1556
|
+
if args.json:
|
|
1557
|
+
print(
|
|
1558
|
+
json.dumps(
|
|
1559
|
+
{
|
|
1560
|
+
"ok": False,
|
|
1561
|
+
"operation": args.operation,
|
|
1562
|
+
"error": str(exc),
|
|
1563
|
+
},
|
|
1564
|
+
ensure_ascii=False,
|
|
1565
|
+
)
|
|
1566
|
+
)
|
|
1567
|
+
else:
|
|
1568
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1569
|
+
return 2
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
if __name__ == "__main__":
|
|
1573
|
+
raise SystemExit(main())
|