Skip to content

Commit a2252c3

Browse files
maisimFizzadar
authored andcommitted
feat(facts): add requires_command guard sentinel and check_preconditions() hook
1 parent 3c1252f commit a2252c3

7 files changed

Lines changed: 353 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
# Unreleased
2+
3+
Core:
4+
5+
- api.facts: `requires_command` now uses an if/then/else shell guard with a sentinel marker
6+
(`##PYINFRA_NOCMD##`) so that "binary absent" can be distinguished from "binary returned no data"
7+
- api.facts: add `check_preconditions(state, host)` hook on `FactBase` for runtime prerequisite checks
8+
(e.g. kernel module loaded, service running) — return a reason string or `None`
9+
- api.exceptions: add `FactNotCollected`, `MissingCommandError`, `FactPreconditionError` exception
10+
hierarchy; both exceptions are phase-aware (silent during prepare, raised during execute)
11+
- facts.zfs: fix `ZfsDatasets.requires_command` returning `"zpool"` instead of `"zfs"`; add
12+
`ZfsPools` fact; add `ZfsDatasets.check_preconditions()` checking kernel module via `server.KernelModules`
13+
114
# v3.7
215

316
Thank you to all contributors - particular shout out to @wowi42 for an incredible run of PRs!

docs/api/facts.md

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,80 @@ and a ``process`` function. The command is executed on the target host and the o
55
passed (as a ``list`` of lines) to the ``process`` handler to generate fact data. Facts can output anything, normally a ``list`` or ``dict``.
66

77
Fact classes may provide a ``default`` function that takes no arguments (except ``self``). The return value of this function is used if an error
8-
occurs during fact collection. Additionally, a ``requires_command`` variable can be set on the fact that specifies a command that must be available
9-
on the host to collect the fact. If this command is not present on the host, the fact will be set to the default, or empty if no ``default`` function
10-
is available.
8+
occurs during fact collection.
9+
10+
## Guarding against missing binaries: `requires_command`
11+
12+
Override `requires_command` to declare a binary that must be present on the remote host before
13+
the fact command is run. When the binary is absent pyinfra emits a unique sentinel instead of
14+
executing the command, then raises `MissingCommandError` internally:
15+
16+
```py
17+
from pyinfra.api import FactBase
18+
19+
class ZfsPools(FactBase):
20+
def requires_command(self) -> str:
21+
return "zpool"
22+
23+
def command(self) -> str:
24+
return "zpool get -H all"
25+
```
26+
27+
**Phase-aware behaviour** — The exception is handled differently depending on the deploy phase:
28+
29+
- **Prepare phase**: the fact returns `default()` silently. The binary may simply not be
30+
installed yet; a later operation will install it.
31+
- **Execute phase** (v3): a warning is logged and `default()` is returned. This preserves
32+
backwards compatibility for deploys that rely on `default()` being returned when a binary
33+
is absent.
34+
- **Execute phase** (v4): `MissingCommandError` will be raised so the developer knows the
35+
deploy is incorrectly ordered (the install step must come first).
36+
37+
## Checking runtime prerequisites: `check_preconditions()`
38+
39+
Some facts require more than just a binary — they need a specific runtime state (e.g. a kernel
40+
module loaded, a service running). Override `check_preconditions` to express these checks:
41+
42+
```py
43+
from pyinfra.api import FactBase
44+
45+
class ZfsDatasets(FactBase):
46+
def requires_command(self) -> str:
47+
return "zfs"
48+
49+
def check_preconditions(self, state, host):
50+
from pyinfra.facts.server import KernelModules
51+
modules = host.get_fact(KernelModules) or {}
52+
if "zfs" not in modules:
53+
return "kernel module 'zfs' is not loaded"
54+
55+
def command(self) -> str:
56+
return "zfs get -H all"
57+
```
58+
59+
Return values:
60+
61+
| Return value | Meaning |
62+
|---|---|
63+
| `None` (or no return) | Prerequisites satisfied — proceed normally |
64+
| `"reason"` | Prerequisite not satisfied with a human-readable explanation |
65+
66+
The framework raises `FactPreconditionError` automatically and applies the same
67+
phase-aware behaviour as `requires_command`: silent during prepare, raised during execute.
68+
Fact authors never need to import any exception class.
69+
70+
## Exception hierarchy
71+
72+
All "fact skipped" situations use a common base class so callers can catch at any level:
73+
74+
```
75+
FactError
76+
└── FactNotCollected # base: fact could not be collected
77+
├── MissingCommandError # requires_command binary absent
78+
└── FactPreconditionError # check_preconditions() not satisfied
79+
```
80+
81+
All three are exported from `pyinfra.api`.
1182

