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,78 @@
|
|
|
1
|
+
"""Read the repository's simple shell-style cluster profiles safely."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .base import BackendError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_ASSIGNMENT = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_profile(path: Path) -> dict[str, str]:
|
|
16
|
+
values: dict[str, str] = {}
|
|
17
|
+
logical_lines: list[tuple[int, str]] = []
|
|
18
|
+
pending = ""
|
|
19
|
+
pending_line = 0
|
|
20
|
+
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
21
|
+
if not pending:
|
|
22
|
+
pending_line = line_number
|
|
23
|
+
if raw_line.endswith("\\"):
|
|
24
|
+
pending += raw_line[:-1]
|
|
25
|
+
continue
|
|
26
|
+
logical_lines.append((pending_line, pending + raw_line))
|
|
27
|
+
pending = ""
|
|
28
|
+
if pending:
|
|
29
|
+
raise BackendError(f"{path}:{pending_line}: unfinished line continuation")
|
|
30
|
+
|
|
31
|
+
for line_number, raw_line in logical_lines:
|
|
32
|
+
line = raw_line.strip()
|
|
33
|
+
if not line or line.startswith("#"):
|
|
34
|
+
continue
|
|
35
|
+
match = _ASSIGNMENT.match(line)
|
|
36
|
+
if not match:
|
|
37
|
+
raise BackendError(f"{path}:{line_number}: unsupported profile syntax")
|
|
38
|
+
key, raw_value = match.groups()
|
|
39
|
+
lexer = shlex.shlex(raw_value, posix=True)
|
|
40
|
+
lexer.whitespace_split = True
|
|
41
|
+
lexer.commenters = "#"
|
|
42
|
+
parts = list(lexer)
|
|
43
|
+
if len(parts) > 1:
|
|
44
|
+
raise BackendError(f"{path}:{line_number}: profile value must be scalar")
|
|
45
|
+
values[key] = parts[0] if parts else ""
|
|
46
|
+
return values
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_profiles(repo_root: Path) -> list[dict[str, str]]:
|
|
50
|
+
cluster_dir = repo_root / "clusters"
|
|
51
|
+
result: list[dict[str, str]] = []
|
|
52
|
+
for path in sorted(cluster_dir.glob("*.conf")):
|
|
53
|
+
if path.name.startswith("_"):
|
|
54
|
+
continue
|
|
55
|
+
profile = parse_profile(path)
|
|
56
|
+
result.append(
|
|
57
|
+
{
|
|
58
|
+
"name": path.stem,
|
|
59
|
+
"cluster_id": profile.get("CLUSTER_ID", path.stem),
|
|
60
|
+
"description": profile.get("CLUSTER_DESC", ""),
|
|
61
|
+
"default_backend": profile.get("DEFAULT_BACKEND", "ssh"),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_profile(repo_root: Path, name: str | None) -> tuple[str | None, dict[str, str]]:
|
|
68
|
+
profiles = list_profiles(repo_root)
|
|
69
|
+
if name:
|
|
70
|
+
path = repo_root / "clusters" / f"{name}.conf"
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
available = ", ".join(item["name"] for item in profiles) or "(none)"
|
|
73
|
+
raise BackendError(f"unknown cluster profile {name!r}; available: {available}")
|
|
74
|
+
return name, parse_profile(path)
|
|
75
|
+
if len(profiles) == 1:
|
|
76
|
+
selected = profiles[0]["name"]
|
|
77
|
+
return selected, parse_profile(repo_root / "clusters" / f"{selected}.conf")
|
|
78
|
+
return None, {}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""SSH/Slurm backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .base import Backend, BackendError, require_option
|
|
11
|
+
from .profile import list_profiles
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _split_pipe(line: str, fields: list[str]) -> dict[str, str]:
|
|
15
|
+
values = line.rstrip("\n").split("|")
|
|
16
|
+
values += [""] * max(0, len(fields) - len(values))
|
|
17
|
+
return dict(zip(fields, values))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SSHBackend(Backend):
|
|
21
|
+
name = "ssh"
|
|
22
|
+
capabilities = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"clusters",
|
|
25
|
+
"queues",
|
|
26
|
+
"limits",
|
|
27
|
+
"job",
|
|
28
|
+
"logs",
|
|
29
|
+
"submit",
|
|
30
|
+
"cancel",
|
|
31
|
+
"upload",
|
|
32
|
+
"download",
|
|
33
|
+
"exec",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _alias(self) -> str:
|
|
38
|
+
alias = self.context.profile.get("CLUSTER_ID")
|
|
39
|
+
if not alias:
|
|
40
|
+
raise BackendError("SSH operations require --cluster and a CLUSTER_ID profile field")
|
|
41
|
+
return alias
|
|
42
|
+
|
|
43
|
+
def _run(self, argv: list[str], *, input_text: str | None = None) -> str:
|
|
44
|
+
try:
|
|
45
|
+
completed = subprocess.run(
|
|
46
|
+
argv,
|
|
47
|
+
input=input_text,
|
|
48
|
+
text=True,
|
|
49
|
+
stdout=subprocess.PIPE,
|
|
50
|
+
stderr=subprocess.PIPE,
|
|
51
|
+
timeout=self.context.timeout,
|
|
52
|
+
check=False,
|
|
53
|
+
)
|
|
54
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
55
|
+
raise BackendError(str(exc)) from exc
|
|
56
|
+
if completed.returncode != 0:
|
|
57
|
+
detail = completed.stderr.strip() or completed.stdout.strip()
|
|
58
|
+
raise BackendError(
|
|
59
|
+
f"command failed with exit {completed.returncode}: {detail or argv[0]}"
|
|
60
|
+
)
|
|
61
|
+
return completed.stdout
|
|
62
|
+
|
|
63
|
+
def _ssh(self, remote_command: str) -> str:
|
|
64
|
+
return self._run(
|
|
65
|
+
[
|
|
66
|
+
"ssh",
|
|
67
|
+
"-o",
|
|
68
|
+
"BatchMode=yes",
|
|
69
|
+
"-o",
|
|
70
|
+
f"ConnectTimeout={min(self.context.timeout, 30)}",
|
|
71
|
+
self._alias(),
|
|
72
|
+
remote_command,
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def op_clusters(self, options: Mapping[str, Any]) -> Any:
|
|
77
|
+
del options
|
|
78
|
+
return list_profiles(self.context.repo_root)
|
|
79
|
+
|
|
80
|
+
def op_queues(self, options: Mapping[str, Any]) -> Any:
|
|
81
|
+
del options
|
|
82
|
+
output = self._ssh(
|
|
83
|
+
"LC_ALL=C sinfo -h -o '%P|%a|%l|%D|%C|%G'"
|
|
84
|
+
)
|
|
85
|
+
fields = [
|
|
86
|
+
"partition",
|
|
87
|
+
"availability",
|
|
88
|
+
"max_walltime",
|
|
89
|
+
"nodes",
|
|
90
|
+
"cpu_state",
|
|
91
|
+
"gres",
|
|
92
|
+
]
|
|
93
|
+
rows = [_split_pipe(line, fields) for line in output.splitlines() if line.strip()]
|
|
94
|
+
for row in rows:
|
|
95
|
+
row["partition"] = row["partition"].rstrip("*")
|
|
96
|
+
return rows
|
|
97
|
+
|
|
98
|
+
def op_limits(self, options: Mapping[str, Any]) -> Any:
|
|
99
|
+
partition = (
|
|
100
|
+
options.get("partition")
|
|
101
|
+
or self.context.profile.get("PARTITION")
|
|
102
|
+
or self.context.profile.get("PARTITION_CPU")
|
|
103
|
+
)
|
|
104
|
+
if not partition:
|
|
105
|
+
raise BackendError("limits requires --partition or a PARTITION profile field")
|
|
106
|
+
if not str(partition).replace("-", "").replace("_", "").isalnum():
|
|
107
|
+
raise BackendError("partition contains unsafe characters")
|
|
108
|
+
output = self._ssh(
|
|
109
|
+
f"LC_ALL=C scontrol show partition {shlex.quote(str(partition))} -o"
|
|
110
|
+
).strip()
|
|
111
|
+
parsed: dict[str, str] = {"partition": str(partition)}
|
|
112
|
+
for token in output.split():
|
|
113
|
+
if "=" in token:
|
|
114
|
+
key, value = token.split("=", 1)
|
|
115
|
+
parsed[key] = value
|
|
116
|
+
keep = {
|
|
117
|
+
"partition",
|
|
118
|
+
"State",
|
|
119
|
+
"TotalNodes",
|
|
120
|
+
"TotalCPUs",
|
|
121
|
+
"DefMemPerCPU",
|
|
122
|
+
"DefMemPerNode",
|
|
123
|
+
"MaxMemPerCPU",
|
|
124
|
+
"MaxMemPerNode",
|
|
125
|
+
"MaxTime",
|
|
126
|
+
"MinNodes",
|
|
127
|
+
"MaxNodes",
|
|
128
|
+
"AllowAccounts",
|
|
129
|
+
"AllowQos",
|
|
130
|
+
"QoS",
|
|
131
|
+
"TRES",
|
|
132
|
+
}
|
|
133
|
+
return {key: value for key, value in parsed.items() if key in keep}
|
|
134
|
+
|
|
135
|
+
def op_job(self, options: Mapping[str, Any]) -> Any:
|
|
136
|
+
job_id = str(require_option(options, "job_id"))
|
|
137
|
+
if not job_id.replace("_", "").isalnum():
|
|
138
|
+
raise BackendError("job id contains unsafe characters")
|
|
139
|
+
fields = [
|
|
140
|
+
"job_id",
|
|
141
|
+
"state",
|
|
142
|
+
"partition",
|
|
143
|
+
"elapsed",
|
|
144
|
+
"time_limit",
|
|
145
|
+
"nodes",
|
|
146
|
+
"cpus",
|
|
147
|
+
"reason_or_nodes",
|
|
148
|
+
]
|
|
149
|
+
output = self._ssh(
|
|
150
|
+
"LC_ALL=C squeue -h "
|
|
151
|
+
f"-j {shlex.quote(job_id)} -o '%i|%T|%P|%M|%l|%D|%C|%R'"
|
|
152
|
+
).strip()
|
|
153
|
+
if output:
|
|
154
|
+
return _split_pipe(output.splitlines()[0], fields)
|
|
155
|
+
output = self._ssh(
|
|
156
|
+
"LC_ALL=C sacct -n -X "
|
|
157
|
+
f"-j {shlex.quote(job_id)} "
|
|
158
|
+
"--format=JobIDRaw,State,Partition,Elapsed,Timelimit,NNodes,NCPUS,ExitCode -P"
|
|
159
|
+
).strip()
|
|
160
|
+
if not output:
|
|
161
|
+
raise BackendError(f"job {job_id!r} was not found")
|
|
162
|
+
history_fields = fields[:-1] + ["exit_code"]
|
|
163
|
+
return _split_pipe(output.splitlines()[0], history_fields)
|
|
164
|
+
|
|
165
|
+
def op_logs(self, options: Mapping[str, Any]) -> Any:
|
|
166
|
+
path = str(require_option(options, "path"))
|
|
167
|
+
if not path.startswith("/") or "\n" in path or "\x00" in path:
|
|
168
|
+
raise BackendError("remote log path must be a safe absolute path")
|
|
169
|
+
lines = int(options.get("lines") or 200)
|
|
170
|
+
if lines < 1 or lines > 10000:
|
|
171
|
+
raise BackendError("--lines must be between 1 and 10000")
|
|
172
|
+
direction = options.get("direction") or "tail"
|
|
173
|
+
command = "tail" if direction == "tail" else "head"
|
|
174
|
+
output = self._ssh(
|
|
175
|
+
f"{command} -n {lines} -- {shlex.quote(path)}"
|
|
176
|
+
)
|
|
177
|
+
return {"path": path, "direction": direction, "lines": output.splitlines()}
|
|
178
|
+
|
|
179
|
+
def op_submit(self, options: Mapping[str, Any]) -> Any:
|
|
180
|
+
remote_path = str(require_option(options, "remote_path"))
|
|
181
|
+
if not remote_path.startswith("/") or "\n" in remote_path:
|
|
182
|
+
raise BackendError("SSH submit requires a safe absolute --remote-path")
|
|
183
|
+
output = self._ssh(
|
|
184
|
+
f"sbatch --parsable -- {shlex.quote(remote_path)}"
|
|
185
|
+
).strip()
|
|
186
|
+
return {"job_id": output.split(";", 1)[0], "raw": output}
|
|
187
|
+
|
|
188
|
+
def preview_submit(self, options: Mapping[str, Any]) -> Any:
|
|
189
|
+
remote_path = str(require_option(options, "remote_path"))
|
|
190
|
+
if not remote_path.startswith("/") or "\n" in remote_path:
|
|
191
|
+
raise BackendError("SSH submit requires a safe absolute --remote-path")
|
|
192
|
+
return {
|
|
193
|
+
"backend": self.name,
|
|
194
|
+
"operation": "submit",
|
|
195
|
+
"command": [
|
|
196
|
+
"ssh",
|
|
197
|
+
self._alias(),
|
|
198
|
+
"sbatch --parsable -- " + shlex.quote(remote_path),
|
|
199
|
+
],
|
|
200
|
+
"remote_path": remote_path,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
def op_cancel(self, options: Mapping[str, Any]) -> Any:
|
|
204
|
+
job_id = str(require_option(options, "job_id"))
|
|
205
|
+
if not job_id.replace("_", "").isalnum():
|
|
206
|
+
raise BackendError("job id contains unsafe characters")
|
|
207
|
+
self._ssh(f"scancel -- {shlex.quote(job_id)}")
|
|
208
|
+
return {"job_id": job_id, "cancelled": True}
|
|
209
|
+
|
|
210
|
+
def preview_cancel(self, options: Mapping[str, Any]) -> Any:
|
|
211
|
+
job_id = str(require_option(options, "job_id"))
|
|
212
|
+
if not job_id.replace("_", "").isalnum():
|
|
213
|
+
raise BackendError("job id contains unsafe characters")
|
|
214
|
+
return {
|
|
215
|
+
"backend": self.name,
|
|
216
|
+
"operation": "cancel",
|
|
217
|
+
"command": ["ssh", self._alias(), "scancel -- " + shlex.quote(job_id)],
|
|
218
|
+
"job_id": job_id,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
def op_upload(self, options: Mapping[str, Any]) -> Any:
|
|
222
|
+
local_path = Path(str(require_option(options, "local_path"))).expanduser()
|
|
223
|
+
remote_path = str(require_option(options, "remote_path"))
|
|
224
|
+
if not local_path.is_file():
|
|
225
|
+
raise BackendError(f"local file does not exist: {local_path}")
|
|
226
|
+
if not remote_path.startswith("/") or "\n" in remote_path:
|
|
227
|
+
raise BackendError("remote path must be a safe absolute path")
|
|
228
|
+
remote_q = shlex.quote(remote_path)
|
|
229
|
+
name_q = shlex.quote(local_path.name)
|
|
230
|
+
check = self._ssh(
|
|
231
|
+
f"target={remote_q}; "
|
|
232
|
+
f"if test -d \"$target\"; then target=\"$target\"/{name_q}; fi; "
|
|
233
|
+
"if test -e \"$target\"; then state=EXISTS; else state=MISSING; fi; "
|
|
234
|
+
"printf '%s\\n%s\\n' \"$state\" \"$target\""
|
|
235
|
+
).splitlines()
|
|
236
|
+
state = check[0] if check else ""
|
|
237
|
+
destination = check[1] if len(check) > 1 else remote_path
|
|
238
|
+
if state == "EXISTS" and not options.get("cover"):
|
|
239
|
+
raise BackendError("remote path already exists; pass --cover to overwrite it")
|
|
240
|
+
self._run(["scp", str(local_path), f"{self._alias()}:{remote_path}"])
|
|
241
|
+
return {"local_path": str(local_path), "remote_path": destination}
|
|
242
|
+
|
|
243
|
+
def op_download(self, options: Mapping[str, Any]) -> Any:
|
|
244
|
+
remote_path = str(require_option(options, "remote_path"))
|
|
245
|
+
local_path = Path(str(require_option(options, "local_path"))).expanduser()
|
|
246
|
+
if not remote_path.startswith("/") or "\n" in remote_path:
|
|
247
|
+
raise BackendError("remote path must be a safe absolute path")
|
|
248
|
+
if local_path.exists() and not options.get("cover"):
|
|
249
|
+
raise BackendError("local path already exists; pass --cover to overwrite it")
|
|
250
|
+
self._run(["scp", "-r", f"{self._alias()}:{remote_path}", str(local_path)])
|
|
251
|
+
return {"remote_path": remote_path, "local_path": str(local_path)}
|
|
252
|
+
|
|
253
|
+
def op_exec(self, options: Mapping[str, Any]) -> Any:
|
|
254
|
+
command = str(require_option(options, "command"))
|
|
255
|
+
if "\x00" in command:
|
|
256
|
+
raise BackendError("command contains a NUL byte")
|
|
257
|
+
output = self._ssh(command)
|
|
258
|
+
return {"stdout": output}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""User-local, non-secret configuration for the SCNet CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_root() -> Path:
|
|
14
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
15
|
+
if base:
|
|
16
|
+
return Path(base).expanduser() / "scnet-hpc"
|
|
17
|
+
return Path.home() / ".config" / "scnet-hpc"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
return config_root() / "config.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ssh_profile_path(cluster: str) -> Path:
|
|
25
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", cluster):
|
|
26
|
+
raise ValueError("unsafe cluster profile name")
|
|
27
|
+
return config_root() / "ssh" / f"{cluster}.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def openapi_cache_path() -> Path:
|
|
31
|
+
return config_root() / "openapi.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
37
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
38
|
+
return {}
|
|
39
|
+
return data if isinstance(data, dict) else {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_user_config() -> dict[str, Any]:
|
|
43
|
+
data = _read_json(config_path())
|
|
44
|
+
ssh = data.setdefault("ssh", {})
|
|
45
|
+
if isinstance(ssh, dict):
|
|
46
|
+
clusters = ssh.setdefault("clusters", {})
|
|
47
|
+
if isinstance(clusters, dict):
|
|
48
|
+
ssh_dir = config_root() / "ssh"
|
|
49
|
+
for path in sorted(ssh_dir.glob("*.json")):
|
|
50
|
+
profile = _read_json(path)
|
|
51
|
+
if profile:
|
|
52
|
+
clusters[path.stem] = profile
|
|
53
|
+
openapi_cache = _read_json(openapi_cache_path())
|
|
54
|
+
if openapi_cache:
|
|
55
|
+
current = data.setdefault("openapi", {})
|
|
56
|
+
if isinstance(current, dict):
|
|
57
|
+
current.update(openapi_cache)
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_user_config(data: dict[str, Any]) -> Path:
|
|
62
|
+
path = config_path()
|
|
63
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
64
|
+
path.parent.chmod(0o700)
|
|
65
|
+
clean = json.loads(json.dumps(data, ensure_ascii=False))
|
|
66
|
+
cluster = str(clean.get("cluster") or "")
|
|
67
|
+
ssh = clean.get("ssh")
|
|
68
|
+
if isinstance(ssh, dict):
|
|
69
|
+
clusters = ssh.get("clusters")
|
|
70
|
+
if isinstance(clusters, dict):
|
|
71
|
+
for name, profile in clusters.items():
|
|
72
|
+
if isinstance(profile, dict):
|
|
73
|
+
save_ssh_profile(str(name), profile)
|
|
74
|
+
elif cluster and isinstance(ssh.get("username"), str):
|
|
75
|
+
save_ssh_profile(
|
|
76
|
+
cluster,
|
|
77
|
+
{
|
|
78
|
+
key: value
|
|
79
|
+
for key, value in ssh.items()
|
|
80
|
+
if key != "clusters"
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
openapi = clean.get("openapi")
|
|
84
|
+
if isinstance(openapi, dict):
|
|
85
|
+
_save_json_sidecar(openapi_cache_path(), openapi)
|
|
86
|
+
clean.pop("ssh", None)
|
|
87
|
+
clean.pop("openapi", None)
|
|
88
|
+
fd, temporary = tempfile.mkstemp(
|
|
89
|
+
prefix=".config.", suffix=".tmp", dir=str(path.parent)
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
os.fchmod(fd, 0o600)
|
|
93
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
94
|
+
json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
|
95
|
+
stream.write("\n")
|
|
96
|
+
os.replace(temporary, path)
|
|
97
|
+
finally:
|
|
98
|
+
try:
|
|
99
|
+
os.unlink(temporary)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
pass
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _save_json_sidecar(path: Path, data: dict[str, Any]) -> Path:
|
|
106
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
107
|
+
path.parent.chmod(0o700)
|
|
108
|
+
clean = json.loads(json.dumps(data, ensure_ascii=False))
|
|
109
|
+
fd, temporary = tempfile.mkstemp(
|
|
110
|
+
prefix=f".{path.stem}.", suffix=".tmp", dir=str(path.parent)
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
os.fchmod(fd, 0o600)
|
|
114
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
115
|
+
json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
|
116
|
+
stream.write("\n")
|
|
117
|
+
os.replace(temporary, path)
|
|
118
|
+
finally:
|
|
119
|
+
try:
|
|
120
|
+
os.unlink(temporary)
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
pass
|
|
123
|
+
return path
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_ssh_profile(cluster: str, data: dict[str, Any]) -> Path:
|
|
127
|
+
return _save_json_sidecar(ssh_profile_path(cluster), data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reset_openapi_metadata() -> bool:
|
|
131
|
+
path = openapi_cache_path()
|
|
132
|
+
try:
|
|
133
|
+
path.unlink()
|
|
134
|
+
return True
|
|
135
|
+
except FileNotFoundError:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def reset_ssh_metadata(cluster: str) -> bool:
|
|
140
|
+
path = ssh_profile_path(cluster)
|
|
141
|
+
try:
|
|
142
|
+
path.unlink()
|
|
143
|
+
return True
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def reset_all_metadata() -> list[Path]:
|
|
149
|
+
removed: list[Path] = []
|
|
150
|
+
targets = [config_path(), openapi_cache_path()]
|
|
151
|
+
targets.extend(sorted((config_root() / "ssh").glob("*.json")))
|
|
152
|
+
for path in targets:
|
|
153
|
+
try:
|
|
154
|
+
path.unlink()
|
|
155
|
+
removed.append(path)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
pass
|
|
158
|
+
for directory in (config_root() / "ssh", config_root()):
|
|
159
|
+
try:
|
|
160
|
+
directory.rmdir()
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
return removed
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def redacted_config(data: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
"""Return a safe-to-display copy; this config should contain no secrets anyway."""
|
|
168
|
+
result = json.loads(json.dumps(data, ensure_ascii=False))
|
|
169
|
+
for section in ("openapi", "ssh"):
|
|
170
|
+
if isinstance(result.get(section), dict):
|
|
171
|
+
for key in list(result[section]):
|
|
172
|
+
lowered = key.lower()
|
|
173
|
+
if any(marker in lowered for marker in ("key", "token", "secret", "password")):
|
|
174
|
+
result[section][key] = "<not displayed>"
|
|
175
|
+
return result
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Secure OpenAPI credential discovery and storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SERVICE = "scnet-hpc-openapi"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CredentialError(RuntimeError):
|
|
17
|
+
"""A user-facing credential-store failure."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _valid(data: Any) -> bool:
|
|
21
|
+
return isinstance(data, dict) and all(
|
|
22
|
+
isinstance(data.get(key), str) and data[key]
|
|
23
|
+
for key in ("user", "access_key", "secret_key")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def environment_credentials() -> dict[str, str] | None:
|
|
28
|
+
data = {
|
|
29
|
+
"user": os.environ.get("SCNET_OPENAPI_USER", ""),
|
|
30
|
+
"access_key": os.environ.get("SCNET_OPENAPI_ACCESS_KEY", ""),
|
|
31
|
+
"secret_key": os.environ.get("SCNET_OPENAPI_SECRET_KEY", ""),
|
|
32
|
+
}
|
|
33
|
+
return data if _valid(data) else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def secure_store_name() -> str | None:
|
|
37
|
+
if platform.system() == "Darwin" and shutil.which("security"):
|
|
38
|
+
return "macOS Keychain"
|
|
39
|
+
if shutil.which("secret-tool"):
|
|
40
|
+
return "Secret Service"
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_secure_credentials() -> dict[str, str] | None:
|
|
45
|
+
provider = secure_store_name()
|
|
46
|
+
if provider == "macOS Keychain":
|
|
47
|
+
command = ["security", "find-generic-password", "-s", SERVICE, "-w"]
|
|
48
|
+
elif provider == "Secret Service":
|
|
49
|
+
command = ["secret-tool", "lookup", "service", SERVICE]
|
|
50
|
+
else:
|
|
51
|
+
return None
|
|
52
|
+
completed = subprocess.run(
|
|
53
|
+
command,
|
|
54
|
+
text=True,
|
|
55
|
+
stdout=subprocess.PIPE,
|
|
56
|
+
stderr=subprocess.DEVNULL,
|
|
57
|
+
check=False,
|
|
58
|
+
)
|
|
59
|
+
if completed.returncode != 0 or not completed.stdout.strip():
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(completed.stdout)
|
|
63
|
+
except json.JSONDecodeError:
|
|
64
|
+
return None
|
|
65
|
+
return data if _valid(data) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_openapi_credentials() -> tuple[dict[str, str] | None, str | None]:
|
|
69
|
+
data = environment_credentials()
|
|
70
|
+
if data:
|
|
71
|
+
return data, "environment"
|
|
72
|
+
data = load_secure_credentials()
|
|
73
|
+
if data:
|
|
74
|
+
return data, secure_store_name()
|
|
75
|
+
return None, None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def store_openapi_credentials(
|
|
79
|
+
user: str, access_key: str, secret_key: str
|
|
80
|
+
) -> str:
|
|
81
|
+
data = {
|
|
82
|
+
"user": user,
|
|
83
|
+
"access_key": access_key,
|
|
84
|
+
"secret_key": secret_key,
|
|
85
|
+
}
|
|
86
|
+
if not _valid(data):
|
|
87
|
+
raise CredentialError("OpenAPI credentials are incomplete")
|
|
88
|
+
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
89
|
+
provider = secure_store_name()
|
|
90
|
+
if provider == "macOS Keychain":
|
|
91
|
+
command = [
|
|
92
|
+
"security",
|
|
93
|
+
"add-generic-password",
|
|
94
|
+
"-U",
|
|
95
|
+
"-s",
|
|
96
|
+
SERVICE,
|
|
97
|
+
"-a",
|
|
98
|
+
user,
|
|
99
|
+
"-w",
|
|
100
|
+
payload,
|
|
101
|
+
]
|
|
102
|
+
completed = subprocess.run(
|
|
103
|
+
command,
|
|
104
|
+
stdout=subprocess.DEVNULL,
|
|
105
|
+
stderr=subprocess.PIPE,
|
|
106
|
+
text=True,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
elif provider == "Secret Service":
|
|
110
|
+
command = [
|
|
111
|
+
"secret-tool",
|
|
112
|
+
"store",
|
|
113
|
+
"--label=SCNet OpenAPI",
|
|
114
|
+
"service",
|
|
115
|
+
SERVICE,
|
|
116
|
+
"user",
|
|
117
|
+
user,
|
|
118
|
+
]
|
|
119
|
+
completed = subprocess.run(
|
|
120
|
+
command,
|
|
121
|
+
input=payload,
|
|
122
|
+
stdout=subprocess.DEVNULL,
|
|
123
|
+
stderr=subprocess.PIPE,
|
|
124
|
+
text=True,
|
|
125
|
+
check=False,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
raise CredentialError(
|
|
129
|
+
"no supported secure store; use SCNET_OPENAPI_USER, "
|
|
130
|
+
"SCNET_OPENAPI_ACCESS_KEY, and SCNET_OPENAPI_SECRET_KEY"
|
|
131
|
+
)
|
|
132
|
+
if completed.returncode != 0:
|
|
133
|
+
raise CredentialError(
|
|
134
|
+
completed.stderr.strip() or f"failed to write {provider}"
|
|
135
|
+
)
|
|
136
|
+
return str(provider)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def delete_openapi_credentials() -> tuple[bool, str | None]:
|
|
140
|
+
provider = secure_store_name()
|
|
141
|
+
if provider == "macOS Keychain":
|
|
142
|
+
command = ["security", "delete-generic-password", "-s", SERVICE]
|
|
143
|
+
elif provider == "Secret Service":
|
|
144
|
+
command = ["secret-tool", "clear", "service", SERVICE]
|
|
145
|
+
else:
|
|
146
|
+
return False, None
|
|
147
|
+
completed = subprocess.run(
|
|
148
|
+
command,
|
|
149
|
+
stdout=subprocess.DEVNULL,
|
|
150
|
+
stderr=subprocess.PIPE,
|
|
151
|
+
text=True,
|
|
152
|
+
check=False,
|
|
153
|
+
)
|
|
154
|
+
if completed.returncode == 0:
|
|
155
|
+
return True, provider
|
|
156
|
+
if load_secure_credentials() is None:
|
|
157
|
+
return False, provider
|
|
158
|
+
raise CredentialError(
|
|
159
|
+
completed.stderr.strip() or f"failed to clear {provider}"
|
|
160
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Small internal client used by CLI, plugins, and future MCP adapters."""
|
|
2
|
+
|
|
3
|
+
from .client import SCNetClient, canonical_signature, service_endpoint
|
|
4
|
+
from .notebook import NotebookService
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"NotebookService",
|
|
8
|
+
"SCNetClient",
|
|
9
|
+
"canonical_signature",
|
|
10
|
+
"service_endpoint",
|
|
11
|
+
]
|