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,771 @@
|
|
|
1
|
+
"""SCNet OpenAPI 2.0 backend using only the Python standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
from urllib.error import HTTPError, URLError
|
|
12
|
+
from urllib.parse import quote, urlencode
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
from .base import Backend, BackendError, require_option
|
|
16
|
+
from scnet_sdk.client import (
|
|
17
|
+
SCNetClient,
|
|
18
|
+
canonical_signature,
|
|
19
|
+
service_endpoint,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
STATUS_MAP = {
|
|
24
|
+
"statR": "RUNNING",
|
|
25
|
+
"statQ": "PENDING",
|
|
26
|
+
"statH": "HELD",
|
|
27
|
+
"statS": "SUSPENDED",
|
|
28
|
+
"statE": "EXITING",
|
|
29
|
+
"statC": "COMPLETED",
|
|
30
|
+
"statW": "WAITING",
|
|
31
|
+
"statX": "OTHER",
|
|
32
|
+
}
|
|
33
|
+
WALLTIME_RE = re.compile(r"^(?:[0-9]+-)?[0-9]{1,3}:[0-9]{2}:[0-9]{2}$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bounded_int(options: Mapping[str, Any], name: str, minimum: int) -> int:
|
|
37
|
+
try:
|
|
38
|
+
value = int(options.get(name) if options.get(name) is not None else minimum)
|
|
39
|
+
except (TypeError, ValueError) as exc:
|
|
40
|
+
raise BackendError(f"{name} must be an integer") from exc
|
|
41
|
+
if value < minimum:
|
|
42
|
+
raise BackendError(f"{name} must be >= {minimum}")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_job(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
47
|
+
raw_state = item.get("jobStatus") or item.get("state") or ""
|
|
48
|
+
return {
|
|
49
|
+
"job_id": str(item.get("jobId") or item.get("job_id") or ""),
|
|
50
|
+
"name": item.get("jobName") or item.get("name"),
|
|
51
|
+
"state": STATUS_MAP.get(str(raw_state), raw_state),
|
|
52
|
+
"raw_state": raw_state,
|
|
53
|
+
"queue": item.get("queue"),
|
|
54
|
+
"user": item.get("user"),
|
|
55
|
+
"nodes": item.get("nodeNumReq") or item.get("nodeUsed"),
|
|
56
|
+
"cpus": item.get("procNumReq") or item.get("procNumUsed"),
|
|
57
|
+
"gpus": item.get("gpuNumReq") or item.get("gpuNumUsed"),
|
|
58
|
+
"dcus": item.get("dcuNumReq") or item.get("dcuNumUsed"),
|
|
59
|
+
"elapsed": item.get("jobRunTime"),
|
|
60
|
+
"submitted_at": item.get("jobSubmitTime"),
|
|
61
|
+
"started_at": item.get("jobStartTime"),
|
|
62
|
+
"ended_at": item.get("jobEndTime"),
|
|
63
|
+
"exit_code": item.get("exitCode"),
|
|
64
|
+
"reason": item.get("reason"),
|
|
65
|
+
"work_dir": item.get("workDir"),
|
|
66
|
+
"stdout": item.get("outputPath"),
|
|
67
|
+
"stderr": item.get("errorPath"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class OpenAPIBackend(Backend):
|
|
72
|
+
name = "openapi"
|
|
73
|
+
capabilities = frozenset(
|
|
74
|
+
{
|
|
75
|
+
"clusters",
|
|
76
|
+
"queues",
|
|
77
|
+
"limits",
|
|
78
|
+
"job",
|
|
79
|
+
"logs",
|
|
80
|
+
"submit",
|
|
81
|
+
"cancel",
|
|
82
|
+
"files",
|
|
83
|
+
"mkdir",
|
|
84
|
+
"upload",
|
|
85
|
+
"download",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def __init__(self, context):
|
|
90
|
+
super().__init__(context)
|
|
91
|
+
self.client = SCNetClient(context)
|
|
92
|
+
|
|
93
|
+
def _env(self, name: str, default: str | None = None) -> str | None:
|
|
94
|
+
return self.client.env(name, default)
|
|
95
|
+
|
|
96
|
+
def _json_request(
|
|
97
|
+
self,
|
|
98
|
+
method: str,
|
|
99
|
+
url: str,
|
|
100
|
+
*,
|
|
101
|
+
token: str | None = None,
|
|
102
|
+
json_body: Any | None = None,
|
|
103
|
+
form: Mapping[str, Any] | None = None,
|
|
104
|
+
headers: Mapping[str, str] | None = None,
|
|
105
|
+
) -> Any:
|
|
106
|
+
return self.client.request(
|
|
107
|
+
method,
|
|
108
|
+
url,
|
|
109
|
+
token=token,
|
|
110
|
+
json_body=json_body,
|
|
111
|
+
form=form,
|
|
112
|
+
headers=headers,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _regions(self) -> list[dict[str, Any]]:
|
|
116
|
+
return self.client.regions()
|
|
117
|
+
|
|
118
|
+
def _select_region(
|
|
119
|
+
self, options: Mapping[str, Any]
|
|
120
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
121
|
+
requested = (
|
|
122
|
+
options.get("region")
|
|
123
|
+
or self.context.profile.get("OPENAPI_REGION_ID")
|
|
124
|
+
or self._env("SCNET_OPENAPI_REGION_ID")
|
|
125
|
+
)
|
|
126
|
+
return self.client.select_region(str(requested) if requested else None)
|
|
127
|
+
|
|
128
|
+
def _center(
|
|
129
|
+
self, options: Mapping[str, Any]
|
|
130
|
+
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
|
131
|
+
requested = (
|
|
132
|
+
options.get("region")
|
|
133
|
+
or self.context.profile.get("OPENAPI_REGION_ID")
|
|
134
|
+
or self._env("SCNET_OPENAPI_REGION_ID")
|
|
135
|
+
)
|
|
136
|
+
return self.client.center(str(requested) if requested else None)
|
|
137
|
+
|
|
138
|
+
def _enabled_url(self, center: Mapping[str, Any], field: str) -> str:
|
|
139
|
+
return self.client.enabled_url(center, field)
|
|
140
|
+
|
|
141
|
+
def _hpc_context(
|
|
142
|
+
self, options: Mapping[str, Any]
|
|
143
|
+
) -> tuple[str, str, str, str, dict[str, Any]]:
|
|
144
|
+
center, token, region = self._center(options)
|
|
145
|
+
hpc_url = self._enabled_url(center, "hpcUrls")
|
|
146
|
+
schedulers = self._json_request(
|
|
147
|
+
"GET",
|
|
148
|
+
service_endpoint(hpc_url, "hpc", "/openapi/v2/cluster"),
|
|
149
|
+
token=token,
|
|
150
|
+
)
|
|
151
|
+
if not isinstance(schedulers, list):
|
|
152
|
+
raise BackendError("cluster endpoint returned an unexpected data shape")
|
|
153
|
+
requested = (
|
|
154
|
+
options.get("scheduler_id")
|
|
155
|
+
or self.context.profile.get("OPENAPI_SCHEDULER_ID")
|
|
156
|
+
or self._env("SCNET_OPENAPI_SCHEDULER_ID")
|
|
157
|
+
)
|
|
158
|
+
selected: Mapping[str, Any] | None = None
|
|
159
|
+
if requested:
|
|
160
|
+
selected = next(
|
|
161
|
+
(item for item in schedulers if str(item.get("id")) == str(requested)),
|
|
162
|
+
None,
|
|
163
|
+
)
|
|
164
|
+
if selected is None:
|
|
165
|
+
raise BackendError(f"scheduler {requested!r} is not available")
|
|
166
|
+
elif len(schedulers) == 1:
|
|
167
|
+
selected = schedulers[0]
|
|
168
|
+
else:
|
|
169
|
+
available = ", ".join(
|
|
170
|
+
f"{item.get('text')}({item.get('id')})" for item in schedulers
|
|
171
|
+
)
|
|
172
|
+
raise BackendError(
|
|
173
|
+
"multiple schedulers are available; use --scheduler-id: " + available
|
|
174
|
+
)
|
|
175
|
+
user_info = center.get("clusterUserInfo") or {}
|
|
176
|
+
username = (
|
|
177
|
+
options.get("username")
|
|
178
|
+
or self.context.profile.get("OPENAPI_USERNAME")
|
|
179
|
+
or self._env("SCNET_OPENAPI_USERNAME")
|
|
180
|
+
or user_info.get("userName")
|
|
181
|
+
)
|
|
182
|
+
if not username:
|
|
183
|
+
raise BackendError("OpenAPI username is missing from center response")
|
|
184
|
+
return hpc_url, token, str(selected["id"]), str(username), region
|
|
185
|
+
|
|
186
|
+
def _efile_context(
|
|
187
|
+
self, options: Mapping[str, Any]
|
|
188
|
+
) -> tuple[str, str, dict[str, Any]]:
|
|
189
|
+
center, token, region = self._center(options)
|
|
190
|
+
return self._enabled_url(center, "efileUrls"), token, region
|
|
191
|
+
|
|
192
|
+
def discover_regions(self) -> list[dict[str, Any]]:
|
|
193
|
+
"""Return authorized regions for setup and diagnostics."""
|
|
194
|
+
return self._regions()
|
|
195
|
+
|
|
196
|
+
def discover_region_context(self, region: str) -> dict[str, Any]:
|
|
197
|
+
"""Resolve the selected region's scheduler and user without submitting work."""
|
|
198
|
+
_, _, selected = self._center({"region": region})
|
|
199
|
+
_, _, scheduler_id, username, _ = self._hpc_context({"region": region})
|
|
200
|
+
return {
|
|
201
|
+
"region_id": str(selected.get("clusterId") or region),
|
|
202
|
+
"region_name": selected.get("clusterName"),
|
|
203
|
+
"scheduler_id": scheduler_id,
|
|
204
|
+
"username": username,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
def discover_all_region_contexts(self) -> list[dict[str, Any]]:
|
|
208
|
+
"""Resolve every authorized region without requiring IDs from the user."""
|
|
209
|
+
contexts: list[dict[str, Any]] = []
|
|
210
|
+
center_url = self._env(
|
|
211
|
+
"SCNET_OPENAPI_CENTER_URL",
|
|
212
|
+
"https://www.scnet.cn/ac/openapi/v2/center",
|
|
213
|
+
)
|
|
214
|
+
for region in self._regions():
|
|
215
|
+
region_id = str(region.get("clusterId", ""))
|
|
216
|
+
token = region.get("token")
|
|
217
|
+
if region_id == "0" or not token:
|
|
218
|
+
continue
|
|
219
|
+
center = self._json_request("GET", center_url, token=str(token))
|
|
220
|
+
if not isinstance(center, dict):
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
hpc_url = self._enabled_url(center, "hpcUrls")
|
|
224
|
+
except BackendError:
|
|
225
|
+
# An account can be authorized for platform/model regions that do not
|
|
226
|
+
# expose an HPC service. They are not job-submission targets.
|
|
227
|
+
continue
|
|
228
|
+
schedulers = self._json_request(
|
|
229
|
+
"GET",
|
|
230
|
+
service_endpoint(hpc_url, "hpc", "/openapi/v2/cluster"),
|
|
231
|
+
token=str(token),
|
|
232
|
+
)
|
|
233
|
+
if not isinstance(schedulers, list):
|
|
234
|
+
schedulers = []
|
|
235
|
+
user_info = center.get("clusterUserInfo") or {}
|
|
236
|
+
contexts.append(
|
|
237
|
+
{
|
|
238
|
+
"region_id": region_id,
|
|
239
|
+
"name": region.get("clusterName") or center.get("name"),
|
|
240
|
+
"username": user_info.get("userName"),
|
|
241
|
+
"home_path": user_info.get("homePath"),
|
|
242
|
+
"schedulers": [
|
|
243
|
+
{
|
|
244
|
+
"id": str(item.get("id", "")),
|
|
245
|
+
"name": item.get("text"),
|
|
246
|
+
"type": item.get("JobManagerType"),
|
|
247
|
+
}
|
|
248
|
+
for item in schedulers
|
|
249
|
+
if isinstance(item, dict)
|
|
250
|
+
],
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
return contexts
|
|
254
|
+
|
|
255
|
+
def op_clusters(self, options: Mapping[str, Any]) -> Any:
|
|
256
|
+
regions = self._regions()
|
|
257
|
+
result = [
|
|
258
|
+
{
|
|
259
|
+
"region_id": str(item.get("clusterId", "")),
|
|
260
|
+
"name": item.get("clusterName"),
|
|
261
|
+
"available": bool(item.get("token")),
|
|
262
|
+
}
|
|
263
|
+
for item in regions
|
|
264
|
+
if str(item.get("clusterId", "")) != "0"
|
|
265
|
+
]
|
|
266
|
+
if options.get("raw"):
|
|
267
|
+
safe_raw = [
|
|
268
|
+
{
|
|
269
|
+
**item,
|
|
270
|
+
"token": "<redacted>",
|
|
271
|
+
}
|
|
272
|
+
for item in regions
|
|
273
|
+
]
|
|
274
|
+
return {"items": result, "raw": safe_raw}
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
def op_queues(self, options: Mapping[str, Any]) -> Any:
|
|
278
|
+
hpc_url, token, scheduler_id, username, _ = self._hpc_context(options)
|
|
279
|
+
params = urlencode({"strJobManagerID": scheduler_id})
|
|
280
|
+
url = service_endpoint(
|
|
281
|
+
hpc_url,
|
|
282
|
+
"hpc",
|
|
283
|
+
f"/openapi/v2/queuenames/users/{quote(username)}?{params}",
|
|
284
|
+
)
|
|
285
|
+
data = self._json_request("GET", url, token=token)
|
|
286
|
+
result = []
|
|
287
|
+
for item in data or []:
|
|
288
|
+
result.append(
|
|
289
|
+
{
|
|
290
|
+
"partition": item.get("queueName") or item.get("id"),
|
|
291
|
+
"nodes": item.get("queNodes"),
|
|
292
|
+
"free_nodes": item.get("queFreeNodes"),
|
|
293
|
+
"cpus": item.get("queNcpus"),
|
|
294
|
+
"free_cpus": item.get("queFreeNcpus"),
|
|
295
|
+
"max_cpus": item.get("queMaxNcpus"),
|
|
296
|
+
"max_cpus_per_node": item.get("queMaxPPN"),
|
|
297
|
+
"max_nodes": item.get("queMaxNodect"),
|
|
298
|
+
"max_walltime": item.get("queMaxWalltime"),
|
|
299
|
+
"max_gpus_per_node": item.get("queMaxGpuPN"),
|
|
300
|
+
"max_dcus_per_node": item.get("queMaxDcuPN"),
|
|
301
|
+
"charge_rate": item.get("queChargeRate"),
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
return {"items": result, "raw": data} if options.get("raw") else result
|
|
305
|
+
|
|
306
|
+
def op_limits(self, options: Mapping[str, Any]) -> Any:
|
|
307
|
+
hpc_url, token, scheduler_id, _, _ = self._hpc_context(options)
|
|
308
|
+
params = urlencode({"strJobManagerID": scheduler_id})
|
|
309
|
+
data = self._json_request(
|
|
310
|
+
"GET",
|
|
311
|
+
service_endpoint(
|
|
312
|
+
hpc_url, "hpc", f"/openapi/v2/userquotas/userlimit?{params}"
|
|
313
|
+
),
|
|
314
|
+
token=token,
|
|
315
|
+
)
|
|
316
|
+
if not isinstance(data, dict):
|
|
317
|
+
return data
|
|
318
|
+
key_map = {
|
|
319
|
+
"userMaxCpu": "user_max_cpus",
|
|
320
|
+
"userMaxDcu": "user_max_dcus",
|
|
321
|
+
"userMaxGpu": "user_max_gpus",
|
|
322
|
+
"userMaxMlu": "user_max_mlus",
|
|
323
|
+
"userMaxMem": "user_max_memory_mb",
|
|
324
|
+
"userMaxNode": "user_max_nodes",
|
|
325
|
+
"userMaxSubmitJob": "user_max_submitted_jobs",
|
|
326
|
+
"userMaxRunJob": "user_max_running_jobs",
|
|
327
|
+
"accountMaxCpu": "account_max_cpus",
|
|
328
|
+
"accountMaxDcu": "account_max_dcus",
|
|
329
|
+
"accountMaxGpu": "account_max_gpus",
|
|
330
|
+
"accountMaxNode": "account_max_nodes",
|
|
331
|
+
"maxWallTime": "remaining_machine_time_seconds",
|
|
332
|
+
}
|
|
333
|
+
result = {target: data.get(source) for source, target in key_map.items()}
|
|
334
|
+
return {"data": result, "raw": data} if options.get("raw") else result
|
|
335
|
+
|
|
336
|
+
def op_job(self, options: Mapping[str, Any]) -> Any:
|
|
337
|
+
job_id = str(require_option(options, "job_id"))
|
|
338
|
+
hpc_url, token, _, _, _ = self._hpc_context(options)
|
|
339
|
+
data = self._json_request(
|
|
340
|
+
"GET",
|
|
341
|
+
service_endpoint(hpc_url, "hpc", f"/openapi/v2/jobs/{quote(job_id)}"),
|
|
342
|
+
token=token,
|
|
343
|
+
)
|
|
344
|
+
if not isinstance(data, dict):
|
|
345
|
+
raise BackendError("job endpoint returned an unexpected data shape")
|
|
346
|
+
result = normalize_job(data)
|
|
347
|
+
if options.get("raw"):
|
|
348
|
+
result["raw"] = data
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
def op_logs(self, options: Mapping[str, Any]) -> Any:
|
|
352
|
+
path = str(require_option(options, "path"))
|
|
353
|
+
if not path.startswith("/"):
|
|
354
|
+
raise BackendError("OpenAPI log path must be absolute")
|
|
355
|
+
hpc_url, token, _, _, _ = self._hpc_context(options)
|
|
356
|
+
direction = options.get("direction") or "tail"
|
|
357
|
+
roll = "UP" if direction == "tail" else "DOWN"
|
|
358
|
+
page = int(options.get("page") or 1)
|
|
359
|
+
data = self._json_request(
|
|
360
|
+
"POST",
|
|
361
|
+
service_endpoint(hpc_url, "hpc", "/openapi/v2/file/content"),
|
|
362
|
+
token=token,
|
|
363
|
+
form={
|
|
364
|
+
"hostName": options.get("host_name") or "",
|
|
365
|
+
"dirPath": path,
|
|
366
|
+
"triggerNum": page,
|
|
367
|
+
"rollDirection": roll,
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
if not isinstance(data, dict):
|
|
371
|
+
raise BackendError("log endpoint returned an unexpected data shape")
|
|
372
|
+
lines = str(data.get("data") or "").splitlines()
|
|
373
|
+
requested_lines = int(options.get("lines") or 200)
|
|
374
|
+
if direction == "tail":
|
|
375
|
+
lines = lines[-requested_lines:]
|
|
376
|
+
else:
|
|
377
|
+
lines = lines[:requested_lines]
|
|
378
|
+
result = {
|
|
379
|
+
"path": path,
|
|
380
|
+
"direction": direction,
|
|
381
|
+
"page": page,
|
|
382
|
+
"total_pages": data.get("totalTriggerTimes"),
|
|
383
|
+
"total_lines": data.get("allLineTotal"),
|
|
384
|
+
"lines": lines,
|
|
385
|
+
}
|
|
386
|
+
return {"data": result, "raw": data} if options.get("raw") else result
|
|
387
|
+
|
|
388
|
+
def _submit_payload(self, options: Mapping[str, Any]) -> dict[str, Any]:
|
|
389
|
+
name = str(require_option(options, "name"))
|
|
390
|
+
command = str(require_option(options, "command"))
|
|
391
|
+
work_dir = str(require_option(options, "work_dir"))
|
|
392
|
+
queue = str(require_option(options, "queue"))
|
|
393
|
+
if not work_dir.startswith("/"):
|
|
394
|
+
raise BackendError("OpenAPI work directory must be absolute")
|
|
395
|
+
if any(char in name for char in "\r\n") or any(
|
|
396
|
+
char in queue for char in "\r\n"
|
|
397
|
+
):
|
|
398
|
+
raise BackendError("job name and queue must not contain newlines")
|
|
399
|
+
nodes = _bounded_int(options, "nodes", 1)
|
|
400
|
+
cpus = _bounded_int(options, "cpus", 1)
|
|
401
|
+
gpus = _bounded_int(options, "gpus", 0)
|
|
402
|
+
dcus = _bounded_int(options, "dcus", 0)
|
|
403
|
+
walltime = str(options.get("walltime") or "24:00:00")
|
|
404
|
+
if not WALLTIME_RE.fullmatch(walltime):
|
|
405
|
+
raise BackendError("walltime must use HH:MM:SS or D-HH:MM:SS")
|
|
406
|
+
scheduler_options = options.get("scheduler_options") or []
|
|
407
|
+
if not isinstance(scheduler_options, (list, tuple)):
|
|
408
|
+
raise BackendError("scheduler_options must be a list")
|
|
409
|
+
return {
|
|
410
|
+
"name": name,
|
|
411
|
+
"command": command,
|
|
412
|
+
"work_dir": work_dir,
|
|
413
|
+
"queue": queue,
|
|
414
|
+
"nodes": nodes,
|
|
415
|
+
"cpus": cpus,
|
|
416
|
+
"gpus": gpus,
|
|
417
|
+
"dcus": dcus,
|
|
418
|
+
"memory": str(options.get("memory") or ""),
|
|
419
|
+
"walltime": walltime,
|
|
420
|
+
"exclusive": bool(options.get("exclusive")),
|
|
421
|
+
"stdout": options.get("stdout")
|
|
422
|
+
or f"{work_dir.rstrip('/')}/std.out.%j",
|
|
423
|
+
"stderr": options.get("stderr")
|
|
424
|
+
or f"{work_dir.rstrip('/')}/std.err.%j",
|
|
425
|
+
"scheduler_options": list(scheduler_options),
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
def _submit_request(self, options: Mapping[str, Any]) -> tuple[str, str, dict[str, Any]]:
|
|
429
|
+
payload = self._submit_payload(options)
|
|
430
|
+
hpc_url, token, scheduler_id, _, region = self._hpc_context(options)
|
|
431
|
+
job_info = {
|
|
432
|
+
"GAP_CMD_FILE": payload["command"],
|
|
433
|
+
"GAP_NNODE": str(payload["nodes"]),
|
|
434
|
+
"GAP_NODE_STRING": options.get("node_string") or "",
|
|
435
|
+
"GAP_SUBMIT_TYPE": "cmd",
|
|
436
|
+
"GAP_JOB_NAME": payload["name"],
|
|
437
|
+
"GAP_WORK_DIR": payload["work_dir"],
|
|
438
|
+
"GAP_QUEUE": payload["queue"],
|
|
439
|
+
"GAP_NPROC": str(payload["cpus"]),
|
|
440
|
+
"GAP_PPN": "",
|
|
441
|
+
"GAP_NGPU": str(payload["gpus"] or ""),
|
|
442
|
+
"GAP_NDCU": str(payload["dcus"] or ""),
|
|
443
|
+
"GAP_JOB_MEM": payload["memory"],
|
|
444
|
+
"GAP_WALL_TIME": payload["walltime"],
|
|
445
|
+
"GAP_EXCLUSIVE": "1" if payload["exclusive"] else "",
|
|
446
|
+
"GAP_APPNAME": "BASE",
|
|
447
|
+
"GAP_MULTI_SUB": "",
|
|
448
|
+
"GAP_STD_OUT_FILE": payload["stdout"],
|
|
449
|
+
"GAP_STD_ERR_FILE": payload["stderr"],
|
|
450
|
+
"GAP_SCHEDULER_OPT_WEB": "\n".join(payload["scheduler_options"]),
|
|
451
|
+
"GAP_CLUSTER_ID": str(region.get("clusterId") or ""),
|
|
452
|
+
}
|
|
453
|
+
return hpc_url, token, {
|
|
454
|
+
"strJobManagerID": scheduler_id,
|
|
455
|
+
"mapAppJobInfo": job_info,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
def preview_submit(self, options: Mapping[str, Any]) -> Any:
|
|
459
|
+
payload = self._submit_payload(options)
|
|
460
|
+
return {
|
|
461
|
+
"backend": self.name,
|
|
462
|
+
"operation": "submit",
|
|
463
|
+
"requires_network_resolution": True,
|
|
464
|
+
"request": payload,
|
|
465
|
+
"note": "scheduler and region IDs will be resolved before POST; no request sent",
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
def op_submit(self, options: Mapping[str, Any]) -> Any:
|
|
469
|
+
hpc_url, token, body = self._submit_request(options)
|
|
470
|
+
data = self._json_request(
|
|
471
|
+
"POST",
|
|
472
|
+
service_endpoint(
|
|
473
|
+
hpc_url, "hpc", "/openapi/v2/apptemplates/BASIC/BASE/job"
|
|
474
|
+
),
|
|
475
|
+
token=token,
|
|
476
|
+
json_body=body,
|
|
477
|
+
)
|
|
478
|
+
result = {"job_id": str(data)}
|
|
479
|
+
return {"data": result, "raw": data} if options.get("raw") else result
|
|
480
|
+
|
|
481
|
+
def op_cancel(self, options: Mapping[str, Any]) -> Any:
|
|
482
|
+
job_id = str(require_option(options, "job_id"))
|
|
483
|
+
hpc_url, token, scheduler_id, username, _ = self._hpc_context(options)
|
|
484
|
+
method = self._env("SCNET_OPENAPI_CANCEL_METHOD", "5")
|
|
485
|
+
data = self._json_request(
|
|
486
|
+
"DELETE",
|
|
487
|
+
service_endpoint(hpc_url, "hpc", "/openapi/v2/jobs"),
|
|
488
|
+
token=token,
|
|
489
|
+
form={
|
|
490
|
+
"jobMethod": method,
|
|
491
|
+
"strJobInfoMap": f"{scheduler_id},{username}:{job_id}:",
|
|
492
|
+
},
|
|
493
|
+
)
|
|
494
|
+
return {"job_id": job_id, "cancelled": True, "result": data}
|
|
495
|
+
|
|
496
|
+
def preview_cancel(self, options: Mapping[str, Any]) -> Any:
|
|
497
|
+
job_id = str(require_option(options, "job_id"))
|
|
498
|
+
return {
|
|
499
|
+
"backend": self.name,
|
|
500
|
+
"operation": "cancel",
|
|
501
|
+
"job_id": job_id,
|
|
502
|
+
"note": "no DELETE request sent",
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
def op_mkdir(self, options: Mapping[str, Any]) -> Any:
|
|
506
|
+
path = str(require_option(options, "path"))
|
|
507
|
+
if (
|
|
508
|
+
not path.startswith("/")
|
|
509
|
+
or "\n" in path
|
|
510
|
+
or "\r" in path
|
|
511
|
+
or "\x00" in path
|
|
512
|
+
):
|
|
513
|
+
raise BackendError("directory path must be a safe absolute path")
|
|
514
|
+
efile_url, token, _ = self._efile_context(options)
|
|
515
|
+
url = (
|
|
516
|
+
service_endpoint(efile_url, "efile", "/openapi/v2/file/mkdir")
|
|
517
|
+
+ "?"
|
|
518
|
+
+ urlencode(
|
|
519
|
+
{
|
|
520
|
+
"path": path,
|
|
521
|
+
"createParents": "true" if options.get("parents", False) else "false",
|
|
522
|
+
}
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
data = self._json_request("POST", url, token=token, json_body={})
|
|
526
|
+
result = {"path": path, "created": True}
|
|
527
|
+
return {"data": result, "raw": data} if options.get("raw") else result
|
|
528
|
+
|
|
529
|
+
def preview_mkdir(self, options: Mapping[str, Any]) -> Any:
|
|
530
|
+
path = str(require_option(options, "path"))
|
|
531
|
+
if not path.startswith("/"):
|
|
532
|
+
raise BackendError("directory path must be absolute")
|
|
533
|
+
return {
|
|
534
|
+
"backend": self.name,
|
|
535
|
+
"operation": "mkdir",
|
|
536
|
+
"path": path,
|
|
537
|
+
"parents": bool(options.get("parents", False)),
|
|
538
|
+
"note": "no POST request sent",
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
def op_files(self, options: Mapping[str, Any]) -> Any:
|
|
542
|
+
efile_url, token, _ = self._efile_context(options)
|
|
543
|
+
limit = _bounded_int(options, "limit", 1)
|
|
544
|
+
start = _bounded_int(options, "start", 0)
|
|
545
|
+
params = {
|
|
546
|
+
"limit": limit,
|
|
547
|
+
"start": start,
|
|
548
|
+
"order": options.get("order") or "asc",
|
|
549
|
+
"orderBy": options.get("order_by") or "name",
|
|
550
|
+
}
|
|
551
|
+
if options.get("path"):
|
|
552
|
+
params["path"] = options["path"]
|
|
553
|
+
data = self._json_request(
|
|
554
|
+
"GET",
|
|
555
|
+
service_endpoint(
|
|
556
|
+
efile_url, "efile", "/openapi/v2/file/list"
|
|
557
|
+
)
|
|
558
|
+
+ "?"
|
|
559
|
+
+ urlencode(params),
|
|
560
|
+
token=token,
|
|
561
|
+
)
|
|
562
|
+
if not isinstance(data, dict):
|
|
563
|
+
return data
|
|
564
|
+
result = {
|
|
565
|
+
"path": data.get("path"),
|
|
566
|
+
"total": data.get("total"),
|
|
567
|
+
"files": [
|
|
568
|
+
{
|
|
569
|
+
"name": item.get("name"),
|
|
570
|
+
"path": item.get("path"),
|
|
571
|
+
"size": item.get("size"),
|
|
572
|
+
"directory": item.get("isDirectory"),
|
|
573
|
+
"modified_at": item.get("lastModifiedTime"),
|
|
574
|
+
"permission": item.get("permission"),
|
|
575
|
+
}
|
|
576
|
+
for item in data.get("fileList") or []
|
|
577
|
+
],
|
|
578
|
+
}
|
|
579
|
+
return {"data": result, "raw": data} if options.get("raw") else result
|
|
580
|
+
|
|
581
|
+
def op_upload(self, options: Mapping[str, Any]) -> Any:
|
|
582
|
+
local_path = Path(str(require_option(options, "local_path"))).expanduser()
|
|
583
|
+
remote_path = str(require_option(options, "remote_path"))
|
|
584
|
+
if not local_path.is_file():
|
|
585
|
+
raise BackendError(f"local file does not exist: {local_path}")
|
|
586
|
+
if (
|
|
587
|
+
not remote_path.startswith("/")
|
|
588
|
+
or "\n" in remote_path
|
|
589
|
+
or "\r" in remote_path
|
|
590
|
+
or "\x00" in remote_path
|
|
591
|
+
):
|
|
592
|
+
raise BackendError("remote upload directory must be absolute")
|
|
593
|
+
chunk_size = int(options.get("chunk_size") or 0)
|
|
594
|
+
if chunk_size < 0:
|
|
595
|
+
raise BackendError("chunk size must be non-negative")
|
|
596
|
+
if chunk_size and chunk_size < 1024 * 1024:
|
|
597
|
+
raise BackendError("chunk size must be at least 1 MiB or 0")
|
|
598
|
+
if chunk_size and local_path.stat().st_size > chunk_size:
|
|
599
|
+
return self._op_chunked_upload(local_path, remote_path, options, chunk_size)
|
|
600
|
+
efile_url, token, _ = self._efile_context(options)
|
|
601
|
+
content_type = mimetypes.guess_type(local_path.name)[0] or "application/octet-stream"
|
|
602
|
+
self._multipart_request(
|
|
603
|
+
service_endpoint(efile_url, "efile", "/openapi/v2/file/upload"),
|
|
604
|
+
token,
|
|
605
|
+
{
|
|
606
|
+
"cover": "cover" if options.get("cover") else "uncover",
|
|
607
|
+
"path": remote_path,
|
|
608
|
+
},
|
|
609
|
+
local_path.name,
|
|
610
|
+
local_path.read_bytes(),
|
|
611
|
+
content_type,
|
|
612
|
+
)
|
|
613
|
+
return {"local_path": str(local_path), "remote_path": remote_path}
|
|
614
|
+
|
|
615
|
+
def _multipart_request(
|
|
616
|
+
self,
|
|
617
|
+
url: str,
|
|
618
|
+
token: str,
|
|
619
|
+
fields: Mapping[str, Any],
|
|
620
|
+
file_name: str,
|
|
621
|
+
file_bytes: bytes,
|
|
622
|
+
content_type: str,
|
|
623
|
+
) -> Any:
|
|
624
|
+
boundary = "----scnet-hpc-" + uuid.uuid4().hex
|
|
625
|
+
parts: list[bytes] = []
|
|
626
|
+
for name, value in fields.items():
|
|
627
|
+
parts.append(
|
|
628
|
+
(
|
|
629
|
+
f"--{boundary}\r\n"
|
|
630
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
|
|
631
|
+
f"{value}\r\n"
|
|
632
|
+
).encode("utf-8")
|
|
633
|
+
)
|
|
634
|
+
safe_name = file_name.replace('"', "_").replace("\r", "_").replace("\n", "_")
|
|
635
|
+
parts.append(
|
|
636
|
+
(
|
|
637
|
+
f"--{boundary}\r\n"
|
|
638
|
+
f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'
|
|
639
|
+
f"Content-Type: {content_type}\r\n\r\n"
|
|
640
|
+
).encode("utf-8")
|
|
641
|
+
+ file_bytes
|
|
642
|
+
+ b"\r\n"
|
|
643
|
+
)
|
|
644
|
+
parts.append(f"--{boundary}--\r\n".encode("ascii"))
|
|
645
|
+
request = Request(
|
|
646
|
+
url,
|
|
647
|
+
data=b"".join(parts),
|
|
648
|
+
method="POST",
|
|
649
|
+
headers={
|
|
650
|
+
"token": token,
|
|
651
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
652
|
+
"Accept": "application/json",
|
|
653
|
+
"User-Agent": "scnet-hpc/1",
|
|
654
|
+
},
|
|
655
|
+
)
|
|
656
|
+
try:
|
|
657
|
+
with urlopen(request, timeout=self.context.timeout) as response:
|
|
658
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
659
|
+
except (HTTPError, URLError, OSError, json.JSONDecodeError) as exc:
|
|
660
|
+
raise BackendError(f"OpenAPI multipart upload failed: {exc}") from exc
|
|
661
|
+
if not isinstance(payload, dict) or str(payload.get("code", "")) != "0":
|
|
662
|
+
raise BackendError(
|
|
663
|
+
f"SCNet OpenAPI error {payload.get('code')}: {payload.get('msg')}"
|
|
664
|
+
)
|
|
665
|
+
return payload.get("data")
|
|
666
|
+
|
|
667
|
+
def _op_chunked_upload(
|
|
668
|
+
self,
|
|
669
|
+
local_path: Path,
|
|
670
|
+
remote_path: str,
|
|
671
|
+
options: Mapping[str, Any],
|
|
672
|
+
chunk_size: int,
|
|
673
|
+
) -> dict[str, Any]:
|
|
674
|
+
efile_url, token, _ = self._efile_context(options)
|
|
675
|
+
total_size = local_path.stat().st_size
|
|
676
|
+
total_chunks = max(1, (total_size + chunk_size - 1) // chunk_size)
|
|
677
|
+
filename = local_path.name
|
|
678
|
+
relative_path = str(options.get("relative_path") or filename)
|
|
679
|
+
cover = "cover" if options.get("cover") else "uncover"
|
|
680
|
+
identifier = str(options.get("identifier") or f"{total_size}-{filename}")
|
|
681
|
+
endpoint = service_endpoint(efile_url, "efile", "/openapi/v2/file/burst")
|
|
682
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
683
|
+
with local_path.open("rb") as stream:
|
|
684
|
+
for chunk_number in range(1, total_chunks + 1):
|
|
685
|
+
chunk = stream.read(chunk_size)
|
|
686
|
+
if not chunk:
|
|
687
|
+
break
|
|
688
|
+
self._multipart_request(
|
|
689
|
+
endpoint,
|
|
690
|
+
token,
|
|
691
|
+
{
|
|
692
|
+
"chunkNumber": chunk_number,
|
|
693
|
+
"cover": cover,
|
|
694
|
+
"filename": filename,
|
|
695
|
+
"identifier": identifier,
|
|
696
|
+
"path": remote_path,
|
|
697
|
+
"relativePath": relative_path,
|
|
698
|
+
"totalChunks": total_chunks,
|
|
699
|
+
"totalSize": total_size,
|
|
700
|
+
"chunkSize": chunk_size,
|
|
701
|
+
"currentChunkSize": len(chunk),
|
|
702
|
+
},
|
|
703
|
+
filename,
|
|
704
|
+
chunk,
|
|
705
|
+
content_type,
|
|
706
|
+
)
|
|
707
|
+
merge_data = self._json_request(
|
|
708
|
+
"POST",
|
|
709
|
+
service_endpoint(efile_url, "efile", "/openapi/v2/file/merge"),
|
|
710
|
+
token=token,
|
|
711
|
+
form={
|
|
712
|
+
"cover": cover,
|
|
713
|
+
"filename": filename,
|
|
714
|
+
"id": "",
|
|
715
|
+
"identifier": identifier,
|
|
716
|
+
"path": remote_path,
|
|
717
|
+
"relativePath": relative_path,
|
|
718
|
+
},
|
|
719
|
+
)
|
|
720
|
+
result = {
|
|
721
|
+
"local_path": str(local_path),
|
|
722
|
+
"remote_path": remote_path,
|
|
723
|
+
"bytes": total_size,
|
|
724
|
+
"chunks": total_chunks,
|
|
725
|
+
}
|
|
726
|
+
return {"data": result, "raw": merge_data} if options.get("raw") else result
|
|
727
|
+
|
|
728
|
+
def op_download(self, options: Mapping[str, Any]) -> Any:
|
|
729
|
+
remote_path = str(require_option(options, "remote_path"))
|
|
730
|
+
local_path = Path(str(require_option(options, "local_path"))).expanduser()
|
|
731
|
+
if (
|
|
732
|
+
not remote_path.startswith("/")
|
|
733
|
+
or "\n" in remote_path
|
|
734
|
+
or "\r" in remote_path
|
|
735
|
+
or "\x00" in remote_path
|
|
736
|
+
):
|
|
737
|
+
raise BackendError("remote download path must be absolute")
|
|
738
|
+
if local_path.exists() and not options.get("cover"):
|
|
739
|
+
raise BackendError("local path already exists; pass --cover to overwrite it")
|
|
740
|
+
efile_url, token, _ = self._efile_context(options)
|
|
741
|
+
url = (
|
|
742
|
+
service_endpoint(efile_url, "efile", "/openapi/v2/file/download")
|
|
743
|
+
+ "?"
|
|
744
|
+
+ urlencode({"path": remote_path})
|
|
745
|
+
)
|
|
746
|
+
request = Request(
|
|
747
|
+
url,
|
|
748
|
+
method="GET",
|
|
749
|
+
headers={"token": token, "User-Agent": "scnet-hpc/1"},
|
|
750
|
+
)
|
|
751
|
+
try:
|
|
752
|
+
with urlopen(request, timeout=self.context.timeout) as response:
|
|
753
|
+
content_type = response.headers.get("Content-Type", "")
|
|
754
|
+
content = response.read()
|
|
755
|
+
except (HTTPError, URLError, OSError) as exc:
|
|
756
|
+
raise BackendError(f"OpenAPI download failed: {exc}") from exc
|
|
757
|
+
if "application/json" in content_type:
|
|
758
|
+
try:
|
|
759
|
+
payload = json.loads(content.decode("utf-8"))
|
|
760
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
761
|
+
payload = None
|
|
762
|
+
if isinstance(payload, dict) and str(payload.get("code", "0")) != "0":
|
|
763
|
+
raise BackendError(
|
|
764
|
+
f"SCNet OpenAPI error {payload.get('code')}: {payload.get('msg')}"
|
|
765
|
+
)
|
|
766
|
+
local_path.write_bytes(content)
|
|
767
|
+
return {
|
|
768
|
+
"remote_path": remote_path,
|
|
769
|
+
"local_path": str(local_path),
|
|
770
|
+
"bytes": len(content),
|
|
771
|
+
}
|