From e2b51cf1ee3f3c12cac7ff648fc3469799b78710 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Fri, 19 Jun 2026 17:49:29 +0200 Subject: [PATCH] Exclude localhost from Ansible facts freshness check Cached facts for localhost (and 127.0.0.1/::1) are a byproduct of locally executed or delegated plays, not regular inventory hosts. They are never refreshed by 'osism sync facts', which only targets inventory hosts, so they age past FACTS_MAX_AGE and trigger a stale warning that the suggested remedy can never clear. Skip these hosts in check_ansible_facts() so the freshness check no longer emits permanent, unactionable warnings for them. Closes #2267 Assisted-by: Claude:claude-opus-4-8[1m] Signed-off-by: Christian Berendt --- osism/utils/__init__.py | 11 ++++++ tests/unit/utils/test_init_task_output.py | 44 +++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/osism/utils/__init__.py b/osism/utils/__init__.py index ca79be7c..719250ea 100644 --- a/osism/utils/__init__.py +++ b/osism/utils/__init__.py @@ -18,6 +18,12 @@ _secondary_nb_initialized = False _cleanup_registered = False +# Hosts whose cached facts are a byproduct of locally executed or delegated +# plays rather than regular inventory hosts. Their facts are never refreshed by +# 'osism sync facts' (which only targets inventory hosts), so excluding them +# from the freshness check avoids permanent, unactionable stale warnings. +LOCAL_FACT_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + def _init_redis(): global _redis @@ -605,6 +611,11 @@ def check_ansible_facts(max_age=None): key_str = key.decode() if isinstance(key, bytes) else key hostname = key_str.replace("ansible_facts", "", 1) + # Skip localhost and friends: their facts are never refreshed via + # 'osism sync facts', so reporting them as stale is misleading. + if hostname in LOCAL_FACT_HOSTS: + continue + data = r.get(key) if not data: continue diff --git a/tests/unit/utils/test_init_task_output.py b/tests/unit/utils/test_init_task_output.py index d4755c05..c66eea3a 100644 --- a/tests/unit/utils/test_init_task_output.py +++ b/tests/unit/utils/test_init_task_output.py @@ -695,3 +695,47 @@ def test_check_ansible_facts_explicit_max_age_overrides_settings(mocker, loguru_ warnings = [r["message"] for r in loguru_logs if r["level"] == "WARNING"] assert any("older than 10 seconds" in m for m in warnings) + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "::1"]) +def test_check_ansible_facts_local_hosts_never_stale(mocker, loguru_logs, host): + import time as time_mod + + now = time_mod.time() + mock_r = mocker.MagicMock() + mock_r.scan.return_value = (0, [f"ansible_facts{host}".encode("utf-8")]) + # Far older than the threshold, but local hosts must be skipped because + # 'osism sync facts' never refreshes them (not part of the inventory). + mock_r.get.return_value = _facts_payload(now - 9999) + mocker.patch("osism.utils._init_redis", return_value=mock_r) + + utils_pkg.check_ansible_facts(max_age=10) + + warnings = [r["message"] for r in loguru_logs if r["level"] == "WARNING"] + assert not any("stale" in m for m in warnings) + + +def test_check_ansible_facts_local_host_skipped_real_host_still_stale( + mocker, loguru_logs +): + import time as time_mod + + now = time_mod.time() + mock_r = mocker.MagicMock() + mock_r.scan.return_value = ( + 0, + [b"ansible_factslocalhost", b"ansible_factshost-a"], + ) + mock_r.get.side_effect = [ + _facts_payload(now - 9999), + _facts_payload(now - 9999), + ] + mocker.patch("osism.utils._init_redis", return_value=mock_r) + + utils_pkg.check_ansible_facts(max_age=10) + + warnings = [r["message"] for r in loguru_logs if r["level"] == "WARNING"] + # Only the real inventory host is reported; localhost is excluded. + assert any("stale for 1 host(s)" in m for m in warnings) + assert any("host-a" in m for m in warnings) + assert not any("localhost" in m for m in warnings)