sqtop 0.1.0__py3-none-any.whl

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.
sqtop/__init__.py ADDED
File without changes
sqtop/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """Entry point for `sqtop`: the dashboard, or a one-shot text report."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import hosts, probes, profiles, report, slurm, snapshot
7
+
8
+
9
+ def find_clusters():
10
+ """This cluster, if Slurm runs here, then each cluster with an ssh host.
11
+
12
+ The local cluster is detected from `scontrol show config`; a configured
13
+ remote with the same name is skipped, since it is already shown.
14
+ """
15
+ local = hosts.Local()
16
+ config = local.run(slurm.CONFIG_COMMAND)
17
+ clusters = []
18
+ local_name = None
19
+ if config is not None:
20
+ local_name = slurm.parse_config(config).get("ClusterName") or None
21
+ profile = profiles.load_profile(local_name or "")
22
+ clusters.append(snapshot.Cluster(local_name or "local", profile, local))
23
+ for profile in profiles.remote_profiles():
24
+ if profile.name != local_name:
25
+ remote = hosts.Remote(profile.ssh)
26
+ clusters.append(snapshot.Cluster(profile.name, profile, remote))
27
+ return clusters
28
+
29
+
30
+ def main(argv=None):
31
+ # No options: clusters are detected and configured in the config, which
32
+ # Config edits in the dashboard, where the probe request and hourly email
33
+ # are set too. Parsing still rejects stray arguments and serves --help.
34
+ config = profiles.short_path(profiles.CONFIG_PATH)
35
+ argparse.ArgumentParser(
36
+ prog="sqtop",
37
+ description=(
38
+ "Partitions, account/QOS limits and estimated start times for the "
39
+ "Slurm cluster you are logged in to; clusters with an ssh host in "
40
+ f"{config} get a tab that connects when opened, Config edits that "
41
+ "config, and Email sends the dashboard hourly through its [smtp]. "
42
+ "Prints a text report of this cluster when output is not a terminal."
43
+ ),
44
+ ).parse_args(argv)
45
+
46
+ try:
47
+ clusters = find_clusters()
48
+ mail = profiles.load_mail()
49
+ except ValueError as error:
50
+ sys.exit(f"sqtop: {profiles.short_path(profiles.CONFIG_PATH)}: {error}")
51
+ if not clusters:
52
+ sys.exit(
53
+ "sqtop: Slurm is not available here ('scontrol' failed), and no "
54
+ f"cluster in {config} has an ssh host."
55
+ )
56
+ settings = probes.Settings()
57
+ if mail is not None:
58
+ settings = probes.Settings(mail_user=mail[0], mail_type=mail[1])
59
+
60
+ if not sys.stdout.isatty():
61
+ # Like the dashboard at startup, the report covers the cluster this
62
+ # runs on; remote ones only when Slurm is not available here.
63
+ reported = [c for c in clusters if c.host.ssh is None] or clusters
64
+ report.print_reports(reported, settings)
65
+ return
66
+
67
+ # Textual is only imported for the dashboard, keeping the report fast.
68
+ from .tui import SqtopApp
69
+
70
+ SqtopApp(clusters, settings).run()
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
sqtop/hosts.py ADDED
@@ -0,0 +1,272 @@
1
+ """Where Slurm commands run: this login node, or another one over ssh.
2
+
3
+ Starting an ssh session on a cluster login node can take seconds (shell
4
+ start-up files, MFA-guarded sshd) while the Slurm commands themselves are
5
+ quick, so a `Remote` runs a whole refresh in one session, and streams a whole
6
+ probe grid through another. Sessions reuse an ssh control master.
7
+
8
+ Every command runs without the terminal and never prompts: sqtop does not
9
+ log in for the user. A cluster that needs a password or MFA is reached through
10
+ a control master the user opened with `ssh` in a terminal of their own.
11
+ """
12
+
13
+ import os
14
+ import shlex
15
+ import subprocess
16
+ import tempfile
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from pathlib import Path
19
+
20
+ # Separates command outputs within one remote session; Slurm never prints it.
21
+ RECORD = "\x1e"
22
+ # Concurrent local probes; each is a will-run test in slurmctld, so stay polite.
23
+ PROBE_WORKERS = 4
24
+ PROBE_TIMEOUT = 30
25
+ # Control sockets when the user's ssh config does not multiplex already.
26
+ CONTROL_DIR = Path(tempfile.gettempdir()) / f"sqtop-{os.getuid()}"
27
+ CONTROL_PERSIST = "10m"
28
+
29
+
30
+ class ConnectionLost(Exception):
31
+ """ssh could not reach the cluster; the message is ssh's own."""
32
+
33
+
34
+ def connect_hint(ssh):
35
+ """How the user gets `ssh` connected, shown with a `ConnectionLost`."""
36
+ return (
37
+ "sqtop never asks for a password or MFA: log in with "
38
+ f"`ssh {ssh}` in another terminal, with ControlMaster set for it in "
39
+ "~/.ssh/config so sqtop can share that connection."
40
+ )
41
+
42
+
43
+ def _run(args, timeout):
44
+ """Return (returncode, stdout, stderr); 127 if missing, 124 on timeout."""
45
+ try:
46
+ done = subprocess.run(
47
+ args,
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=timeout,
51
+ stdin=subprocess.DEVNULL,
52
+ check=False,
53
+ )
54
+ except FileNotFoundError:
55
+ return 127, "", f"'{args[0]}' not found"
56
+ except subprocess.TimeoutExpired:
57
+ return 124, "", f"Timed out after {timeout}s"
58
+ return done.returncode, done.stdout, done.stderr
59
+
60
+
61
+ def _combined(stdout, stderr):
62
+ return "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
63
+
64
+
65
+ class Local:
66
+ """Run commands on this machine."""
67
+
68
+ ssh = None
69
+
70
+ def run(self, args, timeout=60):
71
+ """Return stdout, or None when the command is missing, fails or hangs."""
72
+ code, stdout, _ = _run(args, timeout)
73
+ return stdout if code == 0 else None
74
+
75
+ def act(self, args, timeout=60):
76
+ """Run a command that acts, such as `sbatch` or `scancel`:
77
+ (returncode, output).
78
+
79
+ The output has stdout and stderr together, so a rejection says why.
80
+ """
81
+ code, stdout, stderr = _run(args, timeout)
82
+ return code, _combined(stdout, stderr)
83
+
84
+ def run_batch(self, commands, timeout=120):
85
+ """Run commands in parallel; their stdout in order, None for failures."""
86
+ if not commands:
87
+ return []
88
+ with ThreadPoolExecutor(len(commands)) as pool:
89
+ return list(pool.map(lambda args: self.run(args, timeout), commands))
90
+
91
+ def run_stream(self, commands, on_result, should_stop=lambda: False):
92
+ """Run commands a few at a time, reporting each as it finishes.
93
+
94
+ `on_result(index, returncode, output)` gets stdout and stderr together.
95
+ Once `should_stop()` is true, commands not yet started are dropped.
96
+ """
97
+ with ThreadPoolExecutor(PROBE_WORKERS) as pool:
98
+ futures = {
99
+ pool.submit(_run, args, PROBE_TIMEOUT): index
100
+ for index, args in enumerate(commands)
101
+ }
102
+ for future in as_completed(futures):
103
+ if should_stop():
104
+ pool.shutdown(cancel_futures=True)
105
+ break
106
+ code, stdout, stderr = future.result()
107
+ on_result(futures[future], code, _combined(stdout, stderr))
108
+
109
+
110
+ class Remote:
111
+ """Run commands on a cluster's login node through `ssh <destination>`.
112
+
113
+ `destination` is anything ssh accepts, such as a `~/.ssh/config` alias.
114
+ Commands run with the login shell's PATH (read by `detect_commands`),
115
+ since non-login sessions often lack Slurm's directory.
116
+ """
117
+
118
+ def __init__(self, ssh):
119
+ self.ssh = ssh
120
+ self.path = None
121
+ self._control = None
122
+
123
+ def control_options(self):
124
+ """Reuse a control master: the user's own if configured, else ours."""
125
+ if self._control is None:
126
+ _, stdout, _ = _run(["ssh", "-G", self.ssh], 10)
127
+ settings = dict(
128
+ line.split(" ", 1) for line in stdout.splitlines() if " " in line
129
+ )
130
+ options = ["-o", "ControlMaster=auto"]
131
+ if settings.get("controlpath", "none") == "none":
132
+ CONTROL_DIR.mkdir(mode=0o700, exist_ok=True)
133
+ options += ["-o", f"ControlPath={CONTROL_DIR}/%C"]
134
+ if settings.get("controlpersist", "no") in ("no", "false", "0"):
135
+ options += ["-o", f"ControlPersist={CONTROL_PERSIST}"]
136
+ self._control = options
137
+ return self._control
138
+
139
+ def ssh_command(self, script):
140
+ """ssh running `script` with sh, never allocating a terminal or prompting."""
141
+ options = [*self.control_options(), "-T", "-o", "ConnectTimeout=15"]
142
+ options += ["-o", "BatchMode=yes", "-o", "LogLevel=ERROR"]
143
+ return ["ssh", *options, self.ssh, shlex.join(["sh", "-c", script])]
144
+
145
+ def _prelude(self, login_path=False):
146
+ if login_path:
147
+ # A login shell sets up PATH the way an interactive user sees it.
148
+ return (
149
+ "p=$(bash -lc 'printf %s \"$PATH\"' </dev/null 2>/dev/null)"
150
+ ' && [ -n "$p" ] && PATH=$p; export PATH\n'
151
+ )
152
+ if self.path:
153
+ return f"PATH={shlex.quote(self.path)}; export PATH\n"
154
+ return ""
155
+
156
+ def run(self, args, timeout=60):
157
+ return self.run_batch([args], timeout)[0]
158
+
159
+ def act(self, args, timeout=60):
160
+ """`Local.act` on the cluster; raises `ConnectionLost` if ssh fails."""
161
+ script = (
162
+ f"{self._prelude()}{shlex.join(args)} </dev/null 2>&1; "
163
+ "printf '\\n\\036%s\\n' $?"
164
+ )
165
+ code, stdout, stderr = _run(self.ssh_command(script), timeout)
166
+ output, record, status = stdout.rpartition(RECORD)
167
+ if not record:
168
+ raise ConnectionLost(stderr.strip() or f"ssh exited with status {code}")
169
+ return int(status), output.strip()
170
+
171
+ def run_batch(self, commands, timeout=120, login_path=False):
172
+ """Run commands in parallel in one session; stdout in order, None on failure.
173
+
174
+ Raises `ConnectionLost` when ssh itself fails.
175
+ """
176
+ if not commands:
177
+ return []
178
+ lines = [
179
+ self._prelude(login_path),
180
+ "d=$(mktemp -d) || exit 97",
181
+ "trap 'rm -rf \"$d\"' EXIT",
182
+ ]
183
+ for index, args in enumerate(commands):
184
+ lines.append(
185
+ f'{{ {shlex.join(args)} >"$d/{index}" 2>/dev/null; '
186
+ f'echo $? >"$d/{index}.rc"; }} &'
187
+ )
188
+ indexes = " ".join(str(index) for index in range(len(commands)))
189
+ lines += [
190
+ "wait",
191
+ f"for i in {indexes}; do "
192
+ 'printf \'\\036%s %s\\n\' "$i" "$(cat "$d/$i.rc")"; cat "$d/$i"; done',
193
+ ]
194
+ code, stdout, stderr = _run(self.ssh_command("\n".join(lines)), timeout)
195
+ if RECORD not in stdout:
196
+ raise ConnectionLost(stderr.strip() or f"ssh exited with status {code}")
197
+ outputs = [None] * len(commands)
198
+ for record in stdout.split(RECORD)[1:]:
199
+ header, _, output = record.partition("\n")
200
+ index, _, returncode = header.partition(" ")
201
+ if returncode.strip() == "0":
202
+ outputs[int(index)] = output
203
+ return outputs
204
+
205
+ def run_stream(self, commands, on_result, should_stop=lambda: False):
206
+ """Run commands over a few sessions, reporting each result as it lands.
207
+
208
+ Commands are dealt round-robin to up to `PROBE_WORKERS` sessions, each
209
+ running its share one after another. `on_result(index, returncode,
210
+ output)` gets stdout and stderr together, as soon as each finishes.
211
+ Raises `ConnectionLost` when ssh fails before any result.
212
+ """
213
+ if not commands:
214
+ return
215
+ shares = [
216
+ list(range(len(commands)))[start::PROBE_WORKERS]
217
+ for start in range(min(PROBE_WORKERS, len(commands)))
218
+ ]
219
+ with ThreadPoolExecutor(len(shares)) as pool:
220
+ outcomes = list(
221
+ pool.map(
222
+ lambda share: self._stream_session(
223
+ share, commands, on_result, should_stop
224
+ ),
225
+ shares,
226
+ )
227
+ )
228
+ if not any(received for received, _ in outcomes) and not should_stop():
229
+ raise ConnectionLost(next(error for _, error in outcomes if error))
230
+
231
+ def _stream_session(self, share, commands, on_result, should_stop):
232
+ """Run `commands[i]` for each i in `share` in one session.
233
+
234
+ Returns (results received, ssh's error text or None).
235
+ """
236
+ lines = [self._prelude()]
237
+ for index in share:
238
+ lines.append(
239
+ f"timeout {PROBE_TIMEOUT} {shlex.join(commands[index])} "
240
+ f"</dev/null 2>&1; printf '\\n\\036%s %s\\n' {index} $?"
241
+ )
242
+ try:
243
+ process = subprocess.Popen(
244
+ self.ssh_command("\n".join(lines)),
245
+ stdin=subprocess.DEVNULL,
246
+ stdout=subprocess.PIPE,
247
+ stderr=subprocess.PIPE,
248
+ text=True,
249
+ )
250
+ except FileNotFoundError:
251
+ return 0, "'ssh' not found"
252
+ received = 0
253
+ output = []
254
+ try:
255
+ for line in process.stdout:
256
+ if not line.startswith(RECORD):
257
+ output.append(line)
258
+ continue
259
+ index, _, returncode = line[1:].strip().partition(" ")
260
+ received += 1
261
+ on_result(int(index), int(returncode), "".join(output).strip())
262
+ output = []
263
+ if should_stop():
264
+ break
265
+ finally:
266
+ if process.poll() is None:
267
+ process.terminate()
268
+ process.wait()
269
+ if received:
270
+ return received, None
271
+ error = process.stderr.read().strip()
272
+ return 0, error or f"ssh exited with status {process.returncode}"
sqtop/mailer.py ADDED
@@ -0,0 +1,230 @@
1
+ """The dashboard's hourly email: the text report of each cluster it shows.
2
+
3
+ Each email starts with your job counts and what happened to your jobs since
4
+ the previous email (submitted, started, ended), then has the report
5
+ `sqtop` prints when output is not a terminal. Every email has the
6
+ subject "sqtop" and refers to the first one, so mail clients keep them
7
+ in one conversation rather than as separate emails.
8
+
9
+ The server comes from `[smtp]` in the config (`profiles.load_smtp`). The
10
+ thread and the jobs last reported are kept in `STATE_PATH`, so the
11
+ conversation carries on from one run of the dashboard to the next.
12
+ """
13
+
14
+ import datetime
15
+ import email.message
16
+ import email.utils
17
+ import html
18
+ import io
19
+ import json
20
+ import os
21
+ import smtplib
22
+ import ssl
23
+ from pathlib import Path
24
+
25
+ from . import hosts, report, slurm, views
26
+
27
+ SUBJECT = "sqtop"
28
+ INTERVAL = 3600
29
+ SSL_PORT = 465
30
+ SMTP_TIMEOUT = 60
31
+ STATE_PATH = (
32
+ Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
33
+ / "sqtop"
34
+ / "mail.json"
35
+ )
36
+
37
+
38
+ def compose(smtp, text, thread=None):
39
+ """The email: `text` as plain text and as monospaced HTML.
40
+
41
+ `thread` is the first email's Message-ID. Replying to it, under the same
42
+ subject, keeps every email in one conversation.
43
+ """
44
+ message = email.message.EmailMessage()
45
+ message["Subject"] = SUBJECT
46
+ message["From"] = email.utils.formataddr((SUBJECT, smtp.sender))
47
+ message["To"] = smtp.recipient
48
+ message["Date"] = email.utils.formatdate(localtime=True)
49
+ domain = smtp.sender.rpartition("@")[2] or None
50
+ message["Message-ID"] = email.utils.make_msgid(SUBJECT, domain)
51
+ if thread:
52
+ message["In-Reply-To"] = thread
53
+ message["References"] = thread
54
+ message.set_content(text)
55
+ # The tables only line up in a fixed-width font, which plain text
56
+ # does not get everywhere (Gmail's web client, for one).
57
+ message.add_alternative(
58
+ f'<pre style="font: 12px/1.4 monospace">{html.escape(text)}</pre>',
59
+ subtype="html",
60
+ )
61
+ return message
62
+
63
+
64
+ def send(smtp, message):
65
+ """Deliver `message` over SSL (port 465) or STARTTLS.
66
+
67
+ A password is never sent over a connection that is not encrypted.
68
+ """
69
+ context = ssl.create_default_context()
70
+ if smtp.port == SSL_PORT:
71
+ connection = smtplib.SMTP_SSL(
72
+ smtp.server, smtp.port, timeout=SMTP_TIMEOUT, context=context
73
+ )
74
+ else:
75
+ connection = smtplib.SMTP(smtp.server, smtp.port, timeout=SMTP_TIMEOUT)
76
+ with connection:
77
+ if smtp.port != SSL_PORT:
78
+ connection.ehlo()
79
+ if connection.has_extn("starttls"):
80
+ connection.starttls(context=context)
81
+ connection.ehlo()
82
+ elif smtp.password:
83
+ raise smtplib.SMTPException(
84
+ f"{smtp.server}:{smtp.port} does not offer STARTTLS; "
85
+ "not sending the password unencrypted"
86
+ )
87
+ if smtp.password:
88
+ connection.login(smtp.sender, smtp.password)
89
+ connection.send_message(message)
90
+
91
+
92
+ def job_states(jobs):
93
+ """What the next email compares with: each job's state and name."""
94
+ return {job["id"]: {"state": job["state"], "name": job["name"]} for job in jobs}
95
+
96
+
97
+ def job_changes(previous, jobs, ended=None):
98
+ """One line per job whose state changed since `previous` (`job_states`).
99
+
100
+ `ended` maps jobs that left the queue to their (state, exit code) from
101
+ sacct; without an entry such a job only "left the queue".
102
+ """
103
+ ended = ended or {}
104
+ changes = []
105
+ for job in jobs:
106
+ before = previous.get(job["id"], {}).get("state")
107
+ if before == job["state"]:
108
+ continue
109
+ label = f"{job['id']} ({job['name']})"
110
+ if job["state"] == "RUNNING":
111
+ nodes, _ = views.job_nodes(job)
112
+ changes.append(f"{label} started on {nodes}")
113
+ elif before is None and job["state"] == "PENDING":
114
+ text = f"{label} submitted"
115
+ if (reason := views.job_reason(job)) != "-":
116
+ text += f" ({reason})"
117
+ if (start := views.job_start_time(job, compact=True)) != "-":
118
+ text += f", expected to start {start}"
119
+ changes.append(text)
120
+ else:
121
+ changes.append(f"{label} is now {job['state']}")
122
+ current = {job["id"] for job in jobs}
123
+ for job_id, job in previous.items():
124
+ if job_id in current:
125
+ continue
126
+ label = f"{job_id} ({job['name']})"
127
+ if job_id not in ended:
128
+ changes.append(f"{label} left the queue")
129
+ continue
130
+ state, code = ended[job_id]
131
+ exit_code = "" if code in ("", "0:0") else f", exit code {code}"
132
+ changes.append(f"{label} ended: {state}{exit_code}")
133
+ return changes
134
+
135
+
136
+ def final_states(host, ids):
137
+ """How jobs that left the queue ended, from sacct; {} when it fails."""
138
+ # A pending array (`12_[1-9]`) is not an id sacct takes in a list.
139
+ ids = [job_id for job_id in ids if job_id.replace("_", "").isdigit()]
140
+ if not ids:
141
+ return {}
142
+ try:
143
+ output = host.run(slurm.sacct_command(ids))
144
+ except hosts.ConnectionLost:
145
+ return {}
146
+ return {} if output is None else slurm.parse_sacct_states(output)
147
+
148
+
149
+ def digest(targets, state, now):
150
+ """The email's text, and the jobs the next email compares with.
151
+
152
+ `targets` pairs each cluster with the probe request to report. The text
153
+ has a line of job counts per cluster, each followed by the changes since
154
+ the email `state` records, then each cluster's report.
155
+ """
156
+ seen = state.get("jobs", {})
157
+ since = views.parse_time(state.get("sent_at"))
158
+ if since is not None:
159
+ since = f"{since:%H:%M}" if since.date() == now.date() else f"{since:%F %H:%M}"
160
+ latest = dict(seen)
161
+ heads, reports = [], []
162
+ for cluster, settings in targets:
163
+ output = io.StringIO()
164
+ snap = report.print_cluster(cluster, settings, out=output)
165
+ reports.append(output.getvalue())
166
+ if snap is None or snap.jobs is None:
167
+ heads.append(f"{cluster.name}: cannot read your jobs")
168
+ continue
169
+ counts = slurm.job_counts(snap.jobs)
170
+ head = (
171
+ f"{cluster.name}: {counts['running']} running, {counts['pending']} pending"
172
+ )
173
+ previous = seen.get(cluster.name)
174
+ latest[cluster.name] = job_states(snap.jobs)
175
+ if previous is None or since is None:
176
+ heads.append(head)
177
+ continue
178
+ current = {job["id"] for job in snap.jobs}
179
+ gone = [job_id for job_id in previous if job_id not in current]
180
+ changes = job_changes(previous, snap.jobs, final_states(cluster.host, gone))
181
+ if not changes:
182
+ heads.append(f"{head}; no changes since {since}")
183
+ continue
184
+ heads.append(f"{head}. Since {since}:")
185
+ heads.extend(f" {change}" for change in changes)
186
+ separator = f"{report.SEPARATOR}\n\n"
187
+ return "\n".join(heads) + "\n\n" + separator.join(reports), latest
188
+
189
+
190
+ def load_state(path):
191
+ """What the last email recorded, or {} before the first one."""
192
+ try:
193
+ state = json.loads(Path(path).read_text())
194
+ except (FileNotFoundError, ValueError):
195
+ return {}
196
+ return state if isinstance(state, dict) else {}
197
+
198
+
199
+ def save_state(path, state):
200
+ path = Path(path)
201
+ path.parent.mkdir(parents=True, exist_ok=True)
202
+ temporary = path.with_name(path.name + ".tmp")
203
+ temporary.write_text(json.dumps(state, indent=1))
204
+ temporary.replace(path)
205
+
206
+
207
+ def send_report(targets, smtp, path=None, now=None):
208
+ """Email the report of `targets` once and return the message.
209
+
210
+ Raises OSError (including `smtplib.SMTPException`) when it cannot be
211
+ sent. The state is saved only after the email is sent, so the next email
212
+ still lists every change since the last one that went out.
213
+ """
214
+ path = STATE_PATH if path is None else path
215
+ now = now or datetime.datetime.now()
216
+ state = load_state(path)
217
+ text, jobs = digest(targets, state, now)
218
+ threads = state.get("threads", {})
219
+ message = compose(smtp, text, threads.get(smtp.recipient))
220
+ send(smtp, message)
221
+ threads.setdefault(smtp.recipient, message["Message-ID"])
222
+ save_state(
223
+ path,
224
+ {
225
+ "threads": threads,
226
+ "sent_at": now.isoformat(timespec="seconds"),
227
+ "jobs": jobs,
228
+ },
229
+ )
230
+ return message