Coverage for cli / slurm.py: 100.00%

254 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Business logic behind ``gco slurm`` (cli/commands/slurm_cmd.py). 

2 

3The command module owns the Click surface; this module owns everything that 

4can be tested without a terminal: reading a batch script and its ``#SBATCH`` 

5directives into the API's submission body, Slurm's relative time windows, 

6the human renderings of the API's answers, and the ``kubectl exec`` argv for 

7the login pod (``shell`` / ``output``) that the cluster tunnel reaches. 

8 

9The API half lives in gco/services/api_routes/slurm.py; every request here 

10is pinned to one region because each region runs its own Slurm cluster. 

11""" 

12 

13from __future__ import annotations 

14 

15import re 

16import shlex 

17import subprocess # nosec B404 # kubectl runs as a validated argv list, never through a shell 

18from collections.abc import Mapping, Sequence 

19from datetime import UTC, datetime 

20from pathlib import Path 

21from typing import Any 

22 

23from gco.slurm_config import SLURM_LOGIN_NAME, SLURM_NAMESPACE 

24 

25from . import cluster_ui 

26 

27#: The Slinky LoginSet GCO deploys and its namespace; ``shell`` and ``output`` 

28#: exec into its Deployment (the operator names it after the LoginSet) because 

29#: it mounts the same /home (EFS) and /scratch (FSx) the workers do and 

30#: carries every Slurm client. ``login`` is the operator's container name. 

31LOGIN_NAMESPACE = SLURM_NAMESPACE 

32LOGIN_DEPLOYMENT = SLURM_LOGIN_NAME 

33LOGIN_CONTAINER = "login" 

34 

35#: ``--since`` suffixes to Slurm's relative time units (``now-7days``). 

36_DURATION_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"} 

37_DURATION_RE = re.compile(r"^(?P<count>\d{1,6})(?P<unit>[smhdw])$") 

38#: Absolute Slurm times (``parse_time``): YYYY-MM-DD[THH:MM[:SS]], HH:MM[:SS], MM/DD[/YY], epoch. 

39_SLURM_TIME_RE = re.compile( 

40 r"^(?:\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?)?|\d{2}:\d{2}(?::\d{2})?" 

41 r"|\d{2}/\d{2}(?:/\d{2,4})?|\d{9,10}|now(?:[+-]\d+(?:seconds|minutes|hours|days|weeks))?)$" 

42) 

43_SBATCH_RE = re.compile(r"^#SBATCH\s+(?P<body>.+?)\s*$") 

44_SAFE_PATH_RE = re.compile(r"^/[A-Za-z0-9._/@+%:=,-]{0,1023}$") 

45MAX_SCRIPT_BYTES = 64 * 1024 

46 

47 

48class SlurmCliError(ValueError): 

49 """A request that cannot be shaped: bad script, bad window, bad path.""" 

50 

51 

52# --------------------------------------------------------------------------- 

53# Time windows 

54# --------------------------------------------------------------------------- 

55 

56 

57def slurm_time_window(since: str | None, start: str | None, end: str | None) -> dict[str, str]: 

58 """Translate ``--since 7d`` / ``--start`` / ``--end`` into slurmdb query parameters. 

59 

60 ``since`` is a relative window (``30m``, ``24h``, ``7d``, ``2w``) rendered 

61 as Slurm's own ``now-7days`` form so slurmdbd does the arithmetic in the 

62 cluster's clock; ``start``/``end`` are passed through when they look like 

63 a Slurm time. ``since`` and ``start`` are alternatives. 

