324 lines
9.5 KiB
Python
Executable File
324 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 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()
|