dsh-scnet 0.4.1 → 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.
Files changed (38) hide show
  1. package/README.md +20 -113
  2. package/README_CN.md +20 -1
  3. package/docs/installation.md +10 -0
  4. package/docs/installation.zh-CN.md +13 -1
  5. package/docs/testing.md +11 -0
  6. package/docs/testing.zh-CN.md +10 -0
  7. package/index.mjs +184 -0
  8. package/package.json +4 -2
  9. package/skills/scnet-hpc/SKILL.md +63 -105
  10. package/skills/scnet-hpc/VERSION +1 -0
  11. package/skills/scnet-hpc/clusters/_template.conf +10 -4
  12. package/skills/scnet-hpc/references/adding-cluster.md +6 -1
  13. package/skills/scnet-hpc/references/backends.md +177 -0
  14. package/skills/scnet-hpc/references/notebook.md +60 -0
  15. package/skills/scnet-hpc/references/openapi.md +140 -0
  16. package/skills/scnet-hpc/references/quickstart-en.md +22 -0
  17. package/skills/scnet-hpc/references/setup.md +4 -0
  18. package/skills/scnet-hpc/scripts/__pycache__/compute-probe.cpython-312.pyc +0 -0
  19. package/skills/scnet-hpc/scripts/__pycache__/scnet.cpython-312.pyc +0 -0
  20. package/skills/scnet-hpc/scripts/__pycache__/scnet_config.cpython-312.pyc +0 -0
  21. package/skills/scnet-hpc/scripts/__pycache__/scnet_credentials.cpython-312.pyc +0 -0
  22. package/skills/scnet-hpc/scripts/__pycache__/scnet_version.cpython-312.pyc +0 -0
  23. package/skills/scnet-hpc/scripts/scnet.py +1573 -0
  24. package/skills/scnet-hpc/scripts/scnet_backends/__init__.py +53 -0
  25. package/skills/scnet-hpc/scripts/scnet_backends/base.py +64 -0
  26. package/skills/scnet-hpc/scripts/scnet_backends/external.py +92 -0
  27. package/skills/scnet-hpc/scripts/scnet_backends/openapi.py +771 -0
  28. package/skills/scnet-hpc/scripts/scnet_backends/profile.py +78 -0
  29. package/skills/scnet-hpc/scripts/scnet_backends/ssh.py +258 -0
  30. package/skills/scnet-hpc/scripts/scnet_config.py +175 -0
  31. package/skills/scnet-hpc/scripts/scnet_credentials.py +160 -0
  32. package/skills/scnet-hpc/scripts/scnet_sdk/__init__.py +11 -0
  33. package/skills/scnet-hpc/scripts/scnet_sdk/cli.py +394 -0
  34. package/skills/scnet-hpc/scripts/scnet_sdk/client.py +240 -0
  35. package/skills/scnet-hpc/scripts/scnet_sdk/notebook.py +379 -0
  36. package/skills/scnet-hpc/scripts/scnet_version.py +20 -0
  37. package/skills/scnet-hpc/scripts/setup-ssh.sh +33 -1
  38. 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())