64 """ 

65 if since and start: 

66 raise SlurmCliError("pass either --since or --start, not both") 

67 params: dict[str, str] = {} 

68 if since: 

69 match = _DURATION_RE.match(since.strip().lower()) 

70 if match is None: 

71 raise SlurmCliError( 

72 f"invalid --since {since!r}: use a count and unit such as 30m, 24h, 7d or 2w" 

73 ) 

74 count, unit = int(match["count"]), _DURATION_UNITS[match["unit"]] 

75 if count == 0: 

76 raise SlurmCliError("--since must be a positive window") 

77 params["start"] = f"now-{count}{unit}" 

78 elif start: 

79 params["start"] = _slurm_time(start, "--start") 

80 if end: 

81 params["end"] = _slurm_time(end, "--end") 

82 return params 

83 

84 

85def _slurm_time(value: str, flag: str) -> str: 

86 text = value.strip() 

87 if not _SLURM_TIME_RE.match(text): 

88 raise SlurmCliError( 

89 f"invalid {flag} {value!r}: use YYYY-MM-DD[THH:MM[:SS]], HH:MM, an epoch, " 

90 "or Slurm's relative form such as now-2days" 

91 ) 

92 return text 

93 

94 

95# --------------------------------------------------------------------------- 

96# Batch scripts 

97# --------------------------------------------------------------------------- 

98 

99 

100def read_batch_script(path: Path) -> str: 

101 """Read a batch script, refusing empty, oversized or shebang-less files.""" 

102 try: 

103 raw = path.read_bytes() 

104 except OSError as exc: 

105 raise SlurmCliError(f"cannot read {path}: {exc.strerror or exc}") from exc 

106 if not raw.strip(): 

107 raise SlurmCliError(f"{path} is empty") 

108 if len(raw) > MAX_SCRIPT_BYTES: 

109 raise SlurmCliError( 

110 f"{path} is {len(raw)} bytes; batch scripts are limited to {MAX_SCRIPT_BYTES} " 

111 "(put the payload in a container image or on /home or /scratch)" 

112 ) 

113 try: 

114 text = raw.decode("utf-8") 

115 except UnicodeDecodeError as exc: 

116 raise SlurmCliError(f"{path} is not UTF-8 text") from exc 

117 if not text.lstrip().startswith("#!"): 

118 raise SlurmCliError(f"{path} must start with an interpreter line such as #!/bin/bash") 

119 return text 

120 

121 

122#: ``#SBATCH`` options the CLI understands, mapped to the API body's fields. 

123#: slurmrestd does not read ``#SBATCH`` lines (sbatch the client does), so the 

124#: CLI applies them as defaults the explicit flags override; anything else in 

125#: the script is left for Slurm to ignore, as sbatch would with unknown options. 

126_SBATCH_OPTIONS: dict[str, str] = { 

127 "-J": "name", 

128 "--job-name": "name", 

129 "-p": "partition", 

130 "--partition": "partition", 

131 "-N": "nodes", 

132 "--nodes": "nodes", 

133 "-n": "tasks", 

134 "--ntasks": "tasks", 

135 "-c": "cpus_per_task", 

136 "--cpus-per-task": "cpus_per_task", 

137 "--gpus-per-node": "gpus_per_node", 

138 "--mem": "memory_mb", 

139 "-t": "time_limit", 

140 "--time": "time_limit", 

141 "-D": "working_directory", 

142 "--chdir": "working_directory", 

143 "--comment": "comment", 

144 "--exclusive": "exclusive", 

145} 

146_INT_FIELDS = {"nodes", "tasks", "cpus_per_task", "gpus_per_node"} 

147 

148 

149def sbatch_directives(script: str) -> dict[str, Any]: 

150 """Collect the ``#SBATCH`` directives the API body can carry. 

151 

152 Reads the leading comment block the way sbatch does (directives stop at 

153 the first non-comment, non-blank line). Unknown options are ignored. 

154 ``--mem`` accepts Slurm's K/M/G/T suffixes and lands in MiB; ``--gpus-per-node`` 

155 accepts ``N`` or ``type:N``; ``--exclusive`` is a flag. 

