Skip to content

Commit 3288369

Browse files
committed
Add safeguard to check Ansible facts freshness before apply
Check Redis for cached Ansible facts before executing a role via the apply command. Warns if no facts exist or if facts are stale (older than 43200 seconds by default, configurable via FACTS_MAX_AGE). Skipped for gather-facts/facts roles and --show-tree. AI-assisted: Claude Code Signed-off-by: Christian Berendt <berendt@osism.tech>
1 parent 50acac4 commit 3288369

File tree

3 files changed

+93
-0
lines changed

3 files changed

+93
-0
lines changed

osism/commands/apply.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import argparse
44
import os
5+
import time
56

67
from cliff.command import Command
78
from loguru import logger
@@ -433,6 +434,16 @@ def take_action(self, parsed_args):
433434
dry_run = parsed_args.dry_run
434435
show_tree = parsed_args.show_tree
435436

437+
# Check if Ansible facts in Redis are available and fresh.
438+
# Skip when gathering facts or just showing the tree.
439+
# Use time-based backoff to avoid a costly Redis scan on every invocation.
440+
if role and role not in ("gather-facts", "facts") and not show_tree:
441+
now = time.time()
442+
last_check = getattr(utils, "_last_ansible_facts_check", 0)
443+
if now - last_check > 300:
444+
utils.check_ansible_facts()
445+
utils._last_ansible_facts_check = now
446+
436447
rc = 0
437448

438449
if not role:

osism/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def read_secret(secret_name):
3131

3232
# 43200 seconds = 12 hours
3333
GATHER_FACTS_SCHEDULE = float(os.getenv("GATHER_FACTS_SCHEDULE", "43200.0"))
34+
FACTS_MAX_AGE = int(os.getenv("FACTS_MAX_AGE", str(int(GATHER_FACTS_SCHEDULE))))
3435
INVENTORY_RECONCILER_SCHEDULE = float(
3536
os.getenv("INVENTORY_RECONCILER_SCHEDULE", "600.0")
3637
)

osism/utils/__init__.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,87 @@ def is_task_locked():
558558
return None
559559

560560

561+
def check_ansible_facts(max_age=None):
562+
"""Check if Ansible facts exist in Redis and are not stale.
563+
564+
Scans Redis for ansible_facts* keys and checks the
565+
ansible_date_time.epoch field to determine freshness.
566+
567+
Args:
568+
max_age: Maximum age in seconds (default: settings.FACTS_MAX_AGE)
569+
"""
570+
import json
571+
572+
if max_age is None:
573+
max_age = settings.FACTS_MAX_AGE
574+
575+
try:
576+
r = _init_redis()
577+
578+
# Find all ansible_facts keys
579+
keys = []
580+
cursor = 0
581+
while True:
582+
cursor, batch = r.scan(cursor, match="ansible_facts*", count=100)
583+
keys.extend(batch)
584+
if cursor == 0:
585+
break
586+
except Exception as e:
587+
logger.warning(f"Could not check Ansible facts freshness: {e}")
588+
return
589+
590+
if not keys:
591+
logger.warning(
592+
"No Ansible facts found in Redis cache. "
593+
"Run 'osism sync facts' to gather facts."
594+
)
595+
return
596+
597+
now = time.time()
598+
stale_hosts = []
599+
600+
for key in keys:
601+
data = None
602+
try:
603+
key_str = key.decode() if isinstance(key, bytes) else key
604+
hostname = key_str.replace("ansible_facts", "", 1)
605+
606+
data = r.get(key)
607+
if not data:
608+
continue
609+
facts = json.loads(data)
610+
date_time = facts.get("ansible_date_time", {})
611+
epoch = date_time.get("epoch")
612+
if epoch is None:
613+
logger.debug(
614+
f"Host '{hostname}': facts missing ansible_date_time.epoch"
615+
)
616+
continue
617+
age = now - float(epoch)
618+
if age > max_age:
619+
stale_hosts.append((hostname, int(age)))
620+
except (json.JSONDecodeError, ValueError, TypeError):
621+
truncated_value = data
622+
if isinstance(truncated_value, (bytes, str)):
623+
truncated_value = truncated_value[:200]
624+
logger.debug(
625+
"Skipping malformed ansible_facts entry for key %r: %r",
626+
key,
627+
truncated_value,
628+
exc_info=True,
629+
)
630+
continue
631+
632+
if stale_hosts:
633+
logger.warning(
634+
f"Ansible facts in Redis are stale for {len(stale_hosts)} host(s) "
635+
f"(older than {max_age} seconds). "
636+
f"Run 'osism sync facts' to update facts."
637+
)
638+
for hostname, age in stale_hosts:
639+
logger.warning(f" Host '{hostname}': facts are {age} seconds old")
640+
641+
561642
def check_task_lock_and_exit():
562643
"""
563644
Check if tasks are locked and exit with error message if they are.

0 commit comments

Comments
 (0)