#!/usr/bin/env python3 """ Auto-generate markdown docs from docker-compose.yml files. Usage: python3 scripts/generate_docs.py """ import yaml import sys from pathlib import Path REPO_ROOT = Path(__file__).parent.parent SERVICES_DIR = REPO_ROOT / "services" DOCS_DIR = REPO_ROOT / "docs" / "services" DISPLAY_NAME_OVERRIDES = { "qbittorrent": "qBittorrent", "n8n": "N8N", } # Load root .env.example for shared variables def load_root_env_example(): env_vars = {} env_file = REPO_ROOT / ".env.example" if env_file.exists(): for line in env_file.read_text().splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: key, _, val = line.partition("=") env_vars[key.strip()] = val.strip() return env_vars # Load service .env.example def load_service_env_example(service_dir): env_vars = {} env_file = service_dir / ".env.example" if env_file.exists(): for line in env_file.read_text().splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: key, _, val = line.partition("=") env_vars[key.strip()] = val.strip() return env_vars def format_volumes(volumes): if not volumes: return [] rows = [] for v in volumes: if isinstance(v, str): parts = v.split(":") if len(parts) >= 2: host = parts[0] container = parts[1] mode = parts[2] if len(parts) > 2 else "rw" rows.append((host, container, mode)) else: rows.append((v, "-", "-")) elif isinstance(v, dict): source = v.get("source", v.get("type", "")) target = v.get("target", "") mode = v.get("mode", "rw") rows.append((source, target, mode)) return rows def format_ports(ports): if not ports: return [] rows = [] for p in ports: if isinstance(p, str): # Handle protocol suffix like "8080:8080/tcp" proto = "tcp" port_str = p if "/" in p: port_str, proto = p.rsplit("/", 1) # Use rsplit to handle ${VAR:-default}:port correctly parts = port_str.rsplit(":", 1) if len(parts) == 2: rows.append((parts[0], parts[1], proto)) else: rows.append((parts[0], parts[0], proto)) elif isinstance(p, dict): host = str(p.get("published", "")) container = str(p.get("target", "")) proto = p.get("protocol", "tcp") rows.append((host, container, proto)) return rows def format_env(env_list): if not env_list: return [] rows = [] for e in env_list: if isinstance(e, str) and "=" in e: key, _, val = e.partition("=") rows.append((key.strip(), val.strip())) elif isinstance(e, str): rows.append((e.strip(), "")) return rows def format_labels(labels): if not labels: return [] rows = [] if isinstance(labels, list): for l in labels: if isinstance(l, str) and "=" in l: key, _, val = l.partition("=") rows.append((key.strip(), val.strip())) elif isinstance(l, dict): for k, v in l.items(): rows.append((k, str(v))) elif isinstance(labels, dict): for k, v in labels.items(): rows.append((k, str(v))) return rows def format_command(cmd): if not cmd: return [] if isinstance(cmd, str): return [cmd] if isinstance(cmd, list): return [str(c) for c in cmd] return [] def format_networks(networks): if not networks: return [] rows = [] if isinstance(networks, dict): for name, cfg in networks.items(): external = False if isinstance(cfg, dict): external = cfg.get("external", False) rows.append((name, "external" if external else "internal")) elif isinstance(networks, list): for n in networks: if isinstance(n, str): rows.append((n, "internal")) return rows def format_depends(depends): if not depends: return [] if isinstance(depends, list): return [str(d) for d in depends] if isinstance(depends, dict): return list(depends.keys()) return [] def format_cap_add(caps): if not caps: return [] if isinstance(caps, list): return [str(c) for c in caps] return [] def format_security(sec): if not sec: return [] if isinstance(sec, list): return [str(s) for s in sec] return [] def generate_service_md(service_name, service_dir): """Generate service doc that includes docker-compose.yml, Dockerfile, README, and .env.example.""" include_base = f"services/{service_dir.name}" lines = [] lines.append(f"# {service_name}") lines.append("") lines.append("") # Include README.md first if exists readme_path = service_dir / "README.md" if readme_path.exists(): lines.append(f'--8<-- "{include_base}/README.md"') lines.append("> Below are the configuration files for this service. For details on how to deploy or customize, refer to the README above or the official documentation for the service.") # Include docker-compose.yml compose_file = service_dir / "docker-compose.yml" if compose_file.exists(): lines.append("## Docker Compose Configuration") lines.append("") lines.append("```yaml") lines.append(f'--8<-- "{include_base}/docker-compose.yml"') lines.append("```") lines.append("") # Include Dockerfile if exists dockerfile_path = service_dir / "Dockerfile" if dockerfile_path.exists(): lines.append("## Dockerfile") lines.append("") lines.append("```dockerfile") lines.append(f'--8<-- "{include_base}/Dockerfile"') lines.append("```") lines.append("") # Include .env.example if exists env_example_path = service_dir / ".env.example" if env_example_path.exists(): lines.append("## Environment Variables (`.env.example`)") lines.append("") lines.append("```bash") lines.append(f'--8<-- "{include_base}/.env.example"') lines.append("```") lines.append("") return "\n".join(lines) def service_display_name(service_name): if service_name in DISPLAY_NAME_OVERRIDES: return DISPLAY_NAME_OVERRIDES[service_name] return " ".join(part.capitalize() for part in service_name.split("-")) def update_mkdocs_nav(service_names): mkdocs_file = REPO_ROOT / "mkdocs.yml" cfg = {} if mkdocs_file.exists(): existing = yaml.safe_load(mkdocs_file.read_text()) if isinstance(existing, dict): cfg = existing existing_nav = cfg.get("nav", []) nav_without_services = [] for item in existing_nav: if isinstance(item, dict) and "Services" in item: continue nav_without_services.append(item) if not nav_without_services: nav_without_services = [ {"Home": "index.md"}, {"Recovery Reference": "recovery.md"}, ] services_nav = [] for name in sorted(service_names): services_nav.append({service_display_name(name): f"services/{name}.md"}) cfg["nav"] = nav_without_services + [{"Services": services_nav}] if "site_name" not in cfg: cfg["site_name"] = "Homelab Documentation" if "theme" not in cfg: cfg["theme"] = {"name": "material", "palette": {"scheme": "slate"}} mkdocs_file.write_text(yaml.safe_dump(cfg, sort_keys=False)) print(f"Updated {mkdocs_file.relative_to(REPO_ROOT)}") def cleanup_stale_service_docs(service_names): DOCS_DIR.mkdir(parents=True, exist_ok=True) valid = set(service_names) for md_file in DOCS_DIR.glob("*.md"): if md_file.stem not in valid: md_file.unlink() print(f"Removed stale {md_file.relative_to(REPO_ROOT)}") def main(): if not SERVICES_DIR.exists(): print(f"Services directory not found: {SERVICES_DIR}", file=sys.stderr) return all_services = {} # Find all service directories with docker-compose.yml for item in sorted(SERVICES_DIR.iterdir()): if not item.is_dir(): continue compose_file = item / "docker-compose.yml" if not compose_file.exists(): continue service_name = item.name try: with open(compose_file) as f: compose_data = yaml.safe_load(f) except Exception as e: print(f"Error parsing {compose_file}: {e}", file=sys.stderr) continue if not compose_data: continue all_services[service_name] = compose_data # Generate service doc (stub that includes files) md = generate_service_md(service_name, item) out_file = DOCS_DIR / f"{service_name}.md" out_file.parent.mkdir(parents=True, exist_ok=True) out_file.write_text(md) print(f"Generated {out_file.relative_to(REPO_ROOT)}") # Remove docs for services that no longer exist cleanup_stale_service_docs(all_services.keys()) # Keep mkdocs.yml service nav in sync with discovered service folders update_mkdocs_nav(all_services.keys()) print(f"\nDone. Generated docs for {len(all_services)} services.") if __name__ == "__main__": main()