156 """ 

157 found: dict[str, Any] = {} 

158 for line in script.splitlines()[1:]: 

159 stripped = line.strip() 

160 if not stripped: 

161 continue 

162 if not stripped.startswith("#"): 

163 break 

164 match = _SBATCH_RE.match(stripped) 

165 if match is None: 

166 continue 

167 try: 

168 tokens = shlex.split(match["body"], comments=True) 

169 except ValueError: 

170 continue 

171 index = 0 

172 while index < len(tokens): 

173 token = tokens[index] 

174 option, value = token, None 

175 if token.startswith("--") and "=" in token: 

176 option, value = token.split("=", 1) 

177 elif len(token) > 2 and token[0] == "-" and token[1] != "-": 

178 option, value = token[:2], token[2:] 

179 field = _SBATCH_OPTIONS.get(option) 

180 if field is None: 

181 index += 1 

182 continue 

183 if field == "exclusive": 

184 found[field] = True 

185 index += 1 

186 continue 

187 if value is None: 

188 index += 1 

189 if index >= len(tokens): 

190 break 

191 value = tokens[index] 

192 index += 1 

193 found[field] = _directive_value(field, value) 

194 return found 

195 

196 

197def _directive_value(field: str, value: str) -> Any: 

198 if field in _INT_FIELDS: 

199 text = value.split(":")[-1] if field == "gpus_per_node" else value 

200 try: 

201 return int(text) 

202 except ValueError as exc: 

203 raise SlurmCliError( 

204 f"#SBATCH {field.replace('_', '-')} {value!r} is not a number" 

205 ) from exc 

206 if field == "memory_mb": 

207 return memory_mb(value) 

208 return value 

209 

210 

211def memory_mb(value: str) -> int: 

212 """Slurm's ``--mem`` (``4000``, ``4G``, ``512M``, ``1T``) in MiB.""" 

213 match = re.fullmatch(r"(\d+)([KkMmGgTt]?)", value.strip()) 

214 if match is None: 

215 raise SlurmCliError(f"invalid memory {value!r}: use MiB, or a K/M/G/T suffix such as 64G") 

216 amount, unit = int(match[1]), match[2].upper() or "M" 

217 factor = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}[unit] 

218 return max(int(amount * factor), 1) 

219 

220 

221def submission_body( 

222 script: str, 

223 *, 

224 flags: Mapping[str, Any], 

225 environment: Sequence[str] = (), 

226) -> dict[str, Any]: 

227 """Compose the ``POST /api/v1/slurm/jobs`` body: directives, then flags on top.""" 

228 body: dict[str, Any] = {"script": script} 

229 body.update(sbatch_directives(script)) 

230 for key, value in flags.items(): 

231 if value is None or (key == "exclusive" and value is False): 

232 continue 

233 body[key] = value 

234 env: dict[str, str] = {} 

235 for item in environment: 

236 name, separator, value = item.partition("=") 

237 if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): 

238 raise SlurmCliError(f"invalid --env {item!r}: use NAME=value") 

239 env[name] = value 

240 if env: 

241 body["environment"] = env 

242 return body 

243 

244 

245# --------------------------------------------------------------------------- 

246# Human renderings 

247# --------------------------------------------------------------------------- 

248 

249 

250def _when(epoch: Any) -> str: 

251 if not isinstance(epoch, int) or epoch <= 0: 

252 return "-" 

253 return datetime.fromtimestamp(epoch, tz=UTC).strftime("%Y-%m-%d %H:%M:%S") 

254 

255 

256def _join(states: Any) -> str: 

257 if isinstance(states, list): 

258 return ",".join(str(s) for s in states) or "-" 

259 return str(states or "-") 

260 

261 

262def render_status(status: Mapping[str, Any], region: str) -> str: 

263 lines = [f"\n Slurm — {region} (slurmrestd {status.get('api_version', '?')})", " " + "-" * 70] 

264 for controller in status.get("controller") or []: 

265 state = "UP" if controller.get("responding") else "DOWN" 

266 latency = controller.get("latency_us") 

267 suffix = f" ({latency} µs)" if isinstance(latency, int) else "" 

268 lines.append( 

269 f" Controller {controller.get('hostname')}: {state}{suffix} [{controller.get('mode')}]" 

270 ) 

