Files
project-charlie/docs/strategy.md
T

148 lines
6.6 KiB
Markdown

# Strategy
## Pattern
Pull-based GitOps via Gitea Actions runners on the owner host.
```
dev box ── git push ──► Gitea (Charlie/<stack>) ── webhook ──► runner on owner host
git -C /mnt/nas/stacks/<stack> pull --ff-only
docker compose pull && docker compose up -d
```
The git working tree at `/mnt/nas/stacks/<stack>/` **is** the deployment. Runtime state lives separately under `/mnt/nas/data/<stack>/` (see [layout.md](layout.md)).
## Rules
- One owner host per stack (singleton stacks). Only that host's runner ever touches the working tree.
- Shared stacks (run on multiple hosts — see [Shared stacks](#shared-stacks)) follow the same NFS-shared-tree model but each host runs its own `docker compose up -d` against host-specific overrides.
- No hand-edits on the host once a stack is repo-managed. `git pull --ff-only` will refuse to clobber.
- Runtime state never enters git. Compose files bind into `/mnt/nas/data/<stack>/`, not into the working tree.
- `.env` ignored, `.env.example` tracked. Secrets stay out.
- Image tags pinned. No `:latest`.
- Cross-host self-update: the runner that deploys `Charlie/gitea` lives on morgott; the runner that updates morgott's runner lives on melina. A runner never redeploys itself mid-job.
## Repo types
Three. (We dropped `upstream+overrides` — see [Why no upstream cloning](#why-no-upstream-cloning).)
### `init`
Stack is currently just a NAS folder. `git init` in the new `/mnt/nas/stacks/<stack>/`, push to `Charlie/<name>`. Recipe: [migration.md](migration.md#init).
### `rewrite`
Code we author, currently a clone of a github.com repo. Move to `/mnt/nas/stacks/<stack>/`, `git remote set-url origin` to Charlie. Recipe: [migration.md](migration.md#rewrite).
### `mirror`
Pull-mirror of an upstream we don't customise (or want as offline backup). Configured via Gitea's pull-mirror feature. Read-only, never deployed from. Naming: `Charlie/<name>-mirror`.
## Shared stacks
Some stacks run on **multiple hosts** with the same image but slightly different configuration (e.g. `otelcollector`, `portainer-agent`). The pattern, borrowed from Ansible inventories + Kustomize overlays + Kubernetes DaemonSets:
```
Charlie/<stack>/
├── docker-compose.yml # the "what" — default, used by hosts without an override
├── docker-compose.<host>.yml # full per-host compose (when the delta is structural)
├── envs/
│ ├── morgott.env # per-host env (Ansible host_vars equivalent)
│ ├── melina.env
│ ├── mohg.env
│ └── miquella.env
├── overrides/ # additive overlay deltas (when env can't express it)
│ └── <host>.yml
├── deploy.sh # picks env + compose file based on `hostname -s`
└── README.md
```
### Resolution order in `deploy.sh` (per host)
1. If `docker-compose.<host>.yml` exists → use **only** that (full per-host compose).
2. Else `docker-compose.yml` + (if present) `overrides/<host>.yml` layered on top.
3. If `envs/<host>.env` exists → loaded with `--env-file` in either case.
Hostname is lowercased (`hostname -s | tr '[:upper:]' '[:lower:]'`).
### When to use which
| Delta type | Use |
|---|---|
| Different scalar value (port, env var, URL) | `envs/<host>.env` |
| Add an extra service or scalar field | `overrides/<host>.yml` (compose merges additively) |
| **Replace** a list (volumes, command, networks) | `docker-compose.<host>.yml` (full file) |
| Different image | `docker-compose.<host>.yml` |
Compose's `!override` and `!reset` YAML tags exist for list-replacement, but support is uneven across compose versions (notably broken on Synology DSM's bundled compose). Prefer the full-file approach when replacing lists.
### Canonical `deploy.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
HOST=$(hostname -s | tr '[:upper:]' '[:lower:]')
if [ -f "docker-compose.$HOST.yml" ]; then
args=(-f "docker-compose.$HOST.yml")
else
args=(-f docker-compose.yml)
[ -f "overrides/$HOST.yml" ] && args+=(-f "overrides/$HOST.yml")
fi
[ -f "envs/$HOST.env" ] && args=(--env-file "envs/$HOST.env" "${args[@]}")
exec sudo docker compose "${args[@]}" "$@"
```
### Mental model
- "What" = `docker-compose.yml` (or per-host file).
- "Where" = the host running `./deploy.sh` (no inventory file; the host knows its own name).
- Per-host vars = `envs/<host>.env`.
- Per-host structural deltas = `overrides/<host>.yml` for additive, `docker-compose.<host>.yml` for replacement.
- Group defaults = compose env-var defaults (`${VAR:-default}`).
### Once runners exist (Phase 2)
Workflow declares a host matrix; one job per host, all parallel:
```yaml
strategy:
matrix:
host: [morgott, melina, mohg, miquella]
runs-on: [self-hosted, "${{ matrix.host }}"]
steps:
- run: cd /mnt/nas/stacks/<stack> && ./deploy.sh up -d
```
That's a Kubernetes DaemonSet shape adapted to compose + Gitea Actions.
## Why no upstream cloning
Previously we considered cloning upstream's source on the host and layering a `docker-compose.override.yml`. Dropped because:
- For every "upstream image" stack (`invidious`, `searxng`, `victoriametrics`, `mealie`, `paperless-ngx`), we only ever ran the published image. Their source tree is dead weight on the host.
- Override-on-template means the upstream's compose can change under us silently.
- Two-remote / rebase / force-push dance was the price for dubious value.
Instead: our `docker-compose.yml` references the published image directly. It is the only compose. Upstream's compose is a *reference document* we read in their mirror repo when we want to see what changed.
## Image-update flow (Phase 3)
Diun watches registries; on a tag/digest change it POSTs to a Gitea webhook that triggers a `redeploy` workflow with `force-pull: true` on the owner host. Replaces watchtower.
## Secrets
Open question. Candidate: vaultwarden as the source, with a runner-side fetch step that materialises `.env` per job. Decided when we design the runner stack.
## Why not Portainer/Komodo/Dockge
- **Portainer Stacks-from-Git**: works, but no submodule support, opinionated about working-tree location, adds a control plane on top of git.
- **Komodo**: nice, also pull-based, also relocates working trees. Reconsider if we ever want a UI.
- **Dockge**: file mgmt only, no git wiring.
The runner-driven pattern keeps us in plain `git` + `docker compose` and adds nothing the team doesn't already understand.