Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions osism/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import os
import re
import subprocess
from typing import Optional, Dict, Any, List, cast
from typing import Optional, Dict, Any, List
from uuid import UUID

from fastapi import (
Expand All @@ -26,6 +26,7 @@

from osism.tasks import reconciler, openstack
from osism import utils
from osism.utils import fact_cache
from osism.utils.inventory import get_hosts_from_inventory, get_inventory_path
from osism.services.listener import BaremetalEvents
from osism.services.websocket_manager import websocket_manager
Expand Down Expand Up @@ -941,22 +942,21 @@ async def get_host_hostvar(host: str, variable: str) -> HostvarSingleResponse:
async def get_host_facts(host: str) -> FactsResponse:
"""Get all cached Ansible facts for a specific host."""
try:
data = cast(bytes | None, utils.redis.get(f"ansible_facts{host}"))
facts_data = fact_cache.get_host_facts(utils.redis, host)

if not data:
if not facts_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No facts found in cache for host '{host}'",
)

facts_data = json.loads(data)
facts = [
FactEntry(name=name, value=value)
for name, value in sorted(facts_data.items())
]

return FactsResponse(host=host, facts=facts, count=len(facts), from_cache=True)
except json.JSONDecodeError as e:
except ValueError as e:
logger.error(f"Error parsing facts JSON for {host}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
Expand All @@ -980,16 +980,14 @@ async def get_host_facts(host: str) -> FactsResponse:
async def get_host_fact(host: str, fact: str) -> FactSingleResponse:
"""Get a specific cached Ansible fact for a host."""
try:
data = cast(bytes | None, utils.redis.get(f"ansible_facts{host}"))
facts_data = fact_cache.get_host_facts(utils.redis, host)

if not data:
if not facts_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No facts found in cache for host '{host}'",
)

facts_data = json.loads(data)

