CLean up site generation

This commit is contained in:
James Thompson
2026-03-23 04:29:25 +00:00
parent 716baafbc1
commit 90073c1d7a
40 changed files with 487 additions and 880 deletions

197
scripts/check_env_sync.py Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Sync .env.example: keep structure/comments, merge in keys from .env."""
from __future__ import annotations
import argparse
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKIP_DIRS = {".git", "site", "__pycache__"}
class EnvParseError(Exception):
pass
def discover_pairs(repo_root: Path) -> list[tuple[Path, Path]]:
pairs: list[tuple[Path, Path]] = []
for example_path in sorted(repo_root.rglob(".env.example")):
rel_parts = set(example_path.relative_to(repo_root).parts)
if rel_parts & SKIP_DIRS:
continue
env_path = example_path.with_name(".env")
pairs.append((example_path, env_path))
return pairs
def parse_env_key_values(path: Path) -> tuple[list[str], dict[str, str]]:
keys: list[str] = []
values: dict[str, str] = {}
seen: set[str] = set()
for idx, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise EnvParseError(f"{path}: line {idx} is not KEY=VALUE format")
key, value = line.split("=", 1)
key = key.strip()
if not key:
raise EnvParseError(f"{path}: line {idx} has empty key")
if key in seen:
raise EnvParseError(f"{path}: duplicate key '{key}'")
seen.add(key)
keys.append(key)
values[key] = value
return keys, values
def parse_env_keys(path: Path) -> list[str]:
keys, _ = parse_env_key_values(path)
return keys
def merge_env_into_example(env_path: Path, example_values: dict[str, str]) -> str:
"""
Build .env.example from .env structure.
.env defines: keys, order, comments.
.env.example provides: default values (or placeholders for new keys).
"""
out_lines: list[str] = []
# Read .env to get structure/order/comments, fill in values from .env.example
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in raw_line:
out_lines.append(raw_line)
continue
key, value_from_env = raw_line.split("=", 1)
key = key.strip()
# Use default from .env.example if it exists, otherwise use placeholder
if key in example_values:
out_lines.append(f"{key}={example_values[key]}")
else:
# New key not in template: use placeholder
out_lines.append(f"{key}=__CHANGEME__")
return "\n".join(out_lines) + "\n"
def compare_env(example_path: Path, env_path: Path) -> list[str]:
"""Check if .env.example matches .env schema."""
errors: list[str] = []
if not env_path.exists():
rel_env = env_path.relative_to(REPO_ROOT)
errors.append(f"Missing .env (schema): {rel_env}")
return errors
try:
env_keys, _ = parse_env_key_values(env_path)
example_keys = parse_env_keys(example_path) if example_path.exists() else []
except EnvParseError as exc:
errors.append(str(exc))
return errors
example_set = set(example_keys)
env_set = set(env_keys)
missing_in_example = [k for k in env_keys if k not in example_set]
if missing_in_example:
rel_example = example_path.relative_to(REPO_ROOT)
errors.append(f"{rel_example}: missing keys from .env schema: {', '.join(missing_in_example)}")
return errors
def fix_env_sync(env_path: Path, example_path: Path) -> tuple[bool, str | None]:
"""Update .env.example: .env defines schema, .env.example provides defaults."""
rel_env = env_path.relative_to(REPO_ROOT)
rel_example = example_path.relative_to(REPO_ROOT)
if not env_path.exists():
return False, f"Missing .env file (schema source): {rel_env}"
try:
_, example_values = parse_env_key_values(example_path) if example_path.exists() else ([], {})
desired = merge_env_into_example(env_path, example_values)
except EnvParseError as exc:
return False, str(exc)
current = example_path.read_text(encoding="utf-8") if example_path.exists() else None
if current == desired:
return False, None
example_path.write_text(desired, encoding="utf-8")
if current is None:
return True, f"Created {rel_example} from {rel_env} schema"
return True, f"Synced {rel_example} to match {rel_env}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--fix",
action="store_true",
help="Update .env.example from .env schema, keeping default values",
)
args = parser.parse_args()
pairs = discover_pairs(REPO_ROOT)
if not pairs:
print("No .env.example files found.")
return 0
if args.fix:
changed: list[str] = []
errors: list[str] = []
for example_path, env_path in pairs:
did_change, message = fix_env_sync(env_path, example_path)
if message:
if did_change:
changed.append(message)
else:
errors.append(message)
if changed:
print("Synced .env.example from .env schema:\n")
for item in changed:
print(f"- {item}")
print("\nRe-stage changed .env.example files and commit again.")
return 1
if errors:
print("Environment file sync failed:\n")
for err in errors:
print(f"- {err}")
return 1
print(f"Environment files in sync ({len(pairs)} checked).")
return 0
all_errors: list[str] = []
for example_path, env_path in pairs:
all_errors.extend(compare_env(example_path, env_path))
if all_errors:
print("Environment file validation failed:\n")
for err in all_errors:
print(f"- {err}")
return 1
print(f"Environment files look good ({len(pairs)} checked).")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -12,6 +12,11 @@ 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 = {}
@@ -168,264 +173,108 @@ def format_security(sec):
return []
def generate_service_md(service_name, compose_data, root_env, service_env):
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(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)
# 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"')
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("> 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("")
# Command
cmd = format_command(svc.get("command"))
if cmd:
lines.append("### Command")
lines.append("")
lines.append("```")
lines.append(" ".join(cmd))
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("")
# 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("---")
# 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 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 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():
root_env = load_root_env_example()
all_services = {}
# Find all service directories with docker-compose.yml
@@ -450,21 +299,20 @@ def main():
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)
# 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)}")
# 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)}")
# 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.")