271 nodes = status.get("nodes") or {} 

272 jobs = status.get("jobs") or {} 

273 scaled_in = nodes.get("scaled_in") or 0 

274 node_line = f" Nodes: {nodes.get('total', 0)} {_counts(nodes.get('by_state'))}" 

275 if scaled_in: 

276 node_line += f" + {scaled_in} scaled in (records kept until the next scale-up)" 

277 lines.append(node_line) 

278 lines.append(f" Jobs: {jobs.get('total', 0)} {_counts(jobs.get('by_state'))}") 

279 lines.append("") 

280 lines.extend(_partition_table(status.get("partitions") or [])) 

281 return "\n".join(lines) 

282 

283 

284def _counts(by_state: Any) -> str: 

285 if not isinstance(by_state, dict) or not by_state: 

286 return "" 

287 return "(" + ", ".join(f"{state} {count}" for state, count in sorted(by_state.items())) + ")" 

288 

289 

290def _partition_table(partitions: Sequence[Mapping[str, Any]]) -> list[str]: 

291 lines = [ 

292 f" {'PARTITION':<14} {'STATE':<12} {'NODES':>5} {'CPUS':>5} {'MAX TIME':>10} NODELIST" 

293 ] 

294 lines.append(" " + "-" * 70) 

295 for partition in partitions: 

296 name = str(partition.get("name") or "-") 

297 if partition.get("default"): 

298 name += "*" 

299 max_time = partition.get("max_time_minutes") 

300 lines.append( 

301 f" {name:<14} {_join(partition.get('state')):<12} " 

302 f"{partition.get('total_nodes') if partition.get('total_nodes') is not None else '-':>5} " 

303 f"{partition.get('total_cpus') if partition.get('total_cpus') is not None else '-':>5} " 

304 f"{(str(max_time) + 'm') if isinstance(max_time, int) else 'infinite':>10} " 

305 f"{partition.get('node_names') or '-'}" 

306 ) 

307 if not partitions: 

308 lines.append(" (no partitions)") 

309 return lines 

310 

311 

312def render_partitions(partitions: Sequence[Mapping[str, Any]], region: str) -> str: 

313 return "\n".join([f"\n Slurm partitions — {region}", *_partition_table(partitions), ""]) 

314 

315 

316def render_nodes(nodes: Sequence[Mapping[str, Any]], region: str) -> str: 

317 lines = [ 

318 f"\n Slurm nodes — {region} ({len(nodes)})", 

319 f" {'NODE':<28} {'STATE':<18} {'CPUS':>9} {'MEMORY MB':>17} {'GRES':<20} PARTITIONS", 

320 " " + "-" * 110, 

321 ] 

322 for node in nodes: 

323 cpus = f"{node.get('cpus_allocated') or 0}/{node.get('cpus') or 0}" 

324 memory = f"{node.get('memory_allocated_mb') or 0}/{node.get('real_memory_mb') or 0}" 

325 gres = str(node.get("gres_used") or node.get("gres") or "-") 

326 state = "SCALED-IN" if node.get("scaled_in") else _join(node.get("state")) 

327 lines.append( 

328 f" {node.get('name') or '-'!s:<28} {state:<18} {cpus:>9} " 

329 f"{memory:>17} {gres[:20]:<20} {','.join(node.get('partitions') or []) or '-'}" 

330 ) 

331 if node.get("reason") and not node.get("scaled_in"): 

332 lines.append(f" reason: {node['reason']}") 

333 if not nodes: 

334 lines.append(" (no nodes — autoscaled partitions start at zero workers)") 

335 lines.append("") 

336 return "\n".join(lines) 

337 

338 

339def render_queue(jobs: Sequence[Mapping[str, Any]], total: Any, region: str) -> str: 

340 lines = [ 

341 f"\n Slurm queue — {region} ({len(jobs)} of {total} shown)", 

342 f" {'JOBID':>8} {'NAME':<24} {'PARTITION':<10} {'STATE':<12} {'NODES':>5} {'SUBMITTED':<19} REASON/NODELIST", 

343 " " + "-" * 110, 

344 ] 

