Skip to content

Commit 05b5ff0

Browse files
committed
- implement hooks
- enable user to extend plotman's functionality / plotting flow, by providing external scripts, without need to touch plotmans internals - many of current feature requests can be solved with lighweight scripts (bash/python/...), e.g. #712, #711, #677, #638, #582 and for sure many others - all new code is in new library 'hooks' + two new shell scripts directly inside hooks.d directory (located inside plotman's own config directory - supplied hook serves as reference implementation / example, providing functionality for #677 - modification of existing plotman's code is only to call entrypoint in hooks.py and pass current jobs list containing jobs objects - currently manager.py maybe_start_new_plot() and plotman.py kill is injected with call into hooks.try_run() and hooks.run() respectively - try_run consumes fully refreshed jobs[], compares phase to previous job phase and if it is changed, calls hooks.run() - run() takes plotmans environment, extends it with particular job's metadata and calls all executable files from hooks.d directory having extension .sh or .py - scripts are called synchronously, cmd exec is waiting until the script process returns. that means the implementation is not suitable for LONG running actions. - anyhow, plotman CLI can be called without issues from within the hooks, recursion in job_refresh -> try_run -> hooks script -> plotman cmd is being checked for new: src/plotman/hooks.py src/plotman/resources/hooks.d update: src/plotman/configuration.py src/plotman/manager.py setup.cfg
1 parent 24d0012 commit 05b5ff0

File tree

7 files changed

+181
-5
lines changed

7 files changed

+181
-5
lines changed

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ checks =
7575

7676
[options.data_files]
7777
config = src/plotman/resources/plotman.yaml
78+
src/plotman/resources/hooks.d/.lib.include
79+
src/plotman/resources/hooks.d/01-check-dstdir-free.sh.example
7880
bin = util/listlogs
7981

8082
[isort]

src/plotman/configuration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ class ConfigurationException(Exception):
2626
"""Raised when plotman.yaml configuration is missing or malformed."""
2727

2828

29-
def get_path() -> str:
30-
"""Return path to where plotman.yaml configuration file should exist."""
29+
def get_path(subject="plotman.yaml") -> str:
30+
"""Return location of configuration files (e.g. plotman.yaml)."""
3131
config_dir: str = appdirs.user_config_dir("plotman")
32-
return config_dir + "/plotman.yaml"
32+
return config_dir + "/" + subject
3333

3434

3535
def read_configuration_text(config_path: str) -> str:

src/plotman/hooks.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import os
2+
import subprocess
3+
from pathlib import Path
4+
5+
from plotman import configuration
6+
7+
8+
class JobPhases(object):
9+
'''Holds jobs last known phase progress with simple helper methods.'''
10+
_job_ph = dict()
11+
12+
@classmethod
13+
def changed(cls, job):
14+
"""Checks provided job's phase agains its last known phase.
15+
returns True if job's phase changed, or job was just created.
16+
returns False if job is known and phase matches phase on state."""
17+
if not job.progress():
18+
return False
19+
return not cls._job_ph.get(job.plot_id) or cls._job_ph[job.plot_id] != job.progress()
20+
21+
@classmethod
22+
def update(cls, job_ph):
23+
"""Updates internal state with new 'last known' job phases"""
24+
if job_ph:
25+
cls._job_ph = job_ph
26+
27+
@classmethod
28+
def progress(cls, plot_id):
29+
"""Returns job's last known Phase as provided by Job.progress()"""
30+
return cls._job_ph.get(plot_id)
31+
32+
33+
def run_cmd(command, env):
34+
try:
35+
result = subprocess.run(command, capture_output=True, env=env)
36+
except Exception as ex:
37+
return 1, "", str(ex)
38+
39+
return result.returncode, result.stdout, result.stderr
40+
41+
42+
def try_run(jobs):
43+
"""Iterates over jobs gathered during refresh, executes hooks.d
44+
if phase was changed and updates last known phase info for next iteration."""
45+
phases = dict()
46+
47+
for job in jobs:
48+
if job.progress() is None:
49+
continue
50+
51+
phases[job.plot_id] = job.progress()
52+
if not JobPhases().changed(job):
53+
continue
54+
55+
run(job)
56+
57+
JobPhases().update(phases)
58+
59+
60+
def prepare_env(job, hooks_path):
61+
"""Prepares env dict for the provided job"""
62+
63+
environment = os.environ.copy()
64+
environment['PLOTMAN_HOOKS'] = hooks_path
65+
environment['PLOTMAN_PLOTID'] = job.plot_id
66+
environment['PLOTMAN_PID'] = str(job.proc.pid)
67+
environment['PLOTMAN_TMPDIR'] = job.tmpdir
68+
environment['PLOTMAN_TMP2DIR'] = job.tmp2dir
69+
environment['PLOTMAN_DSTDIR'] = job.dstdir
70+
environment['PLOTMAN_LOGFILE'] = job.logfile
71+
environment['PLOTMAN_STATUS'] = job.get_run_status()
72+
environment['PLOTMAN_PHASE'] = str(job.progress().major) + ':' + str(job.progress().minor)
73+
74+
old_phase = JobPhases().progress(job.plot_id)
75+
if old_phase:
76+
old_phase = str(JobPhases().progress(job.plot_id).major) + ':' + str(JobPhases().progress(job.plot_id).minor)
77+
else:
78+
old_phase = str(old_phase)
79+
environment['PLOTMAN_PHASE_PREV'] = old_phase
80+
81+
return environment
82+
83+
84+
def run(job, trigger="PHASE"):
85+
"""Runs all scripts in alphabetical order from the hooks.d directory
86+
for the provided job.
87+
88+
Job's internal state is added to the Plotman's own environment.
89+
Folowing env VARIABLES are exported:
90+
- PLOTMAN_PLOTID (id of the plot)
91+
- PLOTMAN_PID (pid of the process)
92+
- PLOTMAN_TMPDIR (tmp dir [-t])
93+
- PLOTMAN_TMP2DIR (tmp2 dir [-2])
94+
- PLOTMAN_DSTDIR (dst dir [-d])
95+
- PLOTMAN_LOGFILE (logfile)
96+
- PLOTMAN_STATUS (current state of the process, e.g. RUN, STP - check job class for details)
97+
- PLOTMAN_PHASE (phase, "major:minor" - two numbers, colon delimited)
98+
- PLOTMAN_PHASE_PREV (phase, previous if known, or "None")
99+
- PLOTMAN_TRIGGER (action, which triggered hooks. currently one of "PHASE"-change or "KILL")
100+
"""
101+
102+
hooks_path = configuration.get_path('hooks.d')
103+
104+
if os.getenv('PLOTMAN_HOOKS') is not None:
105+
return
106+
107+
environment = prepare_env(job, hooks_path)
108+
environment['PLOTMAN_TRIGGER'] = trigger
109+
110+
for e in ['*.py','*.sh']:
111+
for script in Path(hooks_path).glob(e):
112+
rc, stdout, stderr = run_cmd([str(script)], environment)
113+

