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.
- package/README.md +20 -113
- 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,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
|
+
)
|