-
Notifications
You must be signed in to change notification settings - Fork 4
Add integration tests for the Ansible facts freshness check #2549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,156 @@ | ||||||||||||||
| # SPDX-License-Identifier: Apache-2.0 | ||||||||||||||
|
|
||||||||||||||
| """Integration tests for ``osism.utils.check_ansible_facts()``. | ||||||||||||||
|
|
||||||||||||||
| The function scans Redis for ``ansible_facts*`` keys with a cursor-based | ||||||||||||||
| ``SCAN`` loop and reads ``ansible_date_time.epoch`` from each JSON value to | ||||||||||||||
| 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 | ||||||||||||||
| round-tripped through a Redis server. The suite is skipped automatically when | ||||||||||||||
| Redis is not reachable (see ``conftest.py``). | ||||||||||||||
| """ | ||||||||||||||
|
|
||||||||||||||
| import json | ||||||||||||||
| import time | ||||||||||||||
| import uuid | ||||||||||||||
|
|
||||||||||||||
| import pytest | ||||||||||||||
|
|
||||||||||||||
| from osism import utils | ||||||||||||||
|
|
||||||||||||||
| pytestmark = pytest.mark.integration | ||||||||||||||
|
|
||||||||||||||
| # Passed explicitly to every call: the ``settings.FACTS_MAX_AGE`` default is 12 | ||||||||||||||
| # hours, which would tie the stale case to the environment. | ||||||||||||||
| MAX_AGE = 300 | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| @pytest.fixture | ||||||||||||||
| def seed_facts(): | ||||||||||||||
| """Seed ``ansible_facts<host>`` keys and remove them after the test.""" | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixture is byte-for-byte identical to Nothing to change in this PR. In a commit of its own afterwards: move |
||||||||||||||
| keys = [] | ||||||||||||||
|
|
||||||||||||||
| def _seed(host, value): | ||||||||||||||
| key = f"ansible_facts{host}" | ||||||||||||||
| utils.redis.set(key, value) | ||||||||||||||
| keys.append(key) | ||||||||||||||
|
|
||||||||||||||
| yield _seed | ||||||||||||||
|
|
||||||||||||||
| for key in keys: | ||||||||||||||
| utils.redis.delete(key) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def _host(): | ||||||||||||||
| """Return a hostname unique to one test. | ||||||||||||||
|
|
||||||||||||||
| The ``itest-`` prefix keeps it clear of ``LOCAL_FACT_HOSTS``, whose facts | ||||||||||||||
| ``check_ansible_facts()`` skips outright. | ||||||||||||||
| """ | ||||||||||||||
| return f"itest-{uuid.uuid4()}" | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def _facts(epoch): | ||||||||||||||
| """Return a facts blob carrying ``epoch``. | ||||||||||||||
|
|
||||||||||||||
| Ansible stores the epoch as a string, and ``check_ansible_facts()`` casts | ||||||||||||||
| it with ``float()``, so the string form is what the test seeds. | ||||||||||||||
| """ | ||||||||||||||
| return json.dumps({"ansible_date_time": {"epoch": str(int(epoch))}}) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def _messages(records, level, needle): | ||||||||||||||
| """Return the captured messages at ``level`` that contain ``needle``.""" | ||||||||||||||
| return [ | ||||||||||||||
| record["message"] | ||||||||||||||
| for record in records | ||||||||||||||
| if record["level"] == level and needle in record["message"] | ||||||||||||||
| ] | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def test_no_facts_warns_about_empty_cache(loguru_logs): | ||||||||||||||
| """An empty facts cache is reported as such. | ||||||||||||||
|
|
||||||||||||||
| ``check_ansible_facts()`` scans the whole database, so a Redis holding | ||||||||||||||
| real facts cannot produce this case. Skip there rather than deleting data | ||||||||||||||
| that does not belong to the suite; the CI container starts empty. | ||||||||||||||
| """ | ||||||||||||||
| leftover = sorted( | ||||||||||||||
| key.decode() for key in utils.redis.scan_iter(match="ansible_facts*") | ||||||||||||||
| ) | ||||||||||||||
| if leftover: | ||||||||||||||
| pytest.skip(f"Redis already holds ansible_facts keys: {leftover}") | ||||||||||||||
|
|
||||||||||||||
| utils.check_ansible_facts(max_age=MAX_AGE) | ||||||||||||||
|
|
||||||||||||||
| assert _messages(loguru_logs, "WARNING", "No Ansible facts found in Redis cache") | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def test_fresh_facts_produce_no_warning(seed_facts, loguru_logs): | ||||||||||||||
| """A host whose facts are current is not reported.""" | ||||||||||||||
| host = _host() | ||||||||||||||
| seed_facts(host, _facts(time.time())) | ||||||||||||||
|
|
||||||||||||||
| utils.check_ansible_facts(max_age=MAX_AGE) | ||||||||||||||
|
|
||||||||||||||
| assert not _messages(loguru_logs, "WARNING", host) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def test_stale_facts_report_the_stale_host_only(seed_facts, loguru_logs): | ||||||||||||||
| """Facts older than ``max_age`` are reported, fresh ones are not.""" | ||||||||||||||
| stale_host = _host() | ||||||||||||||
| fresh_host = _host() | ||||||||||||||
| seed_facts(stale_host, _facts(time.time() - 9999)) | ||||||||||||||
| seed_facts(fresh_host, _facts(time.time())) | ||||||||||||||
|
|
||||||||||||||
| utils.check_ansible_facts(max_age=MAX_AGE) | ||||||||||||||
|
|
||||||||||||||
| stale_messages = _messages( | ||||||||||||||
| loguru_logs, "WARNING", f"Host '{stale_host}': facts are" | ||||||||||||||
| ) | ||||||||||||||
| assert stale_messages | ||||||||||||||
| assert all("seconds old" in message for message in stale_messages) | ||||||||||||||
| assert _messages(loguru_logs, "WARNING", "Run 'osism sync facts' to update facts.") | ||||||||||||||
| assert not _messages(loguru_logs, "WARNING", fresh_host) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def test_facts_without_epoch_are_skipped(seed_facts, loguru_logs): | ||||||||||||||
| """Facts lacking ``ansible_date_time.epoch`` are skipped, not reported.""" | ||||||||||||||
| host = _host() | ||||||||||||||
| seed_facts(host, json.dumps({"ansible_hostname": "node-1"})) | ||||||||||||||
|
|
||||||||||||||
| utils.check_ansible_facts(max_age=MAX_AGE) | ||||||||||||||
|
|
||||||||||||||
| assert _messages( | ||||||||||||||
| loguru_logs, "DEBUG", f"Host '{host}': facts missing ansible_date_time.epoch" | ||||||||||||||
| ) | ||||||||||||||
| assert not _messages(loguru_logs, "WARNING", host) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| 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`` | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This docstring is accurate about the behaviour, but it is worth fixing rather than documenting. loguru formats with That also makes line 144 weaker than it reads. With no host in the message, any malformed One line in 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 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 |
||||||||||||||
| leaves in place, so the recorded message is the literal template. Match on | ||||||||||||||
| its prefix rather than on interpolated values. | ||||||||||||||
| """ | ||||||||||||||
| host = _host() | ||||||||||||||
| seed_facts(host, "{ not valid json") | ||||||||||||||
|
|
||||||||||||||
| utils.check_ansible_facts(max_age=MAX_AGE) | ||||||||||||||
|
|
||||||||||||||
| assert _messages(loguru_logs, "DEBUG", "Skipping malformed ansible_facts entry") | ||||||||||||||
| assert not _messages(loguru_logs, "WARNING", host) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def test_empty_value_is_skipped(seed_facts, loguru_logs): | ||||||||||||||
| """An empty value is skipped silently.""" | ||||||||||||||
| host = _host() | ||||||||||||||
| seed_facts(host, "") | ||||||||||||||
|
|
||||||||||||||
| utils.check_ansible_facts(max_age=MAX_AGE) | ||||||||||||||
|
|
||||||||||||||
| assert not _messages(loguru_logs, "WARNING", host) | ||||||||||||||
| assert not _messages(loguru_logs, "DEBUG", host) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 Measured over 200 trials per case, replacing that
Co-seeding a stale host and asserting it is still reported costs two lines and catches the early- |
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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()callsr.scan(cursor, match="ansible_facts*", count=100)(osism/utils/__init__.py:617), and the largest scenario here seeds two keys. Measured againstredis:7-alpineon an empty database:So on the container the job provides, the
while Truebody runs exactly once andkeys.extend(batch)never accumulates. Worth noting the threshold is well above "seed a few more": 64 keys still completes in one call, becauseSCANwalks hash buckets rather than matching keys andcount=100covers 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 attests/unit/utils/test_init_task_output.py:639-658is 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
SCANmay return the same element more than once while the keyspace rehashes, andosism/utils/__init__.py:614-620extends a flat list with no dedup — which would inflate the count at:673-676and print a host's warning twice. A mocked test that hard-codes distinct keys per batch encodes exactly-once semantics that liveSCANdoes 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: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.