feat: add requires_command guard sentinel and check_preconditions() hook#1647
Conversation
b47a385 to
8aadf42
Compare
DonDebonair
left a comment
There was a problem hiding this comment.
I love this PR! Left a few small suggestions.
| """ | ||
| return None | ||
|
|
||
| def preconditions( |
There was a problem hiding this comment.
Given the return value, I would make the function name a bit more descriptive. Something preconditions_met?
There was a problem hiding this comment.
That seems relevant to me.
|
|
||
| def preconditions( | ||
| self, state: "State", host: "Host" | ||
| ) -> bool | tuple[Literal[False], str] | None: |
There was a problem hiding this comment.
Why allow None as a return value? I think the signature is cleaner if it's just bool | tuple[Literal[False], str]
There was a problem hiding this comment.
The goal if to not have to return True "manually" if all is ok :
@override
def preconditions_met(self, state, host):
from pyinfra.facts.server import KernelModules
modules = host.get_fact(KernelModules) or {}
if "zfs" not in modules:
return False, "kernel module 'zfs' is not loaded"
return True # <-- implies to always add return True at the end of the methodExplicit is better than impliciit, maybe we can do like that
Another way if to just return the reason, a string, and not False or a tulple `False, "resaon", because if we return anithing, it's because there is a problem
@override
def preconditions(self, state, host):
from pyinfra.facts.server import KernelModules
modules = host.get_fact(KernelModules) or {}
if "zfs" not in modules:
return "kernel module 'zfs' is not loaded"It might be less intuitive
What do you think ?
There was a problem hiding this comment.
That is actually a good point. Suggestion: rename the method to check_preconditions(...) and make it return str | None. If it returns a string, some preconditions were not met and the return value gives more details. If nothing (None) is returned, all is well.
1d294fc to
a26c050
Compare
|
Looks good to me. @Fizzadar ready to merge. Recommend not squashing the commits because they represent 2 different (but related) features. |
cfbd2a7 to
f3cc916
Compare
Fizzadar
left a comment
There was a problem hiding this comment.
I love this PR, this is vastly improved vs. the current behavior. My concern is with the breaking change to raise during execution. I think this is quite likely to break existing working deploys that rely on the defaulting behaviour, something like:
# Executed on both webserver.net + dbserver.net, default (due to missing command) on webserver is silently skipped
if host.get_fact(postgres.Databases):
...At the same time, I don't want this stuck waiting for v4. What about logging, rather than raising, during execution phase (perhaps with config to enable raising?).
@maisim @DonDebonair keen for your thoughts!
f3cc916 to
6b6172b
Compare
| except MissingCommandError as e: | ||
| # During the prepare phase the binary might not yet be installed (a prior | ||
| # operation will install it). Silently return the default so change | ||
| # detection can proceed normally. | ||
| if state.current_stage != StateStage.Execute: | ||
| logger.debug( | ||
| "Fact %s skipped on %s during prepare: %s", | ||
| cls.__name__, | ||
| host.print_prefix, | ||
| e, | ||
| ) | ||
| return cls().default() | ||
| # TODO(v4): remove this compat shim and let the exception propagate. | ||
| logger.warning( | ||
| "Fact %s skipped on %s: command not found: %s (this will raise an exception in v4)", | ||
| cls.__name__, | ||
| host.print_prefix, | ||
| e, | ||
| ) | ||
| return cls().default() |
There was a problem hiding this comment.
Made the change here, It does not raise anymore, just log a warning.
6b6172b to
5b8df1f
Compare
Background
Two related reliability gaps existed in the fact-collection layer:
The old shell guard for
requires_commandused:When the binary is absent this produces no stdout, making it impossible to distinguish "binary not installed" from "binary installed but returned no data". Both cases silently return
default(), masking real errors.There was no way for a fact to express runtime prerequisites beyond "this binary must exist" — e.g. ZFS datasets cannot be listed when the
zfskernel module is not loaded, even if thezfsbinary is present.Changes
Replace the old
! … ||guard with an explicitif/then/elsethat emits a unique marker when the binary is absent:The marker
_MISSING_COMMAND_MARKER = "##PYINFRA_NOCMD##"is detected in_get_factand converted into aMissingCommandErrorexception so callers can tell the two cases apart.All three are exported from
pyinfra.api.Both exceptions obey the deploy phase in
get_fact():default(). The binary / module may simply not be installed yet; a later operation will install it.New optional override on
FactBase:Return
None(or no explicit return) to proceed, or a reason string to skip with context. The framework raisesFactPreconditionErrorautomatically — fact authors never need to import exception classes.As an example:
ZfsPoolsadded withrequires_command = "zpool".ZfsDatasets.check_preconditions()uses the existingserver.KernelModulesfact to verify thezfskernel module is loaded before attemptingzfs get -H all— a real-world scenario whererequires_commandalone is not sufficient.Tests
tests/test_api/test_api_facts_missing_command.py— phase-aware behaviour forMissingCommandErrorand the##PYINFRA_NOCMD##sentinel.tests/test_api/test_api_facts_preconditions.py— phase-aware behaviour forFactPreconditionErrorviacheck_preconditions().3.xat this time)scripts/dev-test.sh)scripts/dev-lint.sh)