#!/usr/bin/env python3 """Generate troubleshooting runbooks from service documentation using Ollama LLM. Scans docs/services/individual/*.md, finds matching compose files, and produces structured runbooks in docs/runbooks/. Cron: monthly or on-demand 0 9 1 * * cd /home/homelab/organized/repos/homelab && python3 scripts/runbook-generator.py --all """ import argparse import hashlib import logging import sqlite3 import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from lib.ollama import ollama_generate, ollama_available, OllamaUnavailableError log = logging.getLogger(__name__) REPO_DIR = Path("/home/homelab/organized/repos/homelab") DOCS_DIR = REPO_DIR / "docs" / "services" / "individual" RUNBOOKS_DIR = REPO_DIR / "docs" / "runbooks" HOSTS_DIR = REPO_DIR / "hosts" DB_PATH = Path(__file__).parent / "runbook.db" def init_db(db_path: Path) -> sqlite3.Connection: conn = sqlite3.connect(str(db_path)) conn.execute( "CREATE TABLE IF NOT EXISTS generated " "(service TEXT PRIMARY KEY, doc_hash TEXT, generated_at TEXT)" ) conn.commit() return conn def get_stored_hash(conn: sqlite3.Connection, service: str) -> str | None: row = conn.execute( "SELECT doc_hash FROM generated WHERE service = ?", (service,) ).fetchone() return row[0] if row else None def record_generation(conn: sqlite3.Connection, service: str, doc_hash: str) -> None: from datetime import datetime conn.execute( "INSERT OR REPLACE INTO generated (service, doc_hash, generated_at) VALUES (?, ?, ?)", (service, doc_hash, datetime.now().isoformat()), ) conn.commit() def file_hash(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def find_compose_file(service_name: str) -> Path | None: """Try to locate a compose file for the service under hosts/.""" candidates = list(HOSTS_DIR.rglob(f"*{service_name}*")) for c in candidates: if c.name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"): return c if c.is_dir(): for compose_name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"): compose_path = c / compose_name if compose_path.exists(): return compose_path return None def generate_runbook(doc_content: str, compose_content: str | None) -> str: compose_section = f"\n\nCompose Config:\n{compose_content}" if compose_content else "" prompt = ( "Generate a troubleshooting runbook for this service. " "Format as a decision tree:\n\n" f"Service Doc:\n{doc_content}" f"{compose_section}\n\n" "Format:\n" "## Common Issues\n" "### Issue: [symptom]\n" "1. Check: [what to check]\n" "2. Fix: [what to do]\n" "3. Verify: [how to confirm fix]\n\n" "Include: restart procedures, log locations, dependency checks." ) return ollama_generate(prompt, num_predict=3000, timeout=180) def list_service_docs() -> list[Path]: if not DOCS_DIR.exists(): return [] return sorted(DOCS_DIR.glob("*.md")) def process_service(conn: sqlite3.Connection, doc_path: Path, force: bool, dry_run: bool) -> bool: """Process a single service doc. Returns True if a runbook was generated.""" service_name = doc_path.stem current_hash = file_hash(doc_path) if not force: stored = get_stored_hash(conn, service_name) if stored == current_hash: log.debug("Skipping %s — hash unchanged", service_name) return False log.info("Generating runbook for: %s", service_name) doc_content = doc_path.read_text() # Try to find a matching compose file compose_path = find_compose_file(service_name) compose_content = None if compose_path: log.info(" Found compose: %s", compose_path) compose_content = compose_path.read_text() else: log.debug(" No compose file found for %s", service_name) runbook = generate_runbook(doc_content, compose_content) if dry_run: print(f"\n{'='*60}") print(f"RUNBOOK: {service_name}") print(f"{'='*60}") print(runbook) return True RUNBOOKS_DIR.mkdir(parents=True, exist_ok=True) out_path = RUNBOOKS_DIR / f"{service_name}.md" out_path.write_text(f"# Troubleshooting Runbook: {service_name}\n\n{runbook}\n") log.info(" Wrote %s", out_path) record_generation(conn, service_name, current_hash) return True def main() -> None: parser = argparse.ArgumentParser(description="Generate troubleshooting runbooks from service docs via LLM") parser.add_argument("--service", type=str, help="Generate runbook for a single service (stem name)") parser.add_argument("--all", action="store_true", help="Generate runbooks for all services") parser.add_argument("--force", action="store_true", help="Regenerate even if hash has not changed") parser.add_argument("--dry-run", action="store_true", help="Print runbook without writing files") parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) if not args.service and not args.all: parser.error("Specify --service NAME or --all") if not ollama_available(): log.error("Ollama is not reachable — aborting") sys.exit(1) conn = init_db(DB_PATH) generated_count = 0 if args.service: doc_path = DOCS_DIR / f"{args.service}.md" if not doc_path.exists(): log.error("Service doc not found: %s", doc_path) sys.exit(1) if process_service(conn, doc_path, args.force, args.dry_run): generated_count += 1 else: docs = list_service_docs() if not docs: log.warning("No service docs found in %s", DOCS_DIR) sys.exit(0) log.info("Found %d service docs", len(docs)) for doc_path in docs: try: if process_service(conn, doc_path, args.force, args.dry_run): generated_count += 1 except OllamaUnavailableError as e: log.error("Ollama failed for %s: %s — stopping", doc_path.stem, e) break except Exception: log.exception("Error processing %s — skipping", doc_path.stem) log.info("Generated %d runbook(s)", generated_count) if __name__ == "__main__": main()