1283
## Importing & Using Facts
1384

src/pyinfra/api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
from .deploy import deploy # noqa: F401 # pragma: no cover
1212
from .exceptions import ( # noqa: F401
1313
DeployError, # noqa: F401 # pragma: no cover
14+
FactPreconditionError,
1415
FactError,
16+
FactNotCollected,
1517
FactProcessError,
1618
FactTypeError,
1719
FactValueError,
1820
InventoryError,
21+
MissingCommandError,
1922
OperationError,
2023
OperationTypeError,
2124
OperationValueError,

src/pyinfra/api/exceptions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,38 @@ class NestedOperationError(OperationError):
6060
"""
6161

6262

63+
class FactNotCollected(FactError):
64+
"""
65+
Base exception raised when a fact could not be collected (e.g. binary absent
66+
on the remote host, or the fact was skipped by a condition).
67+
"""
68+
69+
70+
class MissingCommandError(FactNotCollected):
71+
"""
72+
Exception raised when ``requires_command`` specifies a binary that is
73+
not present on the remote host. The fact returns its ``default()`` value
74+
instead of raising, unless explicitly configured otherwise.
75+
"""
76+
77+
def __init__(self, command: str) -> None:
78+
self.command = command
79+
super().__init__(f"Command not found on remote host: {command}")
80+
81+
82+
class FactPreconditionError(FactNotCollected):
83+
"""
84+
Exception raised when a fact's ``check_preconditions()`` returns a reason string
85+
(e.g. a kernel module is not loaded). Like ``MissingCommandError``, this is
86+
silenced during the prepare phase and re-raised during execute.
87+
"""
88+
89+
def __init__(self, fact_cls: type, reason: str) -> None:
90+
self.fact_cls = fact_cls
91+
self.reason = reason
92+
super().__init__(f"Fact precondition not satisfied ({fact_cls.__name__}): {reason}")
93+
94+
6395
class DeployError(PyinfraError):
6496
"""
6597
User exception for raising in deploys or sub deploys.

src/pyinfra/api/facts.py

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from pyinfra.api.output import format_text
2525
from pyinfra.api import StringCommand
2626
from pyinfra.api.arguments import all_global_arguments, pop_global_arguments
27-
from pyinfra.api.exceptions import FactProcessError
27+
from pyinfra.api.exceptions import FactPreconditionError, FactProcessError, MissingCommandError
2828
from pyinfra.api.util import (
2929
get_kwargs_str,
3030
log_error_or_warning,
@@ -36,10 +36,14 @@
3636
from pyinfra.progress import progress_spinner
3737

3838
from .arguments import CONNECTOR_ARGUMENT_KEYS
39+
from .state import StateStage
3940

4041
if TYPE_CHECKING:
4142
from pyinfra.api import Host, State
4243

44+
# Sentinel output line emitted when skip_unless_command binary is absent on the remote host.
45+
_MISSING_COMMAND_MARKER = "##PYINFRA_NOCMD##"
46+
4347
SUDO_REGEX = r"^sudo: unknown user"
4448
SU_REGEXES = (
4549
r"^su: user .+ does not exist",
@@ -60,6 +64,22 @@ class FactBase(Generic[T]):
6064
command: Callable[..., str | StringCommand]
6165

6266
def requires_command(self, *args, **kwargs) -> str | None:
67+
"""Return the binary name that must exist on the remote host for this fact to run.
68+
If the binary is absent the fact returns its ``default()`` value silently.
69+
"""
70+
return None
71+
72+
def check_preconditions(self, state: "State", host: "Host") -> str | None:
73+
"""Check that this fact's prerequisites are satisfied before running.
74+
75+
Override this method to call ``host.get_fact(...)`` and return:
76+
77+
- ``None`` (or no return) — all prerequisites satisfied, proceed normally
78+
- ``"reason message"`` — prerequisite not satisfied with explanation
79+
80+
The framework handles raising ``FactPreconditionError`` and phase-awareness
81+
automatically; fact authors never need to import exception classes.
82+
"""
6383
return None
6484

6585
@override
@@ -186,15 +206,51 @@ def get_fact(
186206
apply_failed_hosts=apply_failed_hosts,
187207
)
188208

189-
return _get_fact(
190-
state,
191-
host,
192-
cls,
193-
args,
194-
kwargs,
195-
ensure_hosts,
196-
apply_failed_hosts,
197-
)
209+
try:
210+
return _get_fact(
211+
state,
212+
host,
213+
cls,
214+
args,
215+
kwargs,
216+
ensure_hosts,
217+
apply_failed_hosts,
218+
)
219+
except MissingCommandError as e:
220+
# During the prepare phase the binary might not yet be installed (a prior
221+
# operation will install it). Silently return the default so change
222+
# detection can proceed normally.
223+
if state.current_stage != StateStage.Execute:
224+
logger.debug(
225+
"Fact %s skipped on %s during prepare: %s",
226+
cls.__name__,
227+
host.print_prefix,
228+
e,
229+
)
230+
return cls().default()
231+
# During the execute phase the binary should already be present. If it
232+
# isn't, the deploy is incorrectly ordered (missing an install step?).
233+
# TODO(v4): remove this compat shim and let the exception propagate.
234+
logger.warning(
235+
"Fact %s skipped on %s: command not found: %s (this will raise an exception in v4)",
236+
cls.__name__,
237+
host.print_prefix,
238+
e,
239+
)
240+
return cls().default()
241+
except FactPreconditionError as e:
242+
# Same phase-aware logic: a precondition not satisfied during prepare
243+
# is normal (e.g. kernel module not yet loaded); during execute it is an
244+
# ordering error in the deploy.
245+
if state.current_stage != StateStage.Execute:
246+
logger.debug(
247+
"Fact %s skipped on %s during prepare: %s",
248+
cls.__name__,
249+
host.print_prefix,
250+
e,
251+
)
252+
return cls().default()
253+
raise
198254

199255

200256
def _get_fact(
@@ -229,20 +285,30 @@ def _get_fact(
229285
if fact.shell_executable:
230286
global_kwargs["_shell_executable"] = fact.shell_executable
231287

288+
# Check preconditions before running this fact's command.
289+
if reason := fact.check_preconditions(state, host):
290+
raise FactPreconditionError(cls, reason)
291+
232292
command = _make_command(fact.command, fact_kwargs)
233293
requires_command = _make_command(fact.requires_command, fact_kwargs)
234294
if requires_command:
235295
command = StringCommand(
236-
# Command doesn't exist, return 0 *or* run & return fact command
237-
"!",
296+
# If binary exists → run the fact command; otherwise emit the sentinel so
297+
# pyinfra can distinguish "binary absent" from "no output".
298+
"if",
238299
"command",
239300
"-v",
240301
requires_command,
241302
">/dev/null",
242-
"||",
303+
"2>&1;",
304+
"then",
243305
"(",
244306
command,
245-
")",
307+
");",
308+
"else",
309+
"echo",
310+
f"'{_MISSING_COMMAND_MARKER}';",
311+
"fi",
246312
)
247313

248314
status = False
@@ -268,6 +334,17 @@ def _get_fact(
268334

269335
stdout_lines, stderr_lines = output.stdout_lines, output.stderr_lines
270336

337+
# Detect the "binary absent" sentinel from the if/then/else shell guard.
338+
if status and stdout_lines == [_MISSING_COMMAND_MARKER]:
339+
cmd_str = str(requires_command) if requires_command else ""
340+
logger.debug(
341+
"Skipping fact %s on %s: command not found: %s",
342+
name,
343+
host.print_prefix,
344+
cmd_str,
345+
)
346+
raise MissingCommandError(cmd_str)
347+
271348
data = fact.default()
272349

273350
if status:
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Tests for MissingCommandError / requires_command phase-aware behaviour.
3+
4+
When a fact declares ``requires_command`` and the requested binary is absent:
5+
6+
- **Prepare phase** – silent: the binary might not yet be installed.
7+
The fact must return ``default()`` so change-detection can proceed.
8+
- **Execute phase** – compat shim (v3): warning logged, ``default()`` returned.
9+
Will raise ``MissingCommandError`` in v4.
10+
"""
11+
12+
from unittest import TestCase
13+
from unittest.mock import MagicMock, patch
14+
15+
from pyinfra.api.exceptions import FactError, FactNotCollected, MissingCommandError
16+
from pyinfra.api.facts import _MISSING_COMMAND_MARKER, get_fact
17+
from pyinfra.api.state import StateStage
18+
from pyinfra.facts.zfs import ZfsPools
19+
20+
21+
def _make_state(stage: StateStage) -> MagicMock:
22+
state = MagicMock()
23+
state.current_stage = stage
24+
return state
25+
26+
27+
class TestMissingCommandPhaseAwareness(TestCase):
28+
"""Phase-aware handling of MissingCommandError in the public get_fact() wrapper."""
29+
30+
def test_prepare_phase_returns_default(self):
31+
"""Binary absent during prepare → fact returns default() silently."""
32+
state = _make_state(StateStage.Prepare)
33+
host = MagicMock()
34+
35+
with patch("pyinfra.api.facts._get_fact", side_effect=MissingCommandError("zpool")):
36+
result = get_fact(state, host, ZfsPools)
37+
38+
assert result == ZfsPools().default()
39+
40+
def test_execute_phase_returns_default_with_warning(self):
41+
"""Binary absent during execute → compat shim: warning logged, default() returned (v3).
42+
TODO(v4): this should raise MissingCommandError instead."""
43+
state = _make_state(StateStage.Execute)
44+
host = MagicMock()
45+
46+
with patch("pyinfra.api.facts._get_fact", side_effect=MissingCommandError("zpool")):
47+
result = get_fact(state, host, ZfsPools)
48+
49+
assert result == ZfsPools().default()
50+
51+
def test_missing_command_error_message(self):
52+
"""MissingCommandError carries a human-readable message."""
53+
exc = MissingCommandError("zpool")
54+
assert "zpool" in str(exc)
55+
56+
def test_missing_command_inherits_fact_not_collected(self):
57+
"""MissingCommandError is a FactNotCollected (and a FactError)."""
58+
exc = MissingCommandError("zpool")
59+
assert isinstance(exc, FactNotCollected)
60+
assert isinstance(exc, FactError)
61+
62+
63+
class TestMissingCommandMarker(TestCase):
64+
"""The sentinel constant is stable – callers may depend on it."""
65+
66+
def test_marker_is_unique_string(self):
67+
assert isinstance(_MISSING_COMMAND_MARKER, str)
68+
assert len(_MISSING_COMMAND_MARKER) > 0
69+
# Must not look like valid shell output from any standard command
70+
assert _MISSING_COMMAND_MARKER.startswith("##")
71+
assert _MISSING_COMMAND_MARKER.endswith("##")

0 commit comments

Comments
 (0)