udevmon 0.8.1__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.
- udevmon/__init__.py +18 -0
- udevmon/__main__.py +7 -0
- udevmon/_scm.py +108 -0
- udevmon/_version.py +3 -0
- udevmon/actions.py +306 -0
- udevmon/cli.py +1044 -0
- udevmon/completion.py +327 -0
- udevmon/config.py +787 -0
- udevmon/configedit.py +893 -0
- udevmon/control.py +179 -0
- udevmon/daemon.py +260 -0
- udevmon/engine.py +238 -0
- udevmon/events.py +113 -0
- udevmon/explain.py +112 -0
- udevmon/matching.py +201 -0
- udevmon/notify.py +66 -0
- udevmon/pause.py +88 -0
- udevmon/runtime.py +30 -0
- udevmon/scan.py +373 -0
- udevmon/service.py +91 -0
- udevmon/session.py +205 -0
- udevmon/sources/__init__.py +3 -0
- udevmon/sources/udev.py +179 -0
- udevmon/sources/udisks.py +302 -0
- udevmon/state.py +51 -0
- udevmon-0.8.1.data/data/share/man/man1/udevmon.1 +958 -0
- udevmon-0.8.1.data/data/share/man/man5/udevmon.toml.5 +1217 -0
- udevmon-0.8.1.dist-info/METADATA +1575 -0
- udevmon-0.8.1.dist-info/RECORD +33 -0
- udevmon-0.8.1.dist-info/WHEEL +5 -0
- udevmon-0.8.1.dist-info/entry_points.txt +2 -0
- udevmon-0.8.1.dist-info/licenses/LICENSE +674 -0
- udevmon-0.8.1.dist-info/top_level.txt +1 -0
udevmon/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Juan Domingo Martín
|
|
3
|
+
"""udevmon: user-level device and mount event automation."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name):
|
|
9
|
+
"""``__version__``: written at build time, else asked from git (a source checkout)."""
|
|
10
|
+
if name == "__version__":
|
|
11
|
+
try:
|
|
12
|
+
from ._version import version
|
|
13
|
+
except ImportError:
|
|
14
|
+
from ._scm import get_version
|
|
15
|
+
version = get_version(Path(__file__).resolve().parents[2])
|
|
16
|
+
globals()["__version__"] = version
|
|
17
|
+
return version
|
|
18
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
udevmon/__main__.py
ADDED
udevmon/_scm.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Juan Domingo Martín
|
|
3
|
+
"""Derive the package version from git, formatted exactly like setuptools-scm.
|
|
4
|
+
|
|
5
|
+
Uses setuptools-scm's default schemes, without depending on it:
|
|
6
|
+
|
|
7
|
+
* ``guess-next-dev``: on a tag the version is the tag (``0.2.0``); after it, the last
|
|
8
|
+
number is bumped and ``.devN`` (N = commits since the tag) is added (``0.2.1.dev5``);
|
|
9
|
+
with no tag at all it is ``0.1.devN`` (N = number of commits).
|
|
10
|
+
* ``node-and-date``: anything that is not exactly a clean tag gets a local part
|
|
11
|
+
``+g<commit>`` (``g`` plus 9 hex digits, as in setuptools-scm 10), plus ``.d<YYYYMMDD>`` (the build date) when tracked files have
|
|
12
|
+
uncommitted changes. Untracked files do not count.
|
|
13
|
+
|
|
14
|
+
Standard library only: setup.py imports this module before anything is installed.
|
|
15
|
+
Without git (or outside a git checkout) the version comes from ``PKG-INFO`` (an sdist),
|
|
16
|
+
else ``0.0.0+unknown``; this never raises.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
FALLBACK = "0.0.0+unknown"
|
|
28
|
+
_DESCRIBE = re.compile(r"^(?P<tag>.+)-(?P<distance>\d+)-g(?P<node>[0-9a-f]+)(?P<dirty>-dirty)?$")
|
|
29
|
+
NODE_LENGTH = 10 # "g" + 9 hex digits: what setuptools-scm keeps of the hash
|
|
30
|
+
_TAG = re.compile(r"^(?:[\w-]+-)?[vV]?(?P<version>\d+(?:\.\d+)*[^+]*)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
34
|
+
"""Run git in ``root``; stdout, or None if git is missing or the command fails."""
|
|
35
|
+
try:
|
|
36
|
+
done = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True,
|
|
37
|
+
env={**os.environ, "LC_ALL": "C"}, timeout=30)
|
|
38
|
+
except (OSError, subprocess.SubprocessError):
|
|
39
|
+
return None
|
|
40
|
+
return done.stdout.strip() if done.returncode == 0 else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bump(version: str) -> str:
|
|
44
|
+
"""Increment the trailing number: 0.1.0 -> 0.1.1, 0.2 -> 0.3, 1.0rc1 -> 1.0rc2."""
|
|
45
|
+
m = re.match(r"^(.*?)(\d+)$", version)
|
|
46
|
+
return f"{m.group(1)}{int(m.group(2)) + 1}" if m else version + ".1"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize_tag(tag: str) -> str | None:
|
|
50
|
+
m = _TAG.match(tag)
|
|
51
|
+
return m.group("version").lower() if m else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def git_version(root: Path, now: datetime | None = None) -> str | None:
|
|
55
|
+
"""The setuptools-scm style version of the git checkout at ``root``, or None."""
|
|
56
|
+
root = Path(root)
|
|
57
|
+
top = _git(root, "rev-parse", "--show-toplevel")
|
|
58
|
+
if top is None or os.path.realpath(top) != os.path.realpath(root):
|
|
59
|
+
return None # not (the top of) a git checkout
|
|
60
|
+
if _git(root, "rev-parse", "--verify", "--quiet", "HEAD") is None:
|
|
61
|
+
return None # no commits yet
|
|
62
|
+
described = _git(root, "describe", "--dirty", "--tags", "--long", "--abbrev=40",
|
|
63
|
+
"--match", "*[0-9]*")
|
|
64
|
+
m = _DESCRIBE.match(described) if described else None
|
|
65
|
+
tag = _normalize_tag(m.group("tag")) if m else None
|
|
66
|
+
if m and tag:
|
|
67
|
+
distance, dirty = int(m.group("distance")), bool(m.group("dirty"))
|
|
68
|
+
node = ("g" + m.group("node"))[:NODE_LENGTH]
|
|
69
|
+
else: # no usable tag
|
|
70
|
+
count = _git(root, "rev-list", "HEAD", "--count")
|
|
71
|
+
head = _git(root, "rev-parse", "HEAD")
|
|
72
|
+
if count is None or head is None:
|
|
73
|
+
return None
|
|
74
|
+
tag, distance, node = "0.0", int(count), ("g" + head)[:NODE_LENGTH]
|
|
75
|
+
dirty = bool(_git(root, "status", "--porcelain", "--untracked-files=no"))
|
|
76
|
+
if distance == 0 and not dirty:
|
|
77
|
+
return tag # exactly a clean tag
|
|
78
|
+
date = (now or datetime.now(timezone.utc)).strftime("%Y%m%d")
|
|
79
|
+
return f"{_bump(tag)}.dev{distance}+{node}" + (f".d{date}" if dirty else "")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pkg_info_version(root: Path) -> str | None:
|
|
83
|
+
try:
|
|
84
|
+
text = (Path(root) / "PKG-INFO").read_text()
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
m = re.search(r"^Version:\s*(\S+)", text, re.M)
|
|
88
|
+
return m.group(1) if m else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_version(root: Path) -> str:
|
|
92
|
+
"""The version: from git, else from ``PKG-INFO``, else ``0.0.0+unknown``. Never raises."""
|
|
93
|
+
return git_version(root) or _pkg_info_version(root) or FALLBACK
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def version_tuple(version: str) -> tuple:
|
|
97
|
+
"""(0, 1, 'dev4', 'gabc1234.d20260920'): numbers first, then the other parts."""
|
|
98
|
+
main, _, local = version.partition("+")
|
|
99
|
+
parts = [int(p) if p.isdigit() else p for p in main.split(".")]
|
|
100
|
+
return tuple(parts + ([local] if local else []))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_version_file(path: Path, version: str) -> None:
|
|
104
|
+
"""Write the ``_version.py`` that a built package reads its version from."""
|
|
105
|
+
Path(path).write_text(
|
|
106
|
+
"# file generated by setup.py from git; do not edit\n"
|
|
107
|
+
f"__version__ = version = {version!r}\n"
|
|
108
|
+
f"__version_tuple__ = version_tuple = {version_tuple(version)!r}\n")
|
udevmon/_version.py
ADDED
udevmon/actions.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Juan Domingo Martín
|
|
3
|
+
"""Run actions as subprocesses (never via a shell)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
|
|
14
|
+
from . import notify
|
|
15
|
+
from . import pause as pause_flag
|
|
16
|
+
from . import session, state
|
|
17
|
+
from .config import Action, expand
|
|
18
|
+
from .events import Event
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("udevmon.actions")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_env(event: Event, device: str | None, tag: str | None = None,
|
|
24
|
+
variables=None, state_dir: str | None = None) -> dict[str, str]:
|
|
25
|
+
"""The environment for an action: the daemon's own plus ``UDEVMON_EVENT``,
|
|
26
|
+
``UDEVMON_DEVICE``, ``UDEVMON_TAG``, ``UDEVMON_<PROPERTY>``, ``UDEVMON_VAR_<NAME>`` and
|
|
27
|
+
``UDEVMON_STATE_DIR``, each when it applies.
|
|
28
|
+
"""
|
|
29
|
+
env = dict(os.environ)
|
|
30
|
+
env["UDEVMON_EVENT"] = event.kind
|
|
31
|
+
if device:
|
|
32
|
+
env["UDEVMON_DEVICE"] = device
|
|
33
|
+
if tag:
|
|
34
|
+
env["UDEVMON_TAG"] = tag
|
|
35
|
+
for k, v in event.endpoint.props.items():
|
|
36
|
+
env[f"UDEVMON_{k.upper()}"] = v
|
|
37
|
+
for k, v in (variables or {}).items():
|
|
38
|
+
env[f"UDEVMON_VAR_{k.upper()}"] = v
|
|
39
|
+
if state_dir:
|
|
40
|
+
env["UDEVMON_STATE_DIR"] = state_dir
|
|
41
|
+
return env
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_action(action: Action, event: Event, device: str | None, tag: str | None = None, *,
|
|
45
|
+
dry_run: bool = False, variables=None, state_dir: str | None = None,
|
|
46
|
+
outcome: dict | None = None) -> int | None:
|
|
47
|
+
"""Run one action; returns exit status (None if skipped/timeout-killed).
|
|
48
|
+
|
|
49
|
+
``outcome``, if given, gets ``failure``: how a None result failed (a timeout, a
|
|
50
|
+
command that could not start), for the notification; it is left alone otherwise.
|
|
51
|
+
"""
|
|
52
|
+
outcome = {} if outcome is None else outcome
|
|
53
|
+
props = dict(event.props)
|
|
54
|
+
if state_dir:
|
|
55
|
+
props["state_dir"] = state_dir
|
|
56
|
+
props.update({f"var_{k}": v for k, v in (variables or {}).items()})
|
|
57
|
+
if device:
|
|
58
|
+
props["device"] = device
|
|
59
|
+
if tag:
|
|
60
|
+
props["tag"] = tag
|
|
61
|
+
try:
|
|
62
|
+
argv = expand(action.run, props)
|
|
63
|
+
except KeyError as e:
|
|
64
|
+
log.error("action %r skipped: placeholder(s) %s unavailable for event %r",
|
|
65
|
+
action.name, e.args[0], event.kind)
|
|
66
|
+
return None
|
|
67
|
+
if dry_run:
|
|
68
|
+
log.info("[dry-run] action %r would run: %s", action.name, argv)
|
|
69
|
+
return None
|
|
70
|
+
log.info("action %r: %s %s", action.name, "starting (detached)" if action.detach
|
|
71
|
+
else "running", argv)
|
|
72
|
+
env = build_env(event, device, tag, variables, state_dir)
|
|
73
|
+
session.update(env) # DISPLAY etc., which a service may not have had at start
|
|
74
|
+
try:
|
|
75
|
+
proc = subprocess.Popen(
|
|
76
|
+
argv, cwd=os.path.expanduser("~"), env=env,
|
|
77
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
78
|
+
text=True, errors="replace", start_new_session=True)
|
|
79
|
+
except OSError as e:
|
|
80
|
+
log.error("action %r failed to start: %s", action.name, e)
|
|
81
|
+
outcome["failure"] = f"could not start: {e}"
|
|
82
|
+
return None
|
|
83
|
+
# The output is logged as it arrives, by a reader that outlives the command if it has to:
|
|
84
|
+
# a program the command leaves running in the background keeps the pipe open, and
|
|
85
|
+
# waiting for the pipe (instead of the command) made the action look stuck.
|
|
86
|
+
reader = threading.Thread(target=_pump, args=(action, proc), daemon=True,
|
|
87
|
+
name=f"output-{action.name}")
|
|
88
|
+
reader.start()
|
|
89
|
+
if action.detach:
|
|
90
|
+
threading.Thread(target=_reap, args=(action, proc), daemon=True,
|
|
91
|
+
name=f"reaper-{action.name}").start()
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
proc.wait(timeout=action.timeout)
|
|
95
|
+
except subprocess.TimeoutExpired:
|
|
96
|
+
log.error("action %r timed out after %gs; terminating", action.name, action.timeout)
|
|
97
|
+
outcome["failure"] = f"timed out after {action.timeout:g} s"
|
|
98
|
+
_kill_group(proc)
|
|
99
|
+
try:
|
|
100
|
+
proc.wait(timeout=5)
|
|
101
|
+
except subprocess.TimeoutExpired:
|
|
102
|
+
_kill_group(proc, signal.SIGKILL)
|
|
103
|
+
proc.wait()
|
|
104
|
+
reader.join(timeout=1)
|
|
105
|
+
return None
|
|
106
|
+
reader.join(timeout=_DRAIN_WAIT) # normally the pipe is already closed; not for long
|
|
107
|
+
_log_exit(action, proc.returncode)
|
|
108
|
+
return proc.returncode
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_DRAIN_WAIT = 0.2 # seconds to let the reader catch up after the command has exited
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _pump(action, proc) -> None:
|
|
115
|
+
"""Log the command's output line by line until its last writer closes the pipe."""
|
|
116
|
+
try:
|
|
117
|
+
for line in proc.stdout:
|
|
118
|
+
log.info("[%s] %s", action.name, line.rstrip("\r\n"))
|
|
119
|
+
except (OSError, ValueError): # the pipe went away under us
|
|
120
|
+
pass
|
|
121
|
+
finally:
|
|
122
|
+
try:
|
|
123
|
+
proc.stdout.close()
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _reap(action, proc) -> None:
|
|
129
|
+
"""Log how a detached command ended."""
|
|
130
|
+
_log_exit(action, proc.wait(), "detached ")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _log_exit(action, status: int, kind: str = "") -> None:
|
|
134
|
+
log.log(logging.INFO if status == 0 else logging.WARNING,
|
|
135
|
+
"%saction %r exited with status %d", kind, action.name, status)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _kill_group(proc, sig=signal.SIGTERM):
|
|
139
|
+
try:
|
|
140
|
+
os.killpg(proc.pid, sig)
|
|
141
|
+
except ProcessLookupError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class FifoLock:
|
|
146
|
+
"""A mutex that hands the turn out in the order the tickets were taken.
|
|
147
|
+
|
|
148
|
+
A ticket is taken when the event is submitted (on the daemon's single main thread, so in
|
|
149
|
+
the order the events arrived) and used later, by whichever worker runs the job. That
|
|
150
|
+
keeps the actions of a plug ahead of those of the following unplug even when the unplug
|
|
151
|
+
reaches a free worker first, which a plain lock would not.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, name: str):
|
|
155
|
+
self.name = name
|
|
156
|
+
self._cond = threading.Condition()
|
|
157
|
+
self._next = 0 # the next ticket to hand out
|
|
158
|
+
self._serving = 0 # the ticket whose turn it is
|
|
159
|
+
|
|
160
|
+
def take(self) -> int:
|
|
161
|
+
"""Take the next ticket: a place in the queue, for :meth:`wait`."""
|
|
162
|
+
with self._cond:
|
|
163
|
+
ticket = self._next
|
|
164
|
+
self._next += 1
|
|
165
|
+
return ticket
|
|
166
|
+
|
|
167
|
+
def wait(self, ticket: int) -> bool:
|
|
168
|
+
"""Block until it is ``ticket``'s turn; True if it had to wait."""
|
|
169
|
+
with self._cond:
|
|
170
|
+
waited = self._serving != ticket
|
|
171
|
+
while self._serving != ticket:
|
|
172
|
+
self._cond.wait()
|
|
173
|
+
return waited
|
|
174
|
+
|
|
175
|
+
def release(self) -> None:
|
|
176
|
+
"""End the current turn and let the next ticket go."""
|
|
177
|
+
with self._cond:
|
|
178
|
+
self._serving += 1
|
|
179
|
+
self._cond.notify_all()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ActionRunner:
|
|
183
|
+
"""Runs the jobs of each event on a pool of ``max_workers`` threads.
|
|
184
|
+
|
|
185
|
+
The jobs of one event run one after another in one worker; those of different events
|
|
186
|
+
can overlap, unless their actions share a ``lock``. With ``dry_run`` the commands are
|
|
187
|
+
logged, not run.
|
|
188
|
+
"""
|
|
189
|
+
def __init__(self, max_workers: int = 4, dry_run: bool = False):
|
|
190
|
+
self.dry_run = dry_run
|
|
191
|
+
self._pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="action")
|
|
192
|
+
self._locks: dict[str, FifoLock] = {}
|
|
193
|
+
self._stopping = threading.Event() # set by shutdown(): no more retries
|
|
194
|
+
|
|
195
|
+
def _lock(self, name: str) -> FifoLock:
|
|
196
|
+
return self._locks.setdefault(name, FifoLock(name))
|
|
197
|
+
|
|
198
|
+
def submit(self, event: Event, jobs, ended: str | None = None):
|
|
199
|
+
"""Run the jobs (engine.Job) sequentially in one worker.
|
|
200
|
+
|
|
201
|
+
Called in the order the events arrived: a job whose action has a ``lock`` takes its
|
|
202
|
+
ticket now, so locked jobs run in that order and never at the same time. ``ended``
|
|
203
|
+
is the instance whose presence this event ends: its state folder is removed once
|
|
204
|
+
the jobs are done.
|
|
205
|
+
"""
|
|
206
|
+
ticketed = [(job, self._lock(job.action.lock).take() if job.action.lock else None)
|
|
207
|
+
for job in jobs]
|
|
208
|
+
return self._pool.submit(self._run_all, event, ticketed, ended)
|
|
209
|
+
|
|
210
|
+
def _run_all(self, event, ticketed, ended=None):
|
|
211
|
+
"""Run the jobs in order; returns [(action name, exit status or None)]."""
|
|
212
|
+
done = 0
|
|
213
|
+
results = []
|
|
214
|
+
try:
|
|
215
|
+
for job, ticket in ticketed:
|
|
216
|
+
done += 1
|
|
217
|
+
results.append((job.action.name, self._run_one(event, job, ticket)))
|
|
218
|
+
except BaseException:
|
|
219
|
+
for job, ticket in ticketed[done:]: # never leave a ticket unserved
|
|
220
|
+
if ticket is not None:
|
|
221
|
+
lock = self._lock(job.action.lock)
|
|
222
|
+
lock.wait(ticket)
|
|
223
|
+
lock.release()
|
|
224
|
+
raise
|
|
225
|
+
finally:
|
|
226
|
+
if ended:
|
|
227
|
+
state.remove(ended)
|
|
228
|
+
return results
|
|
229
|
+
|
|
230
|
+
def _run_one(self, event, job, ticket):
|
|
231
|
+
lock = self._lock(job.action.lock) if ticket is not None else None
|
|
232
|
+
if lock is not None:
|
|
233
|
+
if lock.wait(ticket):
|
|
234
|
+
log.info("action %r waited for lock %r", job.action.name, lock.name)
|
|
235
|
+
try:
|
|
236
|
+
return self._run_with_retries(event, job)
|
|
237
|
+
except Exception:
|
|
238
|
+
log.exception("action %r crashed", job.action.name)
|
|
239
|
+
return None
|
|
240
|
+
finally:
|
|
241
|
+
if lock is not None:
|
|
242
|
+
lock.release()
|
|
243
|
+
|
|
244
|
+
def _state_dir(self, event) -> str | None:
|
|
245
|
+
"""The state folder of the event's instance, created on first use (not for a dry run)."""
|
|
246
|
+
instance = event.props.get("instance")
|
|
247
|
+
if not instance or self.dry_run:
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
return str(state.ensure(instance))
|
|
251
|
+
except OSError as e:
|
|
252
|
+
log.warning("cannot create the state folder for %s: %s", instance, e)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
def _run_with_retries(self, event, job):
|
|
256
|
+
"""Run the action (with its retries); with ``notify_on_failure``, tell the user
|
|
257
|
+
when the last attempt failed (a non-zero exit, a timeout, a failure to start)."""
|
|
258
|
+
outcome: dict = {}
|
|
259
|
+
status = self._attempts(event, job, outcome)
|
|
260
|
+
if job.action.notify_on_failure and not self.dry_run:
|
|
261
|
+
what = outcome.get("failure") or (f"exit status {status}" if status else None)
|
|
262
|
+
if what:
|
|
263
|
+
if outcome.get("attempts", 1) > 1:
|
|
264
|
+
what += f" (after {outcome['attempts']} attempts)"
|
|
265
|
+
notify.action_failed(job.action.name, job.device, what)
|
|
266
|
+
return status
|
|
267
|
+
|
|
268
|
+
def _attempts(self, event, job, outcome):
|
|
269
|
+
"""Run the action; after a non-zero exit run it again, up to ``retries`` more times.
|
|
270
|
+
|
|
271
|
+
Not retried: a timeout, a command that cannot start, a skipped or dry run (they
|
|
272
|
+
return None), and nothing once automation is paused or the daemon is stopping.
|
|
273
|
+
The lock, if any, is held across the attempts.
|
|
274
|
+
"""
|
|
275
|
+
action = job.action
|
|
276
|
+
attempts = action.retries + 1
|
|
277
|
+
for attempt in range(1, attempts + 1):
|
|
278
|
+
outcome.clear()
|
|
279
|
+
outcome["attempts"] = attempt
|
|
280
|
+
status = run_action(action, event, job.device, job.tag, dry_run=self.dry_run,
|
|
281
|
+
variables=job.vars, state_dir=self._state_dir(event),
|
|
282
|
+
outcome=outcome)
|
|
283
|
+
if status is None or status == 0:
|
|
284
|
+
return status
|
|
285
|
+
if attempt == attempts:
|
|
286
|
+
if action.retries:
|
|
287
|
+
log.warning("action %r gave up after %d attempts (last status %d)",
|
|
288
|
+
action.name, attempts, status)
|
|
289
|
+
return status
|
|
290
|
+
if pause_flag.is_paused():
|
|
291
|
+
log.warning("action %r failed with status %d; automation is paused, so not "
|
|
292
|
+
"retrying", action.name, status)
|
|
293
|
+
return status
|
|
294
|
+
log.warning("action %r attempt %d of %d failed with status %d; retrying in %g s",
|
|
295
|
+
action.name, attempt, attempts, status, action.retry_delay)
|
|
296
|
+
if self._stopping.wait(action.retry_delay):
|
|
297
|
+
log.warning("action %r: the daemon is stopping, so not retrying", action.name)
|
|
298
|
+
return status
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def shutdown(self, wait=False):
|
|
302
|
+
"""Stop retrying and shut the pool down: with ``wait``, after the running and queued jobs;
|
|
303
|
+
else queued jobs are cancelled.
|
|
304
|
+
"""
|
|
305
|
+
self._stopping.set()
|
|
306
|
+
self._pool.shutdown(wait=wait, cancel_futures=not wait)
|