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,379 @@
1
+ """Read-only Notebook domain built on the shared SCNet OpenAPI client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import urlencode, urlsplit, urlunsplit
9
+
10
+ from scnet_backends.base import BackendError
11
+
12
+ from .client import SCNetClient, service_endpoint
13
+
14
+
15
+ SENSITIVE_FIELDS = {
16
+ "sshPassword",
17
+ "password",
18
+ "token",
19
+ "userToken",
20
+ }
21
+
22
+
23
+ def _safe_url(value: Any) -> dict[str, Any] | None:
24
+ if not value:
25
+ return None
26
+ split = urlsplit(str(value))
27
+ return {
28
+ "origin": urlunsplit((split.scheme, split.netloc, "", "", "")),
29
+ "path": split.path,
30
+ "credentials_redacted": bool(split.query),
31
+ }
32
+
33
+
34
+ def _redact(value: Any) -> Any:
35
+ if isinstance(value, dict):
36
+ result = {}
37
+ for key, item in value.items():
38
+ if key in SENSITIVE_FIELDS:
39
+ result[key] = "<redacted>" if item else item
40
+ elif key in {"url", "sshUrl", "serviceUrl"}:
41
+ result[key] = _safe_url(item)
42
+ else:
43
+ result[key] = _redact(item)
44
+ return result
45
+ if isinstance(value, list):
46
+ return [_redact(item) for item in value]
47
+ return value
48
+
49
+
50
+ class NotebookService:
51
+ def __init__(self, client: SCNetClient):
52
+ self.client = client
53
+
54
+ def _context(
55
+ self, region: str | None
56
+ ) -> tuple[str, str, dict[str, Any], dict[str, Any]]:
57
+ return self.client.service_context(region, "aiUrls", "ai")
58
+
59
+ def regions(self) -> list[dict[str, Any]]:
60
+ result = []
61
+ for region in self.client.regions():
62
+ region_id = str(region.get("clusterId", ""))
63
+ if region_id == "0" or not region.get("token"):
64
+ continue
65
+ try:
66
+ ai_url, _, _, center = self._context(region_id)
67
+ except BackendError:
68
+ continue
69
+ result.append(
70
+ {
71
+ "region_id": region_id,
72
+ "name": region.get("clusterName") or center.get("name"),
73
+ "available": True,
74
+ "service": "notebook",
75
+ "endpoint_origin": _safe_url(ai_url),
76
+ }
77
+ )
78
+ return result
79
+
80
+ def resources(
81
+ self, region: str, resource_id: str = ""
82
+ ) -> list[dict[str, Any]]:
83
+ _, token, selected, _ = self._context(region)
84
+ params: dict[str, Any] = {
85
+ "clusterIds": str(selected.get("clusterId"))
86
+ }
87
+ if resource_id:
88
+ params["resourceId"] = resource_id
89
+ data = self.client.request(
90
+ "GET",
91
+ "https://www.scnet.cn/ac/openapi/v2/resources/accelerators?"
92
+ + urlencode(params),
93
+ token=token,
94
+ )
95
+ return _redact(data if isinstance(data, list) else [])
96
+
97
+ def images(
98
+ self,
99
+ region: str,
100
+ *,
101
+ access: str = "public",
102
+ image_type: str = "",
103
+ accelerator_type: str = "",
104
+ name: str = "",
105
+ start: int = 0,
106
+ limit: int = 20,
107
+ ) -> dict[str, Any]:
108
+ ai_url, token, _, _ = self._context(region)
109
+ body = {
110
+ "access": access,
111
+ "start": start,
112
+ "limit": limit,
113
+ "sort": "DESC",
114
+ "orderBy": "create_time",
115
+ "name": name,
116
+ "type": image_type,
117
+ "acceleratorType": accelerator_type,
118
+ }
119
+ data = self.client.request(
120
+ "POST",
121
+ service_endpoint(
122
+ ai_url, "ai", "/openapi/v2/image/images"
123
+ ),
124
+ token=token,
125
+ json_body=body,
126
+ )
127
+ return _redact(data if isinstance(data, dict) else {"data": data})
128
+
129
+ def list_instances(
130
+ self,
131
+ region: str,
132
+ *,
133
+ name: str = "",
134
+ status: str = "",
135
+ page: int = 1,
136
+ size: int = 20,
137
+ ) -> dict[str, Any]:
138
+ ai_url, token, _, _ = self._context(region)
139
+ params: dict[str, Any] = {"page": page, "size": size}
140
+ if name:
141
+ params["notebookName"] = name
142
+ if status:
143
+ params["notebookStatus"] = status
144
+ data = self.client.request(
145
+ "GET",
146
+ service_endpoint(
147
+ ai_url, "ai", "/openapi/v2/notebook/list"
148
+ )
149
+ + "?"
150
+ + urlencode(params),
151
+ token=token,
152
+ )
153
+ return _redact(data if isinstance(data, dict) else {"records": []})
154
+
155
+ def detail(self, region: str, notebook_id: str) -> dict[str, Any]:
156
+ ai_url, token, _, _ = self._context(region)
157
+ data = self.client.request(
158
+ "GET",
159
+ service_endpoint(
160
+ ai_url, "ai", "/openapi/v2/notebook/detail"
161
+ )
162
+ + "?"
163
+ + urlencode({"notebookId": notebook_id}),
164
+ token=token,
165
+ )
166
+ return _redact(data if isinstance(data, dict) else {})
167
+
168
+ def url(
169
+ self, region: str, notebook_id: str, *, reveal: bool = False
170
+ ) -> dict[str, Any]:
171
+ ai_url, token, _, _ = self._context(region)
172
+ data = self.client.request(
173
+ "GET",
174
+ service_endpoint(
175
+ ai_url, "ai", "/openapi/v2/notebook/url"
176
+ )
177
+ + "?"
178
+ + urlencode({"notebookId": notebook_id}),
179
+ token=token,
180
+ )
181
+ if not isinstance(data, dict):
182
+ return {}
183
+ return data if reveal else _redact(data)
184
+
185
+ @staticmethod
186
+ def _number(value: Any, default: int = 10**15) -> int:
187
+ try:
188
+ return int(re.sub(r"[^0-9]", "", str(value)))
189
+ except (TypeError, ValueError):
190
+ return default
191
+
192
+ def create_plan(
193
+ self,
194
+ region: str,
195
+ *,
196
+ resource_group: str = "",
197
+ image_id: str = "",
198
+ accelerator_number: int = 1,
199
+ mount_home: bool = True,
200
+ start_command: str = "",
201
+ ) -> dict[str, Any]:
202
+ if accelerator_number < 1:
203
+ raise BackendError("accelerator number must be at least 1")
204
+ resources = self.resources(region)
205
+ candidates = [
206
+ item
207
+ for item in resources
208
+ if int(item.get("maxFreeNum") or 0) >= accelerator_number
209
+ ]
210
+ if resource_group:
211
+ candidates = [
212
+ item
213
+ for item in candidates
214
+ if item.get("resourceGroupCode") == resource_group
215
+ ]
216
+ candidates.sort(
217
+ key=lambda item: (
218
+ self._number(item.get("ramSize")),
219
+ self._number(item.get("cpuNumber")),
220
+ self._number(item.get("imageSize")),
221
+ )
222
+ )
223
+ if not candidates:
224
+ raise BackendError("no available Notebook resource matches the request")
225
+ resource = candidates[0]
226
+ accelerator_type = str(resource.get("resourceType") or "").lower()
227
+ image_data = self.images(
228
+ region,
229
+ access="public",
230
+ accelerator_type=accelerator_type,
231
+ limit=300,
232
+ )
233
+ images = image_data.get("data") or image_data.get("records") or []
234
+ trusted = [
235
+ item
236
+ for item in images
237
+ if item.get("status") == "Completed"
238
+ and (
239
+ item.get("isPresetImage") is True
240
+ or str(item.get("user", "")).lower() in {"admin", "system"}
241
+ )
242
+ and "jupyter" in (
243
+ str(item.get("name", ""))
244
+ + " "
245
+ + str(item.get("tag", ""))
246
+ + " "
247
+ + str(item.get("version", ""))
248
+ ).lower()
249
+ ]
250
+ if image_id:
251
+ trusted = [
252
+ item for item in trusted if str(item.get("id")) == image_id
253
+ ]
254
+ trusted.sort(key=lambda item: self._number(item.get("imageSize")))
255
+ if not trusted:
256
+ raise BackendError("no trusted preset Jupyter image matches the request")
257
+ image = trusted[0]
258
+ image_name = (
259
+ image.get("version")
260
+ or (
261
+ f"{image.get('name')}:{image.get('tag')}"
262
+ if image.get("tag")
263
+ else image.get("name")
264
+ )
265
+ )
266
+ return {
267
+ "region_id": region,
268
+ "resource": resource,
269
+ "image": image,
270
+ "request": {
271
+ "clusterId": region,
272
+ "imagePath": image.get("path"),
273
+ "imageName": image_name,
274
+ "imageSize": str(image.get("imageSize") or ""),
275
+ "acceleratorType": str(
276
+ resource.get("resourceType") or ""
277
+ ).upper(),
278
+ "acceleratorNumber": str(accelerator_number),
279
+ "resourceGroupCode": resource.get("resourceGroupCode"),
280
+ "mountHome": mount_home,
281
+ "mountInfo": [],
282
+ **(
283
+ {"startCommand": start_command}
284
+ if start_command
285
+ else {}
286
+ ),
287
+ },
288
+ "summary": {
289
+ "region": resource.get("clusterName") or region,
290
+ "resource": resource.get("resourceName"),
291
+ "cards": accelerator_number,
292
+ "cpu": resource.get("cpuNumber"),
293
+ "ram": resource.get("ramSize"),
294
+ "free_cards": resource.get("maxFreeNum"),
295
+ "image": image_name,
296
+ "image_size": image.get("imageSize"),
297
+ "mount_home": mount_home,
298
+ },
299
+ }
300
+
301
+ def create(self, region: str, plan: dict[str, Any]) -> dict[str, Any]:
302
+ _, token, _, _ = self._context(region)
303
+ data = self.client.request(
304
+ "POST",
305
+ "https://www.scnet.cn/ac/openapi/v2/notebook/actions/create",
306
+ token=token,
307
+ json_body=plan["request"],
308
+ )
309
+ return _redact(data if isinstance(data, dict) else {})
310
+
311
+ def start(self, region: str, notebook_id: str) -> Any:
312
+ _, token, _, _ = self._context(region)
313
+ return self.client.request(
314
+ "POST",
315
+ "https://www.scnet.cn/ac/openapi/v2/notebook/actions/start",
316
+ token=token,
317
+ json_body={"notebookId": notebook_id},
318
+ )
319
+
320
+ def stop(
321
+ self, region: str, notebook_id: str, *, save_environment: bool = False
322
+ ) -> Any:
323
+ ai_url, token, _, _ = self._context(region)
324
+ return self.client.request(
325
+ "POST",
326
+ service_endpoint(
327
+ ai_url, "ai", "/openapi/v2/notebook/actions/stop"
328
+ ),
329
+ token=token,
330
+ json_body={
331
+ "notebookId": notebook_id,
332
+ "saveEnv": save_environment,
333
+ },
334
+ )
335
+
336
+ def release(self, region: str, notebook_id: str) -> Any:
337
+ ai_url, token, _, _ = self._context(region)
338
+ return self.client.request(
339
+ "POST",
340
+ service_endpoint(
341
+ ai_url, "ai", "/openapi/v2/notebook/actions/release"
342
+ ),
343
+ token=token,
344
+ json_body={"id": notebook_id},
345
+ )
346
+
347
+ def rename(self, region: str, notebook_id: str, name: str) -> Any:
348
+ if not name.strip():
349
+ raise BackendError("Notebook name must not be empty")
350
+ ai_url, token, _, _ = self._context(region)
351
+ return self.client.request(
352
+ "POST",
353
+ service_endpoint(
354
+ ai_url, "ai", "/openapi/v2/notebook/name"
355
+ ),
356
+ token=token,
357
+ json_body={"id": notebook_id, "notebookName": name.strip()},
358
+ )
359
+
360
+ def wait_status(
361
+ self,
362
+ region: str,
363
+ notebook_id: str,
364
+ statuses: set[str],
365
+ *,
366
+ timeout: int = 600,
367
+ interval: int = 5,
368
+ ) -> dict[str, Any]:
369
+ deadline = time.monotonic() + timeout
370
+ last: dict[str, Any] = {}
371
+ while time.monotonic() < deadline:
372
+ last = self.detail(region, notebook_id)
373
+ if last.get("notebookStatus") in statuses:
374
+ return last
375
+ time.sleep(interval)
376
+ raise BackendError(
377
+ f"Notebook {notebook_id} did not reach {sorted(statuses)}; "
378
+ f"last={last.get('notebookStatus')}"
379
+ )
@@ -0,0 +1,20 @@
1
+ """Version helpers shared by the CLI and OpenAPI client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def version_file() -> Path:
9
+ return Path(__file__).resolve().parent.parent / "VERSION"
10
+
11
+
12
+ def get_version() -> str:
13
+ try:
14
+ value = version_file().read_text(encoding="utf-8").strip()
15
+ except OSError:
16
+ return "0+unknown"
17
+ return value or "0+unknown"
18
+
19
+
20
+ VERSION = get_version()
@@ -95,7 +95,38 @@ if awk -v alias="$CLUSTER_ID" '
95
95
  $1 == "Host" { for (i = 2; i <= NF; i++) if ($i == alias) { found = 1; exit } }
