feat: add ansible source discovery (local + git)

This commit is contained in:
2026-03-17 20:20:48 -04:00
parent c18b707093
commit 90aaf8f80c
2 changed files with 109 additions and 0 deletions
View File
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
INVENTORY_NAMES = {"hosts", "inventory", "inventory.yml", "inventory.ini"}
def get_sources(ansible_cfg: dict) -> list[dict]:
"""Return resolved source list from ansible config section.
Each source dict has: name, type, path (resolved local path),
url (git only), branch (git only), pull_interval_seconds (git only).
Config structure in config.yaml::
ansible:
cache_dir: /var/cache/fablednetmon/ansible
sources:
- name: my-playbooks
type: local
path: /opt/playbooks
- name: infra-repo
type: git
url: https://github.com/user/infra.git
branch: main
pull_interval_seconds: 3600
"""
sources = ansible_cfg.get("sources", [])
cache_dir = ansible_cfg.get("cache_dir", "/var/cache/fablednetmon/ansible")
result = []
for src in sources:
src_type = src.get("type", "local")
if src_type == "git":
path = str(Path(cache_dir) / src["name"])
else:
path = src.get("path", "")
result.append({
"name": src["name"],
"type": src_type,
"path": path,
"url": src.get("url"),
"branch": src.get("branch", "main"),
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
})
return result
def discover_playbooks(source_path: str) -> list[str]:
"""Recursively find .yml and .yaml files in source_path. Returns relative paths."""
root = Path(source_path)
if not root.exists():
return []
playbooks = set()
for ext in ("*.yml", "*.yaml"):
for p in root.rglob(ext):
playbooks.add(str(p.relative_to(root)))
return sorted(playbooks)
def discover_inventories(source_path: str) -> list[str]:
"""Non-recursive: return inventory filenames present in root of source_path."""
root = Path(source_path)
if not root.exists():
return []
return sorted(name for name in INVENTORY_NAMES if (root / name).exists())
def read_playbook(source_path: str, relative_path: str) -> str | None:
"""Return contents of a playbook file, or None if not found / path escape."""
root = Path(source_path).resolve()
target = (root / relative_path).resolve()
# Guard against path traversal
try:
target.relative_to(root)
except ValueError:
return None
if not target.exists() or not target.is_file():
return None
return target.read_text(errors="replace")
async def git_pull(source: dict) -> None:
"""Clone the git repo if absent; pull if already present."""
path = Path(source["path"])
if not (path / ".git").exists():
path.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"git", "clone",
"--branch", source["branch"],
"--single-branch",
source["url"], str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error("git clone failed for %r: %s", source["name"], stderr.decode(errors="replace"))
else:
proc = await asyncio.create_subprocess_exec(
"git", "-C", str(path), "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error("git pull failed for %r: %s", source["name"], stderr.decode(errors="replace"))