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,394 @@
1
+ """CLI adapter for the internal SCNet service client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Mapping
9
+
10
+ from scnet_backends.base import BackendContext, BackendError
11
+
12
+ from .client import SCNetClient
13
+ from .notebook import NotebookService
14
+
15
+
16
+ def add_notebook_parser(subparsers: Any) -> None:
17
+ notebook = subparsers.add_parser(
18
+ "notebook", help="inspect SCNet Notebook resources and instances"
19
+ )
20
+ notebook.add_argument(
21
+ "--region",
22
+ default=argparse.SUPPRESS,
23
+ help="region name or ID; otherwise use the saved Notebook default",
24
+ )
25
+ commands = notebook.add_subparsers(
26
+ dest="notebook_operation", required=True
27
+ )
28
+ regions = commands.add_parser("regions", help="list Notebook-capable regions")
29
+
30
+ resources = commands.add_parser(
31
+ "resources", help="list Notebook accelerator resources"
32
+ )
33
+ resources.add_argument("--region", default=argparse.SUPPRESS)
34
+ resources.add_argument("--resource-id", default="")
35
+
36
+ images = commands.add_parser("images", help="list Notebook images")
37
+ images.add_argument("--region", default=argparse.SUPPRESS)
38
+ images.add_argument("--access", choices=("public", "private"), default="public")
39
+ images.add_argument("--type", dest="image_type", default="")
40
+ images.add_argument("--accelerator-type", default="")
41
+ images.add_argument("--name", default="")
42
+ images.add_argument("--start", type=int, default=0)
43
+ images.add_argument("--limit", type=int, default=20)
44
+
45
+ listing = commands.add_parser("list", help="list Notebook instances")
46
+ listing.add_argument("--region", default=argparse.SUPPRESS)
47
+ listing.add_argument("--name", default="")
48
+ listing.add_argument("--status", default="")
49
+ listing.add_argument("--page", type=int, default=1)
50
+ listing.add_argument("--size", type=int, default=20)
51
+
52
+ show = commands.add_parser("show", help="show one Notebook instance")
53
+ show.add_argument("--region", default=argparse.SUPPRESS)
54
+ show.add_argument("notebook_id")
55
+
56
+ url = commands.add_parser("url", help="show the Jupyter access status")
57
+ url.add_argument("--region", default=argparse.SUPPRESS)
58
+ url.add_argument("notebook_id")
59
+ url.add_argument(
60
+ "--reveal",
61
+ action="store_true",
62
+ help="include the sensitive URL with embedded credentials",
63
+ )
64
+
65
+ create = commands.add_parser(
66
+ "create", help="create a minimal Notebook with safe automatic defaults"
67
+ )
68
+ create.add_argument("--region", default=argparse.SUPPRESS)
69
+ create.add_argument("--resource-group", default="")
70
+ create.add_argument("--image-id", default="")
71
+ create.add_argument("--cards", type=int, default=1)
72
+ create.add_argument("--no-mount-home", action="store_true")
73
+ create.add_argument("--start-command", default="")
74
+ create.add_argument("--no-wait", action="store_true")
75
+ create.add_argument("--timeout", type=int, default=600)
76
+ create.add_argument("--yes", action="store_true")
77
+
78
+ start = commands.add_parser("start", help="start a stopped Notebook")
79
+ start.add_argument("--region", default=argparse.SUPPRESS)
80
+ start.add_argument("notebook_id")
81
+ start.add_argument("--no-wait", action="store_true")
82
+ start.add_argument("--timeout", type=int, default=600)
83
+ start.add_argument("--yes", action="store_true")
84
+
85
+ stop = commands.add_parser("stop", help="stop a running Notebook")
86
+ stop.add_argument("--region", default=argparse.SUPPRESS)
87
+ stop.add_argument("notebook_id")
88
+ stop.add_argument("--save-environment", action="store_true")
89
+ stop.add_argument("--no-wait", action="store_true")
90
+ stop.add_argument("--timeout", type=int, default=300)
91
+ stop.add_argument("--yes", action="store_true")
92
+
93
+ release = commands.add_parser(
94
+ "release", help="permanently release a Notebook"
95
+ )
96
+ release.add_argument("--region", default=argparse.SUPPRESS)
97
+ release.add_argument("notebook_id")
98
+ release.add_argument("--yes", action="store_true")
99
+
100
+ rename = commands.add_parser("rename", help="rename a Notebook")
101
+ rename.add_argument("--region", default=argparse.SUPPRESS)
102
+ rename.add_argument("notebook_id")
103
+ rename.add_argument("name")
104
+ rename.add_argument("--yes", action="store_true")
105
+
106
+ smoke = commands.add_parser(
107
+ "smoke",
108
+ help="create, validate, stop, and release a minimal Notebook",
109
+ )
110
+ smoke.add_argument("--region", default=argparse.SUPPRESS)
111
+ smoke.add_argument("--resource-group", default="")
112
+ smoke.add_argument("--image-id", default="")
113
+ smoke.add_argument("--cards", type=int, default=1)
114
+ smoke.add_argument("--timeout", type=int, default=600)
115
+ smoke.add_argument("--yes", action="store_true")
116
+
117
+
118
+ def _default_region(
119
+ args: argparse.Namespace, config: Mapping[str, Any]
120
+ ) -> str:
121
+ region = getattr(args, "region", None)
122
+ if region:
123
+ return str(region)
124
+ openapi = config.get("openapi")
125
+ if isinstance(openapi, dict):
126
+ value = (
127
+ openapi.get("default_notebook_region_id")
128
+ or openapi.get("default_region_id")
129
+ or openapi.get("region_id")
130
+ )
131
+ if value:
132
+ return str(value)
133
+ raise BackendError(
134
+ "Notebook region is not configured; run `notebook regions` "
135
+ "or pass --region"
136
+ )
137
+
138
+
139
+ def _confirm(args: argparse.Namespace, message: str, expected: str = "") -> None:
140
+ if getattr(args, "yes", False):
141
+ return
142
+ if not sys.stdin.isatty():
143
+ raise BackendError("confirmation required; rerun with --yes")
144
+ if expected:
145
+ answer = input(f"{message}\n输入 {expected} 确认: ").strip()
146
+ if answer != expected:
147
+ raise BackendError("confirmation did not match")
148
+ return
149
+ answer = input(f"{message} [y/N]: ").strip().lower()
150
+ if answer not in {"y", "yes"}:
151
+ raise BackendError("operation cancelled")
152
+
153
+
154
+ def _plan_summary(plan: Mapping[str, Any]) -> str:
155
+ summary = plan.get("summary") or {}
156
+ return (
157
+ f"区域={summary.get('region')}, "
158
+ f"资源={summary.get('resource')}, "
159
+ f"卡数={summary.get('cards')}, "
160
+ f"CPU={summary.get('cpu')}, RAM={summary.get('ram')}, "
161
+ f"镜像={summary.get('image')}, "
162
+ f"挂载Home={summary.get('mount_home')}"
163
+ )
164
+
165
+
166
+ def execute_notebook(
167
+ args: argparse.Namespace,
168
+ context: BackendContext,
169
+ config: Mapping[str, Any],
170
+ ) -> Any:
171
+ service = NotebookService(SCNetClient(context))
172
+ operation = args.notebook_operation
173
+ if operation == "regions":
174
+ return service.regions()
175
+ region = _default_region(args, config)
176
+ if operation == "resources":
177
+ return service.resources(region, args.resource_id)
178
+ if operation == "images":
179
+ return service.images(
180
+ region,
181
+ access=args.access,
182
+ image_type=args.image_type,
183
+ accelerator_type=args.accelerator_type,
184
+ name=args.name,
185
+ start=args.start,
186
+ limit=args.limit,
187
+ )
188
+ if operation == "list":
189
+ return service.list_instances(
190
+ region,
191
+ name=args.name,
192
+ status=args.status,
193
+ page=args.page,
194
+ size=args.size,
195
+ )
196
+ if operation == "show":
197
+ return service.detail(region, args.notebook_id)
198
+ if operation == "url":
199
+ return service.url(
200
+ region, args.notebook_id, reveal=args.reveal
201
+ )
202
+ if operation == "create":
203
+ plan = service.create_plan(
204
+ region,
205
+ resource_group=args.resource_group,
206
+ image_id=args.image_id,
207
+ accelerator_number=args.cards,
208
+ mount_home=not args.no_mount_home,
209
+ start_command=args.start_command,
210
+ )
211
+ if args.dry_run:
212
+ return {"dry_run": True, "plan": plan["summary"]}
213
+ _confirm(
214
+ args,
215
+ "将创建可能计费的 Notebook:\n" + _plan_summary(plan),
216
+ )
217
+ created = service.create(region, plan)
218
+ notebook_id = str(created.get("notebookId") or "")
219
+ if not notebook_id or args.no_wait:
220
+ return {"created": created, "plan": plan["summary"]}
221
+ detail = service.wait_status(
222
+ region,
223
+ notebook_id,
224
+ {"Running", "Failed"},
225
+ timeout=args.timeout,
226
+ )
227
+ return {
228
+ "created": created,
229
+ "detail": detail,
230
+ "plan": plan["summary"],
231
+ }
232
+ if operation == "start":
233
+ if args.dry_run:
234
+ return {
235
+ "dry_run": True,
236
+ "operation": "start",
237
+ "notebook_id": args.notebook_id,
238
+ }
239
+ _confirm(
240
+ args,
241
+ f"启动 Notebook {args.notebook_id} 可能恢复计费。",
242
+ )
243
+ result = service.start(region, args.notebook_id)
244
+ if args.no_wait:
245
+ return {"requested": result}
246
+ return {
247
+ "requested": result,
248
+ "detail": service.wait_status(
249
+ region,
250
+ args.notebook_id,
251
+ {"Running", "Failed"},
252
+ timeout=args.timeout,
253
+ ),
254
+ }
255
+ if operation == "stop":
256
+ if args.dry_run:
257
+ return {
258
+ "dry_run": True,
259
+ "operation": "stop",
260
+ "notebook_id": args.notebook_id,
261
+ "save_environment": args.save_environment,
262
+ }
263
+ _confirm(args, f"停止 Notebook {args.notebook_id}。")
264
+ result = service.stop(
265
+ region,
266
+ args.notebook_id,
267
+ save_environment=args.save_environment,
268
+ )
269
+ if args.no_wait:
270
+ return {"requested": result}
271
+ return {
272
+ "requested": result,
273
+ "detail": service.wait_status(
274
+ region,
275
+ args.notebook_id,
276
+ {"Terminated", "Failed"},
277
+ timeout=args.timeout,
278
+ ),
279
+ }
280
+ if operation == "release":
281
+ if args.dry_run:
282
+ return {
283
+ "dry_run": True,
284
+ "operation": "release",
285
+ "notebook_id": args.notebook_id,
286
+ "destructive": True,
287
+ }
288
+ _confirm(
289
+ args,
290
+ f"释放 Notebook {args.notebook_id} 不可恢复。",
291
+ expected=args.notebook_id,
292
+ )
293
+ return {
294
+ "released": service.release(region, args.notebook_id),
295
+ "notebook_id": args.notebook_id,
296
+ }
297
+ if operation == "rename":
298
+ if args.dry_run:
299
+ return {
300
+ "dry_run": True,
301
+ "operation": "rename",
302
+ "notebook_id": args.notebook_id,
303
+ "name": args.name,
304
+ }
305
+ _confirm(
306
+ args,
307
+ f"将 Notebook {args.notebook_id} 重命名为 {args.name!r}。",
308
+ )
309
+ return {
310
+ "renamed": service.rename(
311
+ region, args.notebook_id, args.name
312
+ )
313
+ }
314
+ if operation == "smoke":
315
+ plan = service.create_plan(
316
+ region,
317
+ resource_group=args.resource_group,
318
+ image_id=args.image_id,
319
+ accelerator_number=args.cards,
320
+ mount_home=True,
321
+ )
322
+ if args.dry_run:
323
+ return {
324
+ "dry_run": True,
325
+ "operation": "smoke",
326
+ "plan": plan["summary"],
327
+ "cleanup": "stop and release",
328
+ }
329
+ _confirm(
330
+ args,
331
+ "将创建一个可能计费的临时 Notebook,"
332
+ "验证后自动停止并释放:\n"
333
+ + _plan_summary(plan),
334
+ )
335
+ record: dict[str, Any] = {
336
+ "started_at": datetime.now(timezone.utc).isoformat(),
337
+ "plan": plan["summary"],
338
+ "events": [],
339
+ }
340
+ notebook_id = ""
341
+ error: Exception | None = None
342
+ try:
343
+ created = service.create(region, plan)
344
+ notebook_id = str(created.get("notebookId") or "")
345
+ record["created"] = created
346
+ if not notebook_id:
347
+ raise BackendError("create returned no notebookId")
348
+ detail = service.wait_status(
349
+ region,
350
+ notebook_id,
351
+ {"Running", "Failed"},
352
+ timeout=args.timeout,
353
+ )
354
+ record["running_detail"] = detail
355
+ if detail.get("notebookStatus") != "Running":
356
+ raise BackendError("Notebook did not reach Running")
357
+ url = service.url(region, notebook_id)
358
+ record["url"] = url
359
+ if url.get("status") != "active":
360
+ raise BackendError("Jupyter URL is not active")
361
+ record["validated"] = True
362
+ except Exception as exc:
363
+ error = exc
364
+ record["error"] = str(exc)
365
+ finally:
366
+ if notebook_id:
367
+ try:
368
+ record["stop"] = service.stop(region, notebook_id)
369
+ record["stopped_detail"] = service.wait_status(
370
+ region,
371
+ notebook_id,
372
+ {"Terminated", "Failed"},
373
+ timeout=min(args.timeout, 300),
374
+ )
375
+ except Exception as exc:
376
+ record["stop_error"] = str(exc)
377
+ try:
378
+ record["release"] = service.release(
379
+ region, notebook_id
380
+ )
381
+ except Exception as exc:
382
+ record["release_error"] = str(exc)
383
+ record["finished_at"] = datetime.now(timezone.utc).isoformat()
384
+ record["ok"] = (
385
+ bool(record.get("validated"))
386
+ and "release_error" not in record
387
+ )
388
+ if error or not record["ok"]:
389
+ raise BackendError(
390
+ "Notebook smoke failed after cleanup: "
391
+ + str(error or record.get("release_error"))
392
+ )
393
+ return record
394
+ raise BackendError(f"unsupported Notebook operation: {operation}")
@@ -0,0 +1,240 @@
1
+ """Shared SCNet OpenAPI transport, authentication, and service discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import Any, Mapping
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import urlencode, urlsplit, urlunsplit
13
+ from urllib.request import Request, urlopen
14
+
15
+ from scnet_backends.base import BackendContext, BackendError
16
+ from scnet_credentials import load_openapi_credentials
17
+ from scnet_version import VERSION
18
+
19
+
20
+ def canonical_signature(
21
+ access_key: str, timestamp: str, user: str, secret_key: str
22
+ ) -> str:
23
+ message = json.dumps(
24
+ {"accessKey": access_key, "timestamp": timestamp, "user": user},
25
+ ensure_ascii=False,
26
+ separators=(",", ":"),
27
+ )
28
+ return hmac.new(
29
+ secret_key.encode("utf-8"),
30
+ message.encode("utf-8"),
31
+ hashlib.sha256,
32
+ ).hexdigest()
33
+
34
+
35
+ def service_endpoint(base_url: str, service: str, suffix: str) -> str:
36
+ split = urlsplit(base_url.rstrip("/"))
37
+ path = split.path.rstrip("/")
38
+ service_part = f"/{service}"
39
+ if not path.endswith(service_part):
40
+ path += service_part
41
+ path += "/" + suffix.lstrip("/")
42
+ return urlunsplit((split.scheme, split.netloc, path, "", ""))
43
+
44
+
45
+ class SCNetClient:
46
+ """One authenticated OpenAPI client shared by service-specific domains."""
47
+
48
+ def __init__(self, context: BackendContext):
49
+ self.context = context
50
+ self._regions_cache: list[dict[str, Any]] | None = None
51
+ self._center_cache: dict[str, dict[str, Any]] = {}
52
+
53
+ @staticmethod
54
+ def env(name: str, default: str | None = None) -> str | None:
55
+ value = os.environ.get(name)
56
+ return value if value not in (None, "") else default
57
+
58
+ def request(
59
+ self,
60
+ method: str,
61
+ url: str,
62
+ *,
63
+ token: str | None = None,
64
+ json_body: Any | None = None,
65
+ form: Mapping[str, Any] | None = None,
66
+ headers: Mapping[str, str] | None = None,
67
+ ) -> Any:
68
+ request_headers = {
69
+ "Accept": "application/json",
70
+ "User-Agent": f"scnet-hpc/{VERSION}",
71
+ }
72
+ if headers:
73
+ request_headers.update(headers)
74
+ if token:
75
+ request_headers["token"] = token
76
+ data: bytes | None = None
77
+ if json_body is not None:
78
+ data = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
79
+ request_headers["Content-Type"] = "application/json"
80
+ elif form is not None:
81
+ data = urlencode(form).encode("utf-8")
82
+ request_headers["Content-Type"] = "application/x-www-form-urlencoded"
83
+ request = Request(url, data=data, method=method, headers=request_headers)
84
+ try:
85
+ with urlopen(request, timeout=self.context.timeout) as response:
86
+ raw = response.read()
87
+ except HTTPError as exc:
88
+ detail = exc.read().decode("utf-8", "replace")
89
+ raise BackendError(
90
+ f"HTTP {exc.code} from SCNet OpenAPI: {detail[:500]}"
91
+ ) from exc
92
+ except (URLError, TimeoutError, OSError) as exc:
93
+ raise BackendError(f"SCNet OpenAPI request failed: {exc}") from exc
94
+ try:
95
+ payload = json.loads(raw.decode("utf-8"))
96
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
97
+ raise BackendError("SCNet OpenAPI returned a non-JSON response") from exc
98
+ if not isinstance(payload, dict):
99
+ raise BackendError("SCNet OpenAPI response must be a JSON object")
100
+ code = str(payload.get("code", ""))
101
+ if code != "0":
102
+ raise BackendError(
103
+ f"SCNet OpenAPI error {code}: "
104
+ f"{payload.get('msg') or 'unknown error'}"
105
+ )
106
+ return payload.get("data")
107
+
108
+ def regions(self, *, refresh: bool = False) -> list[dict[str, Any]]:
109
+ if self._regions_cache is not None and not refresh:
110
+ return self._regions_cache
111
+ direct_token = self.env("SCNET_OPENAPI_TOKEN")
112
+ if direct_token:
113
+ region_id = (
114
+ self.env("SCNET_OPENAPI_REGION_ID")
115
+ or self.context.profile.get("OPENAPI_REGION_ID")
116
+ or ""
117
+ )
118
+ self._regions_cache = [
119
+ {
120
+ "clusterId": region_id,
121
+ "clusterName": self.env(
122
+ "SCNET_OPENAPI_REGION_NAME", "configured"
123
+ ),
124
+ "token": direct_token,
125
+ }
126
+ ]
127
+ return self._regions_cache
128
+
129
+ credentials, _ = load_openapi_credentials()
130
+ user = self.env("SCNET_OPENAPI_USER") or (
131
+ credentials.get("user") if credentials else None
132
+ )
133
+ access_key = self.env("SCNET_OPENAPI_ACCESS_KEY") or (
134
+ credentials.get("access_key") if credentials else None
135
+ )
136
+ secret_key = self.env("SCNET_OPENAPI_SECRET_KEY") or (
137
+ credentials.get("secret_key") if credentials else None
138
+ )
139
+ missing = [
140
+ name
141
+ for name, value in (
142
+ ("SCNET_OPENAPI_USER", user),
143
+ ("SCNET_OPENAPI_ACCESS_KEY", access_key),
144
+ ("SCNET_OPENAPI_SECRET_KEY", secret_key),
145
+ )
146
+ if not value
147
+ ]
148
+ if missing:
149
+ raise BackendError(
150
+ "OpenAPI credentials are not configured; run setup or set "
151
+ + ", ".join(missing)
152
+ )
153
+ timestamp = str(int(time.time()))
154
+ signature = canonical_signature(access_key, timestamp, user, secret_key)
155
+ auth_base = self.env("SCNET_OPENAPI_AUTH_BASE", "https://api.scnet.cn")
156
+ data = self.request(
157
+ "POST",
158
+ auth_base.rstrip("/") + "/api/user/v3/tokens",
159
+ headers={
160
+ "user": user,
161
+ "accessKey": access_key,
162
+ "signature": signature,
163
+ "timestamp": timestamp,
164
+ },
165
+ )
166
+ if not isinstance(data, list):
167
+ raise BackendError("token endpoint returned an unexpected data shape")
168
+ self._regions_cache = [
169
+ item for item in data if isinstance(item, dict)
170
+ ]
171
+ return self._regions_cache
172
+
173
+ def select_region(
174
+ self, requested: str | None
175
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
176
+ regions = self.regions()
177
+ usable = [
178
+ item
179
+ for item in regions
180
+ if str(item.get("clusterId", "")) != "0" and item.get("token")
181
+ ]
182
+ if requested:
183
+ for item in usable:
184
+ if str(item.get("clusterId")) == str(requested) or item.get(
185
+ "clusterName"
186
+ ) == requested:
187
+ return item, regions
188
+ raise BackendError(f"OpenAPI region {requested!r} is not available")
189
+ if len(usable) == 1:
190
+ return usable[0], regions
191
+ names = ", ".join(
192
+ f"{item.get('clusterName')}({item.get('clusterId')})"
193
+ for item in usable
194
+ )
195
+ raise BackendError(
196
+ "multiple OpenAPI regions are available; select one: " + names
197
+ )
198
+
199
+ def center(
200
+ self, requested: str | None
201
+ ) -> tuple[dict[str, Any], str, dict[str, Any]]:
202
+ region, _ = self.select_region(requested)
203
+ region_id = str(region.get("clusterId", ""))
204
+ token = str(region["token"])
205
+ if region_id not in self._center_cache:
206
+ center_url = self.env(
207
+ "SCNET_OPENAPI_CENTER_URL",
208
+ "https://www.scnet.cn/ac/openapi/v2/center",
209
+ )
210
+ data = self.request("GET", center_url, token=token)
211
+ if not isinstance(data, dict):
212
+ raise BackendError(
213
+ "center endpoint returned an unexpected data shape"
214
+ )
215
+ self._center_cache[region_id] = data
216
+ return self._center_cache[region_id], token, region
217
+
218
+ @staticmethod
219
+ def enabled_url(center: Mapping[str, Any], field: str) -> str:
220
+ values = center.get(field)
221
+ if not isinstance(values, list):
222
+ raise BackendError(f"center response has no {field}")
223
+ for item in values:
224
+ if not isinstance(item, dict):
225
+ continue
226
+ enabled = str(item.get("enable", "true")).lower() == "true"
227
+ if enabled and item.get("url"):
228
+ return str(item["url"])
229
+ raise BackendError(f"center response has no enabled URL in {field}")
230
+
231
+ def service_context(
232
+ self, region: str | None, field: str, service: str
233
+ ) -> tuple[str, str, dict[str, Any], dict[str, Any]]:
234
+ center, token, selected = self.center(region)
235
+ return (
236
+ self.enabled_url(center, field),
237
+ token,
238
+ selected,
239
+ center,
240
+ )