src/plotman/manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# Plotman libraries
1616
from plotman import \
1717
archive # for get_archdir_freebytes(). TODO: move to avoid import loop
18-
from plotman import job, plot_util
18+
from plotman import job, plot_util, hooks
1919
import plotman.configuration
2020

2121
# Constants
@@ -93,6 +93,8 @@ def phases_permit_new_job(phases: typing.List[job.Phase], d: str, sched_cfg: plo
9393
def maybe_start_new_plot(dir_cfg: plotman.configuration.Directories, sched_cfg: plotman.configuration.Scheduling, plotting_cfg: plotman.configuration.Plotting, log_cfg: plotman.configuration.Logging) -> typing.Tuple[bool, str]:
9494
jobs = job.Job.get_running_jobs(log_cfg.plots)
9595

96+
hooks.try_run(jobs)
97+
9698
wait_reason = None # If we don't start a job this iteration, this says why.
9799

98100
youngest_job_age = min(jobs, key=job.Job.get_time_wall).get_time_wall() if jobs else MAX_AGE

src/plotman/plotman.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import pendulum
1616

1717
# Plotman libraries
18-
from plotman import analyzer, archive, configuration, interactive, manager, plot_util, reporting, csv_exporter
18+
from plotman import analyzer, archive, configuration, interactive, manager, plot_util, reporting, csv_exporter, hooks
1919
from plotman import resources as plotman_resources
2020
from plotman.job import Job
2121

@@ -331,6 +331,8 @@ def main() -> None:
331331
for f in temp_files:
332332
os.remove(f)
333333

334+
hooks.run(job, "KILL")
335+
334336
elif args.cmd == 'suspend':
335337
print('Suspending ' + job.plot_id)
336338
job.suspend()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/sh
2+
3+
log() {
4+
local dd=$(date --iso-8601=seconds)
5+
local logee="$(basename $0)$(printf "(%0.8s)" ${PLOTMAN_PLOTID})"
6+
local severity="$1"
7+
shift
8+
9+
printf "%s [%-10s] %s: %s\n" "${dd%+*}" "$logee" "$severity" "$*"
10+
}
11+
12+
logInfo() {
13+
log INFO $*
14+
}
15+
16+
logError() {
17+
log ERROR $* >&2
18+
}
19+
20+
### plot k32 size in bytes 101.5 * GiB === 108984795136 bytes
21+
k32PLOTSIZE=$((1015 *1024 *1024 *1024 /10))
22+
23+
exec >>/tmp/plotman-hooks.log 2>&1
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/bin/sh
2+
3+
. ${PLOTMAN_HOOKS}/.lib.include
4+
5+
[ "${PLOTMAN_TRIGGER}" = "PHASE" ] || exit 0
6+
7+
logInfo "processing plot id ${PLOTMAN_PLOTID} (ph: ${PLOTMAN_PHASE})"
8+
9+
### if jobs phase is below 3:6, do nothing
10+
[ x"${PLOTMAN_PHASE}" = x3:6 -o x"${PLOTMAN_PHASE}" = x3:7 ] || {
11+
logInfo "plot not at phase 3:6/7, hook done"
12+
exit 0
13+
}
14+
15+
16+
### get available space on DSTDIR in bytes, check result.
17+
### exit 1 if something went wrong
18+
AVAIL=$(df -B1 --output=avail ${PLOTMAN_DSTDIR} | grep -oE '[0-9]+')
19+
[ -n "${AVAIL}" -a $? -eq 0 ] || {
20+
logError "something went wrong while checking available space at ${PLOTMAN_DSTDIR}, suspending job and exiting."
21+
kill -STOP ${PLOTMAN_PID}
22+
exit 1
23+
}
24+
25+
26+
### continue only if available space is less then plot size
27+
[ "${AVAIL}" -lt "${k32PLOTSIZE}" ] || {
28+
logInfo "there is ${AVAIL} available space at ${PLOTMAN_DSTDIR}, which is > k32plot ${k32PLOTSIZE}. job can continue."
29+
exit 0
30+
}
31+
32+
logError "not enough available space at ${PLOTMAN_DSTDIR}. suspending job."
33+
kill -STOP ${PLOTMAN_PID}
34+

0 commit comments

Comments
 (0)