345 for job in jobs: 

346 tail = job.get("nodes") or job.get("state_reason") or "-" 

347 lines.append( 

348 f" {job.get('job_id') or '-'!s:>8} {str(job.get('name') or '-')[:24]:<24} " 

349 f"{str(job.get('partition') or '-')[:10]:<10} {_join(job.get('state'))[:12]:<12} " 

350 f"{job.get('node_count') if job.get('node_count') is not None else '-':>5} " 

351 f"{_when(job.get('submit_time')):<19} {tail}" 

352 ) 

353 if not jobs: 

354 lines.append(" (queue is empty)") 

355 lines.append("") 

356 return "\n".join(lines) 

357 

358 

359def _state_line(job: Mapping[str, Any]) -> str: 

360 """``PENDING — Resources`` while the job waits; the bare state otherwise. 

361 

362 Slurm's state reason says why a job is still pending. Both daemons keep 

363 reporting the last one afterwards (``ReqNodeNotAvail`` on a COMPLETED job, 

364 ``None`` on a running one), where it is noise. 

365 """ 

366 states = job.get("state") 

367 state = _join(states) 

368 reason = job.get("state_reason") 

369 pending = "PENDING" in (states if isinstance(states, list) else [states]) 

370 if pending and reason and reason != "None": 

371 return f"{state} — {reason}" 

372 return state 

373 

374 

375def render_job(job: Mapping[str, Any], region: str) -> str: 

376 exit_code = job.get("exit_code") or {} 

377 rows = [ 

378 ("Job", f"{job.get('job_id')} ({job.get('name')})"), 

379 ("Region", region), 

380 ( 

381 "Source", 

382 "slurmdbd (finished; slurmctld has forgotten it)" 

383 if job.get("source") == "slurmdbd" 

384 else "slurmctld", 

385 ), 

386 ("State", _state_line(job)), 

387 ("User / account", f"{job.get('user') or '-'} / {job.get('account') or '-'}"), 

388 ("Partition", str(job.get("partition") or "-")), 

389 ( 

390 "Nodes", 

391 f"{job.get('node_count') if job.get('node_count') is not None else '-'} {job.get('nodes') or ''}".strip(), 

392 ), 

393 ("Requested", str(job.get("tres_requested") or "-")), 

394 ("Allocated", str(job.get("tres_allocated") or "-")), 

395 ("Submitted", _when(job.get("submit_time"))), 

396 ("Started", _when(job.get("start_time"))), 

397 ("Ended", _when(job.get("end_time"))), 

398 ( 

399 "Time limit", 

400 f"{job.get('time_limit_minutes')} min" if job.get("time_limit_minutes") else "infinite", 

401 ), 

402 ( 

403 "Exit", 

404 f"{_join(exit_code.get('status'))} rc={exit_code.get('return_code')}" 

405 if exit_code 

406 else "-", 

407 ), 

408 ("Comment", str(job.get("comment") or "-")), 

409 ("Working dir", str(job.get("working_directory") or "-")), 

410 ("Stdout", str(job.get("standard_output") or "-")), 

411 ("Stderr", str(job.get("standard_error") or "-")), 

412 ] 

413 width = max(len(label) for label, _value in rows) 

414 return "\n".join(["", *(f" {label:<{width}} {value}" for label, value in rows), ""]) 

415 

416 

417def render_accounting(jobs: Sequence[Mapping[str, Any]], total: Any, region: str) -> str: 

418 lines = [ 

419 f"\n Slurm accounting — {region} ({len(jobs)} of {total} shown)", 

420 f" {'JOBID':>8} {'NAME':<22} {'USER':<10} {'PARTITION':<10} {'STATE':<12} {'ELAPSED':>9} {'EXIT':>6} {'STARTED':<19} COMMENT", 

421 " " + "-" * 120, 

422 ] 

423 for job in jobs: 

424 exit_code = job.get("exit_code") or {} 