if fact not in facts_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
Expand All @@ -999,7 +997,7 @@ async def get_host_fact(host: str, fact: str) -> FactSingleResponse:
return FactSingleResponse(
host=host, name=fact, value=facts_data[fact], from_cache=True
)
except json.JSONDecodeError as e:
except ValueError as e:
logger.error(f"Error parsing facts JSON for {host}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
Expand Down
7 changes: 3 additions & 4 deletions osism/commands/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from tabulate import tabulate

from osism import utils
from osism.utils import fact_cache
from osism.utils.inventory import get_hosts_from_inventory, get_inventory_path


Expand Down Expand Up @@ -192,9 +193,8 @@ def take_action(self, parsed_args):
fact = parsed_args.fact
cache = not parsed_args.no_cache

data = utils.redis.get(f"ansible_facts{host}")
data = fact_cache.get_host_facts(utils.redis, host)
if data:
data = json.loads(data)
table = []

if fact:
Expand Down Expand Up @@ -355,9 +355,8 @@ def get_parser(self, prog_name):
def take_action(self, parsed_args):
host = parsed_args.host[0]

data = utils.redis.get(f"ansible_facts{host}")
data = fact_cache.get_host_facts(utils.redis, host)
if data:
data = json.loads(data)
table = []

if "ansible_local" in data and "osism" in data["ansible_local"]:
Expand Down
22 changes: 8 additions & 14 deletions osism/commands/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from redis.exceptions import RedisError

from osism import utils
from osism.utils import fact_cache
from osism.utils.inventory import get_hosts_from_inventory, get_inventory_path


Expand Down Expand Up @@ -39,23 +40,17 @@ def take_action(self, parsed_args):
return self._reset_all()

def _reset_all(self):
removed = 0
try:
cursor = 0
while True:
cursor, batch = utils.redis.scan(
cursor, match="ansible_facts*", count=100
)
if batch:
utils.redis.delete(*batch)
removed += len(batch)
if cursor == 0:
break
hosts = fact_cache.scan_host_keys(utils.redis)
keys = [key for host_keys in hosts.values() for key in host_keys]
if keys:
utils.redis.delete(*keys)
utils.redis.delete(fact_cache.CACHE_KEYS)
except RedisError as exc:
logger.error(f"Failed to reset Ansible fact cache: {exc}")
return 1

logger.info(f"Removed cached facts for {removed} host(s)")
logger.info(f"Removed cached facts for {len(hosts)} host(s)")
return 0

def _reset_limited(self, limit):
Expand Down Expand Up @@ -96,9 +91,8 @@ def _reset_limited(self, limit):
logger.warning("No hosts matched the given limit.")
return 0

keys = [f"ansible_facts{host}" for host in hosts]
try:
deleted = utils.redis.delete(*keys)
deleted = fact_cache.delete_host_facts(utils.redis, hosts)
except RedisError as exc:
logger.error(f"Failed to reset Ansible fact cache: {exc}")
return 1
Expand Down
87 changes: 41 additions & 46 deletions osism/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,33 +608,26 @@ def is_task_locked():
def check_ansible_facts(max_age=None):
"""Check if Ansible facts exist in Redis and are not stale.

Scans Redis for ansible_facts* keys and checks the
Looks at every host's cached facts, in both of the fact cache's key
schemas (see osism.utils.fact_cache), and checks the newest entry's
ansible_date_time.epoch field to determine freshness.

Args:
max_age: Maximum age in seconds (default: settings.FACTS_MAX_AGE)
"""
import json
from osism.utils import fact_cache

if max_age is None:
max_age = settings.FACTS_MAX_AGE

try:
r = _init_redis()

# Find all ansible_facts keys
keys = []
cursor = 0
while True:
cursor, batch = r.scan(cursor, match="ansible_facts*", count=100)
keys.extend(batch)
if cursor == 0:
break
hosts = fact_cache.scan_host_keys(r)
except Exception as e:
logger.warning(f"Could not check Ansible facts freshness: {e}")
return

if not keys:
if not hosts:
logger.warning(
"No Ansible facts found in Redis cache. "
"Run 'osism sync facts' to gather facts."
Expand All @@ -644,43 +637,45 @@ def check_ansible_facts(max_age=None):
now = time.time()
stale_hosts = []

for key in keys:
data = None
try:
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
facts = json.loads(data)
date_time = facts.get("ansible_date_time", {})
epoch = date_time.get("epoch")
if epoch is None:
logger.debug(
f"Host '{hostname}': facts missing ansible_date_time.epoch"
for hostname, keys in hosts.items():
# 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

entries = []
for key in keys:
data = None
try:
data = r.get(key)
if not data:
continue
entries.append(fact_cache.decode(data))
except (ValueError, TypeError):
truncated_value = data
if isinstance(truncated_value, (bytes, str)):
truncated_value = truncated_value[:200]
# loguru formats with str.format and knows no exc_info kwarg, so
# printf-style placeholders would be logged verbatim and the
# traceback dropped.
logger.opt(exception=True).debug(
f"Skipping malformed ansible_facts entry for key {key!r}: "
f"{truncated_value!r}"
)
continue
if not entries:
continue

epoch = fact_cache.newest(entries).get("ansible_date_time", {}).get("epoch")
if epoch is None:
logger.debug(f"Host '{hostname}': facts missing ansible_date_time.epoch")
continue
try:
age = now - float(epoch)
if age > max_age:
stale_hosts.append((hostname, int(age)))
except (json.JSONDecodeError, ValueError, TypeError):
truncated_value = data
if isinstance(truncated_value, (bytes, str)):
truncated_value = truncated_value[:200]
# loguru formats with str.format and knows no exc_info kwarg, so
# printf-style placeholders would be logged verbatim and the
# traceback dropped.
logger.opt(exception=True).debug(
f"Skipping malformed ansible_facts entry for key {key!r}: "
f"{truncated_value!r}"
)
except (TypeError, ValueError):
logger.debug(f"Host '{hostname}': unusable ansible_date_time.epoch")
continue
if age > max_age:
stale_hosts.append((hostname, int(age)))

if stale_hosts:
logger.warning(
Expand Down
Loading
Loading