diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 index a5e5d61..6684dd2 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ USER_GID=1000 # Domain DOMAIN=sjhl.nz +DOMAIN_WWW=__CHANGEME__ # Timezone TZ=Pacific/Auckland diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 869f935..b8defd9 --- a/.gitignore +++ b/.gitignore @@ -3,21 +3,21 @@ *.env !.env.example -# Docker runtime -**/acme.json +# Docker runtime files +**/*.json **/logs/ **/*.log -# OS -.DS_Store -Thumbs.db - # MkDocs site/ # Editor .vscode/ -.idea/ *.swp *.swo *~ + +# python +__pycache__/ +*.pyc +*.pyo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..06f8078 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: local + hooks: + - id: env-sync + name: Sync .env files from .env.example + entry: python3 scripts/check_env_sync.py --fix + language: system + pass_filenames: false + always_run: true diff --git a/Makefile b/Makefile old mode 100644 new mode 100755 index 07a887e..8767db8 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -SERVICES=traefik whoami gitea nextcloud qbittorrent jellyfin devbox obsidian n8n supersync +SERVICES=traefik whoami gitea nextcloud devbox -.PHONY: up down restart backup docs generate-docs logs status +.PHONY: up down restart backup init-env env-sync docs generate-docs serve-docs logs status up: @for svc in $(SERVICES); do \ @@ -36,8 +36,38 @@ status: backup: ./backup.sh +init-env: + @echo "Initializing .env files from .env.example..." + @find . -path './.git' -prune -o -type f -name '.env.example' -print | while read -r example; do \ + env_file="$${example%.example}"; \ + if [ -f "$$env_file" ]; then \ + echo "Skip (exists): $$env_file"; \ + else \ + cp "$$example" "$$env_file"; \ + echo "Created: $$env_file"; \ + fi; \ + done + +env-sync: + python3 scripts/check_env_sync.py --fix + generate-docs: python3 scripts/generate_docs.py docs: generate-docs - mkdocs build + @command -v uvx >/dev/null 2>&1 || { \ + echo "Error: uvx is not installed or not in PATH."; \ + echo "Install with: curl -LsSf https://astral.sh/uv/install.sh | sh"; \ + echo "Then run: source $$HOME/.local/bin/env"; \ + exit 1; \ + } + uvx --from mkdocs-material --with mkdocs-include-markdown-plugin mkdocs build + +serve-docs: generate-docs + @command -v uvx >/dev/null 2>&1 || { \ + echo "Error: uvx is not installed or not in PATH."; \ + echo "Install with: curl -LsSf https://astral.sh/uv/install.sh | sh"; \ + echo "Then run: source $$HOME/.local/bin/env"; \ + exit 1; \ + } + uvx --from mkdocs-material --with mkdocs-include-markdown-plugin mkdocs serve --dev-addr 0.0.0.0:8000 diff --git a/README.md b/README.md old mode 100644 new mode 100755 index c7f2325..46f1000 --- a/README.md +++ b/README.md @@ -29,15 +29,10 @@ make up | Service | Description | URL | |---------|-------------|-----| -| traefik | Reverse proxy, TLS termination | `dashboard.sjhl.nz` | -| gitea | Self-hosted Git | `gitea.sjhl.nz` | -| nextcloud | Cloud storage (AIO) | `nextcloud.sjhl.nz` | -| qbittorrent | Torrent client (VPN) | Internal only | -| jellyfin | Media server | `jellyfin.sjhl.nz` | -| devbox | Dev container | SSH:46573 | +| gitea | Self-hosted Git | [gitea.sjhl.nz](https://gitea.sjhl.nz) | +| nextcloud | Cloud storage (AIO) | [nextcloud.sjhl.nz](https://nextcloud.sjhl.nz) | +| devbox | Dev container | Internal only | | obsidian | CouchDB for Obsidian LiveSync | Internal | -| n8n | Workflow automation (planned) | - | -| supersync | File sync (planned) | - | ## Commands @@ -48,9 +43,56 @@ make restart # Restart all services make status # Show status of all services make logs # Show recent logs make backup # Run Restic backup +make init-env # Create missing .env files from templates +make env-sync # Rewrite .env files to match templates make docs # Build MkDocs site ``` +## Documentation + +This repo uses **MkDocs** with the **include-markdown** plugin to generate live documentation. + +### How it works: +1. Each service page is auto-generated to include: + - `docker-compose.yml` configuration + - `Dockerfile` (if exists) + - `README.md` (if exists) + - `.env.example` for environment variables + +2. Files are automatically pulled at build time—changes to service files appear in docs immediately + +3. Build docs with: + ```bash + make docs # Generate docs and build site + make serve-docs # Serve docs locally at http://localhost:8000 + ``` + +### Pre-commit Checks + +This repo includes a pre-commit hook that syncs .env.example from .env schema. + +Behavior: +- `.env` defines the schema: keys, structure, comments +- `.env.example` provides defaults and is regenerated from `.env` +- Keys in `.env.example` are kept only if they exist in `.env` +- Default values are preserved from `.env.example` +- New keys from `.env` get `__CHANGEME__` placeholder if not in template + +One-time install: + +```bash +uvx pre-commit install +``` + +Run manually at any time: + +```bash +uvx pre-commit run --all-files +make env-sync +``` + +If files were updated, re-stage and commit again. + ## Directory Structure ``` @@ -58,16 +100,13 @@ homelab-infra/ ├── .env.example # Global env template ├── Makefile # Service management ├── backup.sh # Restic backup script -├── traefik/ # Reverse proxy -├── gitea/ # Git hosting -├── nextcloud/ # Cloud storage (AIO) -├── qbittorrent/ # Torrent + VPN stack -├── jellyfin/ # Media server -├── devbox/ # Development container -├── obsidian/ # CouchDB for LiveSync -├── n8n/ # Workflow automation -├── supersync/ # File sync -└── docs/ # MkDocs documentation +├── .pre-commit-config.yaml # Pre-commit hooks +├── docs/ # MkDocs documentation +├── mkdocs.yml # MkDocs config +├── scripts/ # Utility scripts +└── [service subdirs]/ # All services have their own subdir + + ``` ## Data Locations @@ -75,5 +114,4 @@ homelab-infra/ - Configs (in Git): this repo - Secrets (not in Git): per-service `.env` files - Persistent data: `/mnt/storage/docker-data/` -- Media: `/mnt/storage` - Backups: `/mnt/storage/backups` diff --git a/devbox/.env.example b/devbox/.env.example old mode 100644 new mode 100755 diff --git a/devbox/docker-compose.yml b/devbox/docker-compose.yml old mode 100644 new mode 100755 diff --git a/docs/dev/check_env_sync.md b/docs/dev/check_env_sync.md new file mode 100644 index 0000000..5f9e1c1 --- /dev/null +++ b/docs/dev/check_env_sync.md @@ -0,0 +1,5 @@ +# check_env_sync + +> Python script documentation + +::: scripts.check_env_sync diff --git a/docs/dev/generate_docs.md b/docs/dev/generate_docs.md new file mode 100644 index 0000000..959b338 --- /dev/null +++ b/docs/dev/generate_docs.md @@ -0,0 +1,5 @@ +# generate_docs + +> Python script documentation + +::: scripts.generate_docs diff --git a/docs/index.md b/docs/index.md old mode 100644 new mode 100755 index fc0b0eb..2712f28 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ -# Homelab Documentation +# SJHL Documentation -> Auto-generated from `docker-compose.yml` files +Welcome to Seirian & James' homelab documentation! This is a docs site that is built to easily show all the configs we use. MOst of the content is auto-generated from the actual config files, so it should always be up to date. This should be completely publically viewable as all private information is kept in `.env` files that are not committed to Git. It should provide good information on how to recover and rebuild the homelab if needed, and also just be a nice reference for how everything is configured. ## Data Classification @@ -17,10 +17,8 @@ |---------|-------|--------| | [devbox](services/devbox.md) | `devbox-devcontainer` | active | | [gitea](services/gitea.md) | `gitea/gitea:1.24.3` | active | -| [jellyfin](services/jellyfin.md) | `jellyfin/jellyfin:latest` | active | | [nextcloud](services/nextcloud.md) | `ghcr.io/nextcloud-releases/all-in-one:latest` | active | -| [obsidian](services/obsidian.md) | `couchdb:latest` | active | -| [qbittorrent](services/qbittorrent.md) | `qmcgaw/gluetun`, `lscr.io/linuxserver/qbittorrent:latest`, `linuxserver/jackett:latest` | active | +| [obsidian-livesync](services/obsidian-livesync.md) | `couchdb:latest` | active | | [traefik](services/traefik.md) | `traefik:v3.6` | active | | [whoami](services/whoami.md) | `traefik/whoami` | active | diff --git a/docs/recovery.md b/docs/recovery.md old mode 100644 new mode 100755 diff --git a/docs/services/devbox.md b/docs/services/devbox.md old mode 100644 new mode 100755 index a84a4d6..7c29b44 --- a/docs/services/devbox.md +++ b/docs/services/devbox.md @@ -1,42 +1,15 @@ # devbox -> Auto-generated from `docker-compose.yml` +> This page auto-includes the service configuration files. -| Field | Value | -|-------|-------| -| **Image** | `devbox-devcontainer` | -| **Container** | `devcontainer` | -| **Restart** | `unless-stopped` | +## Docker Compose Configuration -### Environment +```yaml +--8<-- "devbox/docker-compose.yml" +``` -**Env files:** `../.env` +## Environment Variables (`.env.example`) -### Secrets (from `.env.example`) - -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `DEVBOX_SSH_PORT` | `46573` | -| `DEVBOX_MEM_LIMIT` | `10g` | -| `DEVBOX_SWAP_LIMIT` | `20g` | - -### Ports - -| Host | Container | Protocol | -|------|-----------|----------| -| `${DEVBOX_SSH_PORT:-46573}` | `2222` | tcp | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `${STORAGE_PATH}/devbox` | `/home/devuser` | rw | - -### Resources - -- Memory limit: `${DEVBOX_MEM_LIMIT:-10g}` -- Memory+Swap limit: `${DEVBOX_SWAP_LIMIT:-20g}` - ---- +```bash +--8<-- "devbox/.env.example" +``` diff --git a/docs/services/gitea.md b/docs/services/gitea.md old mode 100644 new mode 100755 index f66a31f..23d472b --- a/docs/services/gitea.md +++ b/docs/services/gitea.md @@ -1,58 +1,15 @@ # gitea -> Auto-generated from `docker-compose.yml` +> This page auto-includes the service configuration files. -| Field | Value | -|-------|-------| -| **Image** | `gitea/gitea:1.24.3` | -| **Container** | `gitea` | -| **Restart** | `always` | +## Docker Compose Configuration -### Environment +```yaml +--8<-- "gitea/docker-compose.yml" +``` -**Env files:** `../.env`, `.env` +## Environment Variables (`.env.example`) -| Variable | Value | -|----------|-------| -| `USER_UID` | `${USER_UID}` | -| `USER_GID` | `${USER_GID}` | -| `GITEA__database__DB_TYPE` | `sqlite3` | -| `GITEA__server__ROOT_URL` | `https://gitea.${DOMAIN}/` | -| `USER` | `git` | -| `GITEA_CUSTOM` | `/data/gitea` | - -### Ports - -| Host | Container | Protocol | -|------|-----------|----------| -| `222` | `22` | tcp | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `${STORAGE_PATH}/gitea` | `/data` | rw | -| `/etc/timezone` | `/etc/timezone` | ro | -| `/etc/localtime` | `/etc/localtime` | ro | - -### Networks - -- `gitea` (internal) -- `web` (internal) - -### Labels - -| Key | Value | -|-----|-------| -| `traefik.enable` | `true` | -| `traefik.http.routers.gitea.entrypoints` | `websecure` | -| `traefik.http.routers.gitea.rule` | `Host(`gitea.${DOMAIN}`)` | -| `traefik.http.routers.gitea.tls.certresolver` | `letsencrypt` | -| `traefik.http.services.gitea.loadbalancer.server.port` | `3000` | - -### Networks (compose-level) - -- `gitea` (internal) -- `web` (external) - ---- +```bash +--8<-- "gitea/.env.example" +``` diff --git a/docs/services/jellyfin.md b/docs/services/jellyfin.md deleted file mode 100644 index ad3b506..0000000 --- a/docs/services/jellyfin.md +++ /dev/null @@ -1,55 +0,0 @@ -# jellyfin - -> Auto-generated from `docker-compose.yml` - -| Field | Value | -|-------|-------| -| **Image** | `jellyfin/jellyfin:latest` | -| **Container** | `jellyfin` | -| **Restart** | `unless-stopped` | - -### Environment - -**Env files:** `../.env` - -### Secrets (from `.env.example`) - -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `JELLYFIN_PORT` | `8096` | - -### Ports - -| Host | Container | Protocol | -|------|-----------|----------| -| `${JELLYFIN_PORT:-8096}` | `8096` | tcp | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `${STORAGE_PATH}/jellyfin/config` | `/config` | rw | -| `${STORAGE_PATH}/jellyfin/cache` | `/cache` | rw | -| `${MEDIA_PATH}` | `/media` | rw | - -### Networks - -- `web` (internal) - -### Labels - -| Key | Value | -|-----|-------| -| `traefik.enable` | `true` | -| `traefik.http.routers.jellyfin.entrypoints` | `websecure` | -| `traefik.http.routers.jellyfin.rule` | `Host(`jellyfin.${DOMAIN}`)` | -| `traefik.http.routers.jellyfin.tls.certresolver` | `letsencrypt` | -| `traefik.http.services.jellyfin.loadbalancer.server.port` | `8096` | - -### Networks (compose-level) - -- `web` (external) - ---- diff --git a/docs/services/n8n.md b/docs/services/n8n.md deleted file mode 100644 index 162fe62..0000000 --- a/docs/services/n8n.md +++ /dev/null @@ -1,9 +0,0 @@ -# N8N - -Workflow automation platform. **Planned - not yet deployed.** - -## Planned Configuration - -- **URL:** `https://n8n.sjhl.nz` -- **Port:** 5678 -- **Data:** `/mnt/storage/docker-data/n8n` diff --git a/docs/services/nextcloud.md b/docs/services/nextcloud.md old mode 100644 new mode 100755 index 51d8fb9..d581b26 --- a/docs/services/nextcloud.md +++ b/docs/services/nextcloud.md @@ -1,56 +1,15 @@ # nextcloud -> Auto-generated from `docker-compose.yml` +> This page auto-includes the service configuration files. -| Field | Value | -|-------|-------| -| **Image** | `ghcr.io/nextcloud-releases/all-in-one:latest` | -| **Container** | `nextcloud-aio-mastercontainer` | -| **Restart** | `always` | +## Docker Compose Configuration -### Environment +```yaml +--8<-- "nextcloud/docker-compose.yml" +``` -**Env files:** `../.env` +## Environment Variables (`.env.example`) -| Variable | Value | -|----------|-------| -| `APACHE_PORT` | `11000` | -| `NEXTCLOUD_DATADIR` | `${STORAGE_PATH}/nextcloud` | -| `APACHE_ADDITIONAL_NETWORK` | `web` | - -### Secrets (from `.env.example`) - -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `AIO_PORT` | `8081` | - -### Ports - -| Host | Container | Protocol | -|------|-----------|----------| -| `${AIO_PORT:-8081}` | `8080` | tcp | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `nextcloud_aio_mastercontainer` | `/mnt/docker-aio-config` | rw | -| `/var/run/docker.sock` | `/var/run/docker.sock` | ro | - -### Networks - -- `nextcloud-aio` (internal) -- `web` (internal) - -### Named Volumes (compose-level) - -- `nextcloud_aio_mastercontainer` (managed by compose) - -### Networks (compose-level) - -- `nextcloud-aio` (internal) -- `web` (external) - ---- +```bash +--8<-- "nextcloud/.env.example" +``` diff --git a/docs/services/obsidian-livesync.md b/docs/services/obsidian-livesync.md new file mode 100755 index 0000000..02203e7 --- /dev/null +++ b/docs/services/obsidian-livesync.md @@ -0,0 +1,15 @@ +# obsidian-livesync + +> This page auto-includes the service configuration files. + +## Docker Compose Configuration + +```yaml +--8<-- "obsidian-livesync/docker-compose.yml" +``` + +## Environment Variables (`.env.example`) + +```bash +--8<-- "obsidian-livesync/.env.example" +``` diff --git a/docs/services/obsidian.md b/docs/services/obsidian.md deleted file mode 100644 index 2f58329..0000000 --- a/docs/services/obsidian.md +++ /dev/null @@ -1,42 +0,0 @@ -# obsidian - -> Auto-generated from `docker-compose.yml` - -| Field | Value | -|-------|-------| -| **Image** | `couchdb:latest` | -| **Container** | `obsidian-livesync` | -| **Restart** | `unless-stopped` | - -### Environment - -**Env files:** `../.env`, `.env` - -| Variable | Value | -|----------|-------| -| `COUCHDB_USER` | `${COUCHDB_USER:-admin}` | -| `COUCHDB_PASSWORD` | `${COUCHDB_PASSWORD}` | - -### Secrets (from `.env.example`) - -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `COUCHDB_USER` | `admin` | -| `COUCHDB_PASSWORD` | `REPLACE_WITH_STRONG_PASSWORD` | -| `COUCHDB_PORT` | `5984` | - -### Ports - -| Host | Container | Protocol | -|------|-----------|----------| -| `${COUCHDB_PORT:-5984}` | `5984` | tcp | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `${STORAGE_PATH}/obsidian` | `/opt/couchdb/data` | rw | - ---- diff --git a/docs/services/qbittorrent.md b/docs/services/qbittorrent.md deleted file mode 100644 index 9534bd0..0000000 --- a/docs/services/qbittorrent.md +++ /dev/null @@ -1,153 +0,0 @@ -# qbittorrent - -> Auto-generated from `docker-compose.yml` - -## `gluetun` - -| Field | Value | -|-------|-------| -| **Image** | `qmcgaw/gluetun` | -| **Container** | `qbittorrent_gluetun` | -| **Restart** | `unless-stopped` | - -### Environment - -**Env files:** `../.env`, `.env` - -| Variable | Value | -|----------|-------| -| `VPN_SERVICE_PROVIDER` | `protonvpn` | -| `VPN_TYPE` | `wireguard` | -| `VPN_PORT_FORWARDING` | `on` | -| `TZ` | `${TZ}` | -| `SERVER_COUNTRIES` | `New Zealand` | -| `PORT_FORWARD_ONLY` | `on` | -| `VPN_PORT_FORWARDING_UP_COMMAND` | `/bin/sh -c 'wget -O- --post-data "json={\"listen_port\":{{PORTS}}}" http://localhost:8080/api/v2/app/setPreferences 2>/dev/null || true'` | - -### Secrets (from `.env.example`) - -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `WIREGUARD_PRIVATE_KEY` | `REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY` | -| `QBITTORRENT_PORT` | `8080` | -| `JACKETT_PORT` | `9117` | - -### Ports - -| Host | Container | Protocol | -|------|-----------|----------| -| `${QBITTORRENT_PORT:-8080}` | `8080` | tcp | -| `${JACKETT_PORT:-9117}` | `9117` | tcp | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `${STORAGE_PATH}/qbittorrent/gluetun` | `/gluetun` | rw | -| `${STORAGE_PATH}/qbittorrent/wireguard` | `/gluetun/wireguard` | rw | - -### Networks - -- `qbittorrent` (internal) - -### Capabilities - -`NET_ADMIN` - -### Devices - -- `/dev/net/tun:/dev/net/tun` - -### Networks (compose-level) - -- `qbittorrent` (internal) - ---- - -## `qbittorrent` - -| Field | Value | -|-------|-------| -| **Image** | `lscr.io/linuxserver/qbittorrent:latest` | -| **Container** | `qbittorrent` | -| **Restart** | `unless-stopped` | - -### Environment - -| Variable | Value | -|----------|-------| -| `TZ` | `${TZ}` | -| `WEBUI_PORT` | `8080` | -| `PUID` | `${USER_UID}` | -| `PGID` | `${USER_GID}` | - -### Secrets (from `.env.example`) - -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `WIREGUARD_PRIVATE_KEY` | `REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY` | -| `QBITTORRENT_PORT` | `8080` | -| `JACKETT_PORT` | `9117` | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `${STORAGE_PATH}/qbittorrent/config` | `/config` | rw | -| `${MEDIA_PATH}/torrents` | `/downloads` | rw | - -### Dependencies - -- `gluetun` - -### Networks (compose-level) - -- `qbittorrent` (internal) - ---- - -## `jackett` - -| Field | Value | -|-------|-------| -| **Image** | `linuxserver/jackett:latest` | -| **Container** | `jackett` | -| **Restart** | `unless-stopped` | - -### Environment - -| Variable | Value | -|----------|-------| -| `TZ` | `${TZ}` | -| `PUID` | `${USER_UID}` | -| `PGID` | `${USER_GID}` | - -### Secrets (from `.env.example`) - -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `WIREGUARD_PRIVATE_KEY` | `REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY` | -| `QBITTORRENT_PORT` | `8080` | -| `JACKETT_PORT` | `9117` | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `${STORAGE_PATH}/qbittorrent/config/jackett` | `/config` | rw | - -### Dependencies - -- `gluetun` - -### Networks (compose-level) - -- `qbittorrent` (internal) - ---- diff --git a/docs/services/supersync.md b/docs/services/supersync.md deleted file mode 100644 index 24ae5bc..0000000 --- a/docs/services/supersync.md +++ /dev/null @@ -1,9 +0,0 @@ -# Supersync - -File synchronization service. **Planned - not yet deployed.** - -## Planned Configuration - -- **URL:** `https://supersync.sjhl.nz` -- **Port:** 8443 -- **Data:** `/mnt/storage/docker-data/supersync` diff --git a/docs/services/traefik.md b/docs/services/traefik.md old mode 100644 new mode 100755 index 4ca12ca..0eef96f --- a/docs/services/traefik.md +++ b/docs/services/traefik.md @@ -1,67 +1,16 @@ # traefik -> Auto-generated from `docker-compose.yml` +> This page auto-includes the service configuration files. -| Field | Value | -|-------|-------| -| **Image** | `traefik:v3.6` | -| **Container** | `traefik` | -| **Restart** | `unless-stopped` | +--8<-- "traefik/README.md" +## Docker Compose Configuration -### Command - -``` ---entrypoints.web.address=:80 --entrypoints.web.http.redirections.entrypoint.to=websecure --entrypoints.web.http.redirections.entrypoint.scheme=https --entrypoints.websecure.address=:443 --providers.docker=true --providers.docker.exposedbydefault=false --providers.docker.network=web --certificatesresolvers.letsencrypt.acme.httpchallenge=true --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} --certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json --metrics.prometheus=true --accesslog=true --providers.file.directory=/dynamic --providers.file.watch=true +```yaml +--8<-- "traefik/docker-compose.yml" ``` -### Secrets (from `.env.example`) +## Environment Variables (`.env.example`) -These variables must be set in the service's `.env` file: - -| Variable | Default | -|----------|---------| -| `TRAEFIK_DASHBOARD_PORT` | `8082` | -| `ACME_EMAIL` | `letsencrypt@example.com` | -| `DASHBOARD_BASIC_AUTH` | `admin:$$apr1$$changeme$$REPLACE_WITH_HTPASSWD_HASH` | - -### Ports - -| Host | Container | Protocol | -|------|-----------|----------| -| `80` | `80` | tcp | -| `443` | `443` | tcp | -| `${TRAEFIK_DASHBOARD_PORT:-8082}` | `8080` | tcp | - -### Volumes - -| Host Path | Container Path | Mode | -|-----------|----------------|------| -| `/var/run/docker.sock` | `/var/run/docker.sock` | ro | -| `${STORAGE_PATH}/traefik/certs` | `/certs` | rw | -| `${STORAGE_PATH}/traefik/dynamic` | `/dynamic` | ro | - -### Networks - -- `web` (internal) - -### Security Options - -- `no-new-privileges:true` - -### Labels - -| Key | Value | -|-----|-------| -| `traefik.enable` | `true` | -| `traefik.http.middlewares.dashboard-auth.basicauth.users` | `${DASHBOARD_BASIC_AUTH}` | -| `traefik.http.routers.dashboard.entrypoints` | `websecure` | -| `traefik.http.routers.dashboard.middlewares` | `dashboard-auth@docker` | -| `traefik.http.routers.dashboard.rule` | `Host(`dashboard.${DOMAIN}`)` | -| `traefik.http.routers.dashboard.service` | `api@internal` | -| `traefik.http.routers.dashboard.tls` | `true` | - -### Networks (compose-level) - -- `web` (external) - ---- +```bash +--8<-- "traefik/.env.example" +``` diff --git a/docs/services/whoami.md b/docs/services/whoami.md old mode 100644 new mode 100755 index adeeccc..7af73f9 --- a/docs/services/whoami.md +++ b/docs/services/whoami.md @@ -1,28 +1,9 @@ # whoami -> Auto-generated from `docker-compose.yml` +> This page auto-includes the service configuration files. -| Field | Value | -|-------|-------| -| **Image** | `traefik/whoami` | -| **Container** | `whoami` | -| **Restart** | `unless-stopped` | +## Docker Compose Configuration -### Networks - -- `web` (internal) - -### Labels - -| Key | Value | -|-----|-------| -| `traefik.enable` | `true` | -| `traefik.http.routers.whoami.entrypoints` | `websecure` | -| `traefik.http.routers.whoami.rule` | `Host(`whoami.${DOMAIN}`)` | -| `traefik.http.routers.whoami.tls` | `true` | - -### Networks (compose-level) - -- `web` (external) - ---- +```yaml +--8<-- "whoami/docker-compose.yml" +``` diff --git a/gitea/.env.example b/gitea/.env.example old mode 100644 new mode 100755 diff --git a/gitea/docker-compose.yml b/gitea/docker-compose.yml old mode 100644 new mode 100755 diff --git a/jellyfin/.env.example b/jellyfin/.env.example deleted file mode 100644 index 7691a3e..0000000 --- a/jellyfin/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# jellyfin/.env -# Copy to .env and fill in real values. NEVER commit .env. - -JELLYFIN_PORT=8096 diff --git a/jellyfin/docker-compose.yml b/jellyfin/docker-compose.yml deleted file mode 100644 index 46221a6..0000000 --- a/jellyfin/docker-compose.yml +++ /dev/null @@ -1,25 +0,0 @@ -services: - jellyfin: - image: jellyfin/jellyfin:latest - container_name: jellyfin - restart: unless-stopped - volumes: - - ${STORAGE_PATH}/jellyfin/config:/config - - ${STORAGE_PATH}/jellyfin/cache:/cache - - ${MEDIA_PATH}:/media - ports: - - "${JELLYFIN_PORT:-8096}:8096" - networks: - - web - env_file: - - ../.env - labels: - - "traefik.enable=true" - - "traefik.http.routers.jellyfin.entrypoints=websecure" - - "traefik.http.routers.jellyfin.rule=Host(`jellyfin.${DOMAIN}`)" - - "traefik.http.routers.jellyfin.tls.certresolver=letsencrypt" - - "traefik.http.services.jellyfin.loadbalancer.server.port=8096" - -networks: - web: - external: true diff --git a/mkdocs.yml b/mkdocs.yml old mode 100644 new mode 100755 index 2021448..2c4437d --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,17 +1,22 @@ -site_name: Homelab Documentation +site_name: Seirian & James' Homelab +plugins: +- search +markdown_extensions: +- toc: + permalink: true +- pymdownx.snippets: + base_path: + - . nav: - - Home: index.md - - Recovery Reference: recovery.md - - Services: - - Traefik: services/traefik.md - - Gitea: services/gitea.md - - Nextcloud: services/nextcloud.md - - qBittorrent: services/qbittorrent.md - - Jellyfin: services/jellyfin.md - - Devbox: services/devbox.md - - Obsidian: services/obsidian.md - - N8N: services/n8n.md - - Supersync: services/supersync.md +- Home: index.md +- Recovery Reference: recovery.md +- Services: + - Devbox: services/devbox.md + - Gitea: services/gitea.md + - Nextcloud: services/nextcloud.md + - Obsidian Livesync: services/obsidian-livesync.md + - Traefik: services/traefik.md + - Whoami: services/whoami.md theme: name: material palette: diff --git a/nextcloud/.env.example b/nextcloud/.env.example old mode 100644 new mode 100755 diff --git a/nextcloud/docker-compose.yml b/nextcloud/docker-compose.yml old mode 100644 new mode 100755 diff --git a/obsidian-livesync/.env.example b/obsidian-livesync/.env.example old mode 100644 new mode 100755 diff --git a/obsidian-livesync/docker-compose.yml b/obsidian-livesync/docker-compose.yml old mode 100644 new mode 100755 diff --git a/qbittorrent/.env.example b/qbittorrent/.env.example deleted file mode 100644 index 423f8c4..0000000 --- a/qbittorrent/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# qbittorrent/.env -# Copy to .env and fill in real values. NEVER commit .env. -# WireGuard private key from ProtonVPN - -WIREGUARD_PRIVATE_KEY=REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY - -QBITTORRENT_PORT=8080 -JACKETT_PORT=9117 diff --git a/qbittorrent/docker-compose.yml b/qbittorrent/docker-compose.yml deleted file mode 100644 index 04e858b..0000000 --- a/qbittorrent/docker-compose.yml +++ /dev/null @@ -1,61 +0,0 @@ -services: - gluetun: - image: qmcgaw/gluetun - container_name: qbittorrent_gluetun - restart: unless-stopped - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun:/dev/net/tun - volumes: - - ${STORAGE_PATH}/qbittorrent/gluetun:/gluetun - - ${STORAGE_PATH}/qbittorrent/wireguard:/gluetun/wireguard - ports: - - "${QBITTORRENT_PORT:-8080}:8080" - - "${JACKETT_PORT:-9117}:9117" - networks: - - qbittorrent - env_file: - - ../.env - - .env - environment: - - VPN_SERVICE_PROVIDER=protonvpn - - VPN_TYPE=wireguard - - VPN_PORT_FORWARDING=on - - TZ=${TZ} - - SERVER_COUNTRIES=New Zealand - - PORT_FORWARD_ONLY=on - - VPN_PORT_FORWARDING_UP_COMMAND=/bin/sh -c 'wget -O- --post-data "json={\"listen_port\":{{PORTS}}}" http://localhost:8080/api/v2/app/setPreferences 2>/dev/null || true' - - qbittorrent: - image: lscr.io/linuxserver/qbittorrent:latest - container_name: qbittorrent - restart: unless-stopped - network_mode: "service:gluetun" - depends_on: - - gluetun - environment: - - TZ=${TZ} - - WEBUI_PORT=8080 - - PUID=${USER_UID} - - PGID=${USER_GID} - volumes: - - ${STORAGE_PATH}/qbittorrent/config:/config - - ${MEDIA_PATH}/torrents:/downloads - - jackett: - image: linuxserver/jackett:latest - container_name: jackett - restart: unless-stopped - network_mode: "service:gluetun" - depends_on: - - gluetun - environment: - - TZ=${TZ} - - PUID=${USER_UID} - - PGID=${USER_GID} - volumes: - - ${STORAGE_PATH}/qbittorrent/config/jackett:/config - -networks: - qbittorrent: diff --git a/scripts/__pycache__/generate_docs.cpython-313.pyc b/scripts/__pycache__/generate_docs.cpython-313.pyc deleted file mode 100644 index f938b14..0000000 Binary files a/scripts/__pycache__/generate_docs.cpython-313.pyc and /dev/null differ diff --git a/scripts/check_env_sync.py b/scripts/check_env_sync.py new file mode 100644 index 0000000..0697a33 --- /dev/null +++ b/scripts/check_env_sync.py @@ -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()) diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py index b00297a..bb7f523 100755 --- a/scripts/generate_docs.py +++ b/scripts/generate_docs.py @@ -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//.env` |") - lines.append("| **Runtime Configs** | `acme.json`, service configs | No | Yes | `/mnt/storage/docker-data/` |") - lines.append("| **Persistent Data** | DB data, uploads, media | No | Yes | `/mnt/storage/docker-data/` |") - 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.") diff --git a/traefik/.env.example b/traefik/.env.example old mode 100644 new mode 100755 diff --git a/traefik/README.md b/traefik/README.md new file mode 100644 index 0000000..0a591ec --- /dev/null +++ b/traefik/README.md @@ -0,0 +1,3 @@ +THis is the project + +More information hear. \ No newline at end of file diff --git a/traefik/docker-compose.yml b/traefik/docker-compose.yml old mode 100644 new mode 100755 diff --git a/whoami/docker-compose.yml b/whoami/docker-compose.yml old mode 100644 new mode 100755