Files
homelab-infra/scripts/generate_docs.py
2026-03-23 04:29:25 +00:00

322 lines
9.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Auto-generate markdown docs from docker-compose.yml files.
Usage: python3 scripts/generate_docs.py
"""
import yaml
import os
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
SERVICES_DIR = REPO_ROOT
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."""
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<-- "{service_dir.name}/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<-- "{service_dir.name}/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<-- "{service_dir.name}/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<-- "{service_dir.name}/.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():
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
# Skip scripts/docs directories
if item.name in ("scripts", "docs", "__pycache__"):
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()