Add integration tests for the Ansible facts freshness check - #2549
Add integration tests for the Ansible facts freshness check#2549berendt wants to merge 1 commit into
Conversation
check_ansible_facts() scans Redis for ansible_facts* keys and reads ansible_date_time.epoch to decide whether cached facts are stale. Its unit tests drive it through a MagicMock client, so the cursor-based SCAN loop, the bytes decoding of keys and values, and JSON parsing of data returned by a real server are only simulated. The new module covers those paths against the live Redis the python-osism-integration-tests job provides: an empty cache, fresh facts, a stale host alongside a fresh one, facts without an epoch field, a malformed JSON value, and an empty value. Seeded keys use itest-<uuid4> hostnames and are removed after each test. Every assertion is scoped to a seeded hostname or to a message unique to its scenario, so unrelated keys in a shared Redis cannot fail the suite. The empty-cache test skips instead of deleting when the database already holds ansible_facts keys. Assisted-by: Claude:claude-opus-5 Signed-off-by: Christian Berendt <berendt@osism.tech>
| decide whether the cached facts are stale, reporting the outcome through | ||
| loguru. The unit tests cover the same scenarios against a ``MagicMock`` | ||
| client; running them against a live Redis exercises what the mocks stand in | ||
| for: the cursor loop, keys and values arriving as ``bytes``, and JSON that |
There was a problem hiding this comment.
The cursor loop is listed first among the things a live Redis exercises, and it is the one path this file does not reach. check_ansible_facts() calls r.scan(cursor, match="ansible_facts*", count=100) (osism/utils/__init__.py:617), and the largest scenario here seeds two keys. Measured against redis:7-alpine on an empty database:
| keys seeded | first cursor | loop iterations |
|---|---|---|
| 2 | 0 | 1 |
| 50 | 0 | 1 |
| 64 | 0 | 1 |
| 100 | 127 | 2 |
| 200 | 193 | 2 |
| 300 | 138 | 3 |
So on the container the job provides, the while True body runs exactly once and keys.extend(batch) never accumulates. Worth noting the threshold is well above "seed a few more": 64 keys still completes in one call, because SCAN walks hash buckets rather than matching keys and count=100 covers the whole table until it grows past that.
The consequence is concrete. Refactoring the loop to a single call — cursor, keys = r.scan(0, match="ansible_facts*", count=100), a plausible simplification — leaves this file entirely green: 20 of 20 runs pass at two keys. A 200-host version fails 20 of 20, reporting only 100–102 of the 200 stale hosts. That is the regression the docstring implies is covered, and the mocked pagination test at tests/unit/utils/test_init_task_output.py:639-658 is what currently catches it.
Either exercise it or drop "the cursor loop" from the list; the bytes and round-tripped-JSON claims are real and worth keeping. I would exercise it, for a reason beyond the refactor above: Redis documents that SCAN may return the same element more than once while the keyspace rehashes, and osism/utils/__init__.py:614-620 extends a flat list with no dedup — which would inflate the count at :673-676 and print a host's warning twice. A mocked test that hard-codes distinct keys per batch encodes exactly-once semantics that live SCAN does not promise, so that class of defect is only reachable here.
If you exercise it, a behavioural assertion beats counting scan() calls — a call-count spy means wrapping the live client, which reintroduces the mock this file exists to escape:
def test_stale_facts_across_scan_batches(seed_facts, loguru_logs):
hosts = [_host() for _ in range(200)]
for host in hosts:
seed_facts(host, _facts(time.time() - 9999))
# Precondition: a single SCAN cannot cover this keyspace, so the loop in
# check_ansible_facts() must iterate. COUNT is documented as a hint, so this
# fails loudly if a future Redis makes 200 keys insufficient rather than
# silently passing without testing pagination.
cursor, _ = utils.redis.scan(0, match="ansible_facts*", count=100)
assert cursor != 0
utils.check_ansible_facts(max_age=MAX_AGE)
assert _messages(loguru_logs, "WARNING", f"stale for {len(hosts)} host(s)")
for host in hosts:
assert _messages(loguru_logs, "WARNING", f"Host '{host}': facts are")Asserting every host is what proves the batches accumulated. The commit message carries the same claim about the SCAN loop and would want the same correction.
| def test_malformed_json_is_skipped(seed_facts, loguru_logs): | ||
| """A value that is not JSON is skipped without failing the check. | ||
|
|
||
| The debug call uses printf-style placeholders that loguru's ``str.format`` |
There was a problem hiding this comment.
This docstring is accurate about the behaviour, but it is worth fixing rather than documenting. loguru formats with str.format, so the template at osism/utils/__init__.py:664-669 discards key and truncated_value entirely — the recorded message is the literal Skipping malformed ansible_facts entry for key %r: %r. exc_info=True is inert too: loguru has no such kwarg, so it is swallowed as a format argument and record["exception"] stays None. Verified against loguru 0.7.3. An operator hitting corrupt cached facts gets a message naming neither the host, nor the payload, nor the parse error — on the one path where all three matter.
That also makes line 144 weaker than it reads. With no host in the message, any malformed ansible_facts* entry in a shared Redis satisfies it: this test's own key could never be processed and the assertion would still pass. It is also the exception to the commit message's claim that "every assertion is scoped to a seeded hostname or to a message unique to its scenario" — that sentence wants qualifying either way.
One line in osism/utils/__init__.py:
logger.opt(exception=True).debug(
f"Skipping malformed ansible_facts entry for key {key!r}: {truncated_value!r}"
)then this paragraph goes away and line 144 becomes assert _messages(loguru_logs, "DEBUG", host). Confirmed that the fix produces Skipping malformed ansible_facts entry for key b'ansible_factsitest-…': b'{ not valid json' with a JSONDecodeError attached.
If you would rather keep the diff tests-only, that splits cleanly — with the fix applied, this file and the affected unit modules stay green (12 and 77 tests), because every assertion is a substring match on a prefix the interpolated message retains. But please drop this paragraph in this PR regardless: nothing will turn red to signal that prose describing placeholders is stale once they are gone. The same pattern exists at osism/commands/console.py:124 and would want the same treatment.
| utils.check_ansible_facts(max_age=MAX_AGE) | ||
|
|
||
| assert not _messages(loguru_logs, "WARNING", host) | ||
| assert not _messages(loguru_logs, "DEBUG", host) |
There was a problem hiding this comment.
Both assertions here are absence-only and the seeded input produces no log output at all, so nothing distinguishes "skipped the empty entry and kept scanning" from "skipped it and stopped". Line 156 in particular has almost no failure mode: the only reachable DEBUG message containing a hostname is the missing-epoch one at osism/utils/__init__.py:653-655, which an empty value never reaches, so even removing if not data: continue — making json.loads("") raise — leaves this green.
Measured over 200 trials per case, replacing that continue with an early exit:
| test | continue → return |
continue → break |
|---|---|---|
| as written (one empty key) | 0/200 caught | 0/200 caught |
| co-seeding 1 stale host | 200/200 | 78/200 |
| co-seeding 5 stale hosts | 200/200 | 173/200 |
Co-seeding a stale host and asserting it is still reported costs two lines and catches the early-return case deterministically — order-independently, because stale hosts are reported after the loop, so an early return skips the summary regardless of which key SCAN returns first. Five stale hosts also covers most of the break case. The correct implementation passed 200/200 in every configuration, so there is no flakiness cost.
|
|
||
| @pytest.fixture | ||
| def seed_facts(): | ||
| """Seed ``ansible_facts<host>`` keys and remove them after the test.""" |
There was a problem hiding this comment.
This fixture is byte-for-byte identical to tests/integration/test_api_facts.py:38-51, docstring included. Both build f"ansible_facts{host}" — the key format the two suites exist to pin, one for check_ansible_facts(), one for the inventory-facts endpoints — so changing that format and updating only one copy leaves the other seeding the old layout and still passing.
Nothing to change in this PR. In a commit of its own afterwards: move seed_facts into the existing tests/integration/conftest.py and delete both copies, and add a unique-host fixture there to replace _host() here and the six inline f"itest-{uuid.uuid4()}" in test_api_facts.py. That one wants to be a factory rather than a plain string fixture, since test_stale_facts_report_the_stale_host_only needs two hosts in one test.
Closes #2403.
check_ansible_facts()(osism/utils/__init__.py:596) is whatosism applycalls before running plays (osism/commands/apply.py:422) to warn operators about a missing or outdated facts cache. Its unit tests (tests/unit/utils/test_init_task_output.py:450) drive it through aMagicMockclient, so the parts most likely to regress are the parts the mocks simulate: the cursor-basedSCANloop, the bytes decoding of keys and values, and JSON parsing of data a real server returned.This adds
tests/integration/test_facts.py, which exercises those paths against the live Redis thepython-osism-integration-testsjob provides. No source file changes.The commit
Add integration tests for the Ansible facts freshness check— one new module with six tests and a module-localseed_factsfixture.What the tests cover
test_no_facts_warns_about_empty_cachetest_fresh_facts_produce_no_warningtest_stale_facts_report_the_stale_host_onlynow-9999, one freshtest_facts_without_epoch_are_skippedansible_date_time.epochtest_malformed_json_is_skipped{ not valid jsonjson.JSONDecodeErroris caught, the skip is logged at debug leveltest_empty_value_is_skippedTwo properties keep the module deterministic against a Redis that is not empty. Hostnames are
itest-<uuid4>, and every assertion is scoped to a seeded hostname or to a message unique to its scenario, so unrelated keys cannot fail the suite.check_ansible_facts()scans the whole database, so the empty-cache case cannot be produced on a Redis holding real facts; that test skips and names the leftover keys rather than deleting data the suite does not own.max_age=300is passed explicitly everywhere, since thesettings.FACTS_MAX_AGEdefault of 12 hours would tie the stale case to the environment.Verification
Against a throwaway
redis:7-alpine:pytest tests/integration/test_facts.py— 6 passed, run twice in a row, withKEYS 'ansible_facts*'empty afterwards.ansible_factskey present: the empty-cache test is reported as skipped, the key survives, and the other five tests stay green.OSISM_REQUIRE_REDIS=1the session exits non-zero.black --checkandflake8are clean.Three mutations of
check_ansible_facts()were each caught by the matching test and then reverted: disabling the staleness comparison failedtest_stale_facts_report_the_stale_host_only, downgrading the empty-cache warning failedtest_no_facts_warns_about_empty_cache, and removing the missing-epoch debug line failedtest_facts_without_epoch_are_skipped.Part of #2400 (Tier 1).