96
96
  END { exit(found ? 0 : 1) }
97
97
  ' "$SSH_CONFIG"; then
98
- info "已存在 Host ${CLUSTER_ID},跳过(如需重配请先手工删除该段)"
98
+ # 只更新由本工具创建的块:旧版本没有 marker,但 IdentityFile 指向同一目标。
99
+ if awk -v alias="$CLUSTER_ID" -v key="$KEY_DST" '
100
+ $1 == "Host" {
101
+ in_block = 0
102
+ for (i = 2; i <= NF; i++) if ($i == alias) in_block = 1
103
+ }
104
+ in_block && $1 == "IdentityFile" && $2 == key { managed = 1 }
105
+ END { exit(managed ? 0 : 1) }
106
+ ' "$SSH_CONFIG"; then
107
+ backup="$SSH_CONFIG.bak.$(date +%Y%m%d%H%M%S)"
108
+ cp "$SSH_CONFIG" "$backup"
109
+ temporary=$(mktemp "$HOME/.ssh/config.XXXXXX")
110
+ awk -v alias="$CLUSTER_ID" -v host="$SSH_HOST" -v port="$SSH_PORT" \
111
+ -v user="$USER_NAME" -v key="$KEY_DST" '
112
+ $1 == "Host" {
113
+ in_block = 0
114
+ for (i = 2; i <= NF; i++) if ($i == alias) in_block = 1
115
+ print
116
+ next
117
+ }
118
+ in_block && $1 == "HostName" { print " HostName " host; next }
119
+ in_block && $1 == "User" { print " User " user; next }
120
+ in_block && $1 == "Port" { print " Port " port; next }
121
+ in_block && $1 == "IdentityFile" { print " IdentityFile " key; next }
122
+ { print }
123
+ ' "$SSH_CONFIG" >"$temporary"
124
+ chmod 600 "$temporary"
125
+ mv "$temporary" "$SSH_CONFIG"
126
+ info "已更新 Host ${CLUSTER_ID}(备份:$(basename "$backup"))"
127
+ else
128
+ info "已存在非本工具管理的 Host ${CLUSTER_ID},保留原配置;私钥文件已更新"
129
+ fi
99
130
  else
100
131
  # macOS 的 ssh-agent 能存进 Keychain;Linux 上这两个选项不被识别
101
132
  keychain_opts=""
@@ -115,6 +146,7 @@ else
115
146
 
116
147
  cat >> "$SSH_CONFIG" <<EOF
117
148
 
149
+ # scnet-hpc managed: ${CLUSTER_ID}
118
150
  Host ${CLUSTER_ID} ${SSH_HOST}
119
151
  HostName ${SSH_HOST}
120
152
  User ${USER_NAME}