425 elapsed = job.get("elapsed_seconds") 

426 lines.append( 

427 f" {job.get('job_id') or '-'!s:>8} {str(job.get('name') or '-')[:22]:<22} " 

428 f"{str(job.get('user') or '-')[:10]:<10} {str(job.get('partition') or '-')[:10]:<10} " 

429 f"{_join(job.get('state'))[:12]:<12} {_elapsed(elapsed):>9} " 

430 f"{exit_code.get('return_code') if exit_code.get('return_code') is not None else '-'!s:>6} " 

431 f"{_when(job.get('start_time')):<19} {str(job.get('comment') or '')[:40]}" 

432 ) 

433 if not jobs: 

434 lines.append(" (no accounting records in this window)") 

435 lines.append("") 

436 return "\n".join(lines) 

437 

438 

439def _elapsed(seconds: Any) -> str: 

440 if not isinstance(seconds, int) or seconds < 0: 

441 return "-" 

442 hours, remainder = divmod(seconds, 3600) 

443 minutes, secs = divmod(remainder, 60) 

444 return f"{hours:02d}:{minutes:02d}:{secs:02d}" 

445 

446 

447# --------------------------------------------------------------------------- 

448# Login pod access (kubectl exec over the cluster tunnel) 

449# --------------------------------------------------------------------------- 

450 

451 

452def login_exec_command( 

453 args: Sequence[str], 

454 *, 

455 interactive: bool = False, 

456 server: str | None = None, 

457 tls_server_name: str | None = None, 

458) -> list[str]: 

459 """``kubectl exec`` into the login Deployment with the tunnel's flags.""" 

460 cmd = ["kubectl", "exec"] 

461 if interactive: 

462 cmd.append("-it") 

463 cmd += [ 

464 f"deploy/{LOGIN_DEPLOYMENT}", 

465 "-n", 

466 LOGIN_NAMESPACE, 

467 "-c", 

468 LOGIN_CONTAINER, 

469 *cluster_ui.kubectl_server_flags(server, tls_server_name), 

470 "--", 

471 *args, 

472 ] 

473 return cmd 

474 

475 

476def validate_remote_path(path: str) -> str: 

477 """An absolute path the login pod may read (``cat`` / ``tail``), with no shell metacharacters.""" 

478 if not _SAFE_PATH_RE.match(path) or ".." in path.split("/"): 

479 raise SlurmCliError( 

480 f"refusing to read {path!r}: expected an absolute path on the login pod" 

481 ) 

482 return path 

483 

484 

485def run_foreground(cmd: Sequence[str]) -> int: 

486 """Run kubectl with inherited stdio (an interactive shell, a tail -f); return its exit code.""" 

487 try: 

488 completed = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - argv built by login_exec_command; list form, no shell=True 

489 list(cmd), check=False 

490 ) 

491 except FileNotFoundError as exc: 

492 raise RuntimeError( 

493 "kubectl not found. Install kubectl and ensure it's on your PATH." 

494 ) from exc 

495 return int(completed.returncode) 

496 

497 

498def run_capture(cmd: Sequence[str], *, timeout: float = 120.0) -> str: 

499 """Run kubectl and return its stdout; a failure carries kubectl's stderr.""" 

500 try: 

501 completed = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - argv built by login_exec_command; list form, no shell=True 

502 list(cmd), capture_output=True, text=True, check=False, timeout=timeout 

503 ) 

504 except FileNotFoundError as exc: 

505 raise RuntimeError( 

506 "kubectl not found. Install kubectl and ensure it's on your PATH." 

507 ) from exc 

508 except subprocess.TimeoutExpired as exc: 

509 raise RuntimeError(f"Timed out after {timeout:.0f}s running {cmd[0]} {cmd[1]}") from exc 

510 if completed.returncode != 0: 

511 raise RuntimeError( 

512 f"kubectl exec failed ({completed.returncode}): {(completed.stderr or '').strip()}" 

513 ) 

514 return completed.stdout