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 +0 -0
- sqtop/cli.py +74 -0
- sqtop/hosts.py +272 -0
- sqtop/mailer.py +230 -0
- sqtop/probes.py +401 -0
- sqtop/profiles.py +262 -0
- sqtop/report.py +281 -0
- sqtop/slurm.py +786 -0
- sqtop/snapshot.py +704 -0
- sqtop/tui.py +1736 -0
- sqtop/views.py +274 -0
- sqtop-0.1.0.dist-info/METADATA +324 -0
- sqtop-0.1.0.dist-info/RECORD +16 -0
- sqtop-0.1.0.dist-info/WHEEL +4 -0
- sqtop-0.1.0.dist-info/entry_points.txt +3 -0
- sqtop-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|