AI given structure
This commit is contained in:
BIN
scripts/__pycache__/generate_docs.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/generate_docs.cpython-313.pyc
Normal file
Binary file not shown.
473
scripts/generate_docs.py
Executable file
473
scripts/generate_docs.py
Executable file
@@ -0,0 +1,473 @@
|
||||
#!/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"
|
||||
|
||||
# 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, compose_data, root_env, service_env):
|
||||
lines = []
|
||||
lines.append(f"# {service_name}")
|
||||
lines.append("")
|
||||
lines.append(f"> Auto-generated from `docker-compose.yml`")
|
||||
lines.append("")
|
||||
|
||||
services = compose_data.get("services", {})
|
||||
if not services:
|
||||
lines.append("No services defined.")
|
||||
return "\n".join(lines)
|
||||
|
||||
for svc_name, svc in services.items():
|
||||
if len(services) > 1:
|
||||
lines.append(f"## `{svc_name}`")
|
||||
lines.append("")
|
||||
|
||||
# Image
|
||||
image = svc.get("image", "N/A")
|
||||
container = svc.get("container_name", svc_name)
|
||||
lines.append(f"| Field | Value |")
|
||||
lines.append(f"|-------|-------|")
|
||||
lines.append(f"| **Image** | `{image}` |")
|
||||
lines.append(f"| **Container** | `{container}` |")
|
||||
lines.append(f"| **Restart** | `{svc.get('restart', 'no')}` |")
|
||||
lines.append("")
|
||||
|
||||
# Command
|
||||
cmd = format_command(svc.get("command"))
|
||||
if cmd:
|
||||
lines.append("### Command")
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
lines.append(" ".join(cmd))
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
# Environment
|
||||
env = format_env(svc.get("environment"))
|
||||
env_file = svc.get("env_file")
|
||||
env_from_file = []
|
||||
if env_file:
|
||||
if isinstance(env_file, str):
|
||||
env_file = [env_file]
|
||||
env_from_file = env_file
|
||||
|
||||
if env or env_from_file:
|
||||
lines.append("### Environment")
|
||||
lines.append("")
|
||||
if env_from_file:
|
||||
lines.append("**Env files:** " + ", ".join(f"`{e}`" for e in env_from_file))
|
||||
lines.append("")
|
||||
if env:
|
||||
lines.append("| Variable | Value |")
|
||||
lines.append("|----------|-------|")
|
||||
for key, val in env:
|
||||
if "${" in val or val == "":
|
||||
lines.append(f"| `{key}` | `{val}` |")
|
||||
else:
|
||||
lines.append(f"| `{key}` | `{val}` |")
|
||||
lines.append("")
|
||||
|
||||
# Service secrets from .env.example
|
||||
if service_env:
|
||||
lines.append("### Secrets (from `.env.example`)")
|
||||
lines.append("")
|
||||
lines.append("These variables must be set in the service's `.env` file:")
|
||||
lines.append("")
|
||||
lines.append("| Variable | Default |")
|
||||
lines.append("|----------|---------|")
|
||||
for key, val in service_env.items():
|
||||
lines.append(f"| `{key}` | `{val}` |")
|
||||
lines.append("")
|
||||
|
||||
# Ports
|
||||
ports = format_ports(svc.get("ports"))
|
||||
if ports:
|
||||
lines.append("### Ports")
|
||||
lines.append("")
|
||||
lines.append("| Host | Container | Protocol |")
|
||||
lines.append("|------|-----------|----------|")
|
||||
for host, container, proto in ports:
|
||||
lines.append(f"| `{host}` | `{container}` | {proto} |")
|
||||
lines.append("")
|
||||
|
||||
# Volumes
|
||||
volumes = format_volumes(svc.get("volumes"))
|
||||
if volumes:
|
||||
lines.append("### Volumes")
|
||||
lines.append("")
|
||||
lines.append("| Host Path | Container Path | Mode |")
|
||||
lines.append("|-----------|----------------|------|")
|
||||
for host, container, mode in volumes:
|
||||
lines.append(f"| `{host}` | `{container}` | {mode} |")
|
||||
lines.append("")
|
||||
|
||||
# Networks
|
||||
nets = format_networks(svc.get("networks"))
|
||||
if nets:
|
||||
lines.append("### Networks")
|
||||
lines.append("")
|
||||
for name, ntype in nets:
|
||||
lines.append(f"- `{name}` ({ntype})")
|
||||
lines.append("")
|
||||
|
||||
# Depends on
|
||||
depends = format_depends(svc.get("depends_on"))
|
||||
if depends:
|
||||
lines.append("### Dependencies")
|
||||
lines.append("")
|
||||
for d in depends:
|
||||
lines.append(f"- `{d}`")
|
||||
lines.append("")
|
||||
|
||||
# Capabilities
|
||||
caps = format_cap_add(svc.get("cap_add"))
|
||||
if caps:
|
||||
lines.append("### Capabilities")
|
||||
lines.append("")
|
||||
lines.append(", ".join(f"`{c}`" for c in caps))
|
||||
lines.append("")
|
||||
|
||||
# Security
|
||||
sec = format_security(svc.get("security_opt"))
|
||||
if sec:
|
||||
lines.append("### Security Options")
|
||||
lines.append("")
|
||||
for s in sec:
|
||||
lines.append(f"- `{s}`")
|
||||
lines.append("")
|
||||
|
||||
# Labels
|
||||
labels = format_labels(svc.get("labels"))
|
||||
if labels:
|
||||
lines.append("### Labels")
|
||||
lines.append("")
|
||||
lines.append("| Key | Value |")
|
||||
lines.append("|-----|-------|")
|
||||
for key, val in labels:
|
||||
lines.append(f"| `{key}` | `{val}` |")
|
||||
lines.append("")
|
||||
|
||||
# Devices
|
||||
devices = svc.get("devices")
|
||||
if devices:
|
||||
lines.append("### Devices")
|
||||
lines.append("")
|
||||
for d in devices:
|
||||
if isinstance(d, str):
|
||||
lines.append(f"- `{d}`")
|
||||
lines.append("")
|
||||
|
||||
# tmpfs
|
||||
tmpfs = svc.get("tmpfs")
|
||||
if tmpfs:
|
||||
lines.append("### Tmpfs")
|
||||
lines.append("")
|
||||
if isinstance(tmpfs, list):
|
||||
for t in tmpfs:
|
||||
lines.append(f"- `{t}`")
|
||||
lines.append("")
|
||||
|
||||
# shm_size
|
||||
shm = svc.get("shm_size")
|
||||
if shm:
|
||||
lines.append(f"### SHM Size: `{shm}`")
|
||||
lines.append("")
|
||||
|
||||
# resource limits
|
||||
mem = svc.get("mem_limit")
|
||||
swap = svc.get("memswap_limit")
|
||||
if mem or swap:
|
||||
lines.append("### Resources")
|
||||
lines.append("")
|
||||
if mem:
|
||||
lines.append(f"- Memory limit: `{mem}`")
|
||||
if swap:
|
||||
lines.append(f"- Memory+Swap limit: `{swap}`")
|
||||
lines.append("")
|
||||
|
||||
# Compose-level volumes (named)
|
||||
comp_volumes = compose_data.get("volumes")
|
||||
if comp_volumes and isinstance(comp_volumes, dict):
|
||||
lines.append("### Named Volumes (compose-level)")
|
||||
lines.append("")
|
||||
for vname, vcfg in comp_volumes.items():
|
||||
external = False
|
||||
if isinstance(vcfg, dict):
|
||||
external = vcfg.get("external", False)
|
||||
ntype = "external" if external else "managed by compose"
|
||||
lines.append(f"- `{vname}` ({ntype})")
|
||||
lines.append("")
|
||||
|
||||
# Compose-level networks
|
||||
comp_nets = compose_data.get("networks")
|
||||
if comp_nets and isinstance(comp_nets, dict):
|
||||
lines.append("### Networks (compose-level)")
|
||||
lines.append("")
|
||||
for nname, ncfg in comp_nets.items():
|
||||
external = False
|
||||
if isinstance(ncfg, dict):
|
||||
external = ncfg.get("external", False)
|
||||
ntype = "external" if external else "internal"
|
||||
lines.append(f"- `{nname}` ({ntype})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_index(all_services):
|
||||
lines = []
|
||||
lines.append("# Homelab Documentation")
|
||||
lines.append("")
|
||||
lines.append("> Auto-generated from `docker-compose.yml` files")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Data Classification")
|
||||
lines.append("")
|
||||
lines.append("| Type | Example | Git Repo? | Backup? | Location |")
|
||||
lines.append("|------|---------|-----------|---------|----------|")
|
||||
lines.append("| **Source Configs** | `docker-compose.yml`, `.env.example`, `Makefile` | Yes | No | `/srv/homelab-infra` |")
|
||||
lines.append("| **Service Secrets** | `.env` (DB passwords, API keys) | No | Yes | `/srv/homelab-infra/<service>/.env` |")
|
||||
lines.append("| **Runtime Configs** | `acme.json`, service configs | No | Yes | `/mnt/storage/docker-data/<service>` |")
|
||||
lines.append("| **Persistent Data** | DB data, uploads, media | No | Yes | `/mnt/storage/docker-data/<service>` |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Services")
|
||||
lines.append("")
|
||||
lines.append("| Service | Image | Status |")
|
||||
lines.append("|---------|-------|--------|")
|
||||
for name, data in sorted(all_services.items()):
|
||||
services = data.get("services", {})
|
||||
images = []
|
||||
for svc in services.values():
|
||||
img = svc.get("image", "N/A")
|
||||
images.append(f"`{img}`")
|
||||
status = "active" if "planned" not in name.lower() else "planned"
|
||||
lines.append(f"| [{name}](services/{name}.md) | {', '.join(images)} | {status} |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Quick Start")
|
||||
lines.append("")
|
||||
lines.append("```bash")
|
||||
lines.append("cp .env.example .env")
|
||||
lines.append("for svc in */; do [ -f \"$svc/.env.example\" ] && cp \"$svc/.env.example\" \"$svc/.env\"; done")
|
||||
lines.append("docker network create web")
|
||||
lines.append("make up")
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
root_env = load_root_env_example()
|
||||
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
|
||||
|
||||
service_env = load_service_env_example(item)
|
||||
all_services[service_name] = compose_data
|
||||
|
||||
# Generate service doc
|
||||
md = generate_service_md(service_name, compose_data, root_env, service_env)
|
||||
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)}")
|
||||
|
||||
# Generate index
|
||||
index_md = generate_index(all_services)
|
||||
index_file = REPO_ROOT / "docs" / "index.md"
|
||||
index_file.write_text(index_md)
|
||||
print(f"Generated {index_file.relative_to(REPO_ROOT)}")
|
||||
|
||||
print(f"\nDone. Generated docs for {len(all_services)} services.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user