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,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
|
-
|
|
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}
|