diff --git a/fablednetmon/ansible/__init__.py b/fablednetmon/ansible/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/ansible/sources.py b/fablednetmon/ansible/sources.py new file mode 100644 index 0000000..cb68490 --- /dev/null +++ b/fablednetmon/ansible/sources.py @@ -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"))