Initial checkin of code for managing homelab
Some checks are pending
deploy / deploy (push) Waiting to run
Some checks are pending
deploy / deploy (push) Waiting to run
This commit is contained in:
commit
1250c9cef6
131 changed files with 10107 additions and 0 deletions
116
.forgejo/workflows/deploy.yml
Normal file
116
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
name: deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
# No build/config/terraform/** — this workflow only deploys to Unraid,
|
||||||
|
# and `terraform apply` is run by hand. Add it if that ever changes.
|
||||||
|
- "src/**"
|
||||||
|
- "build/config/ansible/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Requires a self-hosted Forgejo Actions runner on your LAN (labelled
|
||||||
|
# `unraid-deploy`) with: network access to Vault and to the Unraid hosts'
|
||||||
|
# SSH + Postgres ports, and Python + pip available. See README.md
|
||||||
|
# "Bootstrapping" — this workflow can only run once that runner exists,
|
||||||
|
# which is why the first deploy has to happen by hand. The SSH key
|
||||||
|
# `ansible_user` (root) authenticates with is fetched from Vault below,
|
||||||
|
# not stored as a runner or repo secret — see README.md "SSH access".
|
||||||
|
runs-on: unraid-deploy
|
||||||
|
env:
|
||||||
|
VAULT_ADDR: ${{ secrets.VAULT_ADDR }}
|
||||||
|
VAULT_AUTH_METHOD: approle
|
||||||
|
VAULT_ROLE_ID: ${{ secrets.VAULT_ROLE_ID }}
|
||||||
|
VAULT_SECRET_ID: ${{ secrets.VAULT_SECRET_ID }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # need history to diff — a shallow clone can't compute this
|
||||||
|
|
||||||
|
- name: Install Ansible and Python dependencies
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
python3 -m pip install ansible hvac psycopg2-binary
|
||||||
|
|
||||||
|
- name: Install Ansible collections
|
||||||
|
working-directory: build/config/ansible
|
||||||
|
run: ansible-galaxy collection install -r requirements.yml
|
||||||
|
|
||||||
|
- name: Fetch SSH deploy key from Vault
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
import hvac
|
||||||
|
|
||||||
|
client = hvac.Client(url=os.environ["VAULT_ADDR"])
|
||||||
|
client.auth.approle.login(
|
||||||
|
role_id=os.environ["VAULT_ROLE_ID"],
|
||||||
|
secret_id=os.environ["VAULT_SECRET_ID"],
|
||||||
|
)
|
||||||
|
secret = client.secrets.kv.v2.read_secret_version(
|
||||||
|
path="homelab/ci/ssh", mount_point="kv"
|
||||||
|
)["data"]["data"]
|
||||||
|
|
||||||
|
key_path = os.path.join(os.environ["RUNNER_TEMP"], "unraid_ansible_key")
|
||||||
|
with open(key_path, "w") as f:
|
||||||
|
f.write(secret["PRIVATE_KEY"].rstrip() + "\n")
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
PY
|
||||||
|
echo "ANSIBLE_PRIVATE_KEY_FILE=$RUNNER_TEMP/unraid_ansible_key" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Determine changed stacks
|
||||||
|
id: changed
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# Three possible outcomes, and they are not the same thing:
|
||||||
|
# all=true deploy every stack on every host
|
||||||
|
# only_stacks=a,b deploy just those
|
||||||
|
# skip=true nothing here affects the Unraid deployment
|
||||||
|
# An empty only_stacks must never be read as "deploy everything" —
|
||||||
|
# that is exactly what a Proxmox-only or Terraform-only push
|
||||||
|
# produces.
|
||||||
|
#
|
||||||
|
# A change under build/config/ansible/ can affect how every stack
|
||||||
|
# is deployed (the role, inventory, playbook), so don't try to
|
||||||
|
# narrow it. Same if the diff can't be computed at all (force-push,
|
||||||
|
# or a first push where `before` is all-zeros): fall back to a full
|
||||||
|
# deploy rather than silently deploying nothing.
|
||||||
|
if ! changed_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" 2>/dev/null); then
|
||||||
|
echo "all=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if echo "$changed_files" | grep -q '^build/config/ansible/'; then
|
||||||
|
echo "all=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Only changes that affect the Unraid deployment count, since that
|
||||||
|
# is all this job runs. A stack's common/ and ansible/unraid/ do;
|
||||||
|
# its terraform/ and ansible/proxmox/ don't.
|
||||||
|
stacks=$(echo "$changed_files" \
|
||||||
|
| { grep '^src/' || true; } \
|
||||||
|
| { grep -Ev '/(terraform|ansible/proxmox)/' || true; } \
|
||||||
|
| awk -F/ '{ print ($2 == "shared") ? $3 : $2 }' \
|
||||||
|
| sort -u | paste -sd, -)
|
||||||
|
if [ -z "$stacks" ]; then
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "only_stacks=$stacks" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unraid only. Deploying to Proxmox from CI would need the runner to
|
||||||
|
# reach the Proxmox API and the guests, and `terraform apply` is
|
||||||
|
# deliberately kept manual — creating and destroying LXCs on a push is
|
||||||
|
# a bigger blast radius than restarting a Compose stack. Run
|
||||||
|
# playbooks/proxmox.yml by hand for now.
|
||||||
|
- name: Run deploy playbook
|
||||||
|
if: steps.changed.outputs.skip != 'true'
|
||||||
|
working-directory: build/config/ansible
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.changed.outputs.all }}" = "true" ]; then
|
||||||
|
ansible-playbook playbooks/unraid.yml
|
||||||
|
else
|
||||||
|
ansible-playbook playbooks/unraid.yml -e only_stacks="${{ steps.changed.outputs.only_stacks }}"
|
||||||
|
fi
|
||||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Ansible
|
||||||
|
*.retry
|
||||||
|
build/config/ansible/inventory/*.retry
|
||||||
|
|
||||||
|
# Rendered secrets — only .env.example is ever committed
|
||||||
|
**/.env
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
# .terraform/ holds the provider cache and, after
|
||||||
|
# `terraform init -backend-config=...`, the Postgres connection string with
|
||||||
|
# its password — never commit it. .terraform.lock.hcl is deliberately NOT
|
||||||
|
# ignored: it pins provider versions and belongs in git.
|
||||||
|
.terraform/
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
*.tfvars
|
||||||
|
!*.tfvars.example
|
||||||
|
# A saved plan (`terraform plan -out=…`) is not just a diff: it embeds a copy
|
||||||
|
# of the state it was made against *and* the values of every input variable,
|
||||||
|
# `sensitive` ones included — so build/config/terraform/deploy.plan contains
|
||||||
|
# the Proxmox API token in the clear. Ignored so that a `git add -A` can't
|
||||||
|
# publish it.
|
||||||
|
*.plan
|
||||||
|
|
||||||
|
# OS cruft
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Scratch output that isn't state and isn't secret-free — e.g. the k3s
|
||||||
|
# kubeconfig playbooks/k3s.yml fetches to local/k3s/<cluster>.kubeconfig,
|
||||||
|
# which carries a client cert.
|
||||||
|
local/
|
||||||
597
CLAUDE.md
Normal file
597
CLAUDE.md
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
# Homelab IaC — Project Notes
|
||||||
|
|
||||||
|
Manages apps as infrastructure-as-code across three platforms — Unraid
|
||||||
|
server(s), Proxmox, and a bare-metal k3s cluster — with secrets sourced from
|
||||||
|
HashiCorp Vault and CI/CD via Forgejo Actions (self-hosted, on the Forgejo
|
||||||
|
instance this repo deploys).
|
||||||
|
|
||||||
|
Ansible is the deployment tool on **all three**; what differs is the
|
||||||
|
deployment primitive and who creates the host:
|
||||||
|
|
||||||
|
| | Unraid | Proxmox | k3s |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Provision the host | already exists | Terraform (`bpg/proxmox`) creates an LXC | already exists (4 Raspberry Pis) |
|
||||||
|
| Install the app | Ansible + `compose_stack` → `docker compose up -d` | Ansible + `lxc_app` → native install + systemd | Ansible + `k3s_app` → manifests into k3s's auto-deploy dir |
|
||||||
|
| App config source | `common/vars.yml` + `ansible/unraid/vars.yml` | `common/vars.yml` + `ansible/proxmox/vars.yml` | `common/vars.yml` + `ansible/kubernetes/vars.yml` |
|
||||||
|
| App list lives in | `host_vars/<host>.yml` `stacks:` | `host_vars/<guest>.yml` `apps:` | `group_vars/k3s_cluster.yml` `k3s_apps:` |
|
||||||
|
|
||||||
|
The k3s column is the newest and reached that shape late: `homelab-utils`
|
||||||
|
started as cluster-bootstrap-only, with no `src/<app>/` entry, and the third
|
||||||
|
column was added when Authentik moved onto it (see
|
||||||
|
`docs/authentik-migration.md`). Two things about it don't reduce to the
|
||||||
|
table:
|
||||||
|
|
||||||
|
- **The cluster itself has no `src/` entry.** Terraform never provisions it
|
||||||
|
and it isn't an app, so it gets an inventory group (`k3s_cluster`) and
|
||||||
|
`roles/k3s_node`, the same way the Proxmox *node* gets `roles/pve_backup`
|
||||||
|
while its guests get `lxc_app`.
|
||||||
|
- **Cluster services aren't apps.** `k3s_metallb`, `k3s_monitoring`,
|
||||||
|
`k3s_postgres` and `k3s_cert_manager` are the cluster's own
|
||||||
|
infrastructure — one hard-coded role each, config in `group_vars`. Apps go
|
||||||
|
through the generic `k3s_app` and live in `src/`. Both end up as
|
||||||
|
`HelmChart` CRs, so they look alike on disk; the distinction is whether
|
||||||
|
anything would still need it if no app were deployed. See "K3s cluster
|
||||||
|
(homelab-utils)" below.
|
||||||
|
|
||||||
|
### Hosts
|
||||||
|
|
||||||
|
Two Unraid entries are gone from the original plan: `nas2` was retired, and its
|
||||||
|
address (192.168.50.2) was reassigned to `turtle-proxmox-01` — so a stale copy
|
||||||
|
of it fails by reaching the wrong machine rather than by timing out. What
|
||||||
|
remains: `nas01` (Unraid, 192.168.50.1), `turtle-proxmox-01`/`-02` (Proxmox,
|
||||||
|
.2/.3), the guests on them from the API inventory, and the four k3s Pis
|
||||||
|
(.60–.63, MetalLB pool .80–.99). Gateway is 192.168.50.254.
|
||||||
|
|
||||||
|
**Proxmox LXC guests live in 192.168.50.50–.59** — Forgejo .52, shared
|
||||||
|
Postgres .54. Addresses here are assigned by hand from that band, not derived
|
||||||
|
from anything, so a new guest's `ip_address` is a question for whoever owns
|
||||||
|
the network rather than a value to pick.
|
||||||
|
|
||||||
|
The shared Postgres for everything outside the cluster is now the Proxmox LXC
|
||||||
|
at 192.168.50.54 — see `docs/postgres-proxmox.md`. The Unraid Compose
|
||||||
|
deployment under `src/shared/postgres/ansible/unraid/` is kept for the
|
||||||
|
platform-shape reason every unused platform folder is kept, not because it is
|
||||||
|
running anywhere.
|
||||||
|
|
||||||
|
This repo supersedes the planning done in `../homelab/CLAUDE.md` — that file
|
||||||
|
has the original decision log if you want the "why" behind the architecture
|
||||||
|
in more detail. Multi-host support, the shared-Postgres pattern, and the
|
||||||
|
Forgejo-Actions-hosts-itself bootstrap problem were decided after that doc
|
||||||
|
was written; this file and `README.md` are the current source of truth.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `src/<app>/common/vars.yml` — `env_defaults:` that hold regardless of where
|
||||||
|
the app runs (version, ports, domain, DB name/user).
|
||||||
|
- `src/<app>/ansible/unraid/` — the Compose deployment: `docker-compose.yml`,
|
||||||
|
`.env.example`, a `vars.yml` of Unraid-specific overrides (appdata paths,
|
||||||
|
the `shared-postgres` Docker network alias), and optionally an `icon.png`
|
||||||
|
for the Unraid Docker page.
|
||||||
|
- `src/<app>/ansible/proxmox/` — the native install: `vars.yml` of
|
||||||
|
Proxmox-specific overrides, `install.yml` of app-specific install steps,
|
||||||
|
and templates for the app's own config file and systemd unit. Two apps
|
||||||
|
ship one: `src/forgejo/` (binary + systemd unit, the shape the role was
|
||||||
|
written for) and `src/shared/postgres/` (distro packaging, so the unit
|
||||||
|
comes from the package and `install.yml` only reconfigures the cluster).
|
||||||
|
- `src/<app>/ansible/kubernetes/` — the k3s deployment: `vars.yml` of
|
||||||
|
cluster-specific overrides, and one or more `*.yaml.j2` manifests (normally
|
||||||
|
a `HelmChart` CR, plus anything the chart won't create — see
|
||||||
|
`src/authentik/`). "kubernetes" rather than "k3s": the contents are plain
|
||||||
|
Kubernetes apart from the `HelmChart` CR.
|
||||||
|
- `src/<app>/terraform/` — a module defining this app's LXC (template, cores,
|
||||||
|
memory, disk, IP), called from `build/config/terraform/main.tf`.
|
||||||
|
- `src/shared/<service>/` — shared services (databases, etc.) used by
|
||||||
|
multiple apps, same shape.
|
||||||
|
- `build/config/ansible/` — inventory, playbooks, and the roles. Three of
|
||||||
|
them deploy *apps*, one per platform, and share the same layering and
|
||||||
|
`state: present|absent` contract: `compose_stack` (Unraid), `lxc_app`
|
||||||
|
(Proxmox guests), `k3s_app` (the cluster). `compose_stack` and `k3s_app`
|
||||||
|
each split into `deploy.yml`/`remove.yml`, dispatched by `main.yml` on the
|
||||||
|
entry's `state:`. The rest configure infrastructure rather than apps:
|
||||||
|
`k3s_node` (bootstraps a Pi as k3s server or agent, picked by inventory
|
||||||
|
group), `k3s_metallb`, `k3s_monitoring`, `k3s_postgres`,
|
||||||
|
`k3s_cert_manager` (cluster services), `k3s_maintenance` (drain/reboot/
|
||||||
|
uncordon), `unattended_upgrades`, and `pve_backup` (the Proxmox node's
|
||||||
|
backup storage and vzdump schedule).
|
||||||
|
- `build/config/terraform/` — Proxmox provider + `backend "pg"` state config,
|
||||||
|
and the module calls saying what infrastructure exists.
|
||||||
|
- `.forgejo/workflows/` — CI/CD, runs once Forgejo + a self-hosted runner
|
||||||
|
exist (see "Bootstrapping" in README.md — chicken-and-egg on the first
|
||||||
|
deploy).
|
||||||
|
|
||||||
|
## Key decisions
|
||||||
|
|
||||||
|
- Compose files are static and reference `${VAR}`; only the rendered `.env`
|
||||||
|
changes per deploy. Nothing secret is ever committed.
|
||||||
|
- Config is layered, not duplicated: `common/vars.yml` merged with
|
||||||
|
`ansible/<platform>/vars.yml` (platform wins), then Vault secrets on top
|
||||||
|
(secrets win). Both roles do this the same way, so moving an app between
|
||||||
|
platforms means writing a new platform vars file, not restating its config.
|
||||||
|
- One Vault KV path per app (`homelab/<app>`), fetched as a whole dict via
|
||||||
|
`community.hashi_vault.vault_kv2_get`. The prefix is `homelab/`, not
|
||||||
|
`unraid/`, because an app's secrets don't change with the platform it lands
|
||||||
|
on.
|
||||||
|
- Unraid draws a Compose deployment's stack row and its containers from
|
||||||
|
unrelated sources, so one committed `src/<app>/ansible/unraid/icon.png` is
|
||||||
|
copied to two places. The **stack row** is Compose Manager serving
|
||||||
|
`<project dir>/icon.png` off disk — a file or nothing, no label and no URL,
|
||||||
|
and only in the maintained fork (Compose Manager Plus); on the original
|
||||||
|
plugin the copy is inert. The **containers** get icon, WebUI link and
|
||||||
|
console shell from the dockerMan template that created them, which a Compose
|
||||||
|
stack doesn't have, so each service carries `net.unraid.docker.*` labels
|
||||||
|
(6.10+ falls back to them). The label path is a second copy under
|
||||||
|
`/mnt/user/appdata/icons/` rather than the project-folder one, because the
|
||||||
|
webgui resolves it on every Docker page load and that shouldn't hit the
|
||||||
|
flash drive.
|
||||||
|
- Of those labels only the icon is role-supplied: `compose_stack` injects
|
||||||
|
`STACK_ICON` *underneath* both vars.yml layers, so an app can override it
|
||||||
|
with a hosted URL. WebUI and shell stay literal in the compose file — the
|
||||||
|
WebUI port has to be the published one and the shell is a property of the
|
||||||
|
base image, so both belong next to `ports:` and `image:` rather than in a
|
||||||
|
variable. Details, including where SVG does and doesn't work, in README.md →
|
||||||
|
"Icons on the Unraid Docker page".
|
||||||
|
- Shared Postgres is provisioned once per host; per-app DB/user creation
|
||||||
|
is idempotent via `community.postgresql`, not `docker-entrypoint-initdb.d`
|
||||||
|
(which only runs once on an empty volume — wrong fit for a shared service
|
||||||
|
apps get added to over time). The Unraid instance was pinned to 13 because
|
||||||
|
its data directory predated this repo; the Proxmox one is 17 from the PGDG
|
||||||
|
archive, a fresh cluster with nothing to stay compatible with.
|
||||||
|
- The Proxmox shared Postgres lives on the `AppData` ZFS pool with a
|
||||||
|
`proxmox_replication` job to the second node, and **everything is on one
|
||||||
|
volume** — no `mount_point` splitting PGDATA from the WAL. Unlike Forgejo's
|
||||||
|
"no bind mounts", which is about what `vzdump` includes, this is a
|
||||||
|
correctness requirement: replication snapshots each of a guest's volumes
|
||||||
|
independently, so two volumes replicate as two snapshots taken at different
|
||||||
|
instants, and the far node can end up with a data directory and a WAL that
|
||||||
|
disagree. One volume means one atomic snapshot — exactly the
|
||||||
|
crash-consistent image WAL replay is designed for. It also means running
|
||||||
|
out of space has one fix and not two: raise `disk_size` on the module call
|
||||||
|
(256 GiB as of this writing, up from the original 32 once Forgejo's
|
||||||
|
database made the OS-sized rootfs look optimistic), never add a second
|
||||||
|
disk. Cheap to do — on ZFS the resize is a refquota change applied to a
|
||||||
|
running guest — but one-way, since shrinking is a replacement and
|
||||||
|
`prevent_destroy` blocks it. Runbook in `docs/postgres-proxmox.md`. Two
|
||||||
|
further consequences worth keeping straight: replication is configured *per guest*, not per pool, so a
|
||||||
|
container created on `AppData` without a job is a single point of failure
|
||||||
|
that looks identical in the storage view (hence Terraform owning the job
|
||||||
|
rather than a hand-run `pvesr`); and it's asynchronous, so a failover loses
|
||||||
|
up to `replication_schedule` — five minutes — of commits. It is not a
|
||||||
|
backup; `pve_backup`'s vzdump is.
|
||||||
|
- `full_page_writes = off` on the Proxmox instance, and only there. It guards
|
||||||
|
against a torn page, which copy-on-write storage cannot produce, so on ZFS
|
||||||
|
the full-page images are pure write amplification that also becomes
|
||||||
|
replication traffic. The same setting on ext4 or xfs risks unrecoverable
|
||||||
|
corruption — which is why it's in `ansible/proxmox/vars.yml` and must never
|
||||||
|
migrate to `common/vars.yml`.
|
||||||
|
- Terraform provisions the guest and stops there — it has no way to deploy an
|
||||||
|
app into an LXC, so Ansible picks up from the point the container exists.
|
||||||
|
- Proxmox guests keep all their state on their own rootfs; no bind mounts, no
|
||||||
|
NFS shares mounted into a container. Counter-intuitive if the goal is "back
|
||||||
|
the repos up to the NAS", but `vzdump` excludes bind mounts by design, so a
|
||||||
|
bind-mounted repo directory is the one thing *missing* from the archive.
|
||||||
|
Everything local means one archive is a complete filesystem restore point.
|
||||||
|
Getting a copy off-box is the node's job (`pve_backup` → NFS storage on the
|
||||||
|
NAS + a vzdump schedule), not the container's.
|
||||||
|
- State an app keeps *outside* its container is that app's problem. Forgejo's
|
||||||
|
database is on the shared Postgres LXC — a different guest, and so a
|
||||||
|
different vzdump archive — so a systemd timer in the
|
||||||
|
container `pg_dump`s it onto the container's own disk, and the vzdump archive
|
||||||
|
carries both halves. The dump schedule and the vzdump window are a pair —
|
||||||
|
move one, move the other.
|
||||||
|
- Proxmox hosts come from the `community.proxmox` dynamic inventory plugin
|
||||||
|
querying the Proxmox API, so `terraform apply` creating an LXC doesn't also
|
||||||
|
require an inventory edit.
|
||||||
|
- The `terraform` tag is what marks a Proxmox guest as this repo's to manage.
|
||||||
|
The dynamic inventory returns every LXC on the cluster, hand-made ones
|
||||||
|
included, so `inventory/proxmox.yml` filters on that tag — both Terraform
|
||||||
|
modules set it (`tags` in their `variables.tf`), nothing else does. It
|
||||||
|
filters rather than narrowing the `proxmox_guests` condition, so an
|
||||||
|
unmanaged guest is absent from `all` as well and no future play can reach
|
||||||
|
it by accident. The failure mode is silent in one direction: a new module
|
||||||
|
that forgets the tag creates a container Ansible simply never visits, with
|
||||||
|
no error anywhere — same shape as an omitted `stacks:` entry, and the first
|
||||||
|
thing to check if a freshly-applied guest is never deployed to.
|
||||||
|
- Terraform state lives in Postgres (`backend "pg"`), not a local file, so CI
|
||||||
|
and a workstation see the same state — specifically the **CloudNativePG
|
||||||
|
cluster on k3s**, not the shared Postgres this configuration provisions on
|
||||||
|
Proxmox. That would be circular: Terraform would need the database to exist
|
||||||
|
in order to create the container the database runs in. State goes somewhere
|
||||||
|
Terraform has no hand in building, which breaks the cycle outright at the
|
||||||
|
cost of a dependency the rest of the repo avoids — `terraform apply` now
|
||||||
|
needs the Pis up. Worth it over the alternative (local state, apply,
|
||||||
|
`init -migrate-state`), which has to be got right exactly once. Bootstrap
|
||||||
|
ordering is `docs/postgres-proxmox.md`.
|
||||||
|
- Every app keeps the same `common/` + `ansible/` + `terraform/` shape even
|
||||||
|
when only one platform is in use, so adding the other later doesn't require
|
||||||
|
restructuring.
|
||||||
|
- Removal on Unraid is explicit (`state: absent` on the stack entry), never
|
||||||
|
implied by deleting the entry. Ansible stores no record of the last run, so
|
||||||
|
an omitted stack isn't removed — it's just never visited again, and keeps
|
||||||
|
running unmanaged. The entry stays as a tombstone documenting that the app
|
||||||
|
was deliberately removed. Volumes, images and the app's database are
|
||||||
|
further opt-ins (`remove_volumes`, `remove_images`, `remove_database`)
|
||||||
|
rather than being implied by `state: absent`, and the Vault path is never
|
||||||
|
touched — a default teardown is meant to be reversible. `lxc_app` has no
|
||||||
|
counterpart yet; it would need a per-app `uninstall.yml`.
|
||||||
|
- The k3s cluster's join token is a fixed value from Vault
|
||||||
|
(`homelab/k3s-homelab-utils` → `K3S_TOKEN`), not the one `k3s server`
|
||||||
|
generates for itself on first install. Both the control-plane play and the
|
||||||
|
worker play look it up independently, so a full rebuild — wipe both SD
|
||||||
|
cards, reinstall — reproduces the same cluster identity instead of needing
|
||||||
|
a freshly-generated token fetched off the server and redistributed by hand.
|
||||||
|
Same "generate once, store it, never let a redeploy invent a new one"
|
||||||
|
reasoning as Forgejo's fixed secrets, applied to cluster identity instead
|
||||||
|
of app state.
|
||||||
|
- The SSH key the k3s plays connect with is fetched from Vault
|
||||||
|
(`homelab/ci/ssh-k3s`) by `playbooks/k3s_ssh_key.yml` and written to
|
||||||
|
`local/`, which both `k3s.yml` and `k3s_maintenance.yml` import as their
|
||||||
|
first play — rather than the operator loading it into `ssh-agent` first, as
|
||||||
|
the Unraid manual flow still expects. Same arrangement CI already uses for
|
||||||
|
the Unraid key, just moved into the playbook because these two are
|
||||||
|
manual-only and there's no workflow to hold the fetch step. It's a separate
|
||||||
|
playbook, not a `pre_tasks` block, because both entry points need it and
|
||||||
|
neither can assume the other ran. It targets `k3s_cluster` rather than
|
||||||
|
`localhost` for a non-obvious reason: the implicit localhost isn't a member
|
||||||
|
of `all`, so it wouldn't inherit `vault_addr`/`vault_kv_mount`/
|
||||||
|
`vault_auth_method` from `group_vars/all.yml`. Nothing in it connects to a
|
||||||
|
Pi — `gather_facts: false` plus `delegate_to: localhost`, necessarily, since
|
||||||
|
the key isn't on disk yet at that point. Falling back to a local key takes
|
||||||
|
*two* overrides (`-e k3s_ssh_key_fetch=false` alongside
|
||||||
|
`-e ansible_ssh_private_key_file=...`): pointing at another key alone
|
||||||
|
doesn't help if Vault is what's broken, because the fetch play fails before
|
||||||
|
any other play runs.
|
||||||
|
- `playbooks/k3s.yml` is deliberately **not** wired into `deploy.yml` or
|
||||||
|
`.forgejo/workflows/deploy.yml`. Converging 4 physical nodes on every push
|
||||||
|
is a bigger blast radius than restarting a Compose stack — the same
|
||||||
|
reasoning that keeps `terraform apply` manual for Proxmox. Run it by hand.
|
||||||
|
- `playbooks/k3s.yml`'s plays are tagged so one piece can be converged on its
|
||||||
|
own — `nodes`, `metallb`, `monitoring`, `postgres`, `services` (those three
|
||||||
|
cluster services together), `upgrades`, `kubeconfig`. Play-level tags, one
|
||||||
|
per play, not per-task tags inside the roles: the plays already are the
|
||||||
|
units, and tagging tasks would mean every role carrying tags for a
|
||||||
|
selectivity only this playbook needs. This is the k3s equivalent of
|
||||||
|
`only_stacks` for Unraid — a cluster service isn't a `stacks:` entry, so
|
||||||
|
`only_stacks` can't reach it. The SSH-key import play is tagged `always`
|
||||||
|
rather than getting a name of its own, since it's the prerequisite for
|
||||||
|
anything else connecting and has to survive every `--tags` filter. Note
|
||||||
|
what a tagged service run does *not* cover: the roles only drop manifests
|
||||||
|
for k3s's controllers to reconcile, so a green playbook means the files
|
||||||
|
landed, not that the workload came up.
|
||||||
|
- `k3s_node` is one role, not two, even though a server and an agent install
|
||||||
|
differently. Both need the same OS prep (cgroups, swap), and which install
|
||||||
|
path runs is a group var (`k3s_node_role: server`/`agent`, set per
|
||||||
|
inventory group) rather than a second role — so the shared prep can't drift
|
||||||
|
between the two nodes types the way copy-pasted tasks eventually would.
|
||||||
|
- Extra `INSTALL_K3S_EXEC` flags split across two vars, not one:
|
||||||
|
`k3s_extra_args` (both roles) and `k3s_server_extra_args` (control-plane
|
||||||
|
only). `k3s agent` doesn't understand server-only flags like `--disable`
|
||||||
|
and fails to start if handed one, so a flag like `--disable=servicelb`
|
||||||
|
(which `k3s_metallb` needs to avoid fighting k3s's bundled ServiceLB for
|
||||||
|
the same IPs) can't safely live in the shared list.
|
||||||
|
- `k3s_node`'s install tasks track the exec line they last installed a node
|
||||||
|
with (a small marker file next to k3s's own config) and reinstall —
|
||||||
|
restarting the `k3s`/`k3s-agent` service, not the node — whenever it
|
||||||
|
drifts from what `k3s_extra_args`/`k3s_server_extra_args`/`k3s_api_tls_san`
|
||||||
|
currently say, even if `k3s_version` hasn't changed. Deliberately folded
|
||||||
|
into the ordinary install task rather than a separate maintenance
|
||||||
|
playbook: `playbooks/k3s.yml` is already manual-only and documented as
|
||||||
|
safe to re-run, and unlike the reboots `k3s_maintenance` guards, bouncing
|
||||||
|
the k3s process doesn't take pods down — containerd keeps them running
|
||||||
|
underneath it. The worker play still runs with `serial: 1` so at most one
|
||||||
|
node's kubelet is ever mid-restart at once.
|
||||||
|
- `k3s server`/`k3s agent` are installed with an explicit `--node-name
|
||||||
|
{{ inventory_hostname }}`, not left to default to the OS hostname. This is
|
||||||
|
what lets `roles/k3s_maintenance` address a node by
|
||||||
|
`inventory_hostname` when draining/uncordoning — the k8s node object and
|
||||||
|
the Ansible host are guaranteed to be the same string.
|
||||||
|
- Patching a k3s Pi is split into two roles that don't know about each other
|
||||||
|
directly, only through a file: `unattended_upgrades` installs updates
|
||||||
|
hands-off but with `Automatic-Reboot "false"`, and `k3s_maintenance` is the
|
||||||
|
only thing that actually reboots a node — triggered by the presence of
|
||||||
|
`/var/run/reboot-required`, which is the OS's own signal, not something
|
||||||
|
either role invents. Splitting it this way means the risky half (taking a
|
||||||
|
node out of the cluster) is exactly one thing (`playbooks/k3s_maintenance.yml`,
|
||||||
|
`serial: 1`), not entangled with the routine half (installing packages),
|
||||||
|
which runs unattended every day on every node without anyone thinking about
|
||||||
|
it.
|
||||||
|
- `k3s_maintenance`'s kubectl calls (`drain`, `wait`, `uncordon`) are
|
||||||
|
delegated to the control-plane host and run as `k3s kubectl`, k3s's own
|
||||||
|
bundled client, rather than requiring a kubectl install or a kubeconfig
|
||||||
|
anywhere else — including on whatever eventually runs this on a schedule.
|
||||||
|
This also has to work when the node currently being processed *is* the
|
||||||
|
control plane: delegating a host to itself is just a normal SSH connection,
|
||||||
|
used right up until the moment that connection reboots out from under it.
|
||||||
|
- `k3s_monitoring` (a lean kube-prometheus-stack, for OpenLens and similar
|
||||||
|
tools) is installed as a `HelmChart` custom resource dropped into k3s's own
|
||||||
|
auto-deploying manifests directory, not run through a `helm` binary or the
|
||||||
|
`kubernetes.core.helm` collection — k3s ships a helm-controller that
|
||||||
|
reconciles anything found there, the same mechanism it uses to install its
|
||||||
|
own bundled Traefik and ServiceLB. That keeps the pattern this repo already
|
||||||
|
uses everywhere else (Ansible renders a file, something else converges on
|
||||||
|
it) instead of adding a second, unrelated way to reach the cluster from the
|
||||||
|
controller. Grafana and Alertmanager are left disabled — OpenLens brings
|
||||||
|
its own dashboards and this cluster doesn't page anyone — and the
|
||||||
|
control-plane scrape targets (`kubeControllerManager`, `kubeScheduler`,
|
||||||
|
`kubeProxy`, `kubeEtcd`) are disabled too, since k3s bundles those into one
|
||||||
|
static binary instead of exposing them the way the chart expects; leaving
|
||||||
|
them on just produces permanently-"down" targets, not a working scrape.
|
||||||
|
- `k3s_metallb` (MetalLB, L2 mode) follows the same `HelmChart` CR pattern as
|
||||||
|
`k3s_monitoring`, plus a second, plain manifest for its
|
||||||
|
`IPAddressPool`/`L2Advertisement` config dropped in the same
|
||||||
|
auto-deploying directory — k3s's deploy controller applies any manifest it
|
||||||
|
finds there, not only `HelmChart` CRs, and retries one referencing CRDs
|
||||||
|
that don't exist yet rather than failing outright, so the config doesn't
|
||||||
|
need to wait on the chart install finishing first. It replaces k3s's
|
||||||
|
bundled ServiceLB rather than running next to it — both would otherwise
|
||||||
|
hand out IPs for the same `LoadBalancer` Services — so
|
||||||
|
`k3s_server_extra_args` in `inventory/group_vars/k3s_cluster.yml`
|
||||||
|
carries `--disable=servicelb` (server-only — `k3s_extra_args`, applied to
|
||||||
|
both server and agent, can't carry a server-only flag like `--disable`
|
||||||
|
without breaking agent installs). The IP pool (`k3s_metallb_address_range`, same
|
||||||
|
file) has no built-in default; the role fails fast rather than silently
|
||||||
|
advertising an empty pool.
|
||||||
|
- `k3s_postgres` (CloudNativePG) is the k3s cluster's own shared Postgres —
|
||||||
|
a third platform for `src/shared/postgres/`, alongside Unraid and Proxmox,
|
||||||
|
deployed via Helm rather than Compose or a native install. Chosen over
|
||||||
|
Bitnami's postgresql-ha (repmgr+pgpool, and Bitnami's free chart/image
|
||||||
|
catalog was restructured into a "legacy" repo in 2025) and the Zalando
|
||||||
|
operator (Patroni-based, heavier) because it fits the same `HelmChart` CR
|
||||||
|
pattern already established: the operator installs as a chart CR, same
|
||||||
|
shape as `k3s_metallb`/`k3s_monitoring`, and the actual cluster is a plain
|
||||||
|
`Cluster` CR manifest — same "config manifest that outlives the CRDs it
|
||||||
|
references" trick as `k3s_metallb`'s `IPAddressPool`. It's a separate
|
||||||
|
physical instance from the Docker-based shared/postgres (pinned to 13),
|
||||||
|
but reuses the same Vault path (`homelab/shared/postgres`) rather than a
|
||||||
|
k3s-specific one — one superuser identity for the "shared postgres"
|
||||||
|
concept regardless of which platform it's running on, consistent with how
|
||||||
|
every host already draws from that same path. 1 primary + 1 replica,
|
||||||
|
scheduled on worker Pis only via `nodeAffinity` — the control plane stays
|
||||||
|
free of app pods, same boundary `k3s_metallb`/`k3s_monitoring` already
|
||||||
|
keep. HA replication only for now, no backups.
|
||||||
|
- The CNPG cluster is published on the LAN through MetalLB at a pinned
|
||||||
|
`k3s_postgres_loadbalancer_ip`, on top of the three `ClusterIP` Services
|
||||||
|
CNPG makes for every `Cluster`. Declared inside the `Cluster` CR as a
|
||||||
|
managed service (`.spec.managed.services.additional`, `selectorType: rw`)
|
||||||
|
rather than as a Service manifest of this repo's own, so the operator owns
|
||||||
|
the selector and the address follows a failover instead of needing to be
|
||||||
|
re-pointed by hand. Pinned rather than auto-assigned because the address is
|
||||||
|
meant to be written down in config elsewhere; it comes out of the bottom of
|
||||||
|
`k3s_metallb_address_range` and would need a second `autoAssign: false`
|
||||||
|
pool if anything else on this cluster ever wants a fixed IP. This is what
|
||||||
|
closes the reachability half of per-app DB provisioning — the
|
||||||
|
`community.postgresql` tasks `compose_stack`/`lxc_app` use can now reach
|
||||||
|
this instance the same way they reach the Unraid one. `roles/k3s_app` is
|
||||||
|
the other half, and uses exactly those tasks; note the asymmetry it
|
||||||
|
relies on, which is easy to misread as a mistake. The *provisioning* runs
|
||||||
|
from the Ansible controller and therefore uses the LoadBalancer address
|
||||||
|
(`k3s_postgres_loadbalancer_ip`), because `delegate_to: localhost` is off
|
||||||
|
the cluster and can't route to a ClusterIP. The *app* uses the in-cluster
|
||||||
|
`shared-postgres-rw` DNS name, because sending pod traffic out to the LAN
|
||||||
|
and back would put MetalLB's L2 speaker in the path of every query for no
|
||||||
|
benefit. Two addresses for one database, each correct for its caller.
|
||||||
|
- `roles/k3s_app` is the cluster's counterpart to `compose_stack` and
|
||||||
|
`lxc_app`: the generic role that deploys an *app*, driven by `k3s_apps:`
|
||||||
|
in `group_vars/k3s_cluster.yml`, as distinct from the `k3s_*` roles that
|
||||||
|
each deploy one cluster service. It renders whatever `*.yaml.j2` the app
|
||||||
|
ships under `src/<app>/ansible/kubernetes/` into k3s's auto-deploying
|
||||||
|
manifests directory, so adding a manifest to an app is dropping a file
|
||||||
|
next to the others rather than a role change. Secrets go into a separate
|
||||||
|
Kubernetes Secret rendered from Vault, never interpolated into the
|
||||||
|
manifests — the same committed-config/generated-secrets split as Unraid's
|
||||||
|
static compose file plus rendered `.env`, which is why the manifests can
|
||||||
|
stay 0644 on the node while the Secret is 0600.
|
||||||
|
- `k3s_apps:` lives in `group_vars/k3s_cluster.yml`, not `host_vars/`, unlike
|
||||||
|
both other platforms. An app is deployed to the cluster, not to a node:
|
||||||
|
the play runs against `k3s_control_plane` only because that's where the
|
||||||
|
manifests directory is, which is an implementation detail of how k3s is
|
||||||
|
reached rather than a statement about where the app runs.
|
||||||
|
- Removal on k3s is one pass, not two. `compose_stack` needs the compose file
|
||||||
|
still on disk to run `docker compose down`, so `state: absent` and deleting
|
||||||
|
`src/<app>/` have to be separate commits (see "Removing an app" below).
|
||||||
|
k3s's deploy controller tracks which resources each manifest file created,
|
||||||
|
so deleting the file *is* the teardown and `src/<app>/` can go in the same
|
||||||
|
commit. The tombstone convention still applies for the same reason as
|
||||||
|
everywhere else — an entry dropped from `k3s_apps:` is never visited again
|
||||||
|
and keeps running unmanaged.
|
||||||
|
- `k3s_cert_manager` is the fourth cluster service and the first that exists
|
||||||
|
purely for apps rather than for the cluster — nothing in the cluster needs
|
||||||
|
a certificate, `k3s_app`'s tenants do. It's still a cluster service rather
|
||||||
|
than an app, on the same reasoning as the shared Postgres: one
|
||||||
|
`ClusterIssuer` that every app's Ingress annotates itself against, rather
|
||||||
|
than each app carrying its own ACME account and DNS credentials. DNS-01,
|
||||||
|
not HTTP-01, because HTTP-01 needs Let's Encrypt to reach the cluster from
|
||||||
|
the internet on port 80 and this LAN deliberately isn't reachable — which
|
||||||
|
has the useful side effect that a certificate can be issued *before* DNS
|
||||||
|
points at the cluster, so a migration's TLS is settled before its cutover.
|
||||||
|
The solver stanza itself has no default and the role fails fast without
|
||||||
|
one: it depends on who runs the DNS, which the repo can't know. Same
|
||||||
|
fail-fast-on-unset treatment as `k3s_metallb_address_range`.
|
||||||
|
- Apps on k3s reach the outside through the Traefik k3s already bundles,
|
||||||
|
on the MetalLB address its Service holds, rather than each getting its own
|
||||||
|
LoadBalancer IP. One entrypoint and host-based routing means adding an app
|
||||||
|
costs a DNS record, not a pool address — the opposite of the choice made
|
||||||
|
for `k3s_postgres`, which is pinned to its own IP precisely because
|
||||||
|
Postgres isn't HTTP and can't be name-routed.
|
||||||
|
- `k3s_traefik` is the one cluster-service role that installs nothing: k3s
|
||||||
|
installs Traefik itself, so the role only adjusts it, via a
|
||||||
|
`HelmChartConfig` merged over k3s's own `HelmChart` rather than an edit to
|
||||||
|
the `traefik.yaml` k3s rewrites on every server start. Its scope is the
|
||||||
|
dashboard, which a stock k3s 404s — Traefik still builds it, but the chart
|
||||||
|
stopped shipping the router that reaches it in v28. The role restores that
|
||||||
|
router on Traefik's internal `traefik` entrypoint (port 9000, unpublished →
|
||||||
|
port-forward only), and optionally publishes the dashboard on a hostname
|
||||||
|
behind an Authentik forward-auth middleware when
|
||||||
|
`k3s_traefik_dashboard_host` is set. Three things worth keeping straight:
|
||||||
|
the internal route stays on even when the hostname one exists, because
|
||||||
|
authenticated access depends on Authentik → CNPG → a healthy cluster,
|
||||||
|
exactly what you'd open the dashboard to diagnose; the published route is
|
||||||
|
an `IngressRoute` with an explicit cert-manager `Certificate` rather than
|
||||||
|
an annotated `Ingress`, because the dashboard is `api@internal` and has no
|
||||||
|
Service to point an `Ingress` at; and the role fails fast if a hostname is
|
||||||
|
set without an auth address, since the failure mode is publishing every
|
||||||
|
router, service and middleware on the cluster to the LAN unauthenticated.
|
||||||
|
Authentik's provider must be **forward auth (domain level)** —
|
||||||
|
single-application mode needs `/outpost.goauthentik.io/` routed on the
|
||||||
|
dashboard's own host, a cross-namespace service reference Traefik rejects
|
||||||
|
unless `allowCrossNamespace` is on.
|
||||||
|
|
||||||
|
## Adding a new app
|
||||||
|
|
||||||
|
1. `src/<app>/common/vars.yml` — `env_defaults:` for the portable config.
|
||||||
|
2. Whichever platform(s) it targets:
|
||||||
|
- **Unraid:** `src/<app>/ansible/unraid/` with `docker-compose.yml`
|
||||||
|
(static, `${VAR}`-driven), `vars.yml` of overrides, `.env.example`
|
||||||
|
documenting every var.
|
||||||
|
- **Proxmox:** `src/<app>/ansible/proxmox/` with `vars.yml`,
|
||||||
|
`install.yml`, and config/systemd templates; plus
|
||||||
|
`src/<app>/terraform/` defining its LXC, wired into
|
||||||
|
`build/config/terraform/main.tf`.
|
||||||
|
- **k3s:** `src/<app>/ansible/kubernetes/` with `vars.yml` and at least
|
||||||
|
one `*.yaml.j2` manifest — normally a `HelmChart` CR, plus anything the
|
||||||
|
chart won't create for you. See `src/authentik/` for a worked example,
|
||||||
|
including the two things a chart usually leaves out: a PVC, and secrets
|
||||||
|
referenced by `secretKeyRef` rather than set as values.
|
||||||
|
3. Populate its Vault path (`homelab/<app>`) with real secret values.
|
||||||
|
4. Declare it on the target:
|
||||||
|
- Unraid → `host_vars/<host>.yml` `stacks:` list.
|
||||||
|
- Proxmox → `host_vars/<guest>.yml` `apps:` list.
|
||||||
|
- k3s → `group_vars/k3s_cluster.yml` `k3s_apps:` list.
|
||||||
|
All three take the same entry shape (name, src, vault_path, optional
|
||||||
|
`db:`, optional `state:`). Never declare the same app on more than one at
|
||||||
|
once — they share a Vault path and a database, so two live deployments
|
||||||
|
corrupt each other's state. Moving an app *between* platforms is
|
||||||
|
therefore a cutover with a verification step in the middle, not an edit;
|
||||||
|
`docs/authentik-migration.md` is the worked example.
|
||||||
|
5. Leave the unused platforms' folders as README placeholders rather than
|
||||||
|
deleting them — the shape is the point.
|
||||||
|
|
||||||
|
## Removing an app
|
||||||
|
|
||||||
|
**Unraid.** Set `state: absent` on its `stacks:` entry and run
|
||||||
|
`playbooks/unraid.yml` — don't delete the entry, which removes nothing. Then
|
||||||
|
delete `src/<app>/` in a *second* pass, never before: `docker compose down`
|
||||||
|
needs the compose file to know what it's tearing down. Full detail, including
|
||||||
|
the opt-in flags for volumes/images/database, is in README.md → "Removing an
|
||||||
|
app".
|
||||||
|
|
||||||
|
**k3s.** Same `state: absent` tombstone rule, but one pass — deleting the
|
||||||
|
manifests is the teardown, because k3s's deploy controller garbage-collects
|
||||||
|
what each file created, so `src/<app>/` can go in the same commit. The
|
||||||
|
database, PVCs and Vault path deliberately survive; there's no
|
||||||
|
`remove_volumes`/`remove_database` equivalent yet, and adding one is a
|
||||||
|
"when it's actually wanted" job rather than a guess at the shape.
|
||||||
|
|
||||||
|
**Proxmox.** No teardown path at all — `lxc_app` would need a per-app
|
||||||
|
`uninstall.yml`, and neither app that ships an `install.yml` has one. Note
|
||||||
|
this is worse for the shared Postgres than for an ordinary app: tearing it
|
||||||
|
down means every other app's database as well.
|
||||||
|
|
||||||
|
## K3s cluster (homelab-utils)
|
||||||
|
|
||||||
|
4 Raspberry Pis, `inventory/hosts.yml` → `k3s_cluster` (`k3s_control_plane`:
|
||||||
|
1 host, `k3s_workers`: 3). No Terraform — the Pis already exist. The cluster
|
||||||
|
itself has no `src/<app>/` entry (it isn't an app), but apps now land on it:
|
||||||
|
`k3s_apps:` in `group_vars/k3s_cluster.yml`, deployed by `roles/k3s_app` from
|
||||||
|
`src/<app>/ansible/kubernetes/`. `playbooks/k3s.yml` is manual-only (see "Key
|
||||||
|
decisions" above); run it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/ansible
|
||||||
|
ansible-playbook playbooks/k3s.yml
|
||||||
|
|
||||||
|
# or one piece of it — see "Key decisions" above for the tag list
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags postgres
|
||||||
|
|
||||||
|
# apps only, or one app
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags apps
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags apps -e only_apps=authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
`--tags services` covers the cluster's own infrastructure and `--tags apps`
|
||||||
|
what runs on top of it; neither implies the other, so "everything except
|
||||||
|
reinstalling k3s" is `--tags services,apps`.
|
||||||
|
|
||||||
|
Prerequisites, once, before the first run: the `ansible` user exists on every
|
||||||
|
Pi with NOPASSWD sudo and the **public** half of this repo's k3s SSH key
|
||||||
|
installed, and `homelab/k3s-homelab-utils` → `K3S_TOKEN` plus
|
||||||
|
`homelab/ci/ssh-k3s` → `PRIVATE_KEY` are set in Vault. Nothing needs setting
|
||||||
|
up on the controller beyond `VAULT_ADDR`/`VAULT_TOKEN` — the private key comes
|
||||||
|
from Vault at run time (see "Key decisions" above). Full detail in README.md →
|
||||||
|
"K3s (Raspberry Pi)" and `docs/vault-secrets.md`.
|
||||||
|
|
||||||
|
**Rebuilding**: reimage the SD card(s), redo the `ansible` user + SSH key
|
||||||
|
step, then re-run the playbook — the fixed Vault token means the rebuilt
|
||||||
|
node(s) rejoin the same cluster identity rather than needing a token hunted
|
||||||
|
down from a live server. **Adding a fifth Pi**: add it under
|
||||||
|
`k3s_control_plane` or `k3s_workers` in `hosts.yml`; no `host_vars/` entry
|
||||||
|
needed, `k3s_node_role` comes from the group. This repo's `k3s_node/server.yml`
|
||||||
|
assumes a single control-plane node — adding a second one for HA needs
|
||||||
|
changes there, not just an inventory edit.
|
||||||
|
|
||||||
|
**Patching**: `playbooks/k3s.yml` also applies `roles/unattended_upgrades` to
|
||||||
|
every node, so updates install themselves daily with no login required — but
|
||||||
|
never reboot themselves (`Automatic-Reboot "false"`). Run
|
||||||
|
`playbooks/k3s_maintenance.yml` to actually apply a pending reboot: it checks
|
||||||
|
`/var/run/reboot-required` per node and, only where set, drains → reboots →
|
||||||
|
waits for `Ready` → uncordons, `serial: 1` so only one node is ever down.
|
||||||
|
Manual-only for now, same as `playbooks/k3s.yml` — meant to eventually run on
|
||||||
|
a schedule from a self-hosted Forgejo Actions runner rather than by hand.
|
||||||
|
|
||||||
|
**Metrics**: `playbooks/k3s.yml` also applies `roles/k3s_monitoring`, a lean
|
||||||
|
kube-prometheus-stack (no Grafana/Alertmanager) so OpenLens and similar tools
|
||||||
|
can show node/pod metrics — see "Key decisions" above for why it's a
|
||||||
|
`HelmChart` CR rather than a `helm` install, and README.md → "Metrics
|
||||||
|
(Prometheus)" for pointing OpenLens at it.
|
||||||
|
|
||||||
|
**LoadBalancer IPs**: `playbooks/k3s.yml` also applies `roles/k3s_metallb`,
|
||||||
|
MetalLB in L2 mode, so `type: LoadBalancer` Services get real LAN IPs instead
|
||||||
|
of staying `ClusterIP`-only. Set the pool it hands out from —
|
||||||
|
`k3s_metallb_address_range` in `inventory/group_vars/k3s_cluster.yml` — to a
|
||||||
|
range your DHCP scope and every static assignment in `hosts.yml` steer clear
|
||||||
|
of before the first run; see "Key decisions" above for why it replaces
|
||||||
|
ServiceLB rather than running alongside it, and README.md → "LoadBalancer
|
||||||
|
IPs (MetalLB)" for more.
|
||||||
|
|
||||||
|
There's no *node* teardown path, unlike `state: absent` for apps — removing a
|
||||||
|
Pi today means wiping its SD card and deleting its inventory entry by hand.
|
||||||
|
|
||||||
|
**Shared Postgres**: `playbooks/k3s.yml` also applies `roles/k3s_postgres`,
|
||||||
|
CloudNativePG — the cluster's own shared Postgres, a third platform for
|
||||||
|
`src/shared/postgres/` alongside Unraid and Proxmox. See "Key decisions"
|
||||||
|
above for why CloudNativePG and the topology, and README.md → "Postgres
|
||||||
|
(CloudNativePG)" for connecting to it. Per-app database provisioning works
|
||||||
|
now (`roles/k3s_app`, `db:` on a `k3s_apps:` entry) — note the two-address
|
||||||
|
asymmetry described in "Key decisions". Still **no backups**: HA replication
|
||||||
|
only, which protects against a dead SD card and not against a dropped table.
|
||||||
|
That gap matters more now that Authentik's database lives here rather than
|
||||||
|
inside whatever covers Unraid's `/mnt/user/appdata`; a CNPG `ScheduledBackup`
|
||||||
|
is the obvious next piece of work.
|
||||||
|
|
||||||
|
**TLS**: `playbooks/k3s.yml` also applies `roles/k3s_cert_manager` —
|
||||||
|
cert-manager plus one `ClusterIssuer` that every app's Ingress annotates
|
||||||
|
itself against. Two values have no default and the role refuses to run
|
||||||
|
without them: `k3s_cert_manager_acme_email` and `k3s_cert_manager_solver`
|
||||||
|
(the DNS-01 stanza, which depends on your DNS provider — a commented
|
||||||
|
Cloudflare example is in `group_vars/k3s_cluster.yml`). Use the Let's Encrypt
|
||||||
|
staging directory while working a solver out; production allows five failed
|
||||||
|
validations per hostname per hour.
|
||||||
|
|
||||||
|
**Traefik dashboard**: `playbooks/k3s.yml` also applies `roles/k3s_traefik` —
|
||||||
|
the only cluster-service role that installs nothing, since k3s installs
|
||||||
|
Traefik itself. Out of the box the dashboard is reachable only by
|
||||||
|
port-forward (`kubectl -n kube-system port-forward deploy/traefik 9000:9000`,
|
||||||
|
then `http://127.0.0.1:9000/dashboard/` — trailing slash required; without
|
||||||
|
this role a stock k3s 404s there). Publishing it on a hostname behind
|
||||||
|
Authentik is one commented-out line, `k3s_traefik_dashboard_host` in
|
||||||
|
`group_vars/k3s_cluster.yml`, plus a domain-level forward-auth Proxy Provider
|
||||||
|
on the Authentik side. See "Key decisions" above and README.md → "Dashboard
|
||||||
|
(Traefik)".
|
||||||
|
|
||||||
|
**Apps**: `k3s_apps:` in `group_vars/k3s_cluster.yml`, deployed by
|
||||||
|
`roles/k3s_app`. Authentik is the first and currently only one — it moved off
|
||||||
|
a hand-made Unraid container rather than being deployed fresh, so
|
||||||
|
`src/authentik/ansible/unraid/` is a README explaining why there's no compose
|
||||||
|
file there. The cutover procedure is `docs/authentik-migration.md`; the part
|
||||||
|
worth knowing without reading it is that the deployed `AUTHENTIK_VERSION`
|
||||||
|
must match the version a restored database was dumped from, because
|
||||||
|
Authentik's migrations run on startup and are one-way.
|
||||||
966
README.md
Normal file
966
README.md
Normal file
|
|
@ -0,0 +1,966 @@
|
||||||
|
# Homelab IaC
|
||||||
|
|
||||||
|
Deploy apps as code across two platforms: Unraid server(s), where apps run as
|
||||||
|
Docker Compose stacks, and Proxmox, where Terraform creates an LXC and the
|
||||||
|
app is installed into it natively. Ansible is the deployment tool on both.
|
||||||
|
Secrets are pulled from HashiCorp Vault at deploy time; CI/CD is Forgejo
|
||||||
|
Actions once Forgejo itself is running.
|
||||||
|
|
||||||
|
There's also a third piece of infrastructure alongside those two: a bare-metal
|
||||||
|
k3s cluster (`homelab-utils`) across 4 Raspberry Pis, bootstrapped by Ansible
|
||||||
|
too. It doesn't fit the `src/<app>/` table above the way Unraid/Proxmox apps
|
||||||
|
do — Terraform never provisions it, and no `stacks:`/`apps:` list drives
|
||||||
|
what's on it — so it gets its own inventory group and role instead. It does
|
||||||
|
run shared services deployed via Helm, though: `k3s_monitoring`, `k3s_metallb`,
|
||||||
|
and the cluster's own shared Postgres (`k3s_postgres`, a third platform for
|
||||||
|
`src/shared/postgres/`). See `playbooks/k3s.yml` and "K3s (Raspberry Pi)"
|
||||||
|
below.
|
||||||
|
|
||||||
|
See `CLAUDE.md` for the short version of the architecture decisions.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
<app>/
|
||||||
|
common/vars.yml config that holds on either platform
|
||||||
|
ansible/
|
||||||
|
unraid/ docker-compose.yml + .env.example + platform overrides
|
||||||
|
(+ optional icon.png for the Unraid Docker page)
|
||||||
|
proxmox/ install.yml + config/systemd templates + platform overrides
|
||||||
|
kubernetes/ vars.yml + *.yaml.j2 manifests (normally a HelmChart CR)
|
||||||
|
terraform/ this app's LXC definition (Proxmox only)
|
||||||
|
shared/<service>/ same shape, for services multiple apps use
|
||||||
|
build/config/
|
||||||
|
ansible/
|
||||||
|
inventory/
|
||||||
|
hosts.yml static: unraid_servers, proxmox_nodes, k3s_cluster
|
||||||
|
proxmox.yml dynamic: proxmox_guests, from the Proxmox API
|
||||||
|
group_vars/all.yml Vault connection, Compose Manager project path
|
||||||
|
group_vars/k3s_*.yml k3s cluster name/version, per-role vars (server/agent)
|
||||||
|
host_vars/<host>.yml which stacks/apps deploy there
|
||||||
|
(k3s apps are in group_vars/k3s_cluster.yml instead)
|
||||||
|
roles/compose_stack/ Unraid: sync compose + render .env + provision DB + up
|
||||||
|
(deploy.yml / remove.yml, picked by the entry's state:)
|
||||||
|
roles/lxc_app/ Proxmox: install natively + render config + systemd
|
||||||
|
roles/k3s_app/ k3s: render app manifests + Secret + provision DB
|
||||||
|
(deploy.yml / remove.yml, picked by the entry's state:)
|
||||||
|
roles/pve_backup/ Proxmox node: NAS backup storage + vzdump schedule
|
||||||
|
roles/k3s_node/ k3s Pi: cgroups/swap prep + install server or agent
|
||||||
|
roles/k3s_monitoring/ k3s control plane: lean kube-prometheus-stack via HelmChart CR
|
||||||
|
roles/k3s_metallb/ k3s control plane: MetalLB (LoadBalancer IPs) via HelmChart CR
|
||||||
|
roles/k3s_postgres/ k3s control plane: shared Postgres (CloudNativePG) via HelmChart CR
|
||||||
|
roles/k3s_cert_manager/ k3s control plane: cert-manager + ClusterIssuer via HelmChart CR
|
||||||
|
roles/unattended_upgrades/ any apt host: hands-off patching, no auto-reboot
|
||||||
|
roles/k3s_maintenance/ k3s Pi: drain + reboot-if-required + uncordon, one at a time
|
||||||
|
playbooks/
|
||||||
|
deploy.yml everything (Unraid + Proxmox — not k3s, see below)
|
||||||
|
unraid.yml just Unraid
|
||||||
|
proxmox.yml just the Proxmox guests
|
||||||
|
pve_host.yml just the Proxmox node (backups)
|
||||||
|
k3s.yml bootstraps the k3s cluster (manual-only, not in deploy.yml)
|
||||||
|
k3s_maintenance.yml rolls pending reboots across it, serial: 1 (manual-only)
|
||||||
|
terraform/ Proxmox provider, pg state backend, LXC module calls
|
||||||
|
.forgejo/workflows/ CI/CD (needs a self-hosted runner, see below)
|
||||||
|
docs/vault-secrets.md Vault path/key reference
|
||||||
|
docs/authentik-migration.md hand-run cutover: Authentik from Unraid to k3s
|
||||||
|
```
|
||||||
|
|
||||||
|
Terraform's scope stops at the guest — it creates the LXC and nothing more,
|
||||||
|
because the Proxmox provider has no way to install an application. Ansible
|
||||||
|
picks up from there, which is why it's the common tool and Terraform is
|
||||||
|
Proxmox-only.
|
||||||
|
|
||||||
|
## How config is layered
|
||||||
|
|
||||||
|
An app's settings are split by whether they survive a change of platform:
|
||||||
|
|
||||||
|
- `src/<app>/common/vars.yml` — version, ports, domain, database name/user.
|
||||||
|
- `src/<app>/ansible/<platform>/vars.yml` — the rest. Data paths differ
|
||||||
|
(`/mnt/user/appdata/forgejo` vs `/var/lib/forgejo`), and so does how the
|
||||||
|
database is reached (the `shared-postgres` Docker network alias only exists
|
||||||
|
on Unraid).
|
||||||
|
|
||||||
|
Both roles merge them the same way: common, then platform overrides, then
|
||||||
|
Vault secrets last. Nothing is restated between platforms.
|
||||||
|
|
||||||
|
## How a deploy works
|
||||||
|
|
||||||
|
**Unraid.** For each stack in a host's `stacks:` list, `compose_stack`:
|
||||||
|
|
||||||
|
1. Copies `src/<stack>/ansible/unraid/docker-compose.yml` to the host's
|
||||||
|
Compose Manager project folder, unmodified — it's static and only
|
||||||
|
references `${VAR}`.
|
||||||
|
2. Merges the two `vars.yml` layers with the stack's Vault KV path and
|
||||||
|
renders the project's `.env`. Nothing secret ever touches git.
|
||||||
|
3. If the stack declares a `db:` block, ensures its database/role exist on
|
||||||
|
the shared Postgres instance (idempotent — safe to add new apps to the
|
||||||
|
shared service over time without touching existing data).
|
||||||
|
4. Copies `icon.png`, if the stack ships one, to both the project folder and
|
||||||
|
`/mnt/user/appdata/icons/` (see "Icons on the Unraid Docker page" below).
|
||||||
|
5. Runs `docker compose up -d` for the stack.
|
||||||
|
|
||||||
|
**Proxmox.** `terraform apply` creates the LXC first. Then, for each app in a
|
||||||
|
guest's `apps:` list, `lxc_app` does the same config merge and database
|
||||||
|
provisioning, runs that app's `ansible/proxmox/install.yml`, and manages its
|
||||||
|
systemd unit instead of a Compose project.
|
||||||
|
|
||||||
|
**k3s.** For each app in `k3s_apps:` (`group_vars/k3s_cluster.yml`, not
|
||||||
|
host_vars — an app belongs to the cluster, not a node), `k3s_app`:
|
||||||
|
|
||||||
|
1. Does the same two-layer config merge, using
|
||||||
|
`src/<app>/ansible/kubernetes/vars.yml` as the platform layer.
|
||||||
|
2. Provisions the app's database/role on the CloudNativePG cluster if it
|
||||||
|
declares a `db:` block — the same `community.postgresql` tasks the other
|
||||||
|
two platforms use, pointed at MetalLB's LoadBalancer address for the CNPG
|
||||||
|
primary.
|
||||||
|
3. Renders the app's Vault path into a Kubernetes `Secret` (plus the
|
||||||
|
`Namespace`), keys passed through verbatim.
|
||||||
|
4. Renders every `*.yaml.j2` the app ships into
|
||||||
|
`/var/lib/rancher/k3s/server/manifests/`, where k3s's own deploy and helm
|
||||||
|
controllers pick them up.
|
||||||
|
|
||||||
|
Nothing here talks to the Kubernetes API — no `helm`, no kubeconfig, no
|
||||||
|
`kubernetes.core`. Ansible writes files; k3s reconciles them. The consequence
|
||||||
|
worth remembering: **a green run means the manifests landed, not that the
|
||||||
|
workload came up.** Check with `kubectl -n <namespace> get pods`.
|
||||||
|
|
||||||
|
Secrets land on disk in the rendered `.env` (root-readable, on the Unraid
|
||||||
|
box) or, on k3s, in a 0600 manifest and then in etcd as an ordinary
|
||||||
|
Kubernetes Secret — this is "fetch from Vault at deploy time," not a
|
||||||
|
zero-secrets-at-rest model. Fine for a homelab; revisit if that changes.
|
||||||
|
|
||||||
|
## Icons on the Unraid Docker page
|
||||||
|
|
||||||
|
There are two icons per stack, and they come from completely different places.
|
||||||
|
Commit **one** `src/<app>/ansible/unraid/icon.png` and the deploy feeds both.
|
||||||
|
A stack running more than one container can commit
|
||||||
|
`icon-<service>.png` alongside it for the extra services — see "More than one
|
||||||
|
container" below.
|
||||||
|
|
||||||
|
### The stack row
|
||||||
|
|
||||||
|
Compose Manager draws the collapsible header row for the whole project, and
|
||||||
|
serves its icon straight off disk from `icon.png` in the project folder —
|
||||||
|
`/boot/config/plugins/compose.manager/projects/<stack>/icon.png`, the same
|
||||||
|
directory the compose file and rendered `.env` land in. No label, no
|
||||||
|
template, no URL: the file is there or the row has no icon. `.jpg`, `.gif`,
|
||||||
|
`.svg` and an extensionless `icon` also work, since the plugin serves the file
|
||||||
|
itself rather than handing a path to the webgui — but stick to PNG so the same
|
||||||
|
file can serve the labels below.
|
||||||
|
|
||||||
|
This is a feature of **Compose Manager Plus** (`mstrhakr/compose_plugin`), the
|
||||||
|
maintained fork. The original Docker Compose Manager plugin (`dcflachs`,
|
||||||
|
archived April 2026) has no icon support at all — on that one the copied file
|
||||||
|
is simply ignored, so the deploy is safe either way, you just won't see it.
|
||||||
|
|
||||||
|
### The containers under it
|
||||||
|
|
||||||
|
Unraid's Docker page gets each *container's* icon, WebUI link and console
|
||||||
|
shell out of the dockerMan template that created it, under
|
||||||
|
`/boot/config/plugins/dockerMan/templates-user/`. A Compose stack never
|
||||||
|
creates one, which is why compose-deployed containers show a question mark and
|
||||||
|
no WebUI entry. Unraid 6.10+ falls back to Docker labels when there's no
|
||||||
|
template, so each service sets them itself:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
net.unraid.docker.icon: ${STACK_ICON}
|
||||||
|
net.unraid.docker.webui: "http://[IP]:${FORGEJO_HTTP_PORT}/"
|
||||||
|
net.unraid.docker.shell: sh
|
||||||
|
```
|
||||||
|
|
||||||
|
They're per **service**, not per stack — a stack with two containers labels
|
||||||
|
each one separately, and can point them at different icons.
|
||||||
|
|
||||||
|
- **icon** — `${STACK_ICON}` is set by the role, not by hand. The same
|
||||||
|
committed `icon.png` is copied a second time to
|
||||||
|
`/mnt/user/appdata/icons/<stack>.png` and the label points there; ship no
|
||||||
|
`icon.png` and the variable renders empty, leaving the placeholder. To use a
|
||||||
|
hosted image instead, set `STACK_ICON` in the app's
|
||||||
|
`ansible/unraid/vars.yml` — it's merged over the role's value.
|
||||||
|
|
||||||
|
A second copy rather than reusing the project-folder one because this label
|
||||||
|
is a path the *webgui* resolves on every Docker page load, and that path
|
||||||
|
shouldn't run through the flash drive.
|
||||||
|
|
||||||
|
**PNG only here** — unlike the stack row, this goes through the webgui's own
|
||||||
|
icon handling, where SVG renders as the question-mark fallback and WebP
|
||||||
|
renders nothing at all. A remote URL is fetched when the page renders, so an
|
||||||
|
unreachable host breaks the icon — the reason a committed file is the
|
||||||
|
default.
|
||||||
|
|
||||||
|
- **webui** — `[IP]` is substituted with the host's address. The port must be
|
||||||
|
the *published* one, so interpolate the stack's own port variable rather
|
||||||
|
than the container port. Omit the label entirely for something with no web
|
||||||
|
interface (as `shared/postgres` does) — an empty value still draws a WebUI
|
||||||
|
entry that goes nowhere.
|
||||||
|
|
||||||
|
- **shell** — `sh` or `bash`, whichever the image actually has. Alpine-based
|
||||||
|
images (Forgejo) need `sh`; Debian-based ones (Postgres, Shelfarr) can take
|
||||||
|
`bash`.
|
||||||
|
|
||||||
|
One caveat: editing a compose container's labels through the Unraid UI writes
|
||||||
|
a template for it, and the template then wins over the labels until you clear
|
||||||
|
it. Change these in the compose file and redeploy, not in the webgui.
|
||||||
|
|
||||||
|
### More than one container
|
||||||
|
|
||||||
|
`${STACK_ICON}` is a single value — the stack's `icon.png` — so a stack with
|
||||||
|
two services would otherwise have to give both the same image. Since the
|
||||||
|
labels are per-service, commit `icon-<service>.png` next to `icon.png` for
|
||||||
|
each additional container, named for the **compose service**:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/arr/ansible/unraid/
|
||||||
|
icon.png → ${STACK_ICON} → /mnt/user/appdata/icons/arr.png
|
||||||
|
icon-prowlarr.png → ${STACK_ICON_PROWLARR} → /mnt/user/appdata/icons/arr-prowlarr.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The variable is the service name uppercased with `-` folded to `_`
|
||||||
|
(`icon-shelfarr-libation.png` → `${STACK_ICON_SHELFARR_LIBATION}`). These get
|
||||||
|
the appdata copy only — there is one Compose Manager stack row and `icon.png`
|
||||||
|
already has it — and, like `STACK_ICON`, each can be overridden with a hosted
|
||||||
|
URL by setting the same name in the app's `ansible/unraid/vars.yml`.
|
||||||
|
|
||||||
|
The variable only exists when the file does. Interpolating
|
||||||
|
`${STACK_ICON_FOO}` with no `icon-foo.png` committed gets Compose's
|
||||||
|
unset-variable warning and the question-mark placeholder — so if the icon is
|
||||||
|
meant to be a URL, set it in `vars.yml` rather than relying on the file.
|
||||||
|
|
||||||
|
## Backups (Proxmox)
|
||||||
|
|
||||||
|
Proxmox guests keep **all** their state on their own rootfs. Nothing is
|
||||||
|
bind-mounted in from the NAS, which is the opposite of the obvious instinct —
|
||||||
|
the reason is that `vzdump` deliberately excludes bind mounts, so a
|
||||||
|
bind-mounted repository directory would be the one thing missing from the
|
||||||
|
backup that was made to protect it. Keeping everything local means one archive
|
||||||
|
is a complete copy of the guest's filesystem.
|
||||||
|
|
||||||
|
`playbooks/pve_host.yml` (role: `pve_backup`) configures the off-box copy on
|
||||||
|
the node: an NFS storage pointing at the NAS, and a scheduled `vzdump` job
|
||||||
|
writing to it. Retention is the storage's `prune-backups` settings. Point it at
|
||||||
|
a Proxmox Backup Server instead by setting `pve_backup_storage_type: pbs` in
|
||||||
|
`host_vars/<node>.yml`.
|
||||||
|
|
||||||
|
**Data that lives outside a container is that app's problem, not vzdump's.**
|
||||||
|
Forgejo's database is on the shared Postgres over on Unraid, so restoring its
|
||||||
|
container alone would give back every repository with no issues, pull requests,
|
||||||
|
users or permissions. The fix is a `pg_dump` on a systemd timer *inside* the
|
||||||
|
container, writing into its own filesystem — which puts the dump inside the
|
||||||
|
same archive as the repositories it belongs to. That only works if it finishes
|
||||||
|
before the backup window, so the two schedules are a pair:
|
||||||
|
|
||||||
|
| | Set in | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| Forgejo database dump | `src/forgejo/ansible/proxmox/vars.yml` → `FORGEJO_DB_DUMP_ONCALENDAR` | 01:30 |
|
||||||
|
| vzdump job | `inventory/host_vars/pve.yml` → `pve_backup_schedule` | 02:00 |
|
||||||
|
|
||||||
|
Any future app with external state should follow the same pattern. Restore
|
||||||
|
steps are in `src/forgejo/ansible/proxmox/README.md`.
|
||||||
|
|
||||||
|
None of this covers the Unraid side, which has its own backup arrangements
|
||||||
|
outside this repo.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Both platforms
|
||||||
|
|
||||||
|
- Ansible control node needs to be Linux — **on Windows, run it from WSL2**,
|
||||||
|
not natively.
|
||||||
|
- If this repo is checked out on a Windows mount (e.g. under `/mnt/c/...`,
|
||||||
|
which is where a Seafile-synced folder ends up) rather than the native WSL
|
||||||
|
filesystem, Ansible will warn that `ansible.cfg` is being ignored because
|
||||||
|
the directory looks world-writable — that's DrvFs reporting `777` on
|
||||||
|
everything, not a real permissions problem. Either `export
|
||||||
|
ANSIBLE_CONFIG="$(pwd)/ansible.cfg"` before running (from the
|
||||||
|
`build/config/ansible/` dir), or fix it permanently by adding to
|
||||||
|
`/etc/wsl.conf` inside WSL:
|
||||||
|
```ini
|
||||||
|
[automount]
|
||||||
|
options = "metadata,umask=22,fmask=11"
|
||||||
|
```
|
||||||
|
then `wsl --shutdown` from PowerShell and reopen the shell.
|
||||||
|
- `pip install ansible hvac psycopg2-binary`
|
||||||
|
- `ansible-galaxy collection install -r build/config/ansible/requirements.yml`
|
||||||
|
- A reachable Vault instance, with secrets populated per
|
||||||
|
`docs/vault-secrets.md`.
|
||||||
|
|
||||||
|
### Unraid
|
||||||
|
|
||||||
|
- Root SSH enabled on the Unraid box(es) (Settings → Management Access).
|
||||||
|
- Compose Manager (Docker Compose plugin) installed on Unraid — already
|
||||||
|
done.
|
||||||
|
- **Python3 on each Unraid host itself**, not just the controller: the
|
||||||
|
`compose_stack` role's tasks (`file`, `copy`, `template`,
|
||||||
|
`docker_compose_v2`) run on the target, and Ansible modules — unlike
|
||||||
|
`raw`/ad hoc shell — need a Python interpreter there to execute at all.
|
||||||
|
Stock Unraid doesn't ship one. Installing it by hand into the running
|
||||||
|
system doesn't survive a reboot, same reason as the SSH-key gotcha above —
|
||||||
|
Unraid rebuilds its root filesystem from the flash drive on every boot.
|
||||||
|
Install the **NerdTools** plugin (Community Applications → search
|
||||||
|
"NerdTools", formerly "NerdPack") and enable `python3` in its package
|
||||||
|
list — it reinstalls whatever you've selected on every boot itself, same
|
||||||
|
mechanism Compose Manager already relies on. `ansible.cfg` already sets
|
||||||
|
`interpreter_python = auto_silent`, so Ansible finds it automatically
|
||||||
|
wherever NerdTools puts it; no path to hardcode. Verify after installing:
|
||||||
|
```sh
|
||||||
|
ansible unraid_servers -m ping
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxmox
|
||||||
|
|
||||||
|
- Terraform CLI (>= 1.6) on whatever runs `terraform apply`.
|
||||||
|
- A Proxmox API token with rights to create containers, stored in Vault at
|
||||||
|
`homelab/ci/proxmox` (see `docs/vault-secrets.md`). Both the Terraform
|
||||||
|
provider and Ansible's dynamic inventory authenticate with it.
|
||||||
|
- A `terraform_state` database and `terraform` role on the **CloudNativePG
|
||||||
|
cluster on k3s** (192.168.50.81), for `backend "pg"` — not on either shared
|
||||||
|
Postgres this repo deploys, which would be circular. See "Bootstrapping"
|
||||||
|
below.
|
||||||
|
- Container templates downloaded on the Proxmox node for whatever OS the
|
||||||
|
LXCs are built from.
|
||||||
|
- An NFS export on the NAS for backup archives, reachable from the Proxmox
|
||||||
|
node — set it in `inventory/host_vars/pve.yml`. See "Backups (Proxmox)".
|
||||||
|
- Root SSH from the Ansible controller to the Proxmox node itself, not just
|
||||||
|
the guests: `playbooks/pve_host.yml` configures the node over SSH.
|
||||||
|
- Unlike Unraid, nothing special is needed *inside* the guests: they're
|
||||||
|
ordinary Linux containers with Python already present, so Ansible works
|
||||||
|
without the NerdTools workaround above.
|
||||||
|
|
||||||
|
### K3s (Raspberry Pi)
|
||||||
|
|
||||||
|
- 4 Raspberry Pis running Ubuntu Server (64-bit), already imaged and on the
|
||||||
|
network at the addresses in `inventory/hosts.yml` → `k3s_cluster`.
|
||||||
|
- An `ansible` user on each Pi with **NOPASSWD sudo** and this repo's k3s SSH
|
||||||
|
key installed as an authorized key — see "SSH access" below. Not automated
|
||||||
|
by this repo; create it by hand (or via cloud-init at image time) before the
|
||||||
|
first run of `playbooks/k3s.yml`.
|
||||||
|
- `homelab/k3s-homelab-utils` → `K3S_TOKEN` set in Vault *before* the first
|
||||||
|
run — see `docs/vault-secrets.md`. Both the server and every agent read
|
||||||
|
this same fixed value rather than one generating it and handing it to the
|
||||||
|
other, which is what makes a rebuild reproducible.
|
||||||
|
- Nothing else special: `python3` ships with Ubuntu Server, and the k3s
|
||||||
|
install script (`get.k3s.io`) handles containerd, the systemd unit, and
|
||||||
|
everything else that isn't Pi-specific. The one Pi-specific thing —
|
||||||
|
ensuring the memory cgroup controller is on — is handled by
|
||||||
|
`roles/k3s_node` itself, not a prerequisite here.
|
||||||
|
|
||||||
|
#### Keeping it patched
|
||||||
|
|
||||||
|
`playbooks/k3s.yml` also applies `roles/unattended_upgrades` to every node —
|
||||||
|
apt updates install themselves on their own daily schedule, no login
|
||||||
|
required. It sets `Unattended-Upgrade::Automatic-Reboot "false"` though, so a
|
||||||
|
kernel or containerd update that needs a reboot to take effect just sits
|
||||||
|
applied-but-inactive until one happens; blindly auto-rebooting a k3s node
|
||||||
|
takes its pods down with no warning.
|
||||||
|
|
||||||
|
`playbooks/k3s_maintenance.yml` is the other half: it checks
|
||||||
|
`/var/run/reboot-required` on each node and, only where it's set, cordons and
|
||||||
|
drains the node, reboots it, waits for it to report `Ready` again, then
|
||||||
|
uncordons it — one node at a time (`serial: 1`), so patching the cluster never
|
||||||
|
means dropping every workload at once. Manual-only for now, same as
|
||||||
|
`playbooks/k3s.yml`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ansible-playbook playbooks/k3s_maintenance.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Eventually this is meant to run on a schedule from a self-hosted Forgejo
|
||||||
|
Actions runner rather than by hand — not wired up yet, see the header comment
|
||||||
|
in the playbook.
|
||||||
|
|
||||||
|
#### Metrics (Prometheus)
|
||||||
|
|
||||||
|
`playbooks/k3s.yml` also deploys `roles/k3s_monitoring`: a lean
|
||||||
|
kube-prometheus-stack (Prometheus + prometheus-operator + node-exporter +
|
||||||
|
kube-state-metrics — no Grafana, no Alertmanager) so tools like OpenLens can
|
||||||
|
show node/pod metrics. It's installed as a `HelmChart` custom resource
|
||||||
|
dropped into k3s's own auto-deploying manifests directory
|
||||||
|
(`/var/lib/rancher/k3s/server/manifests/`) rather than run through a `helm`
|
||||||
|
binary — k3s's bundled helm-controller reconciles it the same way it
|
||||||
|
installs its own Traefik and ServiceLB, so this needs no extra Ansible
|
||||||
|
collection or kubeconfig on the controller. See
|
||||||
|
`roles/k3s_monitoring/defaults/main.yml` for the resource sizing (tuned for
|
||||||
|
a Raspberry Pi 4, not a real node) and why the control-plane component
|
||||||
|
scrapers (`kubeControllerManager`, `kubeScheduler`, `kubeProxy`, `kubeEtcd`)
|
||||||
|
are disabled — k3s doesn't expose those the way kube-prometheus-stack
|
||||||
|
expects, so leaving them on just produces permanently-"down" targets.
|
||||||
|
|
||||||
|
To point OpenLens at it: open the cluster's Settings → Metrics, set
|
||||||
|
Prometheus to "Auto detect" or explicitly to the `Operator` provider — it
|
||||||
|
should find the `kube-prometheus-stack-prometheus` service in the
|
||||||
|
`monitoring` namespace via the API server proxy, the same path OpenLens
|
||||||
|
already uses to reach the cluster, so nothing needs to be exposed outside
|
||||||
|
it.
|
||||||
|
|
||||||
|
#### LoadBalancer IPs (MetalLB)
|
||||||
|
|
||||||
|
`playbooks/k3s.yml` also deploys `roles/k3s_metallb`: MetalLB in L2 mode,
|
||||||
|
handing out real LAN IPs to `type: LoadBalancer` Services instead of the
|
||||||
|
`ClusterIP`-only world k3s would otherwise leave homelab-utils apps in. It's
|
||||||
|
installed the same way as monitoring — a `HelmChart` CR for k3s's
|
||||||
|
helm-controller to reconcile — plus a plain `IPAddressPool`/`L2Advertisement`
|
||||||
|
manifest dropped in the same directory; k3s's deploy controller applies both
|
||||||
|
kinds of file and retries the config manifest until the HelmChart's CRDs
|
||||||
|
exist.
|
||||||
|
|
||||||
|
MetalLB replaces k3s's bundled ServiceLB (Klipper), it doesn't run alongside
|
||||||
|
it — both would otherwise compete to satisfy the same Services. That's why
|
||||||
|
`inventory/group_vars/k3s_cluster.yml` sets `k3s_server_extra_args:
|
||||||
|
['--disable=servicelb']` — a separate, server-only var from `k3s_extra_args`,
|
||||||
|
since `k3s agent` doesn't understand `--disable` and would fail to start if
|
||||||
|
it were passed there too. The IP pool itself is
|
||||||
|
`k3s_metallb_address_range` in the same file — a range on the cluster's LAN
|
||||||
|
(`192.168.50.0/24`) that DHCP and every static assignment in
|
||||||
|
`inventory/hosts.yml` steer clear of. There's no built-in default: the role
|
||||||
|
fails fast if it's still empty.
|
||||||
|
|
||||||
|
Enabling `--disable=servicelb` on an already-running cluster doesn't need a
|
||||||
|
separate step: `roles/k3s_node` notices its exec line has changed and
|
||||||
|
reinstalls (restarting just the `k3s` service, not the node) the next time
|
||||||
|
`playbooks/k3s.yml` runs — see `CLAUDE.md` → "Key decisions".
|
||||||
|
|
||||||
|
#### Postgres (CloudNativePG)
|
||||||
|
|
||||||
|
`playbooks/k3s.yml` also deploys `roles/k3s_postgres`: CloudNativePG (CNPG),
|
||||||
|
a Postgres operator, installed the same way as monitoring and MetalLB — a
|
||||||
|
`HelmChart` CR for k3s's helm-controller to reconcile — plus a plain
|
||||||
|
`Cluster` CR (the actual database) and a `Secret` (superuser credentials),
|
||||||
|
dropped in the same auto-deploying manifests directory. This is a third
|
||||||
|
platform for `src/shared/postgres/`, alongside the Unraid Compose stack and
|
||||||
|
the Proxmox placeholder: `roles/k3s_postgres` layers
|
||||||
|
`src/shared/postgres/common/vars.yml` with
|
||||||
|
`src/shared/postgres/ansible/kubernetes/vars.yml` the same way
|
||||||
|
`compose_stack`/`lxc_app` layer an app's config, and pulls the superuser
|
||||||
|
password from the same `homelab/shared/postgres` Vault path those platforms
|
||||||
|
already use (see `docs/vault-secrets.md`) — a separate physical instance,
|
||||||
|
not a shared login across platforms, just the same identity.
|
||||||
|
|
||||||
|
1 primary + 1 replica, kept off the control-plane Pi via `nodeAffinity` (see
|
||||||
|
`roles/k3s_postgres/templates/postgres-cluster.yaml.j2`) — CNPG's own pod
|
||||||
|
anti-affinity then spreads the two across the 3 worker Pis. Storage is k3s's
|
||||||
|
default `local-path` StorageClass; that's node-local, but resilience here
|
||||||
|
comes from CNPG's own streaming replication between instances, not from
|
||||||
|
shared storage, so losing one instance's disk doesn't lose the data as long
|
||||||
|
as the other instance is up. HA replication only for now — no
|
||||||
|
`ScheduledBackup` to the NAS or anywhere else yet.
|
||||||
|
|
||||||
|
To connect: `kubectl -n shared-postgres get svc` lists four Services. Three
|
||||||
|
are CNPG's own, all `ClusterIP` — `shared-postgres-rw` (the current primary),
|
||||||
|
`-ro` (replicas only) and `-r` (any instance). An app running on the cluster
|
||||||
|
uses the first of those by DNS and needs nothing else:
|
||||||
|
|
||||||
|
```
|
||||||
|
shared-postgres-rw.shared-postgres.svc.cluster.local:5432
|
||||||
|
```
|
||||||
|
|
||||||
|
The fourth, `shared-postgres-lb`, is a `LoadBalancer` on
|
||||||
|
`k3s_postgres_loadbalancer_ip` (`inventory/group_vars/k3s_cluster.yml` —
|
||||||
|
inside the MetalLB pool above), which is how the cluster is reached from
|
||||||
|
outside it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
psql -h 192.168.50.81 -U postgres # password: homelab/shared/postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
It's declared in the `Cluster` CR as a CNPG *managed service* rather than as
|
||||||
|
a Service manifest of our own, so the operator keeps its selector pointed at
|
||||||
|
whichever instance is currently primary — a hand-written Service would need
|
||||||
|
re-pointing by hand after a failover. Credentials are the
|
||||||
|
`shared-postgres-superuser-vault` Secret Ansible renders; CNPG separately
|
||||||
|
auto-creates `shared-postgres-app` for the default `app` database it
|
||||||
|
bootstraps, which nothing uses yet.
|
||||||
|
|
||||||
|
The `-vault` suffix keeps that Secret clear of `<cluster>-superuser`, the
|
||||||
|
name CNPG uses for the superuser secret it generates itself. Name our own
|
||||||
|
object that and the operator treats it as one it already authored, so a
|
||||||
|
rotated password reaches the Secret and never reaches the database — the
|
||||||
|
Secret reads correctly, `psql` from the LAN keeps failing authentication, and
|
||||||
|
only `select rolpassword is null from pg_authid where rolname='postgres'`
|
||||||
|
inside the pod shows the disagreement.
|
||||||
|
|
||||||
|
That LoadBalancer is what makes the controller-reachable `host:port` the
|
||||||
|
`community.postgresql` tasks need — the same ones `compose_stack` and
|
||||||
|
`lxc_app` use to create an app's database and role — available here too, and
|
||||||
|
`roles/k3s_app` now uses exactly those tasks for any app declaring a `db:`
|
||||||
|
block.
|
||||||
|
|
||||||
|
Note the deliberate asymmetry, which reads like a mistake until you know why:
|
||||||
|
provisioning uses `192.168.50.81` (the LoadBalancer) because it runs on the
|
||||||
|
Ansible controller, which is off-cluster and can't route to a ClusterIP;
|
||||||
|
apps use `shared-postgres-rw.shared-postgres.svc.cluster.local` because
|
||||||
|
sending pod traffic out to the LAN and back would put MetalLB's L2 speaker in
|
||||||
|
the path of every query for nothing. Two addresses, one database, each
|
||||||
|
correct for its caller.
|
||||||
|
|
||||||
|
Without `kubectl` to hand, or if MetalLB is having a bad day, the in-cluster
|
||||||
|
routes still work:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl -n shared-postgres port-forward svc/shared-postgres-rw 5432:5432
|
||||||
|
kubectl -n shared-postgres exec -it shared-postgres-1 -- psql -U postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**No backups.** HA replication only — a streaming replica on another Pi,
|
||||||
|
which covers a dead SD card and not a dropped table, a bad migration, or a
|
||||||
|
mistyped `DROP`. This mattered less when the cluster held nothing; it matters
|
||||||
|
now that Authentik's database lives here rather than on Unraid. A CNPG
|
||||||
|
`ScheduledBackup` to the NAS is the obvious next piece of work and isn't
|
||||||
|
built yet.
|
||||||
|
|
||||||
|
#### TLS (cert-manager)
|
||||||
|
|
||||||
|
`playbooks/k3s.yml` also applies `roles/k3s_cert_manager`: cert-manager plus
|
||||||
|
one `ClusterIssuer` named `letsencrypt`, both via the usual `HelmChart` CR +
|
||||||
|
plain manifest pair. An app's Ingress opts in with a single annotation —
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
— and cert-manager creates and renews the certificate on its own.
|
||||||
|
|
||||||
|
Two values have no default and the role refuses to run without them, both in
|
||||||
|
`inventory/group_vars/k3s_cluster.yml`:
|
||||||
|
|
||||||
|
- `k3s_cert_manager_acme_email` — an ACME account is registered against it.
|
||||||
|
- `k3s_cert_manager_solver` — the DNS-01 stanza for your DNS provider,
|
||||||
|
rendered into the issuer as-is. A commented Cloudflare example is in that
|
||||||
|
file; for anything else take the stanza from
|
||||||
|
[cert-manager's docs](https://cert-manager.io/docs/configuration/acme/dns01/).
|
||||||
|
Its API token goes in Vault at `homelab/k3s-cert-manager`.
|
||||||
|
|
||||||
|
DNS-01 rather than HTTP-01 because HTTP-01 needs Let's Encrypt to reach this
|
||||||
|
cluster from the internet on port 80, which it can't. That has a useful
|
||||||
|
consequence: a certificate can be issued **before** DNS points at the
|
||||||
|
cluster, so a migration's TLS is settled before its cutover rather than
|
||||||
|
after.
|
||||||
|
|
||||||
|
While working out a solver, point `k3s_cert_manager_acme_server` at Let's
|
||||||
|
Encrypt staging — production allows five failed validations per hostname per
|
||||||
|
hour, and exhausting it means waiting, not retrying. Check it registered:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl get clusterissuer letsencrypt -o jsonpath='{.status.conditions[*].message}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dashboard (Traefik)
|
||||||
|
|
||||||
|
Traefik is the one chart here this repo doesn't install — k3s installs it
|
||||||
|
itself. `roles/k3s_traefik` only adjusts it, through a `HelmChartConfig`
|
||||||
|
merged over k3s's own `HelmChart`. Editing
|
||||||
|
`/var/lib/rancher/k3s/server/manifests/traefik.yaml` on the node instead
|
||||||
|
works until the next server restart rewrites it.
|
||||||
|
|
||||||
|
A stock k3s answers **404** on the dashboard, which looks like a broken
|
||||||
|
install and isn't: Traefik still builds the dashboard, but the Traefik chart
|
||||||
|
stopped creating the router that reaches it in v28. The role puts that router
|
||||||
|
back on Traefik's internal `traefik` entrypoint, which isn't published on the
|
||||||
|
Service — so it's reachable by port-forward and nothing else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl -n kube-system port-forward deploy/traefik 9000:9000
|
||||||
|
# then http://127.0.0.1:9000/dashboard/ — the trailing slash is required
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things to check if that still 404s. `curl -i http://127.0.0.1:9000/ping`
|
||||||
|
returning 200 means the port-forward is fine and only the router is missing
|
||||||
|
(so the role hasn't run, or its `HelmChartConfig` hasn't reconciled yet —
|
||||||
|
`kubectl -n kube-system get ingressroute` should list `traefik-dashboard`).
|
||||||
|
And forward to `deploy/traefik`, not `svc/traefik`: the Service only publishes
|
||||||
|
80/443, so going through it lands you on the `web` entrypoint, where an
|
||||||
|
unmatched request also returns 404.
|
||||||
|
|
||||||
|
**Publishing it on a hostname** is one commented line in
|
||||||
|
`inventory/group_vars/k3s_cluster.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
k3s_traefik_dashboard_host: traefik.turtlesystems.uk
|
||||||
|
```
|
||||||
|
|
||||||
|
That renders an `IngressRoute` on `websecure` with a cert-manager certificate
|
||||||
|
and an Authentik forward-auth middleware in front. `k3s_traefik_dashboard_auth_address`
|
||||||
|
must be set too — the role refuses to run otherwise rather than publishing an
|
||||||
|
unauthenticated view of every router, service and middleware on the cluster.
|
||||||
|
It's already set in that file, so in practice this is the single line above
|
||||||
|
plus a DNS record pointing at Traefik's MetalLB address
|
||||||
|
(`kubectl -n kube-system get svc traefik`).
|
||||||
|
|
||||||
|
On the Authentik side, first: a Proxy Provider in **forward auth (domain
|
||||||
|
level)** mode, assigned to an application, added to the embedded outpost.
|
||||||
|
Domain level rather than single-application because the latter needs
|
||||||
|
`/outpost.goauthentik.io/` routed to Authentik on the dashboard's own
|
||||||
|
hostname, which from `kube-system` is a cross-namespace service reference
|
||||||
|
Traefik rejects by default.
|
||||||
|
|
||||||
|
An `IngressRoute` rather than an `Ingress`, unlike every app here, because
|
||||||
|
the dashboard is served by Traefik's internal `api@internal` and has no
|
||||||
|
Kubernetes Service for an `Ingress` to point at. That's also why its
|
||||||
|
certificate is an explicit `Certificate` resource — cert-manager watches
|
||||||
|
`Ingress`, not `IngressRoute`.
|
||||||
|
|
||||||
|
Clearing the hostname again is a real teardown: the role deletes the
|
||||||
|
manifest, and k3s's deploy controller garbage-collects the route, middleware
|
||||||
|
and certificate it created.
|
||||||
|
|
||||||
|
The port-forward stays enabled alongside the published route on purpose. Once
|
||||||
|
the dashboard is behind Authentik it depends on Authentik, which depends on
|
||||||
|
CNPG, which depends on a healthy cluster — the things you'd open the
|
||||||
|
dashboard to diagnose. Port-forward talks to the pod and traverses none of
|
||||||
|
it, so it's the break-glass path, not a leftover.
|
||||||
|
|
||||||
|
#### Apps on the cluster
|
||||||
|
|
||||||
|
Apps go in `k3s_apps:` in `inventory/group_vars/k3s_cluster.yml` — the k3s
|
||||||
|
equivalent of a host's `stacks:`/`apps:` list, in group_vars because an app
|
||||||
|
is deployed to the cluster rather than to a node. Same entry shape as the
|
||||||
|
other platforms (name, src, vault_path, optional `db:`, optional `state:`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags apps
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags apps -e only_apps=authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
Ingress goes through the Traefik k3s already bundles, on the MetalLB address
|
||||||
|
its Service holds (`kubectl -n kube-system get svc traefik`) — so adding an
|
||||||
|
app costs a DNS record pointed at that one address, not a pool IP each.
|
||||||
|
|
||||||
|
Removal is `state: absent` on the entry, same tombstone rule as Unraid: keep
|
||||||
|
the entry, don't delete it, or nothing is torn down and the workload keeps
|
||||||
|
running unmanaged. Unlike Unraid it's a single pass — deleting the manifests
|
||||||
|
*is* the teardown, because k3s's deploy controller garbage-collects what each
|
||||||
|
file created — so `src/<app>/` can go in the same commit. The database, PVCs
|
||||||
|
and Vault path deliberately survive.
|
||||||
|
|
||||||
|
**Authentik** is currently the only app, and it got here by migrating off a
|
||||||
|
hand-made Unraid container rather than being deployed fresh. If you're
|
||||||
|
repeating that for something else, `docs/authentik-migration.md` is the
|
||||||
|
worked procedure; the part worth knowing up front is that the deployed
|
||||||
|
version must match the version a restored database was dumped from, because
|
||||||
|
Authentik runs its migrations on startup and they don't go backwards.
|
||||||
|
|
||||||
|
## SSH access
|
||||||
|
|
||||||
|
Ansible connects as `ansible_user: root` (set in
|
||||||
|
`build/config/ansible/inventory/hosts.yml`) over SSH. Password auth would mean either an interactive prompt every run —
|
||||||
|
which doesn't work from CI at all — or a plaintext password somewhere, so
|
||||||
|
use a dedicated key pair instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/unraid_ansible -C "ansible@unraid-iac" -N ""
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the public half as an authorized key on **every** Unraid host this
|
||||||
|
repo targets. Don't just `ssh-copy-id` it into `~/.ssh/authorized_keys` —
|
||||||
|
Unraid boots from the flash drive and `/root` lives on a RAM-backed overlay,
|
||||||
|
so anything written there is gone on the next reboot. Persist it through
|
||||||
|
`/boot/config/ssh/root.authorized_keys` instead, which Unraid copies into
|
||||||
|
place at every boot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat ~/.ssh/unraid_ansible.pub | ssh root@<unraid-host> \
|
||||||
|
'cat >> /boot/config/ssh/root.authorized_keys'
|
||||||
|
```
|
||||||
|
|
||||||
|
For manual/bootstrap runs, load the key into `ssh-agent` and Ansible picks it
|
||||||
|
up automatically — no config file changes needed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
eval "$(ssh-agent -s)"
|
||||||
|
ssh-add ~/.ssh/unraid_ansible
|
||||||
|
```
|
||||||
|
|
||||||
|
For CI, rather than adding yet another place secrets live, the private key
|
||||||
|
is stored in Vault (`homelab/ci/ssh`, see `docs/vault-secrets.md`) and the
|
||||||
|
workflow fetches it at the start of each run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/ci/ssh PRIVATE_KEY=@~/.ssh/unraid_ansible
|
||||||
|
```
|
||||||
|
|
||||||
|
**K3s Pis** work the same way but as a non-root `ansible` user, not root, and
|
||||||
|
with their own key pair (`~/.ssh/k3s_ansible`, Vault path
|
||||||
|
`homelab/ci/ssh-k3s`) — see `docs/vault-secrets.md` for the full commands.
|
||||||
|
Three differences from the Unraid steps above. The public key goes in the
|
||||||
|
ordinary `~/.ssh/authorized_keys` for the `ansible` user (Ubuntu's root
|
||||||
|
filesystem isn't rebuilt from flash on every boot, so there's no persistence
|
||||||
|
quirk to work around), and that user needs NOPASSWD sudo configured in
|
||||||
|
`/etc/sudoers.d/` — `playbooks/k3s.yml` installs k3s via `become: true`.
|
||||||
|
|
||||||
|
And the private half doesn't need loading into `ssh-agent` at all, even for a
|
||||||
|
manual run: `playbooks/k3s_ssh_key.yml` fetches it from Vault and writes it to
|
||||||
|
`local/k3s/homelab-utils.key`, and both `playbooks/k3s.yml` and
|
||||||
|
`playbooks/k3s_maintenance.yml` import that as their first play. So a k3s run
|
||||||
|
needs `VAULT_ADDR`/`VAULT_TOKEN` in the environment and nothing else — the
|
||||||
|
same "Vault is the only place secrets live" arrangement CI already uses for
|
||||||
|
the Unraid key, rather than a second setup step to remember. To fall back to a
|
||||||
|
local copy (Vault down, say), skip the fetch **and** point at the key —
|
||||||
|
overriding the path alone isn't enough, since the fetch play would still fail
|
||||||
|
before anything else ran:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ansible-playbook playbooks/k3s.yml \
|
||||||
|
-e k3s_ssh_key_fetch=false \
|
||||||
|
-e ansible_ssh_private_key_file=~/.ssh/k3s_ansible
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bootstrapping (chicken-and-egg on the very first deploy)
|
||||||
|
|
||||||
|
CI/CD runs on Forgejo Actions — but Forgejo doesn't exist yet on the first
|
||||||
|
run, and Actions needs a self-hosted runner besides. So the first deploy is
|
||||||
|
manual:
|
||||||
|
|
||||||
|
1. Fill in `build/config/ansible/inventory/hosts.yml` with nas2's real
|
||||||
|
address.
|
||||||
|
2. Set up the SSH key pair per "SSH access" above and install the public key
|
||||||
|
on nas2 (and nas1, once you migrate anything to it).
|
||||||
|
3. `vault kv put` the secrets in `docs/vault-secrets.md` for
|
||||||
|
`homelab/shared/postgres`, `homelab/forgejo`, and `homelab/ci/ssh`.
|
||||||
|
4. From WSL, with `VAULT_ADDR` / `VAULT_TOKEN` exported (`vault login` first)
|
||||||
|
and the key loaded in `ssh-agent`:
|
||||||
|
```sh
|
||||||
|
cd build/config/ansible
|
||||||
|
ansible-playbook playbooks/unraid.yml --limit nas2
|
||||||
|
```
|
||||||
|
This brings up shared Postgres, provisions the `forgejo` database/role,
|
||||||
|
and brings up Forgejo. Use `unraid.yml` rather than `deploy.yml` at this
|
||||||
|
point — no Proxmox guests exist yet, so the Proxmox play has nothing to
|
||||||
|
target.
|
||||||
|
5. Log into Forgejo, create this repo there, push it.
|
||||||
|
6. Register a self-hosted Forgejo Actions runner reachable to Vault and to
|
||||||
|
both Unraid hosts (labelled `unraid-deploy` — see
|
||||||
|
`.forgejo/workflows/deploy.yml`). Give it an AppRole (`VAULT_ROLE_ID` /
|
||||||
|
`VAULT_SECRET_ID`) scoped to read the `kv/homelab/*` paths it needs
|
||||||
|
(including `homelab/ci/ssh`), stored as Forgejo Actions secrets alongside
|
||||||
|
`VAULT_ADDR`.
|
||||||
|
7. From then on, pushes to `main` touching `src/**` or `build/config/ansible/**`
|
||||||
|
deploy automatically — and only the stack(s) whose `src/<stack>/` folder
|
||||||
|
actually changed (see "Deploying one platform, one host, one stack"
|
||||||
|
below); a change under `build/config/ansible/` still triggers a full
|
||||||
|
deploy of every stack on every host, since that's a change to how *all* of
|
||||||
|
them get deployed.
|
||||||
|
|
||||||
|
### Bringing Proxmox online later
|
||||||
|
|
||||||
|
The shared Postgres and its LXC are the first thing to stand up here, and the
|
||||||
|
ordering is fiddly enough to have its own runbook: **`docs/postgres-proxmox.md`**.
|
||||||
|
The short version, and the reason it isn't just "run Terraform":
|
||||||
|
|
||||||
|
Terraform stores its state in Postgres, so provisioning the Postgres LXC with
|
||||||
|
Terraform would be circular. It's broken by keeping state on the
|
||||||
|
CloudNativePG cluster on the k3s Pis instead — something this configuration
|
||||||
|
has no hand in building — which means `terraform apply` now depends on the
|
||||||
|
cluster being up. That's a cross-platform dependency the rest of the repo
|
||||||
|
avoids, and it's worth it because the alternative is a local-state-then-migrate
|
||||||
|
dance that has to be got right exactly once.
|
||||||
|
|
||||||
|
After that, `docs/postgres-proxmox.md` walks through the template download,
|
||||||
|
`terraform apply -target=module.postgres`, checking the ZFS replication job
|
||||||
|
actually exists, installing Postgres with
|
||||||
|
`ansible-playbook playbooks/proxmox.yml -e only_stacks=postgres`, and
|
||||||
|
`playbooks/pve_host.yml` for node backups. It also covers failing over between
|
||||||
|
the two nodes and repointing clients afterwards.
|
||||||
|
|
||||||
|
Forgejo comes after all of that, on **turtle-proxmox-02** — its database is
|
||||||
|
the shared Postgres the runbook above creates, so none of it works until that
|
||||||
|
one is finished. `docs/forgejo-proxmox.md` walks through the extra Vault key
|
||||||
|
the native install needs, `terraform apply -target=module.forgejo`,
|
||||||
|
`ansible-playbook playbooks/proxmox.yml -e only_stacks=forgejo`, and the parts
|
||||||
|
Ansible deliberately does not do — moving repository data, and the DNS/SSH-port
|
||||||
|
cutover.
|
||||||
|
|
||||||
|
## Deploying one platform, one host, one stack
|
||||||
|
|
||||||
|
`playbooks/deploy.yml` runs everything; `playbooks/unraid.yml`,
|
||||||
|
`playbooks/proxmox.yml` and `playbooks/pve_host.yml` do one slice each
|
||||||
|
(Unraid stacks, Proxmox guests, and the Proxmox node's backup config
|
||||||
|
respectively). Within any of them, `--limit <host>`
|
||||||
|
narrows by host and `-e only_stacks=<name>[,<name>...]` narrows by stack or
|
||||||
|
app (name = the `name:` in the host's `stacks:`/`apps:` list, e.g. `forgejo`
|
||||||
|
or `postgres` — not the `src:` path):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# everything, both platforms
|
||||||
|
ansible-playbook playbooks/deploy.yml
|
||||||
|
|
||||||
|
# everything declared for nas2
|
||||||
|
ansible-playbook playbooks/unraid.yml --limit nas2
|
||||||
|
|
||||||
|
# just forgejo on nas2
|
||||||
|
ansible-playbook playbooks/unraid.yml --limit nas2 -e only_stacks=forgejo
|
||||||
|
|
||||||
|
# forgejo and its database, skipping any other stack on nas2
|
||||||
|
ansible-playbook playbooks/unraid.yml --limit nas2 -e only_stacks=forgejo,postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Note `only_stacks` doesn't resolve dependencies for you — if you limit to
|
||||||
|
`forgejo` before `postgres` has ever been deployed, the DB provisioning step
|
||||||
|
will fail because there's nothing to connect to. CI doesn't hit this: it
|
||||||
|
computes `only_stacks` from which `src/**` paths actually changed in the
|
||||||
|
push, so an unrelated app's push never touches Postgres or Forgejo.
|
||||||
|
|
||||||
|
`deploy.yml` runs Unraid before Proxmox by convention rather than necessity —
|
||||||
|
the two platforms no longer depend on each other. Proxmox-side apps provision
|
||||||
|
their databases against the Proxmox shared Postgres (192.168.50.54), and
|
||||||
|
Terraform keeps its state on the CloudNativePG cluster on k3s; neither goes
|
||||||
|
through Unraid. The node's backup config goes last, since a vzdump job pinned
|
||||||
|
to specific VMIDs needs those guests to exist. Note that `only_stacks` has no
|
||||||
|
meaning for `pve_host.yml` — it configures a node, not an app, and ignores it.
|
||||||
|
|
||||||
|
`playbooks/k3s.yml` is separate from all of the above — it isn't included in
|
||||||
|
`deploy.yml` and doesn't take `only_stacks`, because a cluster service there
|
||||||
|
isn't a `stacks:` entry to filter down to. It has two selectors of its own
|
||||||
|
instead: `--limit` narrows *which nodes*, and `--tags` narrows *which piece*.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# the whole cluster: nodes, then the cluster services, then the kubeconfig
|
||||||
|
ansible-playbook playbooks/k3s.yml
|
||||||
|
|
||||||
|
# re-converge one worker, e.g. after reimaging it
|
||||||
|
ansible-playbook playbooks/k3s.yml --limit k3s-node-02
|
||||||
|
|
||||||
|
# just one cluster service, no node install across 4 Pis
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags postgres
|
||||||
|
|
||||||
|
# every cluster service, still no node install
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags services
|
||||||
|
```
|
||||||
|
|
||||||
|
The tags, one per play (see the playbook's header comment): `nodes` (both
|
||||||
|
node plays), `metallb`, `monitoring`, `postgres`, `cert-manager`, `traefik`,
|
||||||
|
`services` (all five of those together), `apps`, `upgrades`, `kubeconfig`.
|
||||||
|
`services` and `apps` don't imply each other, so "everything except
|
||||||
|
reinstalling k3s" is `--tags services,apps`. The SSH-key fetch play is tagged
|
||||||
|
`always` rather than named, so it survives every filter — it's what any of
|
||||||
|
the others connect with, not something you'd pick.
|
||||||
|
|
||||||
|
Deploying a single service this way skips the `kubeconfig` play too, so
|
||||||
|
`local/`'s copy isn't refreshed. That only matters on a cluster rebuild;
|
||||||
|
add `--tags postgres,kubeconfig` if you want both.
|
||||||
|
|
||||||
|
Worth knowing what a tagged run does and doesn't tell you: every service role
|
||||||
|
just templates manifests into `/var/lib/rancher/k3s/server/manifests/`
|
||||||
|
for k3s's own controllers to reconcile, so the playbook finishing means the
|
||||||
|
files landed, not that the workload is up. Watch that separately, e.g.
|
||||||
|
`kubectl -n shared-postgres get cluster,pods -w`.
|
||||||
|
|
||||||
|
`playbooks/k3s_maintenance.yml` is separate again — see "Keeping it patched"
|
||||||
|
above. It ignores `--limit` grouping in one sense worth knowing: `serial: 1`
|
||||||
|
still applies to whatever `--limit` narrows the run to, so limiting to two
|
||||||
|
nodes still patches them one at a time, not together.
|
||||||
|
|
||||||
|
## Adding a new app
|
||||||
|
|
||||||
|
1. `src/<app>/common/vars.yml` — `env_defaults:` for the config that doesn't
|
||||||
|
depend on where it runs.
|
||||||
|
2. Build the platform side(s) you need:
|
||||||
|
|
||||||
|
**Unraid** — `src/<app>/ansible/unraid/` containing
|
||||||
|
`docker-compose.yml` (static, `${VAR}`-driven, joining `unraid_shared`
|
||||||
|
with `external: true` if it needs the shared Postgres), `vars.yml` of
|
||||||
|
overrides, and `.env.example` documenting every var with secrets blank.
|
||||||
|
Optionally an `icon.png` and the `net.unraid.docker.*` labels, so the
|
||||||
|
Docker page shows something other than a question mark.
|
||||||
|
|
||||||
|
**Proxmox** — `src/<app>/ansible/proxmox/` containing `vars.yml`,
|
||||||
|
`install.yml`, and templates for the app's config file and systemd unit;
|
||||||
|
plus `src/<app>/terraform/` defining its LXC, called from
|
||||||
|
`build/config/terraform/main.tf`.
|
||||||
|
|
||||||
|
3. Add its `homelab/<app>` path to Vault (`docs/vault-secrets.md`).
|
||||||
|
4. Declare it on the target host — `stacks:` in an Unraid host's
|
||||||
|
`host_vars/<host>.yml`, or `apps:` in a Proxmox guest's. Same entry shape
|
||||||
|
either way (name, src, vault_path, optional `db:` block).
|
||||||
|
5. Push — or run the relevant playbook manually before CI exists.
|
||||||
|
6. Leave the platform you didn't build as a README placeholder rather than
|
||||||
|
deleting the folder.
|
||||||
|
|
||||||
|
## Removing an app (Unraid)
|
||||||
|
|
||||||
|
**Deleting the entry from `stacks:` does not remove anything.** Ansible keeps
|
||||||
|
no record of what it deployed last run, so an app that disappears from the
|
||||||
|
list is simply never visited again — its containers keep running on the host,
|
||||||
|
now unmanaged and invisible to the playbook. This is the one place the Unraid
|
||||||
|
side doesn't behave like the Terraform side, where deleting a module call does
|
||||||
|
destroy the LXC.
|
||||||
|
|
||||||
|
Removal is therefore an instruction, not an absence. Set `state: absent` on
|
||||||
|
the entry and leave it in place:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
stacks:
|
||||||
|
- name: arr
|
||||||
|
src: arr
|
||||||
|
vault_path: homelab/arr
|
||||||
|
state: absent
|
||||||
|
```
|
||||||
|
|
||||||
|
The next run of `playbooks/unraid.yml` runs `docker compose down`, then
|
||||||
|
deletes the project folder (and with it the rendered `.env`). Because a
|
||||||
|
`host_vars/` edit is a change under `build/config/ansible/`, CI treats it as a
|
||||||
|
full converge — so pushing that change is enough to action the teardown.
|
||||||
|
|
||||||
|
Keep the entry as a tombstone until you're sure. It's the only record that
|
||||||
|
the app was deliberately removed rather than never deployed, and flipping
|
||||||
|
`state:` back to `present` redeploys it.
|
||||||
|
|
||||||
|
### What survives, and how to remove the rest
|
||||||
|
|
||||||
|
Bind mounts are untouched, so `/mnt/user/appdata/<app>` — which for a
|
||||||
|
SQLite-backed app like Shelfarr *is* the application — survives a teardown and
|
||||||
|
makes it reversible. External networks (`caddy-net`, `unraid_shared`) are left
|
||||||
|
alone too; they belong to Unraid or to another stack. Both copies of the
|
||||||
|
stack's icon *are* deleted without needing to be asked — the project-folder
|
||||||
|
one goes with the folder, and the one under `/mnt/user/appdata/icons/` is
|
||||||
|
removed explicitly. Neither is state; they're copies of a file in the repo,
|
||||||
|
and a redeploy puts them back.
|
||||||
|
|
||||||
|
Three things are destructive enough to stay opt-in, per entry:
|
||||||
|
|
||||||
|
| Key | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `remove_volumes: true` | Also delete the project's named volumes. Bind mounts are unaffected either way. |
|
||||||
|
| `remove_images: local` | Also delete its images (`local` or `all`, as `docker compose down --rmi`). |
|
||||||
|
| `remove_database: true` | Drop the Postgres database **and** its role. Only for stacks with a `db:` block. |
|
||||||
|
|
||||||
|
Nothing removes the app's `homelab/<app>` path from Vault — that has its own
|
||||||
|
lifecycle, and destroying it would make the teardown irreversible. Delete it
|
||||||
|
by hand with `vault kv metadata delete` once you're finished with the app.
|
||||||
|
|
||||||
|
### Order of operations
|
||||||
|
|
||||||
|
`docker compose down` reads the compose file to know what it's removing, so
|
||||||
|
the teardown has to run **before** you delete `src/<app>/` or the host's
|
||||||
|
project folder. Delete those first and there's nothing left to tell Docker
|
||||||
|
what belonged to the project — you're cleaning up by hand with `docker rm` and
|
||||||
|
`docker network rm` instead. So: set `state: absent`, run the playbook, then
|
||||||
|
delete files in a second pass.
|
||||||
|
|
||||||
|
There's no equivalent on the Proxmox side yet. `lxc_app` only ever installs,
|
||||||
|
and a symmetric teardown needs a per-app `uninstall.yml` convention that no
|
||||||
|
app implements — the role is still a skeleton. For now, removing a Proxmox app
|
||||||
|
means stopping and disabling its systemd unit by hand, or destroying the
|
||||||
|
container via Terraform.
|
||||||
|
|
||||||
|
## Adding a host
|
||||||
|
|
||||||
|
**Unraid:** add it under `unraid_servers` in
|
||||||
|
`build/config/ansible/inventory/hosts.yml`, then create
|
||||||
|
`build/config/ansible/inventory/host_vars/<name>.yml` with its own `stacks:`
|
||||||
|
list — each host only runs what it's assigned.
|
||||||
|
|
||||||
|
**Proxmox:** define the LXC as a module in `src/<app>/terraform/`, call it
|
||||||
|
from `build/config/terraform/main.tf`, and `terraform apply`. The guest
|
||||||
|
appears in inventory automatically via the API — no `hosts.yml` edit — but
|
||||||
|
it still needs a `host_vars/<guest>.yml` declaring its `apps:` list.
|
||||||
|
|
||||||
|
**K3s:** add it as a new host under `k3s_control_plane` or `k3s_workers` (per
|
||||||
|
`inventory/hosts.yml` → `k3s_cluster`) — no `terraform apply`, the Pi has to
|
||||||
|
physically exist and have SSH access set up first (see "K3s (Raspberry Pi)"
|
||||||
|
above). No `host_vars/` entry needed beyond that: `roles/k3s_node` reads
|
||||||
|
`k3s_node_role` off the group, not the host, so which group a new Pi joins is
|
||||||
|
the only thing that decides whether it becomes another agent or a second
|
||||||
|
control-plane node — and this repo's role doesn't support the latter (single
|
||||||
|
server, no HA etcd) without changes to `k3s_node/tasks/server.yml`.
|
||||||
18
build/config/ansible/ansible.cfg
Normal file
18
build/config/ansible/ansible.cfg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[defaults]
|
||||||
|
# A directory, not a single file: static Unraid hosts (hosts.yml) and the
|
||||||
|
# Proxmox API-backed dynamic inventory (proxmox.yml) are both sources.
|
||||||
|
inventory = inventory/
|
||||||
|
roles_path = roles
|
||||||
|
host_key_checking = False
|
||||||
|
retry_files_enabled = False
|
||||||
|
interpreter_python = auto_silent
|
||||||
|
|
||||||
|
[inventory]
|
||||||
|
# `auto` must come before `yaml`: it reads the `plugin:` key in
|
||||||
|
# inventory/proxmox.yml and dispatches to the named plugin. With `yaml`
|
||||||
|
# first, that file gets parsed as a static host list instead, and `plugin:`
|
||||||
|
# is read as a group name.
|
||||||
|
enable_plugins = auto, yaml, ini, community.proxmox.proxmox
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
18
build/config/ansible/inventory/group_vars/all.yml
Normal file
18
build/config/ansible/inventory/group_vars/all.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
# HashiCorp Vault connection. VAULT_ADDR/VAULT_TOKEN (or VAULT_ROLE_ID /
|
||||||
|
# VAULT_SECRET_ID for AppRole) are read from the environment of whatever is
|
||||||
|
# running ansible-playbook — a local shell during bootstrap, or Forgejo
|
||||||
|
# Actions secrets once CI is wired up. Never hardcode a token here.
|
||||||
|
vault_addr: "{{ lookup('env', 'VAULT_ADDR') }}"
|
||||||
|
vault_kv_mount: kv
|
||||||
|
|
||||||
|
# AppRole auth (used by CI). Leave unset for local token-based auth
|
||||||
|
# (`vault login` + VAULT_TOKEN env var) during bootstrap.
|
||||||
|
vault_auth_method: "{{ lookup('env', 'VAULT_AUTH_METHOD') | default('token', true) }}"
|
||||||
|
vault_role_id: "{{ lookup('env', 'VAULT_ROLE_ID') | default(omit) }}"
|
||||||
|
vault_secret_id: "{{ lookup('env', 'VAULT_SECRET_ID') | default(omit) }}"
|
||||||
|
|
||||||
|
# Where Compose Manager (the Unraid Docker Compose plugin) expects project
|
||||||
|
# folders. Verify this against your plugin version — override per-host in
|
||||||
|
# host_vars if yours differs.
|
||||||
|
compose_projects_root: /boot/config/plugins/compose.manager/projects
|
||||||
205
build/config/ansible/inventory/group_vars/k3s_cluster.yml
Normal file
205
build/config/ansible/inventory/group_vars/k3s_cluster.yml
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
---
|
||||||
|
# Shared config for the whole k3s_cluster group (both k3s_control_plane and
|
||||||
|
# k3s_workers) — see roles/k3s_node and playbooks/k3s.yml.
|
||||||
|
|
||||||
|
# Used as the kubeconfig context/cluster name and as the Vault path suffix
|
||||||
|
# below, so the cluster has one name in every place it shows up.
|
||||||
|
k3s_cluster_name: homelab-utils
|
||||||
|
|
||||||
|
# Pinned, not "latest" — a rebuild months from now should produce the same
|
||||||
|
# cluster it does today, same reasoning as pinning an app's image tag in
|
||||||
|
# common/vars.yml. Bump deliberately; check the current stable release at
|
||||||
|
# https://github.com/k3s-io/k3s/releases first.
|
||||||
|
k3s_version: v1.31.4+k3s1
|
||||||
|
|
||||||
|
# One Vault path for the whole cluster, same "one path per stack" convention
|
||||||
|
# as everything under homelab/<app> — see docs/vault-secrets.md.
|
||||||
|
k3s_vault_path: "homelab/k3s-{{ k3s_cluster_name }}"
|
||||||
|
|
||||||
|
# The SSH key this group is reached with. A different path from k3s_vault_path
|
||||||
|
# above because it's a different kind of secret: that one is cluster identity
|
||||||
|
# (the join token), this one is access to the hosts, and it lives alongside
|
||||||
|
# the Unraid key under homelab/ci/ rather than with the cluster's own data.
|
||||||
|
# Fetched by playbooks/k3s_ssh_key.yml, which both k3s playbooks import.
|
||||||
|
k3s_ssh_key_vault_path: homelab/ci/ssh-k3s
|
||||||
|
|
||||||
|
# Where that play writes it, and what every play here then connects with — so
|
||||||
|
# a manual run needs Vault credentials in the environment and nothing else,
|
||||||
|
# rather than the key pre-loaded into ssh-agent. Override on the command line
|
||||||
|
# (-e ansible_ssh_private_key_file=~/.ssh/k3s_ansible) to use a local copy
|
||||||
|
# instead, e.g. if Vault itself is down.
|
||||||
|
#
|
||||||
|
# Under local/, which .gitignore already excludes — same as the kubeconfig
|
||||||
|
# below, and for the same reason: generated credential, never committed. Built
|
||||||
|
# from playbook_dir rather than repo_root, unlike k3s_kubeconfig_local_path:
|
||||||
|
# repo_root is a *play* var set in k3s.yml, so it only resolves inside the two
|
||||||
|
# plays that set it, whereas this is a connection var and has to resolve in
|
||||||
|
# every play in the file.
|
||||||
|
k3s_ssh_key_local_path: "{{ playbook_dir }}/../../../../local/k3s/{{ k3s_cluster_name }}.key"
|
||||||
|
ansible_ssh_private_key_file: "{{ k3s_ssh_key_local_path }}"
|
||||||
|
|
||||||
|
# Extra INSTALL_K3S_EXEC args appended on every node, both roles. Empty by
|
||||||
|
# default. Only flags valid on *both* `k3s server` and `k3s agent` belong
|
||||||
|
# here — see k3s_server_extra_args below for server-only flags like
|
||||||
|
# `--disable`, which `k3s agent` doesn't understand and will fail to start
|
||||||
|
# with.
|
||||||
|
k3s_extra_args: []
|
||||||
|
|
||||||
|
# Extra INSTALL_K3S_EXEC args appended on the control-plane node only.
|
||||||
|
# --disable=servicelb turns off k3s's bundled LoadBalancer controller
|
||||||
|
# (Klipper) in favour of roles/k3s_metallb — the two would otherwise both
|
||||||
|
# try to satisfy the same LoadBalancer Services. Add e.g. '--disable=traefik'
|
||||||
|
# here too if homelab-utils apps end up wanting their own ingress instead.
|
||||||
|
#
|
||||||
|
# Changing this (or k3s_extra_args) on an already-installed cluster is picked
|
||||||
|
# up on the next `ansible-playbook playbooks/k3s.yml` run — roles/k3s_node
|
||||||
|
# tracks the exec line it last installed with and reinstalls (restarting the
|
||||||
|
# k3s/k3s-agent service, not the node) whenever it drifts from what's
|
||||||
|
# configured here. See tasks/server.yml and tasks/agent.yml.
|
||||||
|
k3s_server_extra_args:
|
||||||
|
- "--disable=servicelb"
|
||||||
|
|
||||||
|
# MetalLB's LoadBalancer IP pool — see roles/k3s_metallb. Must be addresses
|
||||||
|
# on the LAN (192.168.50.0/24 — see inventory/hosts.yml) that nothing else,
|
||||||
|
# DHCP included, will ever hand out. Adjust to a range your DHCP scope
|
||||||
|
# doesn't cover before the first run.
|
||||||
|
k3s_metallb_address_range: "192.168.50.80-192.168.50.99"
|
||||||
|
|
||||||
|
# The LAN address the shared Postgres primary answers on — see
|
||||||
|
# roles/k3s_postgres, which declares a LoadBalancer Service for it alongside
|
||||||
|
# CNPG's built-in ClusterIP ones. Pinned rather than auto-assigned because
|
||||||
|
# the address gets written down elsewhere (an app's vars.yml, a connection
|
||||||
|
# string in Vault) instead of only being looked up at runtime, so it mustn't
|
||||||
|
# move when the Service is recreated.
|
||||||
|
#
|
||||||
|
# Must be inside the pool above — MetalLB only assigns from its own pools,
|
||||||
|
# and a request for an address outside them leaves the Service pending
|
||||||
|
# forever rather than failing loudly. The caveat of pinning out of the same
|
||||||
|
# pool MetalLB auto-assigns from: it hands unpinned Services the lowest free
|
||||||
|
# address, so if some other LoadBalancer Service is created before this one
|
||||||
|
# and takes .80, this Service is the one left pending. Nothing else here
|
||||||
|
# requests an IP today. If that changes, the fix is a second
|
||||||
|
# IPAddressPool with autoAssign: false in roles/k3s_metallb reserved for
|
||||||
|
# pinned addresses, not a different number here.
|
||||||
|
k3s_postgres_loadbalancer_ip: "192.168.50.81"
|
||||||
|
|
||||||
|
# Where playbooks/k3s.yml fetches the kubeconfig to, on the Ansible
|
||||||
|
# controller. Under local/, which .gitignore already excludes — it's a
|
||||||
|
# generated credential, never committed.
|
||||||
|
k3s_kubeconfig_local_path: "{{ repo_root }}/local/k3s/{{ k3s_cluster_name }}.kubeconfig"
|
||||||
|
|
||||||
|
# --- cert-manager (roles/k3s_cert_manager) ----------------------------------
|
||||||
|
#
|
||||||
|
# TLS for anything this cluster serves over Traefik. See that role's
|
||||||
|
# defaults/main.yml for why DNS-01 rather than HTTP-01, and what each of
|
||||||
|
# these does.
|
||||||
|
|
||||||
|
# Where Let's Encrypt sends expiry warnings. An ACME account is registered
|
||||||
|
# against it.
|
||||||
|
k3s_cert_manager_acme_email: russell.seymour@turtlesystems.co.uk
|
||||||
|
|
||||||
|
# >>> SET BEFORE THE FIRST RUN <<<
|
||||||
|
#
|
||||||
|
# How cert-manager proves control of the domain, rendered into the
|
||||||
|
# ClusterIssuer's solvers list as-is. Left empty deliberately — it depends on
|
||||||
|
# who runs DNS for turtlesystems.uk, which this repo has no way to know.
|
||||||
|
# roles/k3s_cert_manager fails fast while it's empty, rather than creating an
|
||||||
|
# issuer that can never satisfy an order.
|
||||||
|
#
|
||||||
|
# Cloudflare, as the most common case — the token needs Zone:DNS:Edit on the
|
||||||
|
# zone, and is stored in Vault (see k3s_cert_manager_vault_path below and
|
||||||
|
# docs/vault-secrets.md) rather than written here:
|
||||||
|
#
|
||||||
|
# k3s_cert_manager_solver:
|
||||||
|
# dns01:
|
||||||
|
# cloudflare:
|
||||||
|
# apiTokenSecretRef:
|
||||||
|
# name: cert-manager-dns-credentials
|
||||||
|
# key: CLOUDFLARE_API_TOKEN
|
||||||
|
# selector:
|
||||||
|
# dnsZones:
|
||||||
|
# - turtlesystems.uk
|
||||||
|
#
|
||||||
|
# For another provider, take the stanza from
|
||||||
|
# https://cert-manager.io/docs/configuration/acme/dns01/ and point its secret
|
||||||
|
# ref at whichever key you stored at that Vault path.
|
||||||
|
#
|
||||||
|
# Use the Let's Encrypt staging directory while working this out — override
|
||||||
|
# k3s_cert_manager_acme_server. Production allows 5 failed validations per
|
||||||
|
# hostname per hour, and exhausting it means waiting, not retrying.
|
||||||
|
k3s_cert_manager_solver:
|
||||||
|
dns01:
|
||||||
|
cloudflare:
|
||||||
|
apiTokenSecretRef:
|
||||||
|
name: cert-manager-dns-credentials
|
||||||
|
key: CLOUDFLARE_API_TOKEN
|
||||||
|
selector:
|
||||||
|
dnsZones:
|
||||||
|
- turtlesystems.uk
|
||||||
|
|
||||||
|
# --- Traefik (roles/k3s_traefik) --------------------------------------------
|
||||||
|
#
|
||||||
|
# The ingress controller k3s installs for itself; that role installs nothing,
|
||||||
|
# it only adjusts what's already there. Its defaults are enough for the
|
||||||
|
# dashboard over `kubectl port-forward` — see that role's defaults/main.yml
|
||||||
|
# and README.md → "Dashboard (Traefik)".
|
||||||
|
|
||||||
|
# >>> SET WHEN AUTHENTIK IS READY TO PROTECT IT <<<
|
||||||
|
#
|
||||||
|
# Uncomment to publish the dashboard on this hostname, behind the forward-auth
|
||||||
|
# middleware below. Until then the port-forward is the only way in, which is
|
||||||
|
# the safe default rather than a limitation. Needs a DNS record pointing at
|
||||||
|
# Traefik's MetalLB address (`kubectl -n kube-system get svc traefik`).
|
||||||
|
#
|
||||||
|
# Before uncommenting, in Authentik: create a Proxy Provider in **forward auth
|
||||||
|
# (domain level)** mode covering turtlesystems.uk, assign it to an
|
||||||
|
# application, and add that application to the embedded outpost. Domain level
|
||||||
|
# rather than single-application for a Kubernetes-specific reason — see
|
||||||
|
# roles/k3s_traefik/defaults/main.yml.
|
||||||
|
#
|
||||||
|
# k3s_traefik_dashboard_host: traefik.turtlesystems.uk
|
||||||
|
|
||||||
|
# Authentik's embedded outpost, by in-cluster DNS — the `authentik-server`
|
||||||
|
# Service in the `authentik` namespace, i.e. the chart's own service, not a
|
||||||
|
# separately deployed outpost. Unused while the host above is commented out;
|
||||||
|
# set here anyway so enabling the dashboard is one line rather than two.
|
||||||
|
# roles/k3s_traefik refuses to run if the host is set and this isn't.
|
||||||
|
k3s_traefik_dashboard_auth_address: "http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik"
|
||||||
|
|
||||||
|
# --- Apps on the cluster (roles/k3s_app) ------------------------------------
|
||||||
|
#
|
||||||
|
# The k3s equivalent of `stacks:` in host_vars/nas01.yml and `apps:` in a
|
||||||
|
# Proxmox guest's host_vars — the list of *applications* on this cluster, as
|
||||||
|
# opposed to the k3s_* cluster services above, which are the cluster's own
|
||||||
|
# infrastructure.
|
||||||
|
#
|
||||||
|
# In group_vars rather than host_vars because an app is deployed to the
|
||||||
|
# cluster, not to a node: playbooks/k3s.yml runs roles/k3s_app against
|
||||||
|
# k3s_control_plane only (that's where the manifests directory is), but
|
||||||
|
# nothing about an app belongs to that Pi specifically.
|
||||||
|
#
|
||||||
|
# Same entry shape as the other two platforms — name, src, vault_path, an
|
||||||
|
# optional `db:`, and an optional `state:`. And the same removal rule as
|
||||||
|
# Unraid: set `state: absent` to tear an app down and leave the entry here as
|
||||||
|
# a tombstone; deleting the entry removes nothing, it just stops Ansible
|
||||||
|
# visiting it. See roles/k3s_app/tasks/remove.yml.
|
||||||
|
k3s_apps:
|
||||||
|
- name: authentik
|
||||||
|
src: authentik
|
||||||
|
vault_path: homelab/authentik
|
||||||
|
db:
|
||||||
|
name: authentik
|
||||||
|
user: authentik
|
||||||
|
# The key at homelab/authentik holding this role's password. Named for
|
||||||
|
# the environment variable Authentik itself reads, because
|
||||||
|
# roles/k3s_app passes Vault keys through to the app's Secret verbatim
|
||||||
|
# — so one value serves both the provisioning step and the running app.
|
||||||
|
password_vault_key: AUTHENTIK_POSTGRESQL__PASSWORD
|
||||||
|
admin_vault_path: homelab/shared/postgres
|
||||||
|
# The CNPG LoadBalancer, not the in-cluster -rw Service: these tasks
|
||||||
|
# run on the Ansible controller (delegate_to: localhost), which is off
|
||||||
|
# the cluster and can't resolve or route to a ClusterIP. Authentik
|
||||||
|
# itself uses the ClusterIP — see
|
||||||
|
# src/authentik/ansible/kubernetes/vars.yml.
|
||||||
|
provision_host: "{{ k3s_postgres_loadbalancer_ip }}"
|
||||||
|
provision_port: "5432"
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
# Selects the server branch of roles/k3s_node/tasks/main.yml.
|
||||||
|
k3s_node_role: server
|
||||||
|
|
||||||
|
# The API server's cert needs this IP in its SAN list so kubectl can reach it
|
||||||
|
# at the same address agents join through. Defaults to the control-plane
|
||||||
|
# host's own address; override here if you later put a DNS name or VIP in
|
||||||
|
# front of it.
|
||||||
|
k3s_api_tls_san: "{{ ansible_host }}"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
# Selects the agent branch of roles/k3s_node/tasks/main.yml.
|
||||||
|
k3s_node_role: agent
|
||||||
19
build/config/ansible/inventory/group_vars/proxmox_guests.yml
Normal file
19
build/config/ansible/inventory/group_vars/proxmox_guests.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
# Connection settings for the LXCs on the Proxmox nodes — the group
|
||||||
|
# inventory/proxmox.yml composes from the API, targeted by
|
||||||
|
# playbooks/proxmox.yml. As distinct from proxmox_nodes.yml, which is the
|
||||||
|
# hosts those guests run on.
|
||||||
|
#
|
||||||
|
# In group_vars rather than repeated per guest because it is a property of
|
||||||
|
# how every container this repo creates is built, not of any one app: the
|
||||||
|
# Terraform modules install `ssh_public_keys` for root (see
|
||||||
|
# build/config/terraform/variables.tf) and create no other account, so root
|
||||||
|
# is the only user Ansible can authenticate as.
|
||||||
|
#
|
||||||
|
# Needed explicitly because the other three groups set this in hosts.yml
|
||||||
|
# (`root` for unraid_servers and proxmox_nodes, `ansible` for k3s_cluster)
|
||||||
|
# and the guests have no static entry to set it in. Without it Ansible falls
|
||||||
|
# back to the *controller's* login name, which no container has an account
|
||||||
|
# for — a "Permission denied (publickey)" that names a user nothing in the
|
||||||
|
# repo mentions, so there is no obvious thread to pull.
|
||||||
|
ansible_user: root
|
||||||
46
build/config/ansible/inventory/group_vars/proxmox_nodes.yml
Normal file
46
build/config/ansible/inventory/group_vars/proxmox_nodes.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
# Configuration for the Proxmox nodes themselves — as distinct from the guests
|
||||||
|
# on them, which come from the API-backed dynamic inventory and are configured
|
||||||
|
# by playbooks/proxmox.yml. Applied by playbooks/pve_host.yml.
|
||||||
|
#
|
||||||
|
# In group_vars rather than a host_vars file per node: both nodes want the
|
||||||
|
# same backup storage and the same schedule, and each backs up whichever
|
||||||
|
# guests are running on it, so there is nothing here that differs between
|
||||||
|
# them. A node that ever does need to differ can still override in
|
||||||
|
# host_vars/<node>.yml.
|
||||||
|
|
||||||
|
# Where archives go. An export on the NAS, added to Proxmox as a storage so
|
||||||
|
# vzdump can write to it — the node mounts it, no guest ever sees it.
|
||||||
|
#
|
||||||
|
# nas01 (192.168.50.1). This used to point at nas2, which no longer exists —
|
||||||
|
# and note the address it used, 192.168.50.2, now belongs to
|
||||||
|
# turtle-proxmox-01, so a stale copy of this value would have had each node
|
||||||
|
# quietly trying to write its backups to itself.
|
||||||
|
pve_backup_storage: nas-backup
|
||||||
|
pve_backup_storage_type: nfs
|
||||||
|
pve_backup_nfs_server: 192.168.50.1
|
||||||
|
pve_backup_nfs_export: /mnt/user/backups/proxmox
|
||||||
|
|
||||||
|
# Every guest on the node, so anything added later is covered without an edit
|
||||||
|
# here. Set false and list VMIDs (postgres is 161, forgejo 160 — see the
|
||||||
|
# `backup_vmids` output in build/config/terraform/main.tf) to be selective.
|
||||||
|
#
|
||||||
|
# Applied per node, so with two nodes each one archives whatever is currently
|
||||||
|
# running on it. That matters for a replicated guest: it is a real guest on
|
||||||
|
# one node and an inactive replica volume on the other, so it is backed up
|
||||||
|
# once, by whichever node currently owns it — and after a failover, by the
|
||||||
|
# other one, without an edit here.
|
||||||
|
pve_backup_all: true
|
||||||
|
|
||||||
|
# 02:00, half an hour after Forgejo's database dump at 01:30
|
||||||
|
# (src/forgejo/ansible/proxmox/vars.yml). The archive is only a complete
|
||||||
|
# restore point if the dump inside it is from the same night — move one and
|
||||||
|
# move the other.
|
||||||
|
pve_backup_schedule: "02:00"
|
||||||
|
|
||||||
|
# Archive retention, applied by Proxmox. Distinct from the retention inside a
|
||||||
|
# guest: Proxmox prunes archives, not their contents.
|
||||||
|
pve_backup_prune:
|
||||||
|
keep-daily: 7
|
||||||
|
keep-weekly: 4
|
||||||
|
keep-monthly: 6
|
||||||
52
build/config/ansible/inventory/host_vars/forgejo.yml
Normal file
52
build/config/ansible/inventory/host_vars/forgejo.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
# The Proxmox LXC created by src/forgejo/terraform (VMID 160), on
|
||||||
|
# turtle-proxmox-02. Named for the container's hostname, which is how it
|
||||||
|
# appears in the API-backed dynamic inventory.
|
||||||
|
#
|
||||||
|
# This was empty for a long time while Forgejo also ran as a Compose stack on
|
||||||
|
# nas2 — two deployments sharing one Vault path and one database means the
|
||||||
|
# second to start wins and the other quietly serves stale state. nas2 has
|
||||||
|
# since been retired, so there is no longer a second copy to collide with and
|
||||||
|
# the `apps:` list below is live.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES NOT DO: nothing here moves repository data. Ansible
|
||||||
|
# provisions the database and installs the app; a Forgejo with no
|
||||||
|
# /var/lib/forgejo/data/forgejo-repositories is a working, empty forge. If the
|
||||||
|
# nas2 repositories still exist somewhere, restore them into
|
||||||
|
# `FORGEJO_DATA_PATH` and `chown -R git:git /var/lib/forgejo/data` before
|
||||||
|
# pointing DNS at this host — the original instructions here were an rsync
|
||||||
|
# off nas2, which is no longer a machine that can be read from.
|
||||||
|
#
|
||||||
|
# Also note SSH clone URLs move from port 2222 to 22 (see FORGEJO_SSH_PORT in
|
||||||
|
# src/forgejo/ansible/proxmox/vars.yml): existing remotes need editing, and
|
||||||
|
# git.turtlesystems.uk has to resolve to this container.
|
||||||
|
|
||||||
|
# The address Terraform assigns this guest (build/config/terraform/main.tf).
|
||||||
|
# Pinned here rather than left to DNS: the API-backed dynamic inventory
|
||||||
|
# supplies no `ansible_host`, so without this Ansible SSHes to the bare
|
||||||
|
# inventory name `forgejo`, which the LAN's resolver has no record for at
|
||||||
|
# all. These addresses are hand-assigned from the 192.168.50.50-.59 band
|
||||||
|
# anyway, so restating one here duplicates nothing that was ever derived.
|
||||||
|
ansible_host: 192.168.50.52
|
||||||
|
|
||||||
|
apps:
|
||||||
|
- name: forgejo
|
||||||
|
src: forgejo
|
||||||
|
vault_path: homelab/forgejo
|
||||||
|
db:
|
||||||
|
# The shared Postgres LXC, reached over the LAN. Unlike the Unraid
|
||||||
|
# host_vars this can't be `{{ ansible_host }}` — that would be this
|
||||||
|
# container's own address, not the database's. Keep in step with
|
||||||
|
# DB_HOST in src/forgejo/ansible/proxmox/vars.yml; the authoritative
|
||||||
|
# copy of both is the `postgres_address` output in
|
||||||
|
# build/config/terraform/main.tf.
|
||||||
|
#
|
||||||
|
# Provisioning runs from the Ansible controller (`delegate_to:
|
||||||
|
# localhost` in roles/lxc_app), so this address has to be reachable
|
||||||
|
# from wherever you run the playbook, not just from this container.
|
||||||
|
provision_host: 192.168.50.54
|
||||||
|
provision_port: 5432
|
||||||
|
name: forgejo
|
||||||
|
user: forgejo
|
||||||
|
password_vault_key: DB_PASSWORD
|
||||||
|
admin_vault_path: homelab/shared/postgres
|
||||||
19
build/config/ansible/inventory/host_vars/nas01.yml
Normal file
19
build/config/ansible/inventory/host_vars/nas01.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
# Existing Unraid server. Stacks are applied in list order.
|
||||||
|
#
|
||||||
|
# Each entry takes an optional `state:` — `present` (the default) deploys it,
|
||||||
|
# `absent` tears it down. To remove a stack, set `state: absent` and leave the
|
||||||
|
# entry here; deleting it instead removes nothing, it just stops Ansible
|
||||||
|
# managing the containers. See README.md → "Removing an app".
|
||||||
|
stacks:
|
||||||
|
# The arr ecosystem as one stack: Shelfarr (library) plus Prowlarr (indexer
|
||||||
|
# manager) in a single Compose project, so they come up together and share a
|
||||||
|
# network, a rendered .env and a Vault path.
|
||||||
|
- name: arr
|
||||||
|
src: arr
|
||||||
|
vault_path: homelab/arr
|
||||||
|
# No `db:` block — neither container uses an external database. Shelfarr
|
||||||
|
# is SQLite-only (config/database.yml pins production to storage/*.sqlite3
|
||||||
|
# with no DATABASE_URL support) and Prowlarr keeps its own SQLite under
|
||||||
|
# /config, so there is nothing to provision on the shared Postgres. Both
|
||||||
|
# databases live in the appdata bind mounts along with everything else.
|
||||||
28
build/config/ansible/inventory/host_vars/postgres.yml
Normal file
28
build/config/ansible/inventory/host_vars/postgres.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
# The shared Postgres LXC created by src/shared/postgres/terraform (VMID 161,
|
||||||
|
# 192.168.50.54). Named for the container's hostname, which is how it appears
|
||||||
|
# in the API-backed dynamic inventory.
|
||||||
|
#
|
||||||
|
# This is the one app with no `db:` block, because it *is* the database — the
|
||||||
|
# per-app databases are created against it by whichever compose_stack /
|
||||||
|
# lxc_app / k3s_app run declares them, using homelab/shared/postgres as
|
||||||
|
# `admin_vault_path`.
|
||||||
|
|
||||||
|
# The address Terraform assigns this guest (build/config/terraform/main.tf).
|
||||||
|
# Pinned here rather than left to DNS: the API-backed dynamic inventory
|
||||||
|
# supplies no `ansible_host`, so without this Ansible SSHes to the bare
|
||||||
|
# inventory name and whatever the LAN's resolver happens to return. For
|
||||||
|
# `postgres` that resolver answers 192.168.50.53 — the *Unraid* Postgres
|
||||||
|
# container, a different instance of the same service — which fails as a
|
||||||
|
# connection refused rather than as anything that names the real problem.
|
||||||
|
# These addresses are hand-assigned from the 192.168.50.50-.59 band anyway,
|
||||||
|
# so restating one here duplicates nothing that was ever derived.
|
||||||
|
ansible_host: 192.168.50.54
|
||||||
|
|
||||||
|
apps:
|
||||||
|
- name: postgres
|
||||||
|
src: shared/postgres
|
||||||
|
vault_path: homelab/shared/postgres
|
||||||
|
# The packaging's umbrella unit, not postgresql@17-main — see
|
||||||
|
# POSTGRES_SERVICE_NAME in src/shared/postgres/ansible/proxmox/vars.yml.
|
||||||
|
service_name: postgresql
|
||||||
58
build/config/ansible/inventory/hosts.yml
Normal file
58
build/config/ansible/inventory/hosts.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
---
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
unraid_servers:
|
||||||
|
hosts:
|
||||||
|
nas01:
|
||||||
|
ansible_host: 192.168.50.1
|
||||||
|
vars:
|
||||||
|
ansible_user: root
|
||||||
|
|
||||||
|
# The Proxmox host itself, not the guests on it — those come from the
|
||||||
|
# API-backed dynamic inventory (proxmox.yml) as `proxmox_guests`. Node-level
|
||||||
|
# configuration (backup storage, the vzdump schedule) has to be applied over
|
||||||
|
# SSH to the node, which the API inventory gives no way to address.
|
||||||
|
#
|
||||||
|
# VERIFY: depending on its configuration, the community.proxmox inventory
|
||||||
|
# plugin can also emit the node as a host. If it does, this static entry and
|
||||||
|
# the dynamic one merge under the same name — check with
|
||||||
|
# `ansible-inventory --graph` before assuming which vars won.
|
||||||
|
proxmox_nodes:
|
||||||
|
hosts:
|
||||||
|
turtle-proxmox-01:
|
||||||
|
ansible_host: 192.168.50.2
|
||||||
|
turtle-proxmox-02:
|
||||||
|
ansible_host: 192.168.50.3
|
||||||
|
vars:
|
||||||
|
ansible_user: root
|
||||||
|
|
||||||
|
# Bare-metal K3s cluster (4 Raspberry Pis) — a third platform alongside
|
||||||
|
# Unraid and Proxmox, not an app deployed onto either of them. No
|
||||||
|
# Terraform: the hosts already exist. See playbooks/k3s.yml and
|
||||||
|
# roles/k3s_node. Split into two groups, not one, so the playbook can
|
||||||
|
# bootstrap the control plane before any agent tries to join it.
|
||||||
|
#
|
||||||
|
# ansible_user is `ansible`, not root: create this user on every Pi with
|
||||||
|
# NOPASSWD sudo and install the public half of `homelab/ci/ssh-k3s`
|
||||||
|
# (docs/vault-secrets.md) as its authorized key before the first run —
|
||||||
|
# see README.md "SSH access". The private half doesn't need setting up on
|
||||||
|
# the controller: group_vars/k3s_cluster.yml points
|
||||||
|
# ansible_ssh_private_key_file at a path playbooks/k3s_ssh_key.yml fetches
|
||||||
|
# from that same Vault entry.
|
||||||
|
k3s_cluster:
|
||||||
|
vars:
|
||||||
|
ansible_user: ansible
|
||||||
|
ansible_become: true
|
||||||
|
children:
|
||||||
|
k3s_control_plane:
|
||||||
|
hosts:
|
||||||
|
k3s-ctrl-01:
|
||||||
|
ansible_host: 192.168.50.60
|
||||||
|
k3s_workers:
|
||||||
|
hosts:
|
||||||
|
k3s-wkr-01:
|
||||||
|
ansible_host: 192.168.50.61
|
||||||
|
k3s-wkr-02:
|
||||||
|
ansible_host: 192.168.50.62
|
||||||
|
k3s-wkr-03:
|
||||||
|
ansible_host: 192.168.50.63
|
||||||
56
build/config/ansible/inventory/proxmox.yml
Normal file
56
build/config/ansible/inventory/proxmox.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
---
|
||||||
|
# Dynamic inventory: asks the Proxmox API which guests exist rather than
|
||||||
|
# listing them by hand, so LXCs created by `terraform apply` show up without
|
||||||
|
# a second edit here.
|
||||||
|
#
|
||||||
|
# VERIFY BEFORE FIRST USE — two things this config asserts that depend on
|
||||||
|
# your Proxmox and collection versions:
|
||||||
|
#
|
||||||
|
# 1. Collection name. Proxmox content was split out of `community.general`
|
||||||
|
# into its own `community.proxmox` collection. If your installed
|
||||||
|
# Ansible predates that split, this is `community.general.proxmox`
|
||||||
|
# instead — here, in requirements.yml, and in ansible.cfg's
|
||||||
|
# `enable_plugins`. Check with:
|
||||||
|
# ansible-doc -t inventory community.proxmox.proxmox
|
||||||
|
# 2. Auto-generated group names. The plugin builds groups from guest type
|
||||||
|
# and state; `proxmox_all_lxc` is the conventional one but confirm
|
||||||
|
# against your own output before relying on it:
|
||||||
|
# ansible-inventory -i inventory/proxmox.yml --graph
|
||||||
|
#
|
||||||
|
plugin: community.proxmox.proxmox
|
||||||
|
|
||||||
|
url: "{{ lookup('env', 'PROXMOX_URL') }}"
|
||||||
|
user: "{{ lookup('env', 'PROXMOX_USER') }}"
|
||||||
|
|
||||||
|
# API token, not a password — same reasoning as the Unraid SSH key: a
|
||||||
|
# password would mean an interactive prompt (impossible from CI) or a
|
||||||
|
# plaintext secret on disk. Store these in Vault under `homelab/ci/proxmox`
|
||||||
|
# and export them before running; see docs/vault-secrets.md.
|
||||||
|
token_id: "{{ lookup('env', 'PROXMOX_TOKEN_ID') }}"
|
||||||
|
token_secret: "{{ lookup('env', 'PROXMOX_TOKEN_SECRET') }}"
|
||||||
|
|
||||||
|
# Homelab Proxmox typically has a self-signed cert. Flip to true once you
|
||||||
|
# put a real one on it.
|
||||||
|
validate_certs: false
|
||||||
|
|
||||||
|
want_facts: true
|
||||||
|
|
||||||
|
# Manually-created guests are not this repo's to touch. Both Terraform
|
||||||
|
# modules tag the LXCs they create with `terraform` (see
|
||||||
|
# src/*/terraform/variables.tf), so that tag is the line between "managed
|
||||||
|
# here" and "made by hand on the node". Filtering rather than narrowing
|
||||||
|
# the group below keeps hand-made guests out of the inventory entirely,
|
||||||
|
# so no future play can target them by accident either.
|
||||||
|
#
|
||||||
|
# `filters` needs a reasonably recent plugin version. If yours rejects the
|
||||||
|
# option, delete this block and put the same condition on the group:
|
||||||
|
# proxmox_guests: >-
|
||||||
|
# proxmox_vmtype == 'lxc'
|
||||||
|
# and 'terraform' in (proxmox_tags_parsed | default([]))
|
||||||
|
filters:
|
||||||
|
- "'terraform' in (proxmox_tags_parsed | default([]))"
|
||||||
|
|
||||||
|
groups:
|
||||||
|
# The group playbooks/proxmox.yml targets. Containers only — VMs, if you
|
||||||
|
# ever add any, are not what `lxc_app` knows how to install into.
|
||||||
|
proxmox_guests: "proxmox_vmtype == 'lxc'"
|
||||||
21
build/config/ansible/playbooks/deploy.yml
Normal file
21
build/config/ansible/playbooks/deploy.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
# Everything, both platforms. Unraid first only by convention now — the two
|
||||||
|
# platforms no longer depend on each other. Proxmox-side apps provision their
|
||||||
|
# databases against the Proxmox shared Postgres (192.168.50.54), and Terraform
|
||||||
|
# keeps its state on the CloudNativePG cluster on k3s, neither of which is on
|
||||||
|
# Unraid.
|
||||||
|
#
|
||||||
|
# Run a single platform with `ansible-playbook playbooks/unraid.yml` or
|
||||||
|
# `playbooks/proxmox.yml` instead. `--limit <host>` and `-e only_stacks=...`
|
||||||
|
# work the same way against any of the three.
|
||||||
|
- name: Deploy Unraid stacks
|
||||||
|
ansible.builtin.import_playbook: unraid.yml
|
||||||
|
|
||||||
|
- name: Deploy Proxmox apps
|
||||||
|
ansible.builtin.import_playbook: proxmox.yml
|
||||||
|
|
||||||
|
# Last, because the vzdump job can be pinned to specific VMIDs and those guests
|
||||||
|
# have to exist first. Harmless to re-run; skipped entirely if no node is
|
||||||
|
# defined in the `proxmox_nodes` group.
|
||||||
|
- name: Configure Proxmox node backups
|
||||||
|
ansible.builtin.import_playbook: pve_host.yml
|
||||||
241
build/config/ansible/playbooks/k3s.yml
Normal file
241
build/config/ansible/playbooks/k3s.yml
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
---
|
||||||
|
# Bootstraps the homelab-utils k3s cluster from bare Pis: no Terraform, the
|
||||||
|
# hosts already exist (inventory/hosts.yml → k3s_cluster). Manual-only for
|
||||||
|
# now — not wired into .forgejo/workflows/deploy.yml, since converging 4
|
||||||
|
# physical nodes on every push is a bigger blast radius than restarting a
|
||||||
|
# Compose stack (same reasoning the repo already applies to `terraform
|
||||||
|
# apply`). Run by hand:
|
||||||
|
#
|
||||||
|
# ansible-playbook playbooks/k3s.yml
|
||||||
|
#
|
||||||
|
# Every play below the SSH-key import is tagged so a single piece can be
|
||||||
|
# converged without touching the rest — deploying one cluster service
|
||||||
|
# shouldn't mean re-running the node install across 4 Pis:
|
||||||
|
#
|
||||||
|
# --tags nodes k3s install/join + exec-line drift (both node plays)
|
||||||
|
# --tags metallb MetalLB chart + IPAddressPool
|
||||||
|
# --tags monitoring kube-prometheus-stack chart
|
||||||
|
# --tags postgres CloudNativePG operator + Cluster
|
||||||
|
# --tags cert-manager cert-manager chart + ClusterIssuer
|
||||||
|
# --tags traefik dashboard route on k3s's bundled Traefik
|
||||||
|
# --tags services all five of the above, no node install
|
||||||
|
# --tags apps the k3s_apps: list (roles/k3s_app) — apps, not
|
||||||
|
# cluster services; narrow further with -e only_apps=…
|
||||||
|
# --tags upgrades unattended-upgrades config
|
||||||
|
# --tags kubeconfig re-fetch the admin kubeconfig
|
||||||
|
#
|
||||||
|
# e.g. `ansible-playbook playbooks/k3s.yml --tags postgres`. The SSH-key
|
||||||
|
# import is tagged `always` rather than given a name of its own: it's not a
|
||||||
|
# thing you'd select, it's the prerequisite for any of these connecting at
|
||||||
|
# all, so it has to survive every --tags filter.
|
||||||
|
#
|
||||||
|
# `services` covers the cluster's own infrastructure and `apps` covers what
|
||||||
|
# runs on top of it; neither implies the other, so a run meaning "everything
|
||||||
|
# except reinstalling k3s" is `--tags services,apps`.
|
||||||
|
#
|
||||||
|
# Prerequisites (see README.md "K3s (Raspberry Pi)"): the `ansible` user
|
||||||
|
# exists on every Pi with NOPASSWD sudo and this repo's SSH key installed,
|
||||||
|
# and homelab/k3s-homelab-utils/K3S_TOKEN is already set in Vault — see
|
||||||
|
# docs/vault-secrets.md. Both plays fetch that same fixed token independently
|
||||||
|
# rather than one generating it and handing it to the other, which is what
|
||||||
|
# makes a full rebuild (wipe both SD cards, reinstall) reproduce the same
|
||||||
|
# cluster identity.
|
||||||
|
|
||||||
|
# First, before anything tries to connect: pull the SSH key the plays below
|
||||||
|
# authenticate with out of Vault and onto the controller. See
|
||||||
|
# k3s_ssh_key.yml — it's a separate file because k3s_maintenance.yml imports
|
||||||
|
# it too.
|
||||||
|
- ansible.builtin.import_playbook: k3s_ssh_key.yml
|
||||||
|
tags: always
|
||||||
|
|
||||||
|
- name: Bootstrap the k3s control plane
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: true
|
||||||
|
tags: nodes
|
||||||
|
roles:
|
||||||
|
- k3s_node
|
||||||
|
|
||||||
|
# serial: 1 not for joining itself (idempotent, safe in parallel) but for
|
||||||
|
# roles/k3s_node's exec-line drift detection: if k3s_extra_args changes and
|
||||||
|
# every worker's k3s-agent restarts to pick it up, one at a time keeps more
|
||||||
|
# than one node's kubelet from bouncing simultaneously. See tasks/agent.yml.
|
||||||
|
- name: Join k3s worker nodes
|
||||||
|
hosts: k3s_workers
|
||||||
|
gather_facts: true
|
||||||
|
serial: 1
|
||||||
|
tags: nodes
|
||||||
|
roles:
|
||||||
|
- k3s_node
|
||||||
|
|
||||||
|
# Against the control plane only — it just drops manifests for k3s's own
|
||||||
|
# helm-controller and deploy controller to reconcile, so it doesn't need
|
||||||
|
# every node like the plays above. Runs after workers have joined so the
|
||||||
|
# resulting speaker DaemonSet schedules across the whole cluster on first
|
||||||
|
# reconcile, though this doesn't strictly matter: the controller picks up
|
||||||
|
# new nodes on its own regardless of ordering. Before monitoring so a
|
||||||
|
# LoadBalancer Service (e.g. exposing Prometheus/Grafana later) has
|
||||||
|
# somewhere to get an IP from as soon as it's requested.
|
||||||
|
- name: Deploy MetalLB (LoadBalancer IPs)
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: false
|
||||||
|
tags:
|
||||||
|
- services
|
||||||
|
- metallb
|
||||||
|
roles:
|
||||||
|
- k3s_metallb
|
||||||
|
|
||||||
|
# Against the control plane only — it just drops a HelmChart manifest for
|
||||||
|
# k3s's own helm-controller to reconcile, so it doesn't need every node like
|
||||||
|
# the plays above. Runs after workers have joined so the resulting
|
||||||
|
# node-exporter DaemonSet schedules across the whole cluster on first
|
||||||
|
# reconcile, though this doesn't strictly matter: the controller picks up
|
||||||
|
# new nodes on its own regardless of ordering.
|
||||||
|
- name: Deploy cluster monitoring (Prometheus)
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: false
|
||||||
|
tags:
|
||||||
|
- services
|
||||||
|
- monitoring
|
||||||
|
roles:
|
||||||
|
- k3s_monitoring
|
||||||
|
|
||||||
|
# Against the control plane only, same reasoning as monitoring/MetalLB above
|
||||||
|
# — it just drops manifests for k3s's own controllers to reconcile. Needs
|
||||||
|
# repo_root (unlike the other k3s roles) because it reads config from
|
||||||
|
# src/shared/postgres/, the same shared-service config Unraid/Proxmox
|
||||||
|
# already deploy from — see roles/k3s_postgres/tasks/main.yml.
|
||||||
|
- name: Deploy shared Postgres (CloudNativePG)
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: false
|
||||||
|
tags:
|
||||||
|
- services
|
||||||
|
- postgres
|
||||||
|
vars:
|
||||||
|
repo_root: "{{ playbook_dir }}/../../../.."
|
||||||
|
roles:
|
||||||
|
- k3s_postgres
|
||||||
|
|
||||||
|
# Against the control plane only, same reasoning as the services above.
|
||||||
|
# Before the apps play because an app's Ingress annotates itself against the
|
||||||
|
# ClusterIssuer this creates — not that ordering is load-bearing (k3s's
|
||||||
|
# deploy controller retries, and cert-manager picks up an Ingress whenever it
|
||||||
|
# appears), but an app deployed first would sit without a certificate until
|
||||||
|
# this ran, which reads as a broken deploy rather than a pending one.
|
||||||
|
- name: Deploy cert-manager (TLS certificates)
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: false
|
||||||
|
tags:
|
||||||
|
- services
|
||||||
|
- cert-manager
|
||||||
|
roles:
|
||||||
|
- k3s_cert_manager
|
||||||
|
|
||||||
|
# Against the control plane only, same reasoning as the services above. The
|
||||||
|
# odd one out among them: it installs nothing, because k3s installs Traefik
|
||||||
|
# itself — it only adjusts what k3s already put there, via a HelmChartConfig.
|
||||||
|
# After cert-manager because publishing the dashboard on a hostname asks for a
|
||||||
|
# Certificate from the ClusterIssuer that play creates; ordering isn't
|
||||||
|
# load-bearing (the deploy controller retries), it just avoids a route sitting
|
||||||
|
# without a certificate in between.
|
||||||
|
- name: Configure Traefik (ingress + dashboard)
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: false
|
||||||
|
tags:
|
||||||
|
- services
|
||||||
|
- traefik
|
||||||
|
roles:
|
||||||
|
- k3s_traefik
|
||||||
|
|
||||||
|
# Apps, as opposed to the cluster services above — see roles/k3s_app for the
|
||||||
|
# distinction and inventory/group_vars/k3s_cluster.yml for the list. Against
|
||||||
|
# the control plane because that's where the manifests directory is; nothing
|
||||||
|
# about an app belongs to that Pi in particular.
|
||||||
|
#
|
||||||
|
# Note what a green run here does and doesn't mean, the same caveat every
|
||||||
|
# service play above carries: the role renders manifests for k3s's
|
||||||
|
# controllers to reconcile, so success means the files landed and any
|
||||||
|
# database was provisioned — not that the workload came up. Check with
|
||||||
|
# `kubectl -n <namespace> get pods`.
|
||||||
|
- name: Converge apps on the cluster
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: false
|
||||||
|
tags: apps
|
||||||
|
vars:
|
||||||
|
repo_root: "{{ playbook_dir }}/../../../.."
|
||||||
|
# Comma-separated app names to restrict this run to, e.g.
|
||||||
|
# `-e only_apps=authentik`. Empty (the default) converges every app in
|
||||||
|
# k3s_apps. Same knob as only_stacks in playbooks/unraid.yml — --tags
|
||||||
|
# can select the apps play as a whole, but not one app within it.
|
||||||
|
only_apps: ""
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# "Converge", not "deploy": an entry carrying `state: absent` is torn
|
||||||
|
# down rather than brought up. Removals have to stay in the list to be
|
||||||
|
# acted on — see roles/k3s_app/tasks/main.yml.
|
||||||
|
- name: Converge each declared app
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: k3s_app
|
||||||
|
loop: >-
|
||||||
|
{{ k3s_apps | default([]) if only_apps == ''
|
||||||
|
else k3s_apps | default([])
|
||||||
|
| selectattr('name', 'in', only_apps.split(',')) | list }}
|
||||||
|
loop_control:
|
||||||
|
loop_var: app
|
||||||
|
label: "{{ app.name }}"
|
||||||
|
|
||||||
|
# Every node, both roles — hands-off patching so the Pis don't need logging
|
||||||
|
# into just to stay updated. Reboots are deliberately not part of this: see
|
||||||
|
# roles/unattended_upgrades and playbooks/k3s_maintenance.yml.
|
||||||
|
- name: Configure unattended upgrades
|
||||||
|
hosts: k3s_cluster
|
||||||
|
gather_facts: true
|
||||||
|
tags: upgrades
|
||||||
|
roles:
|
||||||
|
- unattended_upgrades
|
||||||
|
|
||||||
|
# Last, and against the control plane specifically (there's only one) — pulls
|
||||||
|
# the admin kubeconfig k3s wrote for itself back to the controller so
|
||||||
|
# `kubectl` works from your workstation.
|
||||||
|
- name: Fetch the cluster kubeconfig
|
||||||
|
hosts: k3s_control_plane
|
||||||
|
gather_facts: false
|
||||||
|
tags: kubeconfig
|
||||||
|
vars:
|
||||||
|
repo_root: "{{ playbook_dir }}/../../../.."
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Read the cluster's kubeconfig
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: /etc/rancher/k3s/k3s.yaml
|
||||||
|
register: k3s_kubeconfig_raw
|
||||||
|
|
||||||
|
- name: Ensure the local kubeconfig directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ k3s_kubeconfig_local_path | dirname }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0700"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# k3s.yaml points at 127.0.0.1 and names everything "default" — both
|
||||||
|
# correct only on the node itself. Rewritten so the file is usable
|
||||||
|
# straight off the controller: the server address becomes reachable from
|
||||||
|
# off-box, and the cluster/context/user names become this cluster's own
|
||||||
|
# rather than colliding with every other "default" in ~/.kube/config.
|
||||||
|
- name: Write the rewritten kubeconfig to the controller
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ k3s_kubeconfig_local_path }}"
|
||||||
|
content: >-
|
||||||
|
{{ (k3s_kubeconfig_raw.content | b64decode)
|
||||||
|
| replace('127.0.0.1', ansible_host)
|
||||||
|
| regex_replace('\\bdefault\\b', k3s_cluster_name) }}
|
||||||
|
mode: "0600"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Show how to use the fetched kubeconfig
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
Kubeconfig written to {{ k3s_kubeconfig_local_path }}. Use it with
|
||||||
|
`export KUBECONFIG={{ k3s_kubeconfig_local_path }}`, or merge it
|
||||||
|
into ~/.kube/config by hand.
|
||||||
37
build/config/ansible/playbooks/k3s_maintenance.yml
Normal file
37
build/config/ansible/playbooks/k3s_maintenance.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
# Rolls a "reboot required" flag left by unattended-upgrades
|
||||||
|
# (roles/unattended_upgrades, Automatic-Reboot disabled) into an actual
|
||||||
|
# reboot, one node at a time — cordon, drain, reboot, wait for Ready,
|
||||||
|
# uncordon — so patching the OS never means dropping every workload on the
|
||||||
|
# cluster at once. A no-op on any node that isn't carrying a pending reboot.
|
||||||
|
#
|
||||||
|
# serial: 1 is the point of this playbook: exactly one node is ever down for
|
||||||
|
# maintenance at a time, whatever `--limit` narrows the run to.
|
||||||
|
#
|
||||||
|
# Host order matters here and isn't left to inventory.yml's own group
|
||||||
|
# order (control plane, then workers) — reversed below so workers are
|
||||||
|
# rebooted while the API server is still up on the control plane, and the
|
||||||
|
# control plane's own reboot (which drops the API for everyone, single-server
|
||||||
|
# cluster, no HA) happens last rather than first.
|
||||||
|
#
|
||||||
|
# Manual-only for now, like playbooks/k3s.yml — run by hand:
|
||||||
|
#
|
||||||
|
# ansible-playbook playbooks/k3s_maintenance.yml
|
||||||
|
#
|
||||||
|
# Intended to eventually run on a schedule from a self-hosted Forgejo Actions
|
||||||
|
# runner (a `schedule:`-triggered workflow, same runner as
|
||||||
|
# .forgejo/workflows/deploy.yml) rather than by hand — not wired up yet, but
|
||||||
|
# the SSH key import below is what makes that possible without also handing
|
||||||
|
# the runner an ssh-agent.
|
||||||
|
|
||||||
|
# Same first play as k3s.yml — the key comes from Vault, and this playbook is
|
||||||
|
# routinely run on its own, so it can't rely on a k3s.yml run having fetched
|
||||||
|
# it. See k3s_ssh_key.yml.
|
||||||
|
- ansible.builtin.import_playbook: k3s_ssh_key.yml
|
||||||
|
|
||||||
|
- name: Roll pending reboots across the k3s cluster
|
||||||
|
hosts: k3s_workers:k3s_control_plane
|
||||||
|
gather_facts: true
|
||||||
|
serial: 1
|
||||||
|
roles:
|
||||||
|
- k3s_maintenance
|
||||||
82
build/config/ansible/playbooks/k3s_ssh_key.yml
Normal file
82
build/config/ansible/playbooks/k3s_ssh_key.yml
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
---
|
||||||
|
# Materializes the SSH key the k3s plays authenticate with, from Vault
|
||||||
|
# (homelab/ci/ssh-k3s) onto the controller. Imported as the first play of both
|
||||||
|
# playbooks/k3s.yml and playbooks/k3s_maintenance.yml — a separate file rather
|
||||||
|
# than copied into each, since both target k3s_cluster and both would
|
||||||
|
# otherwise depend on a file only the other one creates.
|
||||||
|
#
|
||||||
|
# This is the same shape CI already uses for the Unraid key:
|
||||||
|
# .forgejo/workflows/deploy.yml fetches homelab/ci/ssh, writes it to disk and
|
||||||
|
# points ANSIBLE_PRIVATE_KEY_FILE at it. Vault is the one place secrets live,
|
||||||
|
# so a manual k3s run shouldn't need the key hand-loaded into ssh-agent first
|
||||||
|
# — which was the only way it worked before.
|
||||||
|
#
|
||||||
|
# hosts: k3s_cluster, not localhost, and deliberately so: the implicit
|
||||||
|
# localhost is not a member of `all`, so it doesn't inherit group_vars/all.yml
|
||||||
|
# — where vault_addr, vault_kv_mount and vault_auth_method live. Targeting the
|
||||||
|
# group picks those up along with k3s_ssh_key_vault_path/_local_path from
|
||||||
|
# group_vars/k3s_cluster.yml. Nothing here connects to a Pi (gather_facts is
|
||||||
|
# off and every task is delegated or connectionless), which is the whole
|
||||||
|
# point: at this stage the key isn't on disk yet.
|
||||||
|
#
|
||||||
|
# become: false on the delegated tasks because the k3s_cluster group sets
|
||||||
|
# ansible_become: true for the Pis (inventory/hosts.yml) — without it these
|
||||||
|
# would try to sudo on the controller.
|
||||||
|
|
||||||
|
- name: Fetch the k3s SSH deploy key from Vault
|
||||||
|
hosts: k3s_cluster
|
||||||
|
gather_facts: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# The escape hatch, and the reason it's a flag rather than just pointing
|
||||||
|
# ansible_ssh_private_key_file somewhere else: if Vault is unreachable,
|
||||||
|
# overriding the key path alone doesn't help — this play would still fail
|
||||||
|
# before the first real one runs. Skipping it is the only thing that lets
|
||||||
|
# a run proceed on a local key. Both overrides together:
|
||||||
|
#
|
||||||
|
# ansible-playbook playbooks/k3s.yml \
|
||||||
|
# -e k3s_ssh_key_fetch=false \
|
||||||
|
# -e ansible_ssh_private_key_file=~/.ssh/k3s_ansible
|
||||||
|
- name: Fetch and write the key
|
||||||
|
when: k3s_ssh_key_fetch | default(true) | bool
|
||||||
|
block:
|
||||||
|
# run_once because the key is per-cluster, not per-host: one Vault
|
||||||
|
# read for the whole group. Facts set by a run_once task apply to
|
||||||
|
# every host in the play, but nothing outside this play needs it.
|
||||||
|
- name: Look up the k3s SSH deploy key from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_ssh_key_secret: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
k3s_ssh_key_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
run_once: true
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Ensure the local key directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ k3s_ssh_key_local_path | dirname }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0700"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
# trim + an explicit trailing newline: OpenSSH rejects a key file
|
||||||
|
# whose final line isn't terminated, and `vault kv put
|
||||||
|
# PRIVATE_KEY=@file` is as likely to have stored one with trailing
|
||||||
|
# whitespace as not. 0600 for the same reason — ssh refuses a key file
|
||||||
|
# that's group- or world-readable. Both fail at connection time rather
|
||||||
|
# than here, which is a much less obvious error to read.
|
||||||
|
- name: Write the private key to the controller
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ k3s_ssh_key_local_path }}"
|
||||||
|
content: "{{ k3s_ssh_key_secret.PRIVATE_KEY | trim }}\n"
|
||||||
|
mode: "0600"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
no_log: true
|
||||||
29
build/config/ansible/playbooks/proxmox.yml
Normal file
29
build/config/ansible/playbooks/proxmox.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
# Installs apps natively into Proxmox LXCs. The LXCs themselves are created
|
||||||
|
# by Terraform (build/config/terraform) — run that first; this playbook
|
||||||
|
# assumes the guests already exist and are reachable.
|
||||||
|
#
|
||||||
|
# Hosts come from the community.proxmox inventory plugin
|
||||||
|
# (inventory/proxmox.yml), which queries the Proxmox API rather than listing
|
||||||
|
# guests by hand. `apps` is declared per guest in host_vars/<guest>.yml,
|
||||||
|
# mirroring how `stacks` works for Unraid hosts.
|
||||||
|
- name: Install apps into Proxmox LXCs
|
||||||
|
hosts: proxmox_guests
|
||||||
|
gather_facts: true
|
||||||
|
vars:
|
||||||
|
repo_root: "{{ playbook_dir }}/../../../.."
|
||||||
|
# Comma-separated app names to restrict this run to. Shares the
|
||||||
|
# `only_stacks` name with playbooks/unraid.yml so a single `-e` narrows
|
||||||
|
# both platforms in one invocation of deploy.yml.
|
||||||
|
only_stacks: ""
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Install each declared app
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: lxc_app
|
||||||
|
loop: >-
|
||||||
|
{{ apps if only_stacks == ''
|
||||||
|
else apps | selectattr('name', 'in', only_stacks.split(',')) | list }}
|
||||||
|
loop_control:
|
||||||
|
loop_var: app
|
||||||
|
label: "{{ app.name }}"
|
||||||
14
build/config/ansible/playbooks/pve_host.yml
Normal file
14
build/config/ansible/playbooks/pve_host.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
---
|
||||||
|
# Configures the Proxmox node itself — as distinct from the guests on it,
|
||||||
|
# which playbooks/proxmox.yml handles. Right now that means backups: the NAS
|
||||||
|
# storage archives are written to, and the vzdump schedule that fills it.
|
||||||
|
#
|
||||||
|
# Targets the static `proxmox_nodes` group in inventory/hosts.yml rather than
|
||||||
|
# the API-backed dynamic inventory, which enumerates guests. If no node is
|
||||||
|
# defined there yet, this play matches nothing and is skipped.
|
||||||
|
- name: Configure Proxmox nodes
|
||||||
|
hosts: proxmox_nodes
|
||||||
|
gather_facts: true
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- pve_backup
|
||||||
24
build/config/ansible/playbooks/unraid.yml
Normal file
24
build/config/ansible/playbooks/unraid.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
- name: Converge Unraid Docker Compose stacks
|
||||||
|
hosts: unraid_servers
|
||||||
|
gather_facts: true
|
||||||
|
vars:
|
||||||
|
repo_root: "{{ playbook_dir }}/../../../.."
|
||||||
|
# Comma-separated stack names to restrict this run to, e.g.
|
||||||
|
# `-e only_stacks=forgejo` or `-e only_stacks=forgejo,postgres`.
|
||||||
|
# Empty (the default) converges every stack declared for the host.
|
||||||
|
only_stacks: ""
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# "Converge", not "deploy": an entry carrying `state: absent` is torn down
|
||||||
|
# rather than brought up. Removals have to stay in the list to be acted
|
||||||
|
# on — see the header comment in roles/compose_stack/tasks/main.yml.
|
||||||
|
- name: Converge each declared stack
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: compose_stack
|
||||||
|
loop: >-
|
||||||
|
{{ stacks if only_stacks == ''
|
||||||
|
else stacks | selectattr('name', 'in', only_stacks.split(',')) | list }}
|
||||||
|
loop_control:
|
||||||
|
loop_var: stack
|
||||||
|
label: "{{ stack.name }}"
|
||||||
15
build/config/ansible/requirements.yml
Normal file
15
build/config/ansible/requirements.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
collections:
|
||||||
|
- name: community.docker
|
||||||
|
version: ">=3.10.0"
|
||||||
|
- name: community.hashi_vault
|
||||||
|
version: ">=6.2.0"
|
||||||
|
- name: community.postgresql
|
||||||
|
version: ">=3.0.0"
|
||||||
|
- name: ansible.posix
|
||||||
|
version: ">=1.5.0"
|
||||||
|
# Proxmox dynamic inventory (inventory/proxmox.yml). If your Ansible
|
||||||
|
# predates the split of Proxmox content out of community.general, drop
|
||||||
|
# this and use community.general instead — see the note in
|
||||||
|
# inventory/proxmox.yml.
|
||||||
|
- name: community.proxmox
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
compose_projects_root: /boot/config/plugins/compose.manager/projects
|
||||||
|
vault_kv_mount: kv
|
||||||
|
|
||||||
|
# Where a stack's committed icon.png is copied to on the Unraid host, for the
|
||||||
|
# `net.unraid.docker.icon` label to point at. Under appdata rather than
|
||||||
|
# /boot — it's regenerated content, not configuration worth putting on the
|
||||||
|
# flash drive's write budget.
|
||||||
|
unraid_icons_root: /mnt/user/appdata/icons
|
||||||
183
build/config/ansible/roles/compose_stack/tasks/deploy.yml
Normal file
183
build/config/ansible/roles/compose_stack/tasks/deploy.yml
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
---
|
||||||
|
# Deploys one stack to the current Unraid host: sync the compose file, render
|
||||||
|
# its .env from Vault, provision its database if it declares one, then
|
||||||
|
# `docker compose up -d`. Compose files stay static/generic — only the
|
||||||
|
# rendered .env differs per run, which is what keeps re-runs idempotent.
|
||||||
|
#
|
||||||
|
# Included from main.yml when the stack's `state:` is `present`; the facts it
|
||||||
|
# relies on (stack_local_dir, stack_remote_dir) are set there.
|
||||||
|
|
||||||
|
- name: Load portable stack variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ stack_local_dir }}/common/vars.yml"
|
||||||
|
name: stack_common_vars
|
||||||
|
|
||||||
|
- name: Load Unraid-specific stack variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ stack_local_dir }}/ansible/unraid/vars.yml"
|
||||||
|
name: stack_platform_vars
|
||||||
|
|
||||||
|
- name: Ensure remote project directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ stack_remote_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: Copy docker-compose.yml
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ stack_local_dir }}/ansible/unraid/docker-compose.yml"
|
||||||
|
dest: "{{ stack_remote_dir }}/docker-compose.yml"
|
||||||
|
mode: "0640"
|
||||||
|
|
||||||
|
# --- Unraid Docker page presentation ---------------------------------------
|
||||||
|
#
|
||||||
|
# The committed icon.png has two destinations, because Unraid draws the stack
|
||||||
|
# row and the containers under it from entirely different sources:
|
||||||
|
#
|
||||||
|
# stack row — Compose Manager serves <project dir>/icon.png directly
|
||||||
|
# (icon.php, which also accepts .jpg/.gif/.svg or a bare
|
||||||
|
# `icon`). No label, no template, no URL: a file or nothing.
|
||||||
|
# Present only in the maintained fork, Compose Manager Plus;
|
||||||
|
# the original plugin has no icon support at all, and there
|
||||||
|
# the copy is inert rather than harmful.
|
||||||
|
# containers — the Docker page reads a container's icon, WebUI link and
|
||||||
|
# console shell from the dockerMan template that created it.
|
||||||
|
# A Compose stack has no template, so 6.10+ falls back to the
|
||||||
|
# `net.unraid.docker.*` labels the compose files set, and
|
||||||
|
# STACK_ICON below is what the icon label resolves to.
|
||||||
|
#
|
||||||
|
# Hence the appdata copy as well: the label is a path the webgui resolves at
|
||||||
|
# page-render time, and pointing it into /boot to reuse the project-dir copy
|
||||||
|
# would put the flash drive in the path of every Docker page load.
|
||||||
|
#
|
||||||
|
# Only the second of those is per-container, so a stack running more than one
|
||||||
|
# service can also commit icon-<service>.png files. Those get the appdata copy
|
||||||
|
# only — there is exactly one stack row and it already has its icon. See
|
||||||
|
# main.yml, which is where the names are resolved.
|
||||||
|
- name: Sync stack icons to the Unraid host
|
||||||
|
when: stack_icon_dest | length > 0 or stack_service_icon_names | length > 0
|
||||||
|
block:
|
||||||
|
- name: Ensure the icon directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ unraid_icons_root }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Copy stack icon for the container labels
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ stack_local_dir }}/ansible/unraid/icon.png"
|
||||||
|
dest: "{{ stack_icon_dest }}"
|
||||||
|
mode: "0644"
|
||||||
|
when: stack_icon_dest | length > 0
|
||||||
|
|
||||||
|
# Into the project folder on the flash drive, next to the compose file.
|
||||||
|
# `copy` is checksum-based, so a redeploy that hasn't changed the icon
|
||||||
|
# writes nothing — this costs one flash write per icon, not one per run.
|
||||||
|
- name: Copy stack icon for the Compose Manager stack row
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ stack_local_dir }}/ansible/unraid/icon.png"
|
||||||
|
dest: "{{ stack_remote_dir }}/icon.png"
|
||||||
|
mode: "0644"
|
||||||
|
when: stack_icon_dest | length > 0
|
||||||
|
|
||||||
|
# Appdata only, and no flash-drive copy: these label individual containers,
|
||||||
|
# which is the one thing the Compose Manager stack row isn't.
|
||||||
|
- name: Copy per-service icons for the container labels
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ stack_local_dir }}/ansible/unraid/icon-{{ item }}.png"
|
||||||
|
dest: "{{ unraid_icons_root }}/{{ stack.name }}-{{ item }}.png"
|
||||||
|
mode: "0644"
|
||||||
|
loop: "{{ stack_service_icon_names }}"
|
||||||
|
|
||||||
|
- name: Look up stack secrets from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
vault_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
stack.vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Render .env file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: env.j2
|
||||||
|
dest: "{{ stack_remote_dir }}/.env"
|
||||||
|
mode: "0600"
|
||||||
|
vars:
|
||||||
|
# The role-computed icon paths first, then portable values, then
|
||||||
|
# Unraid-specific overrides on top; Vault secrets win over all three (see
|
||||||
|
# env.j2). STACK_ICON and the per-service STACK_ICON_<SERVICE> keys sit at
|
||||||
|
# the bottom so either vars.yml layer can replace one with a hosted URL,
|
||||||
|
# and STACK_ICON is always defined so Compose never warns about an unset
|
||||||
|
# variable in the label block.
|
||||||
|
#
|
||||||
|
# STACK_ICON_<SERVICE> only exists when the matching icon-<service>.png is
|
||||||
|
# committed. A compose file that interpolates one without the file present
|
||||||
|
# gets Compose's empty-value warning and the placeholder icon — set it in
|
||||||
|
# vars.yml instead if the icon is meant to be a URL.
|
||||||
|
env_defaults: >-
|
||||||
|
{{ {'STACK_ICON': stack_icon_dest}
|
||||||
|
| combine(stack_service_icons)
|
||||||
|
| combine(stack_common_vars.env_defaults | default({}))
|
||||||
|
| combine(stack_platform_vars.env_defaults | default({})) }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Provision application database
|
||||||
|
when: stack.db is defined
|
||||||
|
no_log: true
|
||||||
|
block:
|
||||||
|
- name: Look up Postgres superuser credentials from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pg_admin_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
stack.db.admin_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
|
||||||
|
- name: Ensure application database role exists
|
||||||
|
community.postgresql.postgresql_user:
|
||||||
|
name: "{{ stack.db.user }}"
|
||||||
|
password: "{{ vault_secrets[stack.db.password_vault_key] }}"
|
||||||
|
login_host: "{{ stack.db.provision_host }}"
|
||||||
|
login_port: "{{ stack.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: present
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# Role first, then the database with `owner:` — see the same pair of
|
||||||
|
# tasks in roles/lxc_app for the full reasoning. The short version is
|
||||||
|
# that `priv: ALL` on postgresql_user was removed from
|
||||||
|
# community.postgresql in 4.0.0, and ownership is what the replacement
|
||||||
|
# should have been anyway.
|
||||||
|
#
|
||||||
|
# This instance is pinned to Postgres 13, so unlike the Proxmox one it
|
||||||
|
# was never *also* broken by the Postgres 15 `public` schema change —
|
||||||
|
# the removal of `priv` is the only thing that forces the edit here.
|
||||||
|
# Kept identical to the other two roles regardless: the point of the
|
||||||
|
# three app roles sharing this block is that an app moving between
|
||||||
|
# platforms gets the same database either way.
|
||||||
|
- name: Ensure application database exists
|
||||||
|
community.postgresql.postgresql_db:
|
||||||
|
name: "{{ stack.db.name }}"
|
||||||
|
owner: "{{ stack.db.user }}"
|
||||||
|
login_host: "{{ stack.db.provision_host }}"
|
||||||
|
login_port: "{{ stack.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: present
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Deploy stack with Docker Compose
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ stack_remote_dir }}"
|
||||||
|
state: present
|
||||||
|
pull: policy
|
||||||
75
build/config/ansible/roles/compose_stack/tasks/main.yml
Normal file
75
build/config/ansible/roles/compose_stack/tasks/main.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
# Converges one stack (one loop iteration of `stack` from playbooks/unraid.yml)
|
||||||
|
# to the state its host_vars entry asks for.
|
||||||
|
#
|
||||||
|
# `state: present` (the default) deploys; `state: absent` tears down. Removal
|
||||||
|
# has to be asked for explicitly because Ansible keeps no record of what it
|
||||||
|
# deployed last run — deleting a stack from a host's `stacks:` list only stops
|
||||||
|
# the loop visiting it, leaving the containers running and unmanaged on the
|
||||||
|
# host forever. So the entry stays put as a tombstone, with `state: absent`,
|
||||||
|
# until the teardown has actually been applied.
|
||||||
|
#
|
||||||
|
# Unraid-only by design. Proxmox guests install apps natively rather than as
|
||||||
|
# Compose stacks — see the `lxc_app` role.
|
||||||
|
|
||||||
|
- name: Set stack facts
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
stack_local_dir: "{{ repo_root }}/src/{{ stack.src }}"
|
||||||
|
stack_remote_dir: "{{ compose_projects_root }}/{{ stack.name }}"
|
||||||
|
stack_state: "{{ stack.state | default('present') }}"
|
||||||
|
|
||||||
|
- name: Validate requested stack state
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: stack_state in ['present', 'absent']
|
||||||
|
fail_msg: >-
|
||||||
|
Stack '{{ stack.name }}' has state '{{ stack_state }}'; expected
|
||||||
|
'present' or 'absent'.
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
# Which icons this stack ships, worked out from the repo checkout on the
|
||||||
|
# controller rather than from anything on the host — `is exists` and the
|
||||||
|
# `fileglob` lookup both evaluate locally. Computed here rather than in
|
||||||
|
# deploy.yml because both branches need it: deploy copies these files out,
|
||||||
|
# remove deletes them again.
|
||||||
|
#
|
||||||
|
# Two naming conventions, because Unraid has two icon consumers (see
|
||||||
|
# deploy.yml for the full explanation):
|
||||||
|
#
|
||||||
|
# icon.png the stack — Compose Manager's project row, plus the
|
||||||
|
# `net.unraid.docker.icon` label of whichever service
|
||||||
|
# interpolates ${STACK_ICON}.
|
||||||
|
# icon-<service>.png one container — becomes ${STACK_ICON_<SERVICE>}, so a
|
||||||
|
# multi-container stack can label each of its services
|
||||||
|
# with its own image instead of sharing the stack's.
|
||||||
|
#
|
||||||
|
# `<service>` is the compose service name; the variable is it uppercased with
|
||||||
|
# `-` folded to `_` (icon-shelfarr-libation.png → STACK_ICON_SHELFARR_LIBATION).
|
||||||
|
- name: Set stack icon facts
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
# Empty when the stack ships no icon.png — an empty label is what the
|
||||||
|
# webgui already assumes, so it falls back to the placeholder as before.
|
||||||
|
stack_icon_dest: >-
|
||||||
|
{{ ((stack_local_dir ~ '/ansible/unraid/icon.png') is exists)
|
||||||
|
| ternary(unraid_icons_root ~ '/' ~ stack.name ~ '.png', '') }}
|
||||||
|
stack_service_icon_names: "{{ _service_icon_names }}"
|
||||||
|
stack_service_icons: >-
|
||||||
|
{{ dict(_service_icon_names
|
||||||
|
| map('upper') | map('replace', '-', '_')
|
||||||
|
| map('regex_replace', '^(.+)$', 'STACK_ICON_\1')
|
||||||
|
| zip(_service_icon_names
|
||||||
|
| map('regex_replace', '^(.+)$',
|
||||||
|
unraid_icons_root ~ '/' ~ stack.name ~ '-\1.png'))) }}
|
||||||
|
vars:
|
||||||
|
_service_icon_names: >-
|
||||||
|
{{ query('fileglob', stack_local_dir ~ '/ansible/unraid/icon-*.png')
|
||||||
|
| map('basename')
|
||||||
|
| map('regex_replace', '^icon-(.+)\.png$', '\1')
|
||||||
|
| map('lower') | list }}
|
||||||
|
|
||||||
|
- name: Deploy stack
|
||||||
|
ansible.builtin.include_tasks: deploy.yml
|
||||||
|
when: stack_state == 'present'
|
||||||
|
|
||||||
|
- name: Remove stack
|
||||||
|
ansible.builtin.include_tasks: remove.yml
|
||||||
|
when: stack_state == 'absent'
|
||||||
106
build/config/ansible/roles/compose_stack/tasks/remove.yml
Normal file
106
build/config/ansible/roles/compose_stack/tasks/remove.yml
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
---
|
||||||
|
# Tears one stack down off the current Unraid host: `docker compose down`,
|
||||||
|
# then delete the project folder, then optionally drop its database.
|
||||||
|
#
|
||||||
|
# Included from main.yml when the stack's `state:` is `absent`.
|
||||||
|
#
|
||||||
|
# Ordering matters and is not obvious: `docker compose down` reads the compose
|
||||||
|
# file to know what it is removing, so the project folder has to survive until
|
||||||
|
# after that step. Delete the folder (or the stack's `src/` tree) first and
|
||||||
|
# there is nothing left to tell Docker what belonged to the project — the
|
||||||
|
# containers and networks have to be cleaned up by hand instead.
|
||||||
|
#
|
||||||
|
# What `down` takes with it, and what it deliberately doesn't:
|
||||||
|
# - removed: the containers, and any network the project itself created
|
||||||
|
# - kept: bind mounts. Appdata under /mnt/user/appdata/<app> survives, so
|
||||||
|
# a removal is reversible by flipping `state:` back to `present`.
|
||||||
|
# - kept: external networks (caddy-net, unraid_shared) — they belong to
|
||||||
|
# another stack or to Unraid, not to this project.
|
||||||
|
# - opt-in: named volumes (`remove_volumes: true`) and images
|
||||||
|
# (`remove_images: local` or `all`).
|
||||||
|
#
|
||||||
|
# Not touched at all: the stack's `homelab/<app>` path in Vault. That has its
|
||||||
|
# own lifecycle and no reason to be destroyed by a redeployable teardown.
|
||||||
|
|
||||||
|
- name: Check whether the stack's project directory is still present
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ stack_remote_dir }}/docker-compose.yml"
|
||||||
|
register: stack_compose_file
|
||||||
|
|
||||||
|
- name: Tear down stack with Docker Compose
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ stack_remote_dir }}"
|
||||||
|
state: absent
|
||||||
|
# Named volumes and images are destructive beyond "stop running this
|
||||||
|
# here", so they stay opt-in per stack rather than being implied by
|
||||||
|
# `state: absent`.
|
||||||
|
remove_volumes: "{{ stack.remove_volumes | default(false) }}"
|
||||||
|
remove_images: "{{ stack.remove_images | default(omit) }}"
|
||||||
|
remove_orphans: true
|
||||||
|
# Absent already — a re-run after a successful removal, or a stack that was
|
||||||
|
# never deployed to this host. Both are the desired end state, not an error.
|
||||||
|
when: stack_compose_file.stat.exists
|
||||||
|
|
||||||
|
- name: Remove stack project directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ stack_remote_dir }}"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
# Unlike appdata, these are not state — they're copies of PNGs committed in
|
||||||
|
# the repo, put there purely so the Docker page had something to render.
|
||||||
|
# Nothing is lost by deleting them and a redeploy puts them back, so they
|
||||||
|
# aren't opt-in the way volumes and images are.
|
||||||
|
#
|
||||||
|
# Listed from the repo checkout (main.yml) rather than globbed on the host:
|
||||||
|
# `{{ stack.name }}-*.png` would also match the icons of any stack whose name
|
||||||
|
# starts with this one's.
|
||||||
|
- name: Remove stack icons
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ [unraid_icons_root ~ '/' ~ stack.name ~ '.png']
|
||||||
|
+ stack_service_icons.values() | list }}"
|
||||||
|
|
||||||
|
- name: Drop application database
|
||||||
|
when:
|
||||||
|
- stack.db is defined
|
||||||
|
- stack.remove_database | default(false)
|
||||||
|
no_log: true
|
||||||
|
block:
|
||||||
|
# Only the Postgres superuser credentials are needed here. The stack's own
|
||||||
|
# Vault secrets are not read, so a teardown still works after its
|
||||||
|
# `homelab/<app>` path has been deleted.
|
||||||
|
- name: Look up Postgres superuser credentials from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pg_admin_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
stack.db.admin_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
|
||||||
|
# Database before role: Postgres refuses to drop a role that still owns
|
||||||
|
# objects, and the role owns this database.
|
||||||
|
- name: Drop application database
|
||||||
|
community.postgresql.postgresql_db:
|
||||||
|
name: "{{ stack.db.name }}"
|
||||||
|
login_host: "{{ stack.db.provision_host }}"
|
||||||
|
login_port: "{{ stack.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: absent
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Drop application database role
|
||||||
|
community.postgresql.postgresql_user:
|
||||||
|
name: "{{ stack.db.user }}"
|
||||||
|
login_host: "{{ stack.db.provision_host }}"
|
||||||
|
login_port: "{{ stack.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: absent
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{# Non-secret defaults first, then Vault secrets — secrets win on key clashes. #}
|
||||||
|
{% for key, value in (env_defaults | default({})).items() %}
|
||||||
|
{{ key }}="{{ value }}"
|
||||||
|
{% endfor %}
|
||||||
|
{% for key, value in (vault_secrets | default({})).items() %}
|
||||||
|
{{ key }}="{{ value }}"
|
||||||
|
{% endfor %}
|
||||||
34
build/config/ansible/roles/k3s_app/defaults/main.yml
Normal file
34
build/config/ansible/roles/k3s_app/defaults/main.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
# Ansible-side knobs for roles/k3s_app — the k3s equivalent of
|
||||||
|
# compose_stack (Unraid) and lxc_app (Proxmox): the generic role that
|
||||||
|
# deploys one *application* onto the homelab-utils cluster, as opposed to
|
||||||
|
# the k3s_* roles that deploy one *cluster service*.
|
||||||
|
#
|
||||||
|
# The distinction is worth stating, because the file layout looks similar.
|
||||||
|
# roles/k3s_metallb, k3s_monitoring, k3s_postgres and k3s_cert_manager each
|
||||||
|
# hard-code a single thing the cluster itself needs, with their config in
|
||||||
|
# defaults/ and group_vars. This role hard-codes nothing: it's driven by the
|
||||||
|
# `k3s_apps:` list in inventory/group_vars/k3s_cluster.yml and reads each
|
||||||
|
# app's config out of src/<app>/, the same src/ tree Unraid and Proxmox
|
||||||
|
# already deploy from. An app that moves between platforms gets a new
|
||||||
|
# platform vars file, not a rewrite — see CLAUDE.md → "How config is
|
||||||
|
# layered".
|
||||||
|
#
|
||||||
|
# Everything reaches the cluster the same way every other k3s role does:
|
||||||
|
# rendering a file into k3s's auto-deploying manifests directory and letting
|
||||||
|
# k3s's own controllers reconcile it. No helm binary, no kubeconfig, no
|
||||||
|
# kubernetes.core collection on the controller.
|
||||||
|
|
||||||
|
# Where k3s watches for manifests to apply. Anything written here is applied
|
||||||
|
# by k3s's deploy controller, and — importantly for remove.yml — anything
|
||||||
|
# *deleted* from here has its resources torn down again, because the
|
||||||
|
# controller tracks what each file created via an Addon CR.
|
||||||
|
k3s_manifests_dir: /var/lib/rancher/k3s/server/manifests
|
||||||
|
|
||||||
|
vault_kv_mount: kv
|
||||||
|
|
||||||
|
# Which directory under src/<app>/ansible/ holds the Kubernetes deployment.
|
||||||
|
# "kubernetes" rather than "k3s": what's in there is plain Kubernetes plus
|
||||||
|
# k3s's HelmChart CR, and an app moved to a different distribution would
|
||||||
|
# keep most of it.
|
||||||
|
k3s_app_platform_dir: kubernetes
|
||||||
170
build/config/ansible/roles/k3s_app/tasks/deploy.yml
Normal file
170
build/config/ansible/roles/k3s_app/tasks/deploy.yml
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
---
|
||||||
|
# Deploys one app to the k3s cluster: layer its config, provision its
|
||||||
|
# database if it declares one, render its secrets into a Secret, then render
|
||||||
|
# whatever manifests the app ships into k3s's auto-deploying directory.
|
||||||
|
#
|
||||||
|
# The shape deliberately mirrors compose_stack/deploy.yml, because the
|
||||||
|
# separation it enforces is the same one: config that's committed, secrets
|
||||||
|
# that never are. On Unraid the split is "static docker-compose.yml
|
||||||
|
# referencing ${VAR}" + "rendered .env"; here it's "manifests carrying only
|
||||||
|
# non-secret config" + "a rendered Secret the manifests reference by name".
|
||||||
|
# In both cases the committed half is safe to read and the generated half
|
||||||
|
# never lands in git.
|
||||||
|
#
|
||||||
|
# Included from main.yml when the app's `state:` is `present`; the facts it
|
||||||
|
# relies on (app_local_dir) are set there.
|
||||||
|
|
||||||
|
- name: Load portable app variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ app_local_dir }}/common/vars.yml"
|
||||||
|
name: app_common_vars
|
||||||
|
|
||||||
|
- name: Load Kubernetes-specific app variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ app_local_dir }}/ansible/{{ k3s_app_platform_dir }}/vars.yml"
|
||||||
|
name: app_platform_vars
|
||||||
|
|
||||||
|
# Portable values first, platform overrides on top — same precedence as
|
||||||
|
# compose_stack's env_defaults merge, and the same reason: an app's ports and
|
||||||
|
# database name don't change with the platform, its storage class and
|
||||||
|
# ingress class do. Vault secrets are *not* merged in here; they go to the
|
||||||
|
# Secret in a separate task below, so a manifest template can never
|
||||||
|
# accidentally interpolate one into a world-readable file.
|
||||||
|
- name: Merge app configuration
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
app_config: >-
|
||||||
|
{{ (app_common_vars.env_defaults | default({}))
|
||||||
|
| combine(app_platform_vars.env_defaults | default({})) }}
|
||||||
|
|
||||||
|
- name: Look up app secrets from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
app_vault_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
app.vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# --- Database ---------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Identical to compose_stack's block, pointed at a different Postgres. That
|
||||||
|
# it *can* be identical is the whole point of roles/k3s_postgres publishing
|
||||||
|
# the CNPG primary on a MetalLB LoadBalancer: `community.postgresql` runs on
|
||||||
|
# the controller (delegate_to: localhost) and needs a real host:port, which
|
||||||
|
# an in-cluster ClusterIP Service isn't. See CLAUDE.md → "Key decisions" —
|
||||||
|
# this is the deploy path that entry says is missing.
|
||||||
|
#
|
||||||
|
# provision_host is the LoadBalancer address, not the -rw ClusterIP; the app
|
||||||
|
# itself still talks to the ClusterIP by DNS (see the app's vars.yml), so
|
||||||
|
# the LAN-facing address is only ever used by the controller at deploy time.
|
||||||
|
- name: Provision application database
|
||||||
|
when: app.db is defined
|
||||||
|
no_log: true
|
||||||
|
block:
|
||||||
|
- name: Look up Postgres superuser credentials from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pg_admin_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
app.db.admin_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
|
||||||
|
- name: Ensure application database role exists
|
||||||
|
community.postgresql.postgresql_user:
|
||||||
|
name: "{{ app.db.user }}"
|
||||||
|
password: "{{ app_vault_secrets[app.db.password_vault_key] }}"
|
||||||
|
login_host: "{{ app.db.provision_host }}"
|
||||||
|
login_port: "{{ app.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: present
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# After the role, not before, and with owner: — unlike compose_stack,
|
||||||
|
# which creates the database first and then grants on it. The difference
|
||||||
|
# is that this database may be restored into from a pg_dump taken
|
||||||
|
# elsewhere (see docs/authentik-migration.md): a dump recreates objects
|
||||||
|
# with their original ownership, which only resolves if the owning role
|
||||||
|
# already exists and owns the database. Creating it owner-less and
|
||||||
|
# granting after works for an empty database and quietly leaves a
|
||||||
|
# restored one owned by postgres.
|
||||||
|
- name: Ensure application database exists
|
||||||
|
community.postgresql.postgresql_db:
|
||||||
|
name: "{{ app.db.name }}"
|
||||||
|
owner: "{{ app.db.user }}"
|
||||||
|
login_host: "{{ app.db.provision_host }}"
|
||||||
|
login_port: "{{ app.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: present
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# --- Manifests --------------------------------------------------------------
|
||||||
|
|
||||||
|
# Namespace + Secret in one file, so the namespace an app's manifests target
|
||||||
|
# is guaranteed to be created by something even if the app ships only a
|
||||||
|
# HelmChart CR (whose createNamespace: true fires too late for a Secret the
|
||||||
|
# chart's pods mount). Ordering between files doesn't otherwise matter here:
|
||||||
|
# k3s's deploy controller retries a manifest whose namespace or CRDs don't
|
||||||
|
# exist yet rather than failing once and giving up — the same property
|
||||||
|
# roles/k3s_postgres and roles/k3s_metallb already rely on.
|
||||||
|
#
|
||||||
|
# 0600 and no_log because this one carries every value from the app's Vault
|
||||||
|
# path. Note this is on-disk protection on the node only: the Secret's
|
||||||
|
# contents are then base64 in etcd like any Kubernetes Secret, which is the
|
||||||
|
# same trust boundary the rest of this cluster already assumes.
|
||||||
|
- name: Render the app namespace and secrets manifest
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: app-secrets.yaml.j2
|
||||||
|
dest: "{{ k3s_manifests_dir }}/{{ app.name }}-secrets.yaml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
become: true
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# Whatever the app ships — a HelmChart CR, an Ingress, a PVC, a Certificate.
|
||||||
|
# Enumerated from the repo checkout on the controller (fileglob evaluates
|
||||||
|
# locally), so adding a manifest to an app means dropping a .yaml.j2 next to
|
||||||
|
# the others, with no role change.
|
||||||
|
#
|
||||||
|
# Prefixed with the app name on the node, because every app's manifests share
|
||||||
|
# one flat directory there. Name the files for their content and not for the
|
||||||
|
# app (helmchart.yaml.j2, not authentik.helmchart.yaml.j2) — the prefix is
|
||||||
|
# added here, and an app-named file stutters into authentik-authentik.yaml.
|
||||||
|
- name: Find the app's Kubernetes manifest templates
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
app_manifest_templates: >-
|
||||||
|
{{ query('fileglob',
|
||||||
|
app_local_dir ~ '/ansible/' ~ k3s_app_platform_dir ~ '/*.yaml.j2')
|
||||||
|
| sort }}
|
||||||
|
|
||||||
|
- name: Fail fast if the app ships no manifests
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: app_manifest_templates | length > 0
|
||||||
|
fail_msg: >-
|
||||||
|
App '{{ app.name }}' has no *.yaml.j2 under
|
||||||
|
src/{{ app.src }}/ansible/{{ k3s_app_platform_dir }}/ — nothing to
|
||||||
|
deploy. An app on this platform needs at least one manifest (normally
|
||||||
|
a HelmChart CR).
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Render the app's Kubernetes manifests
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ item }}"
|
||||||
|
dest: >-
|
||||||
|
{{ k3s_manifests_dir }}/{{ app.name }}-{{
|
||||||
|
item | basename | regex_replace('\.j2$', '') }}
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop: "{{ app_manifest_templates }}"
|
||||||
|
become: true
|
||||||
37
build/config/ansible/roles/k3s_app/tasks/main.yml
Normal file
37
build/config/ansible/roles/k3s_app/tasks/main.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
# Converges one app (one loop iteration of `app` from playbooks/k3s.yml) to
|
||||||
|
# the state its k3s_apps: entry asks for.
|
||||||
|
#
|
||||||
|
# `state: present` (the default) deploys; `state: absent` tears down. Same
|
||||||
|
# explicit-removal rule as compose_stack, and for the same reason: Ansible
|
||||||
|
# keeps no record of what it deployed last run, so deleting an app from
|
||||||
|
# `k3s_apps:` only stops the loop visiting it — the workload keeps running,
|
||||||
|
# unmanaged, until something says `absent`. The entry stays as a tombstone.
|
||||||
|
#
|
||||||
|
# Unlike compose_stack, removal here does tear the workload down completely
|
||||||
|
# on the first pass, because k3s's deploy controller owns the resources a
|
||||||
|
# manifest created and garbage-collects them when the file goes away. What
|
||||||
|
# it does *not* touch, by the same "a default teardown should be
|
||||||
|
# reversible" reasoning compose_stack applies: the app's database, its
|
||||||
|
# PersistentVolumeClaims, and its Vault path.
|
||||||
|
|
||||||
|
- name: Set app facts
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
app_local_dir: "{{ repo_root }}/src/{{ app.src }}"
|
||||||
|
app_state: "{{ app.state | default('present') }}"
|
||||||
|
|
||||||
|
- name: Validate requested app state
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: app_state in ['present', 'absent']
|
||||||
|
fail_msg: >-
|
||||||
|
App '{{ app.name }}' has state '{{ app_state }}'; expected 'present'
|
||||||
|
or 'absent'.
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Deploy app
|
||||||
|
ansible.builtin.include_tasks: deploy.yml
|
||||||
|
when: app_state == 'present'
|
||||||
|
|
||||||
|
- name: Remove app
|
||||||
|
ansible.builtin.include_tasks: remove.yml
|
||||||
|
when: app_state == 'absent'
|
||||||
60
build/config/ansible/roles/k3s_app/tasks/remove.yml
Normal file
60
build/config/ansible/roles/k3s_app/tasks/remove.yml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
---
|
||||||
|
# Tears one app off the cluster: delete the manifests it was deployed from
|
||||||
|
# and let k3s's deploy controller garbage-collect what they created.
|
||||||
|
#
|
||||||
|
# This is the one place k3s is *less* work than Compose. `compose_stack`
|
||||||
|
# has to run `docker compose down` and therefore needs the compose file to
|
||||||
|
# still be on disk to know what it's tearing down (which is why removing an
|
||||||
|
# app there is a two-pass job — see CLAUDE.md → "Removing an app"). Here the
|
||||||
|
# deploy controller already tracks which resources each manifest file
|
||||||
|
# created, via the Addon CR it writes alongside them, so deleting the file
|
||||||
|
# is the teardown. src/<app>/ can be deleted in the same commit.
|
||||||
|
#
|
||||||
|
# Deliberately *not* removed, same reasoning as compose_stack's opt-in
|
||||||
|
# flags — a default teardown should be reversible:
|
||||||
|
#
|
||||||
|
# - the app's database and role on the shared Postgres
|
||||||
|
# - its PersistentVolumeClaims (the app's manifests own those; if the
|
||||||
|
# chart's PVCs carry a Helm ownership annotation they go with the
|
||||||
|
# HelmChart CR, so check `kubectl -n <ns> get pvc` after)
|
||||||
|
# - its Vault path
|
||||||
|
# - the namespace, which the secrets manifest below creates but the
|
||||||
|
# controller will only remove if nothing else landed in it
|
||||||
|
#
|
||||||
|
# There's no `remove_database`/`remove_volumes` equivalent yet. Add one the
|
||||||
|
# day it's actually wanted rather than guessing at the shape now.
|
||||||
|
|
||||||
|
- name: Find the app's Kubernetes manifest templates
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
app_manifest_templates: >-
|
||||||
|
{{ query('fileglob',
|
||||||
|
app_local_dir ~ '/ansible/' ~ k3s_app_platform_dir ~ '/*.yaml.j2')
|
||||||
|
| sort }}
|
||||||
|
|
||||||
|
# The secrets manifest first, then the app's own. Order is cosmetic — the
|
||||||
|
# controller reconciles each file's removal independently — but removing the
|
||||||
|
# workload's namespace/Secret last would leave pods briefly running without
|
||||||
|
# the credentials they were started with, and losing them noisily in a log
|
||||||
|
# is worse than losing them quietly.
|
||||||
|
- name: Remove the app's Kubernetes manifests
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: >-
|
||||||
|
{{ k3s_manifests_dir }}/{{ app.name }}-{{
|
||||||
|
item | basename | regex_replace('\.j2$', '') }}
|
||||||
|
state: absent
|
||||||
|
loop: "{{ app_manifest_templates }}"
|
||||||
|
become: true
|
||||||
|
|
||||||
|
- name: Remove the app namespace and secrets manifest
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ k3s_manifests_dir }}/{{ app.name }}-secrets.yaml"
|
||||||
|
state: absent
|
||||||
|
become: true
|
||||||
|
|
||||||
|
- name: Report what removal left behind
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
App '{{ app.name }}' manifests removed; k3s will garbage-collect the
|
||||||
|
resources they created. Its database, PVCs and Vault path were left
|
||||||
|
alone on purpose — remove those by hand if the teardown is meant to be
|
||||||
|
permanent.
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_app) — do not edit on the node.
|
||||||
|
|
||||||
|
The k3s counterpart of compose_stack's rendered .env: every key from the
|
||||||
|
app's Vault path, verbatim, in one Secret the app's manifests reference by
|
||||||
|
name. Keys are not renamed or filtered on the way through, which is what
|
||||||
|
keeps this role generic — an app decides what its environment looks like by
|
||||||
|
choosing its Vault keys, exactly as it does on Unraid, and the role stays
|
||||||
|
ignorant of any particular app's variables.
|
||||||
|
|
||||||
|
The intended consumer is an envFrom/secretRef in the app's own manifest,
|
||||||
|
so the values become environment variables without ever being named in a
|
||||||
|
committed file. See src/authentik/ansible/kubernetes/ for the worked
|
||||||
|
example.
|
||||||
|
|
||||||
|
Namespace lives here rather than in the app's manifests so it's guaranteed
|
||||||
|
to exist before anything mounts this Secret — a HelmChart CR's
|
||||||
|
createNamespace: true happens when the chart installs, which is after the
|
||||||
|
helm-controller job needs somewhere to put it.
|
||||||
|
-#}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: {{ app_config.K8S_NAMESPACE }}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ app.name }}-secrets
|
||||||
|
namespace: {{ app_config.K8S_NAMESPACE }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
{% for key, value in (app_vault_secrets | default({})) | dictsort %}
|
||||||
|
{{ key }}: {{ value | string | to_json }}
|
||||||
|
{% endfor %}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
---
|
||||||
|
# Ansible-side knobs for roles/k3s_cert_manager — cert-manager, which issues
|
||||||
|
# and renews the TLS certificates Ingresses on this cluster serve. Installed
|
||||||
|
# via k3s's own bundled helm-controller, same mechanism as roles/k3s_metallb,
|
||||||
|
# k3s_monitoring and k3s_postgres (see tasks/main.yml) — no helm binary or
|
||||||
|
# extra Ansible collection needed on the controller.
|
||||||
|
#
|
||||||
|
# This is the fourth cluster service, and the first one that exists purely
|
||||||
|
# for apps rather than for the cluster: nothing here needs a certificate,
|
||||||
|
# roles/k3s_app's tenants do. It's still a cluster service rather than an
|
||||||
|
# app, because a certificate issuer is shared infrastructure in the same way
|
||||||
|
# the shared Postgres is — one ClusterIssuer, every app's Ingress annotates
|
||||||
|
# itself against it.
|
||||||
|
|
||||||
|
k3s_cert_manager_namespace: cert-manager
|
||||||
|
|
||||||
|
k3s_cert_manager_chart_repo: https://charts.jetstack.io
|
||||||
|
|
||||||
|
# Pinned, same reasoning as every other chart version here — a rebuild
|
||||||
|
# months from now should reproduce today's install, not whatever's newest.
|
||||||
|
# Bump deliberately; check the current release first at
|
||||||
|
# https://github.com/cert-manager/cert-manager/releases.
|
||||||
|
k3s_cert_manager_chart_version: "v1.21.1"
|
||||||
|
|
||||||
|
# The name every app's Ingress annotates itself with
|
||||||
|
# (cert-manager.io/cluster-issuer: <this>). A ClusterIssuer rather than a
|
||||||
|
# per-namespace Issuer, so an app doesn't need its own copy of the ACME
|
||||||
|
# account and DNS credentials in its own namespace.
|
||||||
|
k3s_cert_manager_issuer_name: letsencrypt
|
||||||
|
|
||||||
|
# Let's Encrypt's production directory. Swap for the staging URL
|
||||||
|
# (https://acme-staging-v02.api.letsencrypt.org/directory) while working out
|
||||||
|
# a solver configuration: production has a hard rate limit of 5 failed
|
||||||
|
# validations per account/hostname/hour, and burning it means waiting rather
|
||||||
|
# than retrying. Changing this changes which ACME account the issuer uses, so
|
||||||
|
# existing certificates are re-issued rather than renewed.
|
||||||
|
k3s_cert_manager_acme_server: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
|
||||||
|
# The address Let's Encrypt sends expiry warnings to. No default — an ACME
|
||||||
|
# account is registered against it, so a wrong value is worth failing on
|
||||||
|
# rather than guessing. Set in inventory/group_vars/k3s_cluster.yml.
|
||||||
|
k3s_cert_manager_acme_email: ""
|
||||||
|
|
||||||
|
# Where the ACME account's private key is kept. cert-manager generates it on
|
||||||
|
# first registration; it is *not* the certificate key, and losing it means
|
||||||
|
# re-registering rather than losing certificates.
|
||||||
|
k3s_cert_manager_acme_key_secret: letsencrypt-account-key
|
||||||
|
|
||||||
|
# --- Solver -----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# How cert-manager proves control of the domain. DNS-01 by default, not
|
||||||
|
# HTTP-01, because HTTP-01 requires Let's Encrypt to reach this cluster from
|
||||||
|
# the internet on port 80 — true only if the LAN is port-forwarded, and this
|
||||||
|
# cluster deliberately isn't. DNS-01 needs no inbound path at all, and is the
|
||||||
|
# only option that can issue wildcards.
|
||||||
|
#
|
||||||
|
# The provider block is left open rather than hard-coding Cloudflare, since
|
||||||
|
# it's the one part of this role that depends on who runs the DNS. Set
|
||||||
|
# k3s_cert_manager_solver in inventory/group_vars/k3s_cluster.yml to whatever
|
||||||
|
# your provider's stanza looks like in cert-manager's docs
|
||||||
|
# (https://cert-manager.io/docs/configuration/acme/dns01/); it's rendered
|
||||||
|
# into the ClusterIssuer's solvers list as-is. Any secret it references
|
||||||
|
# should name the Secret this role renders from Vault (below), so no token is
|
||||||
|
# ever committed. tasks/main.yml fails fast while this is empty.
|
||||||
|
k3s_cert_manager_solver: {}
|
||||||
|
|
||||||
|
# The Vault path holding the DNS provider's API credentials. Every key at
|
||||||
|
# this path becomes a key in a Secret named after it in the cert-manager
|
||||||
|
# namespace, the same "Vault keys pass through verbatim" rule roles/k3s_app
|
||||||
|
# uses — so the solver above references whichever key name you stored.
|
||||||
|
# Leave empty for a solver that needs no credentials (e.g. HTTP-01).
|
||||||
|
k3s_cert_manager_vault_path: homelab/k3s-cert-manager
|
||||||
|
k3s_cert_manager_credentials_secret: cert-manager-dns-credentials
|
||||||
75
build/config/ansible/roles/k3s_cert_manager/tasks/main.yml
Normal file
75
build/config/ansible/roles/k3s_cert_manager/tasks/main.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
# Drops a HelmChart CR for cert-manager, plus a ClusterIssuer and the DNS
|
||||||
|
# credentials it solves with, into k3s's auto-deploying manifests directory;
|
||||||
|
# k3s's bundled helm-controller and deploy controller reconcile them — same
|
||||||
|
# mechanism and same two-manifest shape as roles/k3s_metallb (chart CR +
|
||||||
|
# plain config manifest referencing CRDs the chart hasn't installed yet).
|
||||||
|
#
|
||||||
|
# The ClusterIssuer is exactly that case: cert-manager.io/v1 doesn't exist
|
||||||
|
# until the chart has installed, so this manifest is unappliable at the
|
||||||
|
# moment it's written. That's fine and deliberate — k3s's deploy controller
|
||||||
|
# retries a manifest referencing not-yet-existing CRDs until they show up,
|
||||||
|
# rather than failing once and giving up.
|
||||||
|
|
||||||
|
- name: Fail fast if cert-manager is not configured
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- k3s_cert_manager_acme_email | length > 0
|
||||||
|
- k3s_cert_manager_solver | length > 0
|
||||||
|
fail_msg: >-
|
||||||
|
k3s_cert_manager_acme_email and/or k3s_cert_manager_solver are unset —
|
||||||
|
set both in inventory/group_vars/k3s_cluster.yml before running
|
||||||
|
playbooks/k3s.yml. An ACME account is registered against the email, and
|
||||||
|
without a solver the ClusterIssuer would be created but never able to
|
||||||
|
prove domain control, leaving every Certificate pending indefinitely
|
||||||
|
rather than failing loudly.
|
||||||
|
quiet: true
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Deploy the cert-manager HelmChart manifest
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: cert-manager.helmchart.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/cert-manager.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
||||||
|
# Before the ClusterIssuer, because that's what references it. Ordering
|
||||||
|
# between files isn't actually enforced (see the header) — this is for the
|
||||||
|
# reader, not the controller.
|
||||||
|
- name: Look up the DNS provider credentials from Vault
|
||||||
|
when: k3s_cert_manager_vault_path | length > 0
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
cert_manager_vault_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
k3s_cert_manager_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# Separate file and mode from everything else here, same split
|
||||||
|
# roles/k3s_postgres makes: this is the only manifest carrying a credential,
|
||||||
|
# so it's the only one that isn't safe at 0644.
|
||||||
|
- name: Deploy the DNS provider credentials Secret
|
||||||
|
when: k3s_cert_manager_vault_path | length > 0
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: dns-credentials.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/cert-manager-dns-credentials.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
become: true
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Deploy the ClusterIssuer manifest
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: cluster-issuer.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/cert-manager-cluster-issuer.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_cert_manager) — do not edit on the node.
|
||||||
|
|
||||||
|
A HelmChart CR, same pattern as roles/k3s_metallb and roles/k3s_monitoring:
|
||||||
|
k3s's bundled helm-controller reconciles it, the same mechanism it uses to
|
||||||
|
install its own Traefik.
|
||||||
|
-#}
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChart
|
||||||
|
metadata:
|
||||||
|
name: cert-manager
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
chart: cert-manager
|
||||||
|
repo: {{ k3s_cert_manager_chart_repo }}
|
||||||
|
version: "{{ k3s_cert_manager_chart_version }}"
|
||||||
|
targetNamespace: {{ k3s_cert_manager_namespace }}
|
||||||
|
createNamespace: true
|
||||||
|
valuesContent: |-
|
||||||
|
# The chart installs its own CRDs. Without this they'd have to be applied
|
||||||
|
# separately before the chart, which the helm-controller gives no ordering
|
||||||
|
# hook for — and the ClusterIssuer this role also renders would then never
|
||||||
|
# become appliable.
|
||||||
|
crds:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Sized for a Raspberry Pi 4, same reasoning as k3s_monitoring_values and
|
||||||
|
# k3s_postgres_resources. cert-manager is idle almost all of the time —
|
||||||
|
# it wakes to renew a certificate every 60 days — so these are set for
|
||||||
|
# "doesn't get OOM-killed during a renewal", not for throughput.
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
webhook:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
cainjector:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 96Mi
|
||||||
|
limits:
|
||||||
|
memory: 192Mi
|
||||||
|
|
||||||
|
# DNS-01 self-check queries the authoritative nameservers directly rather
|
||||||
|
# than going through the cluster's resolver. Without this, CoreDNS
|
||||||
|
# forwards to whatever the LAN's DNS is, and a split-horizon setup —
|
||||||
|
# where the internal view of the zone doesn't carry the _acme-challenge
|
||||||
|
# TXT record the public view does — makes cert-manager wait for a record
|
||||||
|
# it will never see, until the order times out.
|
||||||
|
dns01RecursiveNameservers: "1.1.1.1:53,9.9.9.9:53"
|
||||||
|
dns01RecursiveNameserversOnly: true
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_cert_manager) — do not edit on the node.
|
||||||
|
|
||||||
|
Plain manifest, not a HelmChart — same pattern as
|
||||||
|
roles/k3s_metallb/templates/metallb-config.yaml.j2 and
|
||||||
|
roles/k3s_postgres/templates/postgres-cluster.yaml.j2: this references a
|
||||||
|
CRD (cert-manager.io) that only exists once the chart alongside it has
|
||||||
|
installed, and k3s's deploy controller retries rather than giving up.
|
||||||
|
|
||||||
|
ClusterIssuer rather than Issuer so it's usable from every namespace —
|
||||||
|
an app's Ingress just annotates itself with
|
||||||
|
`cert-manager.io/cluster-issuer: {{ k3s_cert_manager_issuer_name }}` and
|
||||||
|
needs no ACME account or DNS credentials of its own.
|
||||||
|
-#}
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: ClusterIssuer
|
||||||
|
metadata:
|
||||||
|
name: {{ k3s_cert_manager_issuer_name }}
|
||||||
|
spec:
|
||||||
|
acme:
|
||||||
|
server: {{ k3s_cert_manager_acme_server }}
|
||||||
|
email: {{ k3s_cert_manager_acme_email }}
|
||||||
|
# Where cert-manager keeps the ACME *account* key it generates on first
|
||||||
|
# registration — not any certificate's key. In the cert-manager namespace
|
||||||
|
# because a ClusterIssuer's secrets always resolve there, regardless of
|
||||||
|
# which namespace the Certificate using it lives in.
|
||||||
|
privateKeySecretRef:
|
||||||
|
name: {{ k3s_cert_manager_acme_key_secret }}
|
||||||
|
solvers:
|
||||||
|
{{ [k3s_cert_manager_solver] | to_nice_yaml(indent=2) | indent(6, first=true) }}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_cert_manager) — do not edit on the node.
|
||||||
|
|
||||||
|
The DNS provider API credentials the ACME DNS-01 solver authenticates with,
|
||||||
|
passed through from Vault verbatim — same rule roles/k3s_app's Secret
|
||||||
|
follows, so the key names in k3s_cert_manager_solver are whatever you chose
|
||||||
|
when populating the Vault path, and this role stays ignorant of which
|
||||||
|
provider is in use.
|
||||||
|
|
||||||
|
In the cert-manager namespace because a ClusterIssuer resolves every Secret
|
||||||
|
it references there, never in the namespace of the Certificate being
|
||||||
|
issued.
|
||||||
|
-#}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ k3s_cert_manager_credentials_secret }}
|
||||||
|
namespace: {{ k3s_cert_manager_namespace }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
{% for key, value in (cert_manager_vault_secrets | default({})) | dictsort %}
|
||||||
|
{{ key }}: {{ value | string | to_json }}
|
||||||
|
{% endfor %}
|
||||||
100
build/config/ansible/roles/k3s_maintenance/tasks/main.yml
Normal file
100
build/config/ansible/roles/k3s_maintenance/tasks/main.yml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
---
|
||||||
|
# Rolling "reboot this node if unattended-upgrades left one pending" for one
|
||||||
|
# k3s node — one iteration of the serial: 1 loop in
|
||||||
|
# playbooks/k3s_maintenance.yml. roles/unattended_upgrades installs updates on
|
||||||
|
# its own schedule with Automatic-Reboot disabled, so a kernel/library update
|
||||||
|
# can sit applied-but-inactive on a node indefinitely; this is what actually
|
||||||
|
# reboots it — cordoned and drained first, so workloads move off before the
|
||||||
|
# node disappears rather than during.
|
||||||
|
#
|
||||||
|
# kubectl commands are delegated to the control-plane node and run as
|
||||||
|
# `k3s kubectl`, k3s's own bundled client — no separate kubectl install or
|
||||||
|
# local kubeconfig needed. Works the same whether the node having its turn
|
||||||
|
# right now *is* the control plane: delegating to itself, over SSH, before it
|
||||||
|
# reboots itself.
|
||||||
|
|
||||||
|
- name: Sanity-check there is exactly one control-plane node
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: groups['k3s_control_plane'] | length == 1
|
||||||
|
fail_msg: >-
|
||||||
|
k3s_maintenance delegates kubectl to groups['k3s_control_plane'][0] —
|
||||||
|
it assumes a single control-plane node. Update this role before adding
|
||||||
|
a second one for HA.
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Check whether a reboot is required
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /var/run/reboot-required
|
||||||
|
register: k3s_reboot_required
|
||||||
|
|
||||||
|
- name: Node is up to date — nothing to do
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ inventory_hostname }}: no reboot required, skipping."
|
||||||
|
when: not k3s_reboot_required.stat.exists
|
||||||
|
|
||||||
|
- name: Reboot this node if unattended-upgrades left one pending
|
||||||
|
when: k3s_reboot_required.stat.exists
|
||||||
|
block:
|
||||||
|
# --force: this is a homelab, not a cluster with a policy against bare
|
||||||
|
# pods — better to evict them than have a stray one block every rebuild.
|
||||||
|
# --delete-emptydir-data: emptyDir contents are expected to be
|
||||||
|
# disposable; anything that isn't shouldn't be using emptyDir.
|
||||||
|
- name: Cordon and drain the node
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- k3s
|
||||||
|
- kubectl
|
||||||
|
- drain
|
||||||
|
- "{{ inventory_hostname }}"
|
||||||
|
- --ignore-daemonsets
|
||||||
|
- --delete-emptydir-data
|
||||||
|
- --force
|
||||||
|
- --timeout=120s
|
||||||
|
delegate_to: "{{ groups['k3s_control_plane'][0] }}"
|
||||||
|
become: false
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Reboot the node
|
||||||
|
ansible.builtin.reboot:
|
||||||
|
reboot_timeout: 300
|
||||||
|
|
||||||
|
# Polls rather than trusting the reboot handshake alone — the node can be
|
||||||
|
# reachable over SSH before k3s (and, if this is the control-plane node
|
||||||
|
# itself, the API server it just took down with it) has finished coming
|
||||||
|
# back up.
|
||||||
|
- name: Wait for the node to report Ready again
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- k3s
|
||||||
|
- kubectl
|
||||||
|
- wait
|
||||||
|
- --for=condition=Ready
|
||||||
|
- "node/{{ inventory_hostname }}"
|
||||||
|
- --timeout=20s
|
||||||
|
delegate_to: "{{ groups['k3s_control_plane'][0] }}"
|
||||||
|
become: false
|
||||||
|
register: k3s_node_ready
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
until: k3s_node_ready.rc == 0
|
||||||
|
retries: 12
|
||||||
|
delay: 15
|
||||||
|
|
||||||
|
- name: Fail if the node never came back Ready
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: >-
|
||||||
|
{{ inventory_hostname }} rebooted but never reported Ready again —
|
||||||
|
it's left cordoned; check it by hand before re-running this against
|
||||||
|
the rest of the cluster.
|
||||||
|
when: k3s_node_ready.rc != 0
|
||||||
|
|
||||||
|
- name: Uncordon the node
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [k3s, kubectl, uncordon, "{{ inventory_hostname }}"]
|
||||||
|
delegate_to: "{{ groups['k3s_control_plane'][0] }}"
|
||||||
|
become: false
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Node rebooted and rejoined the cluster
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ inventory_hostname }}: rebooted, drained and uncordoned cleanly."
|
||||||
31
build/config/ansible/roles/k3s_metallb/defaults/main.yml
Normal file
31
build/config/ansible/roles/k3s_metallb/defaults/main.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
# Ansible-side knobs for roles/k3s_metallb — MetalLB, the LoadBalancer
|
||||||
|
# implementation for the homelab-utils cluster. Installed via k3s's own
|
||||||
|
# bundled helm-controller, same mechanism as roles/k3s_monitoring (see
|
||||||
|
# tasks/main.yml) — no helm binary or extra Ansible collection needed on the
|
||||||
|
# controller.
|
||||||
|
#
|
||||||
|
# MetalLB replaces k3s's bundled ServiceLB (Klipper), it doesn't sit
|
||||||
|
# alongside it — both would otherwise try to satisfy the same LoadBalancer
|
||||||
|
# Services and fight over IPs. That's why `k3s_extra_args` in
|
||||||
|
# inventory/group_vars/k3s_cluster.yml carries `--disable=servicelb`.
|
||||||
|
|
||||||
|
k3s_metallb_namespace: metallb-system
|
||||||
|
|
||||||
|
k3s_metallb_chart_repo: https://metallb.github.io/metallb
|
||||||
|
|
||||||
|
# Pinned, same reasoning as k3s_monitoring_chart_version — a rebuild months
|
||||||
|
# from now should reproduce today's stack, not whatever's newest at the
|
||||||
|
# time. Bump deliberately; check the current release first at
|
||||||
|
# https://github.com/metallb/metallb/releases.
|
||||||
|
k3s_metallb_chart_version: "0.14.9"
|
||||||
|
|
||||||
|
# The pool of IPs MetalLB hands out to LoadBalancer-type Services, as a
|
||||||
|
# "first-last" range or CIDR (e.g. "192.168.50.240-192.168.50.250"). Must be
|
||||||
|
# addresses on the cluster's LAN that nothing else — DHCP, static
|
||||||
|
# assignments, the hosts in inventory/hosts.yml — will ever claim: MetalLB
|
||||||
|
# doesn't coordinate with your router, it just hands out whatever's in this
|
||||||
|
# range. No default on purpose; set it in
|
||||||
|
# inventory/group_vars/k3s_cluster.yml before the first run. tasks/main.yml
|
||||||
|
# fails fast if it's still empty.
|
||||||
|
k3s_metallb_address_range: ""
|
||||||
39
build/config/ansible/roles/k3s_metallb/tasks/main.yml
Normal file
39
build/config/ansible/roles/k3s_metallb/tasks/main.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
# Drops a HelmChart CR for MetalLB, plus its IPAddressPool/L2Advertisement
|
||||||
|
# config, into k3s's auto-deploying manifests directory; k3s's bundled
|
||||||
|
# helm-controller and deploy controller reconcile them — same mechanism as
|
||||||
|
# roles/k3s_monitoring (see its tasks/main.yml for why this needs no helm
|
||||||
|
# binary or kubeconfig on the controller).
|
||||||
|
#
|
||||||
|
# The config manifest references CRDs that only exist once the HelmChart
|
||||||
|
# above has actually installed the chart, so it's templated in the same
|
||||||
|
# pass rather than gated behind a "wait for CRDs" step: k3s's deploy
|
||||||
|
# controller retries a manifest referencing not-yet-existing CRDs until they
|
||||||
|
# show up, instead of failing once and giving up.
|
||||||
|
|
||||||
|
- name: Fail fast if no MetalLB address range is configured
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- k3s_metallb_address_range | length > 0
|
||||||
|
fail_msg: >-
|
||||||
|
k3s_metallb_address_range is empty — set it in
|
||||||
|
inventory/group_vars/k3s_cluster.yml before running playbooks/k3s.yml.
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Deploy the MetalLB HelmChart manifest
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: metallb.helmchart.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/metallb.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
||||||
|
- name: Deploy the MetalLB address pool config
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: metallb-config.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/metallb-config.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Managed by Ansible (roles/k3s_metallb) — do not edit on the node.
|
||||||
|
#
|
||||||
|
# Plain manifests, not a HelmChart — these are MetalLB's own CRs, not chart
|
||||||
|
# values. k3s's deploy controller applies anything dropped in this
|
||||||
|
# directory, not only HelmChart CRs, and retries until the CRDs the
|
||||||
|
# HelmChart alongside this file installs actually exist.
|
||||||
|
#
|
||||||
|
# L2 (ARP/NDP) mode, not BGP — the simplest fit for a flat home LAN with no
|
||||||
|
# BGP-speaking router. One pool, one advertisement, both named "default".
|
||||||
|
apiVersion: metallb.io/v1beta1
|
||||||
|
kind: IPAddressPool
|
||||||
|
metadata:
|
||||||
|
name: default
|
||||||
|
namespace: {{ k3s_metallb_namespace }}
|
||||||
|
spec:
|
||||||
|
addresses:
|
||||||
|
- {{ k3s_metallb_address_range }}
|
||||||
|
---
|
||||||
|
apiVersion: metallb.io/v1beta1
|
||||||
|
kind: L2Advertisement
|
||||||
|
metadata:
|
||||||
|
name: default
|
||||||
|
namespace: {{ k3s_metallb_namespace }}
|
||||||
|
spec:
|
||||||
|
ipAddressPools:
|
||||||
|
- default
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Managed by Ansible (roles/k3s_metallb) — do not edit on the node.
|
||||||
|
#
|
||||||
|
# A HelmChart CR, same pattern as
|
||||||
|
# roles/k3s_monitoring/templates/kube-prometheus-stack.helmchart.yaml.j2 —
|
||||||
|
# k3s's bundled helm-controller reconciles it, the same mechanism it uses to
|
||||||
|
# install its own bundled Traefik and ServiceLB. No helm binary, kubeconfig,
|
||||||
|
# or extra Ansible collection needed on the controller.
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChart
|
||||||
|
metadata:
|
||||||
|
name: metallb
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
chart: metallb
|
||||||
|
repo: {{ k3s_metallb_chart_repo }}
|
||||||
|
version: "{{ k3s_metallb_chart_version }}"
|
||||||
|
targetNamespace: {{ k3s_metallb_namespace }}
|
||||||
|
createNamespace: true
|
||||||
86
build/config/ansible/roles/k3s_monitoring/defaults/main.yml
Normal file
86
build/config/ansible/roles/k3s_monitoring/defaults/main.yml
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
# Ansible-side knobs for roles/k3s_monitoring — the kube-prometheus-stack
|
||||||
|
# Helm chart, deployed via k3s's own bundled helm-controller rather than a
|
||||||
|
# helm binary or the kubernetes.core collection (see tasks/main.yml). Lean by
|
||||||
|
# design: OpenLens's Metrics feature reads straight off the Prometheus API
|
||||||
|
# through the k8s API server proxy, so there's no need for Grafana or
|
||||||
|
# Alertmanager in-cluster — both are disabled below, mainly to save RAM on
|
||||||
|
# the Pis.
|
||||||
|
|
||||||
|
k3s_monitoring_namespace: monitoring
|
||||||
|
|
||||||
|
k3s_monitoring_chart_repo: https://prometheus-community.github.io/helm-charts
|
||||||
|
|
||||||
|
# Pinned, same reasoning as k3s_version in inventory/group_vars/k3s_cluster.yml
|
||||||
|
# — a rebuild months from now should reproduce today's stack, not whatever's
|
||||||
|
# newest at the time. Bump deliberately; check the current release first at
|
||||||
|
# https://github.com/prometheus-community/helm-charts/releases (tags look
|
||||||
|
# like kube-prometheus-stack-X.Y.Z).
|
||||||
|
k3s_monitoring_chart_version: "88.3.0"
|
||||||
|
|
||||||
|
# Handed to the chart verbatim as valuesContent (see templates/). Notes on
|
||||||
|
# the less obvious choices:
|
||||||
|
# - grafana/alertmanager: off. OpenLens supplies its own dashboards and
|
||||||
|
# this cluster doesn't page anyone, so both would just be extra Pi RAM.
|
||||||
|
# - kubeControllerManager/kubeScheduler/kubeProxy/kubeEtcd: off. k3s bundles
|
||||||
|
# the control plane inside one static binary instead of exposing these as
|
||||||
|
# separate systemd units on their usual ports, so kube-prometheus-stack's
|
||||||
|
# default scrape targets for them sit permanently "down" — a known false
|
||||||
|
# alarm on k3s, not a sign anything's actually broken. kubelet (and the
|
||||||
|
# cAdvisor/node metrics it serves) is the one k3s does expose normally,
|
||||||
|
# and it's also the one OpenLens's node/pod metrics actually need.
|
||||||
|
# - prometheus retention/storage: short retention, no PVC template (so it
|
||||||
|
# runs on emptyDir). This stack exists to answer "what are the nodes
|
||||||
|
# doing right now", not to keep months of history, so losing the TSDB on
|
||||||
|
# a pod restart is an acceptable trade for not standing up persistent
|
||||||
|
# storage across 4 SD cards.
|
||||||
|
# - every component's resources: sized for a Raspberry Pi 4, not a
|
||||||
|
# datacenter node.
|
||||||
|
k3s_monitoring_values:
|
||||||
|
grafana:
|
||||||
|
enabled: false
|
||||||
|
alertmanager:
|
||||||
|
enabled: false
|
||||||
|
kubeApiServer:
|
||||||
|
enabled: true
|
||||||
|
kubeControllerManager:
|
||||||
|
enabled: false
|
||||||
|
kubeScheduler:
|
||||||
|
enabled: false
|
||||||
|
kubeProxy:
|
||||||
|
enabled: false
|
||||||
|
kubeEtcd:
|
||||||
|
enabled: false
|
||||||
|
kubelet:
|
||||||
|
enabled: true
|
||||||
|
prometheusOperator:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
prometheus:
|
||||||
|
prometheusSpec:
|
||||||
|
retention: 3d
|
||||||
|
scrapeInterval: 30s
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
kube-state-metrics:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 20m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
memory: 64Mi
|
||||||
|
prometheus-node-exporter:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 16Mi
|
||||||
|
limits:
|
||||||
|
memory: 32Mi
|
||||||
16
build/config/ansible/roles/k3s_monitoring/tasks/main.yml
Normal file
16
build/config/ansible/roles/k3s_monitoring/tasks/main.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
# Drops a HelmChart CR into k3s's auto-deploying manifests directory on the
|
||||||
|
# control-plane node; k3s's bundled helm-controller reconciles it, the same
|
||||||
|
# mechanism it uses to install its own bundled Traefik and ServiceLB. See
|
||||||
|
# templates/kube-prometheus-stack.helmchart.yaml.j2 for why this needs no
|
||||||
|
# helm binary or extra Ansible collection, and README.md "Metrics
|
||||||
|
# (Prometheus)" for how to point OpenLens at the result.
|
||||||
|
|
||||||
|
- name: Deploy the kube-prometheus-stack HelmChart manifest
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: kube-prometheus-stack.helmchart.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/kube-prometheus-stack.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
# Managed by Ansible (roles/k3s_monitoring) — do not edit on the node.
|
||||||
|
#
|
||||||
|
# A HelmChart CR, not a `helm install`: k3s ships its own helm-controller
|
||||||
|
# that watches /var/lib/rancher/k3s/server/manifests/ (this file's
|
||||||
|
# destination) the same way it watches for any other auto-deploying
|
||||||
|
# manifest, and reconciles HelmChart resources found there — the same
|
||||||
|
# mechanism k3s uses to install its own bundled Traefik and ServiceLB. That
|
||||||
|
# means no helm binary, kubeconfig, or extra Ansible collection is needed on
|
||||||
|
# the controller; re-running the playbook just re-templates this file and
|
||||||
|
# the controller reconciles the diff.
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChart
|
||||||
|
metadata:
|
||||||
|
name: kube-prometheus-stack
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
chart: kube-prometheus-stack
|
||||||
|
repo: {{ k3s_monitoring_chart_repo }}
|
||||||
|
version: "{{ k3s_monitoring_chart_version }}"
|
||||||
|
targetNamespace: {{ k3s_monitoring_namespace }}
|
||||||
|
createNamespace: true
|
||||||
|
valuesContent: |
|
||||||
|
{{ k3s_monitoring_values | to_nice_yaml(indent=2) | indent(4, first=true) }}
|
||||||
18
build/config/ansible/roles/k3s_node/defaults/main.yml
Normal file
18
build/config/ansible/roles/k3s_node/defaults/main.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
# Ubuntu Server's boot partition. Same path on Raspberry Pi OS, so this
|
||||||
|
# doesn't need to change if the Pis are ever reimaged to that instead.
|
||||||
|
k3s_boot_cmdline_path: /boot/firmware/cmdline.txt
|
||||||
|
|
||||||
|
# k3s (like any Kubernetes distro) needs the memory cgroup controller, which
|
||||||
|
# isn't always on by default on a Pi kernel. Ubuntu's Pi images usually ship
|
||||||
|
# these already — this task is a safety net for that assumption, not the
|
||||||
|
# primary mechanism, and a no-op on most runs. See tasks/prep.yml.
|
||||||
|
k3s_cgroup_params:
|
||||||
|
- cgroup_memory=1
|
||||||
|
- cgroup_enable=memory
|
||||||
|
|
||||||
|
# Safety-net defaults for inventory/group_vars/k3s_cluster.yml's extra-args
|
||||||
|
# vars (see tasks/server.yml, tasks/agent.yml) — empty so the role doesn't
|
||||||
|
# break if a var is left undefined there.
|
||||||
|
k3s_extra_args: []
|
||||||
|
k3s_server_extra_args: []
|
||||||
100
build/config/ansible/roles/k3s_node/tasks/agent.yml
Normal file
100
build/config/ansible/roles/k3s_node/tasks/agent.yml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
---
|
||||||
|
# Joins this node as a k3s agent (worker). Only ever runs after server.yml has
|
||||||
|
# succeeded somewhere — playbooks/k3s.yml targets k3s_control_plane before
|
||||||
|
# k3s_workers — so K3S_URL below always points at an already-live API server.
|
||||||
|
#
|
||||||
|
# --node-name pins the k8s node object to the Ansible inventory_hostname —
|
||||||
|
# see the matching comment in server.yml, same reason.
|
||||||
|
|
||||||
|
- name: Bootstrap the k3s agent
|
||||||
|
no_log: true # K3S_TOKEN passes through this block
|
||||||
|
block:
|
||||||
|
- name: Look up the k3s cluster secrets
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
k3s_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
|
||||||
|
- name: Compute the desired k3s agent exec line
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_agent_exec: "agent --node-name {{ inventory_hostname }} {{ k3s_extra_args | join(' ') }}"
|
||||||
|
|
||||||
|
# Compared against what k3s was last installed with (see the copy task
|
||||||
|
# below) so a change to k3s_extra_args gets applied on the next run
|
||||||
|
# instead of silently sitting unused: the version check below has no way
|
||||||
|
# to notice an exec-line-only change. Missing file (first install) counts
|
||||||
|
# as changed.
|
||||||
|
- name: Read the exec line k3s was last installed with
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: /etc/rancher/k3s/.ansible_install_exec
|
||||||
|
register: k3s_installed_exec_raw
|
||||||
|
failed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
- name: Determine whether the exec line has changed
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_exec_changed: >-
|
||||||
|
{{ k3s_installed_exec_raw.content is not defined
|
||||||
|
or (k3s_installed_exec_raw.content | b64decode) != k3s_agent_exec }}
|
||||||
|
|
||||||
|
- name: Check the installed k3s version
|
||||||
|
ansible.builtin.command: k3s --version
|
||||||
|
register: k3s_installed_version
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
# The installer is safe to re-run — it's a no-op if the requested version
|
||||||
|
# and exec line are already active — but skipping it when neither changed
|
||||||
|
# avoids restarting the agent (and briefly dropping the node's kubelet)
|
||||||
|
# on every playbook run. Restarting the k3s-agent process itself (as
|
||||||
|
# opposed to rebooting the node, which roles/k3s_maintenance handles
|
||||||
|
# separately) doesn't touch already-running pods — containerd keeps them
|
||||||
|
# up underneath it. playbooks/k3s.yml still runs this play with
|
||||||
|
# serial: 1, so at most one node's kubelet is ever bouncing at a time.
|
||||||
|
- name: Install/upgrade k3s agent
|
||||||
|
ansible.builtin.shell: curl -sfL https://get.k3s.io | sh -
|
||||||
|
environment:
|
||||||
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
||||||
|
INSTALL_K3S_EXEC: "{{ k3s_agent_exec }}"
|
||||||
|
# The one control-plane node, addressed by its inventory IP rather
|
||||||
|
# than delegating a lookup to it — simple and correct as long as this
|
||||||
|
# cluster stays single-server. Revisit if it ever gets HA control
|
||||||
|
# plane nodes.
|
||||||
|
K3S_URL: "https://{{ hostvars[groups['k3s_control_plane'][0]].ansible_host }}:6443"
|
||||||
|
K3S_TOKEN: "{{ k3s_secrets.K3S_TOKEN }}"
|
||||||
|
when: >-
|
||||||
|
k3s_installed_version.rc != 0
|
||||||
|
or k3s_version not in k3s_installed_version.stdout
|
||||||
|
or k3s_exec_changed
|
||||||
|
|
||||||
|
- name: Ensure the k3s-agent service is enabled and running
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: k3s-agent
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# k3s creates /etc/rancher/k3s itself once it has something to put there
|
||||||
|
# (a config.yaml, the generated kubeconfig on the server) — an agent with no
|
||||||
|
# extra config doesn't necessarily end up with anything else prompting that,
|
||||||
|
# so this marker can't assume the directory already exists.
|
||||||
|
- name: Ensure /etc/rancher/k3s exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/rancher/k3s
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Record the exec line k3s was installed with
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/rancher/k3s/.ansible_install_exec
|
||||||
|
content: "{{ k3s_agent_exec }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
24
build/config/ansible/roles/k3s_node/tasks/main.yml
Normal file
24
build/config/ansible/roles/k3s_node/tasks/main.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
# Converges one node of the cluster: the same prep either way, then branch on
|
||||||
|
# which k3s role it plays. k3s_node_role is set per group, not per host — see
|
||||||
|
# inventory/group_vars/k3s_control_plane.yml (server) and k3s_workers.yml
|
||||||
|
# (agent) — so adding a fifth Pi later is a hosts.yml edit, not a role change.
|
||||||
|
|
||||||
|
- name: Validate k3s_node_role
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: k3s_node_role in ['server', 'agent']
|
||||||
|
fail_msg: >-
|
||||||
|
k3s_node_role is '{{ k3s_node_role }}'; expected 'server' or 'agent'.
|
||||||
|
Check inventory/group_vars/k3s_control_plane.yml and k3s_workers.yml.
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Prepare the node for k3s
|
||||||
|
ansible.builtin.include_tasks: prep.yml
|
||||||
|
|
||||||
|
- name: Install the k3s server (control plane)
|
||||||
|
ansible.builtin.include_tasks: server.yml
|
||||||
|
when: k3s_node_role == 'server'
|
||||||
|
|
||||||
|
- name: Install the k3s agent (worker)
|
||||||
|
ansible.builtin.include_tasks: agent.yml
|
||||||
|
when: k3s_node_role == 'agent'
|
||||||
80
build/config/ansible/roles/k3s_node/tasks/prep.yml
Normal file
80
build/config/ansible/roles/k3s_node/tasks/prep.yml
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
# OS-level prerequisites, identical for a server and an agent node. Runs
|
||||||
|
# before either install so a from-scratch Pi (fresh Ubuntu Server image, SSH
|
||||||
|
# + the `ansible` user already set up) can go straight to a working cluster
|
||||||
|
# in one playbook run — the "easy to rebuild" part of the design.
|
||||||
|
|
||||||
|
- name: Read the current kernel boot parameters
|
||||||
|
ansible.builtin.command: cat {{ k3s_boot_cmdline_path }}
|
||||||
|
register: k3s_cmdline_current
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
- name: Work out which cgroup parameters are missing
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_cmdline_missing: >-
|
||||||
|
{{ k3s_cgroup_params | reject('in', k3s_cmdline_current.stdout) | list }}
|
||||||
|
|
||||||
|
# cmdline.txt is one line, space-separated — rewritten whole rather than
|
||||||
|
# appended in place, since there's no line-based anchor to insert after.
|
||||||
|
# Existing file mode is left alone (no `mode:` here) rather than guessed at.
|
||||||
|
- name: Add missing cgroup parameters to the boot command line
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ k3s_boot_cmdline_path }}"
|
||||||
|
content: >-
|
||||||
|
{{ (k3s_cmdline_current.stdout.split() + k3s_cmdline_missing) | join(' ') }}
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
register: k3s_cmdline_updated
|
||||||
|
when: k3s_cmdline_missing | length > 0
|
||||||
|
|
||||||
|
- name: Reboot to apply updated boot parameters
|
||||||
|
ansible.builtin.reboot:
|
||||||
|
reboot_timeout: 300
|
||||||
|
when: k3s_cmdline_updated is changed
|
||||||
|
|
||||||
|
- name: Check active swap devices
|
||||||
|
ansible.builtin.command: swapon --summary
|
||||||
|
register: k3s_swap_active
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
- name: Turn off active swap
|
||||||
|
ansible.builtin.command: swapoff -a
|
||||||
|
when: k3s_swap_active.stdout | trim | length > 0
|
||||||
|
|
||||||
|
- name: Comment out swap entries in fstab
|
||||||
|
ansible.builtin.replace:
|
||||||
|
path: /etc/fstab
|
||||||
|
regexp: '^([^#\n]*\sswap\s.*)$'
|
||||||
|
replace: '# \1'
|
||||||
|
|
||||||
|
# Best-effort: Ubuntu's zram-backed swap ships under different unit names
|
||||||
|
# across releases, and most won't be present at all. A missing unit is not a
|
||||||
|
# failure here — only an already-active one that we failed to disable would
|
||||||
|
# leave swap coming back on the next boot, and swapoff -a above already
|
||||||
|
# handles the running instance for this boot.
|
||||||
|
#
|
||||||
|
# `failed_when: false` rather than `ignore_errors: true`: systemd_service
|
||||||
|
# raises "Could not find the requested service" as a hard module failure
|
||||||
|
# when the unit is absent, and that's reported here as a genuine task
|
||||||
|
# failure regardless of ignore_errors — failed_when overrides the result
|
||||||
|
# directly instead of trying to catch it after the fact.
|
||||||
|
- name: Disable Ubuntu's zram-backed swap, if present
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ item }}"
|
||||||
|
enabled: false
|
||||||
|
state: stopped
|
||||||
|
loop:
|
||||||
|
- zram-config.service
|
||||||
|
- systemd-zram-setup@zram0.service
|
||||||
|
register: k3s_zram_disable
|
||||||
|
failed_when: false
|
||||||
|
changed_when: k3s_zram_disable is changed
|
||||||
|
|
||||||
|
- name: Ensure curl is installed
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: curl
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
103
build/config/ansible/roles/k3s_node/tasks/server.yml
Normal file
103
build/config/ansible/roles/k3s_node/tasks/server.yml
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
---
|
||||||
|
# Bootstraps this node as the (single) k3s control plane. playbooks/k3s.yml
|
||||||
|
# runs the k3s_control_plane play before k3s_workers, so by the time
|
||||||
|
# agent.yml runs anywhere, K3S_URL below already points at something live.
|
||||||
|
#
|
||||||
|
# The token comes from Vault as a fixed, pre-shared value rather than letting
|
||||||
|
# k3s generate one on first install — see docs/vault-secrets.md
|
||||||
|
# (homelab/k3s-homelab-utils). That's what makes a full rebuild (wipe both SD
|
||||||
|
# cards, reinstall) reproduce the same cluster identity instead of needing the
|
||||||
|
# new token hunted down and re-distributed by hand.
|
||||||
|
#
|
||||||
|
# --node-name pins the k8s node object to the Ansible inventory_hostname
|
||||||
|
# rather than whatever the OS hostname happens to be — roles/k3s_maintenance
|
||||||
|
# addresses nodes by inventory_hostname when draining/uncordoning, and that
|
||||||
|
# only works if the two names are guaranteed to match.
|
||||||
|
|
||||||
|
- name: Bootstrap the k3s server
|
||||||
|
no_log: true # K3S_TOKEN passes through this block
|
||||||
|
block:
|
||||||
|
- name: Look up the k3s cluster secrets
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
k3s_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
|
||||||
|
- name: Compute the desired k3s server exec line
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_server_exec: >-
|
||||||
|
server --node-name {{ inventory_hostname }} --tls-san {{ k3s_api_tls_san }}
|
||||||
|
--write-kubeconfig-mode 644 {{ (k3s_extra_args + k3s_server_extra_args) | join(' ') }}
|
||||||
|
|
||||||
|
# Compared against what k3s was last installed with (see the copy task
|
||||||
|
# below) so a change to k3s_extra_args/k3s_server_extra_args — or
|
||||||
|
# k3s_api_tls_san — gets applied on the next run instead of silently
|
||||||
|
# sitting unused: the version check below has no way to notice an
|
||||||
|
# exec-line-only change. Missing file (first install) counts as changed.
|
||||||
|
- name: Read the exec line k3s was last installed with
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: /etc/rancher/k3s/.ansible_install_exec
|
||||||
|
register: k3s_installed_exec_raw
|
||||||
|
failed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
- name: Determine whether the exec line has changed
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
k3s_exec_changed: >-
|
||||||
|
{{ k3s_installed_exec_raw.content is not defined
|
||||||
|
or (k3s_installed_exec_raw.content | b64decode) != k3s_server_exec }}
|
||||||
|
|
||||||
|
- name: Check the installed k3s version
|
||||||
|
ansible.builtin.command: k3s --version
|
||||||
|
register: k3s_installed_version
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
# The installer is safe to re-run — it's a no-op if the requested version
|
||||||
|
# and exec line are already active — but skipping it when neither changed
|
||||||
|
# avoids restarting the service (and briefly dropping the API) on every
|
||||||
|
# playbook run. Restarting the k3s process itself (as opposed to
|
||||||
|
# rebooting the node, which roles/k3s_maintenance handles separately) is
|
||||||
|
# a brief control-plane/kubelet blip, not a pod outage — containerd keeps
|
||||||
|
# every already-running pod up underneath it.
|
||||||
|
- name: Install/upgrade k3s server
|
||||||
|
ansible.builtin.shell: curl -sfL https://get.k3s.io | sh -
|
||||||
|
environment:
|
||||||
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
||||||
|
INSTALL_K3S_EXEC: "{{ k3s_server_exec }}"
|
||||||
|
K3S_TOKEN: "{{ k3s_secrets.K3S_TOKEN }}"
|
||||||
|
when: >-
|
||||||
|
k3s_installed_version.rc != 0
|
||||||
|
or k3s_version not in k3s_installed_version.stdout
|
||||||
|
or k3s_exec_changed
|
||||||
|
|
||||||
|
- name: Ensure the k3s service is enabled and running
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: k3s
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# k3s creates /etc/rancher/k3s itself once it has something to put there
|
||||||
|
# (the generated kubeconfig, on the server) — defensive rather than relied
|
||||||
|
# on, so this marker doesn't assume the directory already exists.
|
||||||
|
- name: Ensure /etc/rancher/k3s exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/rancher/k3s
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Record the exec line k3s was installed with
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/rancher/k3s/.ansible_install_exec
|
||||||
|
content: "{{ k3s_server_exec }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
71
build/config/ansible/roles/k3s_postgres/defaults/main.yml
Normal file
71
build/config/ansible/roles/k3s_postgres/defaults/main.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
---
|
||||||
|
# Ansible-side knobs for roles/k3s_postgres — CloudNativePG (CNPG), the
|
||||||
|
# Postgres operator for the homelab-utils cluster's shared database.
|
||||||
|
# Installed via k3s's own bundled helm-controller, same mechanism as
|
||||||
|
# roles/k3s_metallb and roles/k3s_monitoring (see tasks/main.yml) — no helm
|
||||||
|
# binary or extra Ansible collection needed on the controller.
|
||||||
|
#
|
||||||
|
# Chosen over Bitnami's postgresql-ha (repmgr+pgpool, more moving parts per
|
||||||
|
# pod, and Bitnami's free chart/image catalog was restructured into a
|
||||||
|
# "legacy" repo in 2025 — a risky long-term dependency) and the Zalando
|
||||||
|
# operator (mature but Patroni-based, heavier, and its own config
|
||||||
|
# conventions fit less naturally into this repo's lean HelmChart-CR-only
|
||||||
|
# pattern). CNPG installs as one operator chart plus one plain Cluster CR —
|
||||||
|
# the same "HelmChart + plain config manifest" shape already used for
|
||||||
|
# MetalLB (chart + IPAddressPool).
|
||||||
|
#
|
||||||
|
# The app-portable half of this config (instance count, storage size/class)
|
||||||
|
# lives with the app it belongs to instead: see
|
||||||
|
# src/shared/postgres/ansible/kubernetes/vars.yml, loaded into `pg_config`
|
||||||
|
# by tasks/main.yml — same common/vars.yml + platform-vars.yml split
|
||||||
|
# compose_stack and lxc_app use for every other app.
|
||||||
|
|
||||||
|
k3s_postgres_operator_namespace: cnpg-system
|
||||||
|
|
||||||
|
k3s_postgres_chart_repo: https://cloudnative-pg.github.io/charts
|
||||||
|
|
||||||
|
# Pinned, same reasoning as k3s_monitoring_chart_version/k3s_metallb_chart_version
|
||||||
|
# — a rebuild months from now should reproduce today's operator, not
|
||||||
|
# whatever's newest at the time. Bump deliberately; check the current
|
||||||
|
# release first at https://github.com/cloudnative-pg/charts/releases.
|
||||||
|
k3s_postgres_chart_version: "0.29.0"
|
||||||
|
|
||||||
|
# Where the actual Postgres Cluster (not the operator) lives.
|
||||||
|
k3s_postgres_namespace: shared-postgres
|
||||||
|
|
||||||
|
# Postgres major/minor pinned via the operand image, independent of the
|
||||||
|
# chart/operator version above. This is a separate physical instance from
|
||||||
|
# the Docker-based shared/postgres (pinned to 13 there — see
|
||||||
|
# src/shared/postgres/common/vars.yml), so there's no need to match; pin to
|
||||||
|
# a current stable major instead. Multi-arch (amd64/arm64) upstream, same
|
||||||
|
# as every other image this cluster runs.
|
||||||
|
k3s_postgres_image: ghcr.io/cloudnative-pg/postgresql:18.4
|
||||||
|
|
||||||
|
# Same Vault path the Unraid/Proxmox shared-postgres instances already use
|
||||||
|
# (see docs/vault-secrets.md) — a separate physical instance, but one
|
||||||
|
# superuser identity for the "shared postgres" concept everywhere it runs.
|
||||||
|
k3s_postgres_vault_path: homelab/shared/postgres
|
||||||
|
|
||||||
|
# The LAN address the primary is published on, via the LoadBalancer Service
|
||||||
|
# declared in templates/postgres-cluster.yaml.j2. Must be inside
|
||||||
|
# k3s_metallb_address_range — MetalLB only assigns from its own pools, and a
|
||||||
|
# request for anything outside them leaves the Service pending forever rather
|
||||||
|
# than failing loudly.
|
||||||
|
#
|
||||||
|
# Empty here on purpose, same reasoning as k3s_metallb_address_range's own
|
||||||
|
# default: the address is a property of the LAN, not of this role, so it's set
|
||||||
|
# in inventory/group_vars/k3s_cluster.yml next to the pool it has to fall
|
||||||
|
# inside. Unlike the pool, leaving it empty is survivable — the Service is
|
||||||
|
# still created and MetalLB assigns the next free address; you just don't know
|
||||||
|
# which one until you look, which is fine for in-cluster clients and no good
|
||||||
|
# for anything that has to write the address down.
|
||||||
|
k3s_postgres_loadbalancer_ip: ""
|
||||||
|
|
||||||
|
# Resource requests/limits per instance — sized for a Raspberry Pi 4, not a
|
||||||
|
# datacenter node, same reasoning as k3s_monitoring_values.
|
||||||
|
k3s_postgres_resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
91
build/config/ansible/roles/k3s_postgres/tasks/main.yml
Normal file
91
build/config/ansible/roles/k3s_postgres/tasks/main.yml
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
---
|
||||||
|
# Deploys the shared Postgres cluster onto k3s: install CloudNativePG (the
|
||||||
|
# operator) via a HelmChart CR, then define the actual cluster as a plain
|
||||||
|
# Cluster CR — same two-manifest pattern as roles/k3s_metallb (chart CR +
|
||||||
|
# plain config manifest), for the same reason: k3s's deploy controller
|
||||||
|
# retries a manifest referencing CRDs the HelmChart above hasn't installed
|
||||||
|
# yet instead of failing outright, so the Cluster CR doesn't need to wait on
|
||||||
|
# the operator chart finishing first.
|
||||||
|
#
|
||||||
|
# Config is layered the same way compose_stack/lxc_app layer an app's
|
||||||
|
# common/vars.yml + platform vars.yml — src/shared/postgres/ is the same
|
||||||
|
# shared service already used on Unraid/Proxmox, just deployed a third way
|
||||||
|
# here. Secrets come from the same Vault path those platforms already use
|
||||||
|
# (homelab/shared/postgres) — this is a separate physical instance, but
|
||||||
|
# reuses the path rather than inventing a k3s-specific one, same "one path
|
||||||
|
# per app-concept" convention as every other stack.
|
||||||
|
|
||||||
|
- name: Set shared-postgres source facts
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pg_local_dir: "{{ repo_root }}/src/shared/postgres"
|
||||||
|
|
||||||
|
- name: Load portable shared-postgres variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ pg_local_dir }}/common/vars.yml"
|
||||||
|
name: pg_common_vars
|
||||||
|
|
||||||
|
- name: Load Kubernetes-specific shared-postgres variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ pg_local_dir }}/ansible/kubernetes/vars.yml"
|
||||||
|
name: pg_platform_vars
|
||||||
|
|
||||||
|
- name: Merge shared-postgres configuration
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pg_config: >-
|
||||||
|
{{ (pg_common_vars.env_defaults | default({}))
|
||||||
|
| combine(pg_platform_vars.env_defaults | default({})) }}
|
||||||
|
|
||||||
|
- name: Look up shared-postgres superuser credentials from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pg_vault_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
k3s_postgres_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Deploy the CloudNativePG operator HelmChart manifest
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: cnpg-operator.helmchart.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/cnpg-operator.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
||||||
|
# Namespace + Cluster CR, no secrets — safe to render even though the
|
||||||
|
# Cluster CR references the Secret below, for the same "later file, retried
|
||||||
|
# reconcile" reasoning as the Secret referencing this file's Namespace.
|
||||||
|
- name: Deploy the shared-postgres Cluster manifest
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: postgres-cluster.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/shared-postgres-cluster.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
||||||
|
# Separate file (and mode) from the Cluster manifest above purely because
|
||||||
|
# this one carries the Vault-sourced password — everything else here is safe
|
||||||
|
# at 0644, this one isn't.
|
||||||
|
#
|
||||||
|
# The dest filename deliberately doesn't track the Secret's own name, which
|
||||||
|
# gained a `-vault` suffix (see the template's header for why). k3s's deploy
|
||||||
|
# controller records which resources each manifest *file* created and prunes
|
||||||
|
# the ones that disappear from it, so renaming the Secret inside this same
|
||||||
|
# file makes the old `shared-postgres-superuser` object get garbage-collected
|
||||||
|
# on the next run. Renaming the file too would orphan it instead: Ansible
|
||||||
|
# doesn't remove files it no longer writes, so the old manifest would sit
|
||||||
|
# there keeping the stale Secret alive.
|
||||||
|
- name: Deploy the shared-postgres superuser Secret
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: postgres-superuser-secret.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/shared-postgres-superuser-secret.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
become: true
|
||||||
|
no_log: true
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Managed by Ansible (roles/k3s_postgres) — do not edit on the node.
|
||||||
|
#
|
||||||
|
# A HelmChart CR, same pattern as
|
||||||
|
# roles/k3s_metallb/templates/metallb.helmchart.yaml.j2 and
|
||||||
|
# roles/k3s_monitoring — k3s's bundled helm-controller reconciles it, the
|
||||||
|
# same mechanism it uses to install its own bundled Traefik and ServiceLB.
|
||||||
|
# No helm binary, kubeconfig, or extra Ansible collection needed on the
|
||||||
|
# controller. This installs the CloudNativePG operator and its CRDs only —
|
||||||
|
# the actual Postgres cluster is templates/postgres-cluster.yaml.j2, a plain
|
||||||
|
# manifest applied once the CRD it references exists (see that file).
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChart
|
||||||
|
metadata:
|
||||||
|
name: cloudnative-pg
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
chart: cloudnative-pg
|
||||||
|
repo: {{ k3s_postgres_chart_repo }}
|
||||||
|
version: "{{ k3s_postgres_chart_version }}"
|
||||||
|
targetNamespace: {{ k3s_postgres_operator_namespace }}
|
||||||
|
createNamespace: true
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Managed by Ansible (roles/k3s_postgres) — do not edit on the node.
|
||||||
|
#
|
||||||
|
# Plain manifests, not a HelmChart — same pattern as
|
||||||
|
# roles/k3s_metallb/templates/metallb-config.yaml.j2: the Cluster CR below
|
||||||
|
# references a CRD (postgresql.cnpg.io) that only exists once the
|
||||||
|
# cloudnative-pg HelmChart (cnpg-operator.yaml, same manifests directory) has
|
||||||
|
# actually installed the operator, so it's applied here rather than gated
|
||||||
|
# behind a "wait for CRDs" step — k3s's deploy controller retries a manifest
|
||||||
|
# referencing not-yet-existing CRDs until they show up, instead of failing
|
||||||
|
# once and giving up. templates/postgres-superuser-secret.yaml.j2 relies on
|
||||||
|
# the Namespace created here the same way, for the same reason.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: {{ k3s_postgres_namespace }}
|
||||||
|
---
|
||||||
|
apiVersion: postgresql.cnpg.io/v1
|
||||||
|
kind: Cluster
|
||||||
|
metadata:
|
||||||
|
name: shared-postgres
|
||||||
|
namespace: {{ k3s_postgres_namespace }}
|
||||||
|
spec:
|
||||||
|
# 1 primary + 1 replica (src/shared/postgres/ansible/kubernetes/vars.yml).
|
||||||
|
# CNPG's own default pod anti-affinity (preferred, topology key hostname)
|
||||||
|
# spreads them across distinct nodes; the nodeAffinity below narrows which
|
||||||
|
# nodes those can be.
|
||||||
|
instances: {{ pg_config.POSTGRES_INSTANCES }}
|
||||||
|
imageName: {{ k3s_postgres_image }}
|
||||||
|
|
||||||
|
# Password-based superuser login, from the Secret Ansible renders
|
||||||
|
# alongside this file. Off by default in CNPG — without this, the operator
|
||||||
|
# ignores the secret's content and sets the postgres user's password to
|
||||||
|
# NULL, disabling remote login as it.
|
||||||
|
#
|
||||||
|
# The name must stay clear of `<cluster>-superuser` (i.e.
|
||||||
|
# `shared-postgres-superuser`), which is what CNPG calls the secret it
|
||||||
|
# generates for itself when this stanza is absent. Claiming that name for
|
||||||
|
# our own object stops password changes reaching the database — see
|
||||||
|
# postgres-superuser-secret.yaml.j2 for the full symptom.
|
||||||
|
enableSuperuserAccess: true
|
||||||
|
superuserSecret:
|
||||||
|
name: shared-postgres-superuser-vault
|
||||||
|
|
||||||
|
# A LoadBalancer Service for the primary, on top of the ClusterIP -rw/-ro/-r
|
||||||
|
# Services CNPG creates for every Cluster. Declared here as a CNPG *managed
|
||||||
|
# service* rather than as a Service manifest of our own: selectorType: rw
|
||||||
|
# means the operator maintains the selector, so the LB follows a failover to
|
||||||
|
# the other instance the same way the built-in -rw Service does. A
|
||||||
|
# hand-written Service would need its selector re-pointed by hand after
|
||||||
|
# every promotion.
|
||||||
|
#
|
||||||
|
# This is what makes the cluster reachable from the Ansible controller at a
|
||||||
|
# fixed host:port, which is the thing per-app database provisioning needs —
|
||||||
|
# the community.postgresql tasks compose_stack/lxc_app use can't talk to a
|
||||||
|
# ClusterIP, and `kubectl port-forward` isn't a stable address. It does put
|
||||||
|
# the superuser on the LAN: acceptable here for the same reason the Unraid
|
||||||
|
# instance's published port is, and the LAN is the boundary either way.
|
||||||
|
managed:
|
||||||
|
services:
|
||||||
|
additional:
|
||||||
|
- selectorType: rw
|
||||||
|
serviceTemplate:
|
||||||
|
metadata:
|
||||||
|
name: shared-postgres-lb
|
||||||
|
{% if k3s_postgres_loadbalancer_ip %}
|
||||||
|
# Pinned rather than left to MetalLB's next-free pick, because
|
||||||
|
# this address ends up in config elsewhere (inventory, an app's
|
||||||
|
# vars.yml) and shouldn't move when the Service is recreated.
|
||||||
|
annotations:
|
||||||
|
metallb.universe.tf/loadBalancerIPs: "{{ k3s_postgres_loadbalancer_ip }}"
|
||||||
|
{% endif %}
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
# No ports: — the operator fills in Postgres's own (5432) from
|
||||||
|
# the same template it builds the -rw Service from; naming them
|
||||||
|
# here would only risk disagreeing with it.
|
||||||
|
|
||||||
|
# POSTGRES_PORT (common/vars.yml) is 5432, the same port CNPG always
|
||||||
|
# listens on inside the pod/Service — nothing to override here.
|
||||||
|
storage:
|
||||||
|
size: {{ pg_config.POSTGRES_STORAGE_SIZE }}
|
||||||
|
storageClass: {{ pg_config.POSTGRES_STORAGE_CLASS }}
|
||||||
|
|
||||||
|
resources:
|
||||||
|
{{ k3s_postgres_resources | to_nice_yaml(indent=2) | indent(4, first=true) }}
|
||||||
|
|
||||||
|
# Workers only — k3s-ctrl-01 stays free of app pods, same boundary the
|
||||||
|
# cluster already keeps for monitoring/MetalLB.
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: node-role.kubernetes.io/control-plane
|
||||||
|
operator: DoesNotExist
|
||||||
|
|
||||||
|
# No bootstrap: stanza — CNPG's default initdb bootstrap creates a
|
||||||
|
# `postgres` superuser (above) plus a default `app` database owned by an
|
||||||
|
# auto-generated `app` role/secret. That default app database is unused
|
||||||
|
# today: no app is deployed onto k3s yet, and real per-app database
|
||||||
|
# provisioning here is a deferred design problem (see CLAUDE.md → "Key
|
||||||
|
# decisions") — the community.postgresql approach compose_stack/lxc_app
|
||||||
|
# use needs a controller-reachable host:port, which this cluster's
|
||||||
|
# in-cluster -rw Service isn't without further work.
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Managed by Ansible (roles/k3s_postgres) — do not edit on the node.
|
||||||
|
#
|
||||||
|
# Superuser credentials for the shared-postgres Cluster
|
||||||
|
# (postgres-cluster.yaml.j2), sourced from the same Vault path
|
||||||
|
# (homelab/shared/postgres) the Unraid/Proxmox shared-postgres instances
|
||||||
|
# already use — see docs/vault-secrets.md. CNPG requires
|
||||||
|
# `type: kubernetes.io/basic-auth` with username/password keys, and
|
||||||
|
# `enableSuperuserAccess: true` on the Cluster (set there) for this secret to
|
||||||
|
# actually be usable for password login rather than just ignored.
|
||||||
|
#
|
||||||
|
# The `-vault` suffix is load-bearing, and removing it reintroduces a bug
|
||||||
|
# that is very hard to read off the symptoms. CNPG generates its own
|
||||||
|
# superuser secret named `<cluster>-superuser` when the Cluster doesn't name
|
||||||
|
# one — for a Cluster called `shared-postgres` that is exactly
|
||||||
|
# `shared-postgres-superuser`, the name this file used to claim. Writing our
|
||||||
|
# own object at the operator's reserved name meant the operator treated it as
|
||||||
|
# one it had already authored and reconciled, so a password change in Vault
|
||||||
|
# propagated as far as this Secret and stopped there: the role in the
|
||||||
|
# database kept whatever was set at bootstrap. Every check short of querying
|
||||||
|
# pg_authid agrees the change landed, while `psql` from the LAN keeps
|
||||||
|
# returning "password authentication failed for user postgres". Any name
|
||||||
|
# outside the `<cluster>-*` pattern the operator owns avoids it.
|
||||||
|
#
|
||||||
|
# Namespace comes from postgres-cluster.yaml.j2, a separate file in the same
|
||||||
|
# manifests directory — see that file's header for why applying this before
|
||||||
|
# the Namespace exists isn't a problem.
|
||||||
|
#
|
||||||
|
# The username is layered the same way every other value in this repo is —
|
||||||
|
# Vault on top, common/vars.yml underneath — rather than required from Vault.
|
||||||
|
# It isn't a secret (only the password is), and the Vault path is allowed to
|
||||||
|
# carry just POSTGRES_SUPERUSER_PASSWORD; setting POSTGRES_SUPERUSER there
|
||||||
|
# still wins, which is what docs/vault-secrets.md means by "Vault is
|
||||||
|
# authoritative since it's paired with the password".
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: shared-postgres-superuser-vault
|
||||||
|
namespace: {{ k3s_postgres_namespace }}
|
||||||
|
type: kubernetes.io/basic-auth
|
||||||
|
# to_json, not bare interpolation: it emits a double-quoted YAML scalar with
|
||||||
|
# escaping handled, so a password containing `#`, `:`, a leading `*`/`&`, or
|
||||||
|
# leading/trailing whitespace survives intact. Unquoted, YAML silently
|
||||||
|
# reinterprets those (`#` starts a comment, an all-digit password becomes an
|
||||||
|
# int) and the Secret ends up holding something other than what Vault has —
|
||||||
|
# which fails authentication while every value still looks right in playbook
|
||||||
|
# output. Alphanumeric-only passwords hide the problem rather than fix it.
|
||||||
|
stringData:
|
||||||
|
username: {{ (pg_vault_secrets.POSTGRES_SUPERUSER | default(pg_config.POSTGRES_SUPERUSER)) | to_json }}
|
||||||
|
password: {{ pg_vault_secrets.POSTGRES_SUPERUSER_PASSWORD | to_json }}
|
||||||
115
build/config/ansible/roles/k3s_traefik/defaults/main.yml
Normal file
115
build/config/ansible/roles/k3s_traefik/defaults/main.yml
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
---
|
||||||
|
# Ansible-side knobs for roles/k3s_traefik — the ingress controller k3s
|
||||||
|
# bundles. The odd one out among the k3s_* cluster services: every other one
|
||||||
|
# installs a chart this repo chose (roles/k3s_metallb, k3s_monitoring,
|
||||||
|
# k3s_postgres, k3s_cert_manager), whereas Traefik is already installed by
|
||||||
|
# k3s itself before Ansible ever connects. So this role installs nothing —
|
||||||
|
# it only adjusts what's there, via a HelmChartConfig (see
|
||||||
|
# templates/traefik.helmchartconfig.yaml.j2 for why that, and not an edit to
|
||||||
|
# k3s's own traefik.yaml).
|
||||||
|
#
|
||||||
|
# Scope today is the dashboard, which a stock k3s serves 404s for. That's
|
||||||
|
# not a broken install: Traefik builds the dashboard regardless
|
||||||
|
# (`api.dashboard` defaults on), but the Traefik chart stopped creating the
|
||||||
|
# router that reaches it in v28, so there is nothing routing /dashboard/ on
|
||||||
|
# any entrypoint until something puts that router back.
|
||||||
|
|
||||||
|
# Where k3s installs Traefik, and so where the HelmChartConfig has to live —
|
||||||
|
# a HelmChartConfig only applies to the HelmChart of the same name in the
|
||||||
|
# same namespace.
|
||||||
|
k3s_traefik_namespace: kube-system
|
||||||
|
|
||||||
|
# --- The dashboard, reachable by port-forward --------------------------------
|
||||||
|
#
|
||||||
|
# Re-enables the chart's own dashboard IngressRoute, which binds to Traefik's
|
||||||
|
# internal `traefik` entrypoint (port 9000). That entrypoint is not published
|
||||||
|
# on Traefik's Service, so this exposes nothing to the LAN; it makes exactly
|
||||||
|
# one thing work:
|
||||||
|
#
|
||||||
|
# kubectl -n kube-system port-forward deploy/traefik 9000:9000
|
||||||
|
# # then http://127.0.0.1:9000/dashboard/ — the trailing slash is required
|
||||||
|
#
|
||||||
|
# Left on even when the dashboard is also exposed on a hostname below, and
|
||||||
|
# that's deliberate rather than redundant. Once the exposed route is behind
|
||||||
|
# Authentik, the dashboard depends on Authentik being up, which depends on
|
||||||
|
# CNPG being up, which depends on the cluster being healthy — precisely the
|
||||||
|
# things you would open the dashboard to diagnose. Port-forward talks to the
|
||||||
|
# pod and traverses none of it, so it stays as the break-glass path. Don't
|
||||||
|
# "tidy this up" once the hostname works.
|
||||||
|
k3s_traefik_dashboard_enabled: true
|
||||||
|
|
||||||
|
# --- The dashboard, exposed on a hostname ------------------------------------
|
||||||
|
#
|
||||||
|
# Empty (the default) means the port-forward above is the only way in, and
|
||||||
|
# tasks/main.yml renders no IngressRoute at all — it removes one it previously
|
||||||
|
# rendered, so clearing this is a real teardown rather than a no-op.
|
||||||
|
#
|
||||||
|
# Set it to a hostname (e.g. traefik.turtlesystems.uk) to publish the
|
||||||
|
# dashboard on the `websecure` entrypoint with a cert-manager certificate and
|
||||||
|
# the forward-auth middleware below in front of it. Doing so requires
|
||||||
|
# k3s_traefik_dashboard_auth_address to be set as well — the role refuses to
|
||||||
|
# run otherwise, because the failure mode of getting this wrong is a
|
||||||
|
# read-only view of every route, service and middleware in the cluster
|
||||||
|
# published unauthenticated to the LAN.
|
||||||
|
#
|
||||||
|
# The hostname needs a DNS record pointing at Traefik's MetalLB address
|
||||||
|
# (`kubectl -n kube-system get svc traefik`), same as any app's Ingress.
|
||||||
|
k3s_traefik_dashboard_host: ""
|
||||||
|
|
||||||
|
# The ClusterIssuer roles/k3s_cert_manager creates. Must match
|
||||||
|
# k3s_cert_manager_issuer_name — stated rather than referenced because that
|
||||||
|
# lives in the other role's defaults, which aren't in scope here. Same
|
||||||
|
# convention as CERT_ISSUER in src/authentik/ansible/kubernetes/vars.yml.
|
||||||
|
#
|
||||||
|
# An explicit Certificate rather than an Ingress annotation, unlike every app
|
||||||
|
# on this cluster: cert-manager watches Ingress resources, and the dashboard
|
||||||
|
# can't be one. It's served by Traefik's internal `api@internal` service,
|
||||||
|
# which has no Kubernetes Service for an Ingress to point at — hence an
|
||||||
|
# IngressRoute, which cert-manager doesn't watch.
|
||||||
|
k3s_traefik_dashboard_cert_issuer: letsencrypt
|
||||||
|
k3s_traefik_dashboard_tls_secret: traefik-dashboard-tls
|
||||||
|
|
||||||
|
# --- Authentication in front of the exposed dashboard ------------------------
|
||||||
|
#
|
||||||
|
# A Traefik forwardAuth middleware pointing at Authentik, which now runs on
|
||||||
|
# this cluster (roles/k3s_app, src/authentik). No default, and required as
|
||||||
|
# soon as k3s_traefik_dashboard_host is set: a default here would be a guess
|
||||||
|
# at another app's service name, and a wrong guess that happens to be
|
||||||
|
# unreachable is a 500, not an open dashboard — but one that happens to
|
||||||
|
# resolve to the wrong thing is worse. Set it explicitly, next to the host,
|
||||||
|
# in inventory/group_vars/k3s_cluster.yml.
|
||||||
|
#
|
||||||
|
# For Authentik's embedded outpost, in-cluster:
|
||||||
|
#
|
||||||
|
# http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik
|
||||||
|
#
|
||||||
|
# The provider in Authentik must be a Proxy Provider in **forward auth
|
||||||
|
# (domain level)** mode, not single-application. Single-application mode
|
||||||
|
# additionally requires /outpost.goauthentik.io/ to be routed to Authentik on
|
||||||
|
# *this* host, which from an IngressRoute in kube-system means referencing a
|
||||||
|
# Service in the authentik namespace — a cross-namespace reference Traefik
|
||||||
|
# rejects unless started with providers.kubernetescrd.allowCrossNamespace,
|
||||||
|
# which it isn't. Domain-level mode handles the redirect on Authentik's own
|
||||||
|
# hostname instead and needs no such route, so it's the mode that fits.
|
||||||
|
k3s_traefik_dashboard_auth_address: ""
|
||||||
|
|
||||||
|
k3s_traefik_dashboard_auth_middleware: dashboard-auth
|
||||||
|
|
||||||
|
# What Authentik sets on the way back through, forwarded to the dashboard.
|
||||||
|
# The dashboard itself reads none of them — it has no notion of a user — so
|
||||||
|
# this is really about the headers existing for anything else that reuses
|
||||||
|
# this middleware later, and about matching Authentik's documented list
|
||||||
|
# rather than inventing a shorter one.
|
||||||
|
k3s_traefik_dashboard_auth_response_headers:
|
||||||
|
- X-authentik-username
|
||||||
|
- X-authentik-groups
|
||||||
|
- X-authentik-entitlements
|
||||||
|
- X-authentik-email
|
||||||
|
- X-authentik-name
|
||||||
|
- X-authentik-uid
|
||||||
|
- X-authentik-jwt
|
||||||
|
- X-authentik-meta-jwks
|
||||||
|
- X-authentik-meta-outpost
|
||||||
|
- X-authentik-meta-provider
|
||||||
|
- X-authentik-meta-app
|
||||||
|
- X-authentik-meta-version
|
||||||
59
build/config/ansible/roles/k3s_traefik/tasks/main.yml
Normal file
59
build/config/ansible/roles/k3s_traefik/tasks/main.yml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
# Adjusts the Traefik k3s installs for itself, rather than installing
|
||||||
|
# anything: a HelmChartConfig merged over k3s's own Traefik HelmChart, plus —
|
||||||
|
# only when the dashboard is being published on a hostname — the
|
||||||
|
# Certificate/Middleware/IngressRoute trio that puts it behind Authentik.
|
||||||
|
# See defaults/main.yml and templates/traefik.helmchartconfig.yaml.j2.
|
||||||
|
#
|
||||||
|
# Same delivery mechanism as every other k3s_* role: template a file into
|
||||||
|
# /var/lib/rancher/k3s/server/manifests/ and let k3s's bundled helm-controller
|
||||||
|
# and deploy controller reconcile it. No helm binary, kubeconfig or extra
|
||||||
|
# collection on the controller.
|
||||||
|
|
||||||
|
- name: Fail fast if the dashboard would be exposed without authentication
|
||||||
|
when: k3s_traefik_dashboard_host | length > 0
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- k3s_traefik_dashboard_auth_address | length > 0
|
||||||
|
fail_msg: >-
|
||||||
|
k3s_traefik_dashboard_host is set but k3s_traefik_dashboard_auth_address
|
||||||
|
is empty — set both in inventory/group_vars/k3s_cluster.yml, or neither.
|
||||||
|
Publishing the dashboard without the forward-auth middleware would put a
|
||||||
|
read-only view of every router, service and middleware on this cluster
|
||||||
|
on the LAN unauthenticated, so this refuses rather than defaulting to an
|
||||||
|
address that might not be Authentik.
|
||||||
|
quiet: true
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Deploy the Traefik HelmChartConfig
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: traefik.helmchartconfig.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/traefik-config.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
||||||
|
- name: Deploy the exposed dashboard route
|
||||||
|
when: k3s_traefik_dashboard_host | length > 0
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: dashboard-ingressroute.yaml.j2
|
||||||
|
dest: /var/lib/rancher/k3s/server/manifests/traefik-dashboard.yaml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
become: true
|
||||||
|
|
||||||
|
# Clearing k3s_traefik_dashboard_host is a teardown, not just a stop-managing:
|
||||||
|
# k3s's deploy controller garbage-collects the objects a manifest created when
|
||||||
|
# the manifest is removed, so deleting this file withdraws the public route,
|
||||||
|
# its middleware and its certificate. Same reasoning as roles/k3s_app's
|
||||||
|
# removal path — with the difference that this one needs no `state: absent`
|
||||||
|
# tombstone, because the hostname is a single value rather than a list entry
|
||||||
|
# that could be silently dropped.
|
||||||
|
- name: Withdraw the exposed dashboard route when no host is configured
|
||||||
|
when: k3s_traefik_dashboard_host | length == 0
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /var/lib/rancher/k3s/server/manifests/traefik-dashboard.yaml
|
||||||
|
state: absent
|
||||||
|
become: true
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_traefik) — do not edit on the node.
|
||||||
|
|
||||||
|
Plain manifests, not a HelmChart — same pattern as
|
||||||
|
roles/k3s_metallb/templates/metallb-config.yaml.j2 and
|
||||||
|
roles/k3s_cert_manager/templates/cluster-issuer.yaml.j2: CRs belonging to
|
||||||
|
charts installed elsewhere, dropped in the auto-deploying directory and
|
||||||
|
retried by k3s's deploy controller until the CRDs they need exist.
|
||||||
|
|
||||||
|
Rendered only when k3s_traefik_dashboard_host is set; tasks/main.yml
|
||||||
|
deletes this file when it isn't, and k3s's deploy controller
|
||||||
|
garbage-collects what the file created.
|
||||||
|
|
||||||
|
traefik.io/v1alpha1, not traefik.containo.us/v1alpha1 — the group changed
|
||||||
|
with Traefik v3, which is what current k3s bundles.
|
||||||
|
-#}
|
||||||
|
{# Explicit Certificate rather than the cert-manager.io/cluster-issuer
|
||||||
|
annotation every app's Ingress uses: cert-manager watches Ingress
|
||||||
|
resources, and this route can't be one (see below). DNS-01, so this
|
||||||
|
issues without the hostname resolving anywhere yet. -#}
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: traefik-dashboard
|
||||||
|
namespace: {{ k3s_traefik_namespace }}
|
||||||
|
spec:
|
||||||
|
secretName: {{ k3s_traefik_dashboard_tls_secret }}
|
||||||
|
issuerRef:
|
||||||
|
name: {{ k3s_traefik_dashboard_cert_issuer }}
|
||||||
|
kind: ClusterIssuer
|
||||||
|
dnsNames:
|
||||||
|
- {{ k3s_traefik_dashboard_host }}
|
||||||
|
---
|
||||||
|
{# Authentik, in forward-auth (domain level) mode — see defaults/main.yml for
|
||||||
|
why domain level and not single-application. Fails closed: if Authentik is
|
||||||
|
down this returns an error rather than passing the request through, which
|
||||||
|
is the correct direction and the reason the port-forward path stays. -#}
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: {{ k3s_traefik_dashboard_auth_middleware }}
|
||||||
|
namespace: {{ k3s_traefik_namespace }}
|
||||||
|
spec:
|
||||||
|
forwardAuth:
|
||||||
|
address: {{ k3s_traefik_dashboard_auth_address }}
|
||||||
|
# Traefik strips X-Forwarded-* from client requests by default; Authentik
|
||||||
|
# needs them to know which host and scheme the user actually asked for,
|
||||||
|
# and builds its redirect back out of them. Safe here because the only
|
||||||
|
# thing that can set them is Traefik itself — nothing reaches this
|
||||||
|
# middleware without passing through the entrypoint first.
|
||||||
|
trustForwardHeader: true
|
||||||
|
authResponseHeaders:
|
||||||
|
{% for header in k3s_traefik_dashboard_auth_response_headers %}
|
||||||
|
- {{ header }}
|
||||||
|
{% endfor %}
|
||||||
|
---
|
||||||
|
{# An IngressRoute rather than an Ingress, and not by preference: the
|
||||||
|
dashboard is served by api@internal, a Traefik-internal service with no
|
||||||
|
Kubernetes Service behind it, so there is nothing for an Ingress backend
|
||||||
|
to name. That single fact is why the Certificate above is explicit and why
|
||||||
|
the middleware is attached here rather than by annotation. -#}
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: traefik-dashboard
|
||||||
|
namespace: {{ k3s_traefik_namespace }}
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
routes:
|
||||||
|
# Both prefixes, because the dashboard is a static bundle under
|
||||||
|
# /dashboard/ that reads its data from /api — serving the first without
|
||||||
|
# the second gets you a page that loads and then stays empty.
|
||||||
|
- kind: Rule
|
||||||
|
match: Host(`{{ k3s_traefik_dashboard_host }}`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))
|
||||||
|
middlewares:
|
||||||
|
- name: {{ k3s_traefik_dashboard_auth_middleware }}
|
||||||
|
namespace: {{ k3s_traefik_namespace }}
|
||||||
|
services:
|
||||||
|
- kind: TraefikService
|
||||||
|
name: api@internal
|
||||||
|
tls:
|
||||||
|
secretName: {{ k3s_traefik_dashboard_tls_secret }}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_traefik) — do not edit on the node.
|
||||||
|
|
||||||
|
A HelmChartConfig, not a HelmChart, because this is the one chart on the
|
||||||
|
cluster this repo doesn't install: k3s installs Traefik itself, rewriting
|
||||||
|
/var/lib/rancher/k3s/server/manifests/traefik.yaml on every server start
|
||||||
|
and overwriting whatever is in it. A HelmChartConfig carrying the same
|
||||||
|
name and namespace as that HelmChart is the supported way in — k3s's
|
||||||
|
helm-controller merges its valuesContent over the chart's values on the
|
||||||
|
next reconcile. Editing traefik.yaml directly works until the next
|
||||||
|
restart, and then silently doesn't.
|
||||||
|
|
||||||
|
Hence also the filename this renders to (traefik-config.yaml): anything
|
||||||
|
called traefik.yaml here belongs to k3s and would be reclaimed.
|
||||||
|
-#}
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChartConfig
|
||||||
|
metadata:
|
||||||
|
name: traefik
|
||||||
|
namespace: {{ k3s_traefik_namespace }}
|
||||||
|
spec:
|
||||||
|
valuesContent: |-
|
||||||
|
ingressRoute:
|
||||||
|
dashboard:
|
||||||
|
# Traefik builds the dashboard either way — `api.dashboard` is on by
|
||||||
|
# default. What the chart stopped shipping in v28 is the router that
|
||||||
|
# reaches it, which is why a stock k3s answers 404 to
|
||||||
|
# :9000/dashboard/ while :9000/ping happily returns 200. This puts
|
||||||
|
# that router back, on Traefik's internal `traefik` entrypoint
|
||||||
|
# (9000), which isn't published on the Service — so it is reachable
|
||||||
|
# by `kubectl port-forward` and nothing else. See defaults/main.yml
|
||||||
|
# for why that stays true even once the dashboard is also exposed on
|
||||||
|
# a hostname.
|
||||||
|
enabled: {{ k3s_traefik_dashboard_enabled | bool | lower }}
|
||||||
2
build/config/ansible/roles/lxc_app/defaults/main.yml
Normal file
2
build/config/ansible/roles/lxc_app/defaults/main.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
---
|
||||||
|
vault_kv_mount: kv
|
||||||
124
build/config/ansible/roles/lxc_app/tasks/main.yml
Normal file
124
build/config/ansible/roles/lxc_app/tasks/main.yml
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
---
|
||||||
|
# Installs one app (one loop iteration of `app` from playbooks/proxmox.yml)
|
||||||
|
# natively into a Proxmox LXC: merge portable + Proxmox config, fetch secrets
|
||||||
|
# from Vault, provision its database if it declares one, run the app's own
|
||||||
|
# install steps, then manage its systemd unit.
|
||||||
|
#
|
||||||
|
# The Proxmox counterpart to `compose_stack`. Everything up to "Run
|
||||||
|
# app-specific install steps" is deliberately the same shape as that role —
|
||||||
|
# same vars merge, same Vault lookup, same DB provisioning — so config lives
|
||||||
|
# in one place regardless of which platform an app lands on. What differs is
|
||||||
|
# the deployment primitive: a systemd service built from packages/binaries
|
||||||
|
# rather than `docker compose up -d`.
|
||||||
|
#
|
||||||
|
# UNPROVEN: `src/shared/postgres/` and `src/forgejo/` both ship an
|
||||||
|
# `ansible/proxmox/install.yml` now, but neither has been run end to end
|
||||||
|
# against a real LXC. The structure mirrors proven code; the details are not
|
||||||
|
# yet exercised.
|
||||||
|
|
||||||
|
- name: Set app facts
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
app_local_dir: "{{ repo_root }}/src/{{ app.src }}"
|
||||||
|
|
||||||
|
- name: Load portable app variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ app_local_dir }}/common/vars.yml"
|
||||||
|
name: app_common_vars
|
||||||
|
|
||||||
|
- name: Load Proxmox-specific app variables
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ app_local_dir }}/ansible/proxmox/vars.yml"
|
||||||
|
name: app_platform_vars
|
||||||
|
|
||||||
|
- name: Merge app configuration
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
# Portable values first, Proxmox-specific overrides on top. Available to
|
||||||
|
# the app's install.yml, alongside `vault_secrets` below.
|
||||||
|
app_config: >-
|
||||||
|
{{ (app_common_vars.env_defaults | default({}))
|
||||||
|
| combine(app_platform_vars.env_defaults | default({})) }}
|
||||||
|
|
||||||
|
- name: Look up app secrets from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
vault_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
app.vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Provision application database
|
||||||
|
when: app.db is defined
|
||||||
|
no_log: true
|
||||||
|
block:
|
||||||
|
- name: Look up Postgres superuser credentials from Vault
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pg_admin_secrets: >-
|
||||||
|
{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||||
|
app.db.admin_vault_path,
|
||||||
|
engine_mount_point=vault_kv_mount,
|
||||||
|
url=vault_addr,
|
||||||
|
auth_method=vault_auth_method,
|
||||||
|
role_id=vault_role_id | default(omit),
|
||||||
|
secret_id=vault_secret_id | default(omit)).secret }}
|
||||||
|
|
||||||
|
- name: Ensure application database role exists
|
||||||
|
community.postgresql.postgresql_user:
|
||||||
|
name: "{{ app.db.user }}"
|
||||||
|
password: "{{ vault_secrets[app.db.password_vault_key] }}"
|
||||||
|
login_host: "{{ app.db.provision_host }}"
|
||||||
|
login_port: "{{ app.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: present
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# Role first, then the database with `owner:` — the shape roles/k3s_app
|
||||||
|
# already uses, and not the `postgresql_user` + `priv: ALL` this role was
|
||||||
|
# written with. Two independent reasons it had to change:
|
||||||
|
#
|
||||||
|
# 1. `priv` was deprecated in community.postgresql 3.x and REMOVED in
|
||||||
|
# 4.0.0, so the old call is a hard "Unsupported parameters" failure
|
||||||
|
# on any current collection. requirements.yml asks for >=3.0.0,
|
||||||
|
# which installs 4.x.
|
||||||
|
# 2. On this instance it would have been wrong even if it still worked.
|
||||||
|
# `priv: ALL` grants database-level privileges (CONNECT, CREATE,
|
||||||
|
# TEMPORARY); since Postgres 15 the `public` schema no longer grants
|
||||||
|
# CREATE to PUBLIC, so a role holding all of those still cannot
|
||||||
|
# create a table. The Proxmox cluster is 17 (the Unraid one is 13,
|
||||||
|
# which is why the same code never failed that way there). Making
|
||||||
|
# the app role own the database covers it on both: on 15+ `public`
|
||||||
|
# is owned by `pg_database_owner`, which resolves to whoever owns
|
||||||
|
# the database.
|
||||||
|
#
|
||||||
|
# Ownership also makes a pg_dump restore land correctly, for the reason
|
||||||
|
# spelled out at the same tasks in roles/k3s_app.
|
||||||
|
- name: Ensure application database exists
|
||||||
|
community.postgresql.postgresql_db:
|
||||||
|
name: "{{ app.db.name }}"
|
||||||
|
owner: "{{ app.db.user }}"
|
||||||
|
login_host: "{{ app.db.provision_host }}"
|
||||||
|
login_port: "{{ app.db.provision_port }}"
|
||||||
|
login_user: "{{ pg_admin_secrets.POSTGRES_SUPERUSER }}"
|
||||||
|
login_password: "{{ pg_admin_secrets.POSTGRES_SUPERUSER_PASSWORD }}"
|
||||||
|
state: present
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# Everything genuinely app-specific — fetching the binary or package, creating
|
||||||
|
# the service user, laying out data directories, rendering the app's own
|
||||||
|
# config file from `app_config` + `vault_secrets`, and installing a systemd
|
||||||
|
# unit — lives with the app, not here. See src/<app>/ansible/proxmox/.
|
||||||
|
- name: Run app-specific install steps
|
||||||
|
ansible.builtin.include_tasks: "{{ app_local_dir }}/ansible/proxmox/install.yml"
|
||||||
|
|
||||||
|
- name: Enable and start the service
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ app.service_name | default(app.name) }}"
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
daemon_reload: true
|
||||||
61
build/config/ansible/roles/pve_backup/defaults/main.yml
Normal file
61
build/config/ansible/roles/pve_backup/defaults/main.yml
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
# Backup storage on the NAS, and the vzdump job that writes to it. Override
|
||||||
|
# per-node in inventory/host_vars/<node>.yml.
|
||||||
|
|
||||||
|
# Name the storage appears under in Proxmox. Also what `pve_backup_job`
|
||||||
|
# targets.
|
||||||
|
pve_backup_storage: nas-backup
|
||||||
|
|
||||||
|
# `nfs` or `pbs`. NFS is the plain option and needs nothing on the NAS beyond
|
||||||
|
# an export; PBS gets deduplication and incremental backups but needs a Proxmox
|
||||||
|
# Backup Server to exist first. See tasks/main.yml for what each consumes.
|
||||||
|
pve_backup_storage_type: nfs
|
||||||
|
|
||||||
|
# NFS (pve_backup_storage_type: nfs)
|
||||||
|
pve_backup_nfs_server: ""
|
||||||
|
pve_backup_nfs_export: ""
|
||||||
|
# 4.2 rather than 3: better locking and no separate portmapper.
|
||||||
|
pve_backup_nfs_options: vers=4.2
|
||||||
|
|
||||||
|
# PBS (pve_backup_storage_type: pbs)
|
||||||
|
pve_backup_pbs_server: ""
|
||||||
|
pve_backup_pbs_datastore: ""
|
||||||
|
pve_backup_pbs_username: ""
|
||||||
|
pve_backup_pbs_password: ""
|
||||||
|
pve_backup_pbs_fingerprint: ""
|
||||||
|
|
||||||
|
# How many archives the storage keeps. Proxmox prunes archives, not their
|
||||||
|
# contents — anything inside a guest (such as Forgejo's nightly pg_dump output)
|
||||||
|
# is pruned by that guest, on its own schedule.
|
||||||
|
pve_backup_prune:
|
||||||
|
keep-daily: 7
|
||||||
|
keep-weekly: 4
|
||||||
|
keep-monthly: 6
|
||||||
|
|
||||||
|
# systemd calendar event. Guests that dump a database into their own filesystem
|
||||||
|
# first must finish before this — see src/forgejo/ansible/proxmox/README.md.
|
||||||
|
pve_backup_schedule: "02:00"
|
||||||
|
|
||||||
|
# Stable marker used to find this job again on later runs. The Proxmox API
|
||||||
|
# assigns job IDs itself, so matching on the comment is what makes this
|
||||||
|
# idempotent rather than creating a duplicate job every deploy.
|
||||||
|
pve_backup_comment: managed-by-homelab-iac
|
||||||
|
|
||||||
|
# Which guests to back up. `true` covers everything on the node, including
|
||||||
|
# guests added later without an edit here; set it false and list VMIDs in
|
||||||
|
# pve_backup_vmids to be selective.
|
||||||
|
pve_backup_all: true
|
||||||
|
pve_backup_vmids: []
|
||||||
|
|
||||||
|
# snapshot: no downtime, and the guest filesystem is captured as if it had
|
||||||
|
# crashed at that instant. Fine for Forgejo — git repositories tolerate it, and
|
||||||
|
# the database is a separate consistent dump rather than live files.
|
||||||
|
pve_backup_mode: snapshot
|
||||||
|
pve_backup_compress: zstd
|
||||||
|
pve_backup_enabled: true
|
||||||
|
|
||||||
|
# Optional: address Proxmox mails backup reports to. Left empty means the node
|
||||||
|
# default applies. PVE 8 moved most of this to its notification-target system;
|
||||||
|
# `mailto` still works, but if you want anything richer, configure a
|
||||||
|
# notification target in the UI rather than extending this role.
|
||||||
|
pve_backup_mailto: ""
|
||||||
161
build/config/ansible/roles/pve_backup/tasks/main.yml
Normal file
161
build/config/ansible/roles/pve_backup/tasks/main.yml
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
---
|
||||||
|
# Configures backups on a Proxmox node: a storage on the NAS to write archives
|
||||||
|
# to, and a scheduled vzdump job that fills it.
|
||||||
|
#
|
||||||
|
# This is the off-box copy for every Proxmox guest. Guests deliberately keep
|
||||||
|
# all their state on their own rootfs rather than bind-mounting anything in,
|
||||||
|
# because vzdump excludes bind mounts — see src/forgejo/terraform/README.md.
|
||||||
|
# Where an app's data lives outside the container (the shared Postgres), the
|
||||||
|
# app dumps it into its own filesystem on a timer that runs before the window
|
||||||
|
# below, so one archive is one restore point.
|
||||||
|
#
|
||||||
|
# Driven through `pvesm`/`pvesh` rather than modules: as of writing,
|
||||||
|
# `community.proxmox` has no storage or backup-job module. Swap them in if that
|
||||||
|
# changes — the shape here is create-if-absent, update-if-drifted, which is
|
||||||
|
# what a module would do anyway.
|
||||||
|
#
|
||||||
|
# UNVERIFIED — this has not been run against a real node. `pvesm add` and
|
||||||
|
# `pvesh create /cluster/backup` accept slightly different options across PVE
|
||||||
|
# versions; check yours with `pvesm help add`, `pvesh help create
|
||||||
|
# /cluster/backup`, and a `--check` run before trusting it.
|
||||||
|
|
||||||
|
- name: Check that the storage backend is configured
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- pve_backup_storage_type in ['nfs', 'pbs']
|
||||||
|
- pve_backup_storage_type != 'nfs' or (pve_backup_nfs_server | length > 0
|
||||||
|
and pve_backup_nfs_export | length > 0)
|
||||||
|
- pve_backup_storage_type != 'pbs' or (pve_backup_pbs_server | length > 0
|
||||||
|
and pve_backup_pbs_datastore | length > 0)
|
||||||
|
- pve_backup_all or (pve_backup_vmids | length > 0)
|
||||||
|
fail_msg: >-
|
||||||
|
pve_backup is missing required settings for
|
||||||
|
pve_backup_storage_type={{ pve_backup_storage_type }}. Set them in
|
||||||
|
inventory/host_vars/{{ inventory_hostname }}.yml.
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Build the prune-backups option string
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pve_backup_prune_string: >-
|
||||||
|
{% for key, value in pve_backup_prune.items() %}{{ key }}={{ value }}{% if not loop.last %},{% endif %}{% endfor %}
|
||||||
|
|
||||||
|
# --- Storage -------------------------------------------------------------
|
||||||
|
|
||||||
|
- name: Read the configured storages
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [pvesh, get, /storage, --output-format, json]
|
||||||
|
register: pve_storages
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
- name: Add the backup storage (NFS)
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- pvesm
|
||||||
|
- add
|
||||||
|
- nfs
|
||||||
|
- "{{ pve_backup_storage }}"
|
||||||
|
- --server
|
||||||
|
- "{{ pve_backup_nfs_server }}"
|
||||||
|
- --export
|
||||||
|
- "{{ pve_backup_nfs_export }}"
|
||||||
|
- --options
|
||||||
|
- "{{ pve_backup_nfs_options }}"
|
||||||
|
- --content
|
||||||
|
- backup
|
||||||
|
- --prune-backups
|
||||||
|
- "{{ pve_backup_prune_string }}"
|
||||||
|
when:
|
||||||
|
- pve_backup_storage_type == 'nfs'
|
||||||
|
- pve_backup_storage not in (pve_storages.stdout | from_json | map(attribute='storage') | list)
|
||||||
|
|
||||||
|
- name: Add the backup storage (PBS)
|
||||||
|
# The password goes on a command line, which is visible in the node's process
|
||||||
|
# list for the moment the command runs. `pvesm` has no stdin form, so the
|
||||||
|
# alternative is configuring PBS by hand — acceptable for a one-time create
|
||||||
|
# that only fires when the storage is absent.
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- pvesm
|
||||||
|
- add
|
||||||
|
- pbs
|
||||||
|
- "{{ pve_backup_storage }}"
|
||||||
|
- --server
|
||||||
|
- "{{ pve_backup_pbs_server }}"
|
||||||
|
- --datastore
|
||||||
|
- "{{ pve_backup_pbs_datastore }}"
|
||||||
|
- --username
|
||||||
|
- "{{ pve_backup_pbs_username }}"
|
||||||
|
- --password
|
||||||
|
- "{{ pve_backup_pbs_password }}"
|
||||||
|
- --fingerprint
|
||||||
|
- "{{ pve_backup_pbs_fingerprint }}"
|
||||||
|
- --content
|
||||||
|
- backup
|
||||||
|
- --prune-backups
|
||||||
|
- "{{ pve_backup_prune_string }}"
|
||||||
|
when:
|
||||||
|
- pve_backup_storage_type == 'pbs'
|
||||||
|
- pve_backup_storage not in (pve_storages.stdout | from_json | map(attribute='storage') | list)
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# --- Backup job ----------------------------------------------------------
|
||||||
|
|
||||||
|
- name: Read the configured backup jobs
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [pvesh, get, /cluster/backup, --output-format, json]
|
||||||
|
register: pve_backup_jobs
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
# Proxmox assigns job IDs itself, so there is nothing stable to key on except
|
||||||
|
# a comment we set. Without this the role would add a duplicate job on every
|
||||||
|
# run.
|
||||||
|
- name: Find a job this role already created
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pve_backup_existing: >-
|
||||||
|
{{ (pve_backup_jobs.stdout | from_json)
|
||||||
|
| selectattr('comment', 'defined')
|
||||||
|
| selectattr('comment', 'equalto', pve_backup_comment)
|
||||||
|
| list | first | default({}) }}
|
||||||
|
|
||||||
|
- name: Build the vzdump job arguments
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pve_backup_job_args: >-
|
||||||
|
{{ ['--schedule', pve_backup_schedule,
|
||||||
|
'--storage', pve_backup_storage,
|
||||||
|
'--mode', pve_backup_mode,
|
||||||
|
'--compress', pve_backup_compress,
|
||||||
|
'--comment', pve_backup_comment,
|
||||||
|
'--enabled', ('1' if pve_backup_enabled else '0')]
|
||||||
|
+ (['--all', '1'] if pve_backup_all
|
||||||
|
else ['--all', '0', '--vmid', pve_backup_vmids | join(',')])
|
||||||
|
+ (['--mailto', pve_backup_mailto] if pve_backup_mailto | length > 0 else []) }}
|
||||||
|
|
||||||
|
- name: Create the vzdump job
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: "{{ ['pvesh', 'create', '/cluster/backup'] + pve_backup_job_args }}"
|
||||||
|
when: pve_backup_existing | length == 0
|
||||||
|
|
||||||
|
# Compared field by field rather than blindly re-applying, so the role reports
|
||||||
|
# a change only when there is one. The VMID list is compared as the
|
||||||
|
# comma-joined string Proxmox stores, which makes it order-sensitive — reorder
|
||||||
|
# pve_backup_vmids and you get one no-op update.
|
||||||
|
- name: Work out whether the existing job still matches
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pve_backup_drifted: >-
|
||||||
|
{{ pve_backup_existing.schedule | default('') != pve_backup_schedule
|
||||||
|
or pve_backup_existing.storage | default('') != pve_backup_storage
|
||||||
|
or pve_backup_existing.mode | default('') != pve_backup_mode
|
||||||
|
or (pve_backup_existing.enabled | default(1) | int == 1) != pve_backup_enabled
|
||||||
|
or (pve_backup_existing.all | default(0) | int == 1) != pve_backup_all
|
||||||
|
or (not pve_backup_all
|
||||||
|
and pve_backup_existing.vmid | default('') != pve_backup_vmids | join(',')) }}
|
||||||
|
when: pve_backup_existing | length > 0
|
||||||
|
|
||||||
|
- name: Update the vzdump job if its settings drifted
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: "{{ ['pvesh', 'set', '/cluster/backup/' ~ pve_backup_existing.id] + pve_backup_job_args }}"
|
||||||
|
when:
|
||||||
|
- pve_backup_existing | length > 0
|
||||||
|
- pve_backup_drifted | bool
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
# Origins unattended-upgrades installs from — Ubuntu's own stock list
|
||||||
|
# (security, plus the ESM ones, which are inert without Ubuntu Pro attached
|
||||||
|
# rather than an error). Override per-host/group for less than this.
|
||||||
|
unattended_upgrades_origins:
|
||||||
|
- "${distro_id}:${distro_codename}"
|
||||||
|
- "${distro_id}:${distro_codename}-security"
|
||||||
|
- "${distro_id}ESMApps:${distro_codename}-apps-security"
|
||||||
|
- "${distro_id}ESM:${distro_codename}-infra-security"
|
||||||
|
|
||||||
|
# Never reboot automatically. playbooks/k3s_maintenance.yml (role
|
||||||
|
# k3s_maintenance) does that instead — draining the node first, one at a
|
||||||
|
# time — which is the entire reason this role exists rather than just
|
||||||
|
# `apt install unattended-upgrades` with Ubuntu's own defaults (which reboot
|
||||||
|
# at 02:00 unprompted, k3s node or not).
|
||||||
|
unattended_upgrades_automatic_reboot: false
|
||||||
|
|
||||||
|
unattended_upgrades_remove_unused_deps: true
|
||||||
|
unattended_upgrades_remove_unused_kernel_packages: true
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
- name: restart unattended-upgrades
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: unattended-upgrades
|
||||||
|
state: restarted
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
# Debian/Ubuntu-generic — not k3s-specific, kept independent of roles/k3s_node
|
||||||
|
# so it can be pointed at any apt-based host later (a Proxmox guest, say)
|
||||||
|
# without dragging k3s along. Currently only applied to k3s_cluster, from
|
||||||
|
# playbooks/k3s.yml.
|
||||||
|
#
|
||||||
|
# Installs updates hands-off, but leaves rebooting to
|
||||||
|
# playbooks/k3s_maintenance.yml — see unattended_upgrades_automatic_reboot in
|
||||||
|
# defaults/main.yml for why.
|
||||||
|
|
||||||
|
- name: Install unattended-upgrades
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- unattended-upgrades
|
||||||
|
- update-notifier-common
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
- name: Configure unattended-upgrades
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: 50unattended-upgrades.j2
|
||||||
|
dest: /etc/apt/apt.conf.d/50unattended-upgrades
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: restart unattended-upgrades
|
||||||
|
|
||||||
|
- name: Enable periodic apt updates and unattended-upgrades
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||||
|
content: |
|
||||||
|
APT::Periodic::Update-Package-Lists "1";
|
||||||
|
APT::Periodic::Unattended-Upgrade "1";
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Ensure unattended-upgrades is enabled and running
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: unattended-upgrades
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Managed by Ansible (roles/unattended_upgrades) — edits here are overwritten
|
||||||
|
// on the next run.
|
||||||
|
|
||||||
|
Unattended-Upgrade::Allowed-Origins {
|
||||||
|
{% for origin in unattended_upgrades_origins %}
|
||||||
|
"{{ origin }}";
|
||||||
|
{% endfor %}
|
||||||
|
};
|
||||||
|
|
||||||
|
Unattended-Upgrade::Remove-Unused-Dependencies "{{ unattended_upgrades_remove_unused_deps | lower }}";
|
||||||
|
Unattended-Upgrade::Remove-Unused-Kernel-Packages "{{ unattended_upgrades_remove_unused_kernel_packages | lower }}";
|
||||||
|
|
||||||
|
// The whole point of this role: never reboot on its own. A reboot-required
|
||||||
|
// package (kernel, libc, ...) is left applied-but-inactive until
|
||||||
|
// playbooks/k3s_maintenance.yml (role k3s_maintenance) drains this node and
|
||||||
|
// reboots it deliberately, one node at a time.
|
||||||
|
Unattended-Upgrade::Automatic-Reboot "{{ unattended_upgrades_automatic_reboot | lower }}";
|
||||||
23
build/config/terraform/.terraform.lock.hcl
generated
Normal file
23
build/config/terraform/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/bpg/proxmox" {
|
||||||
|
version = "0.111.1"
|
||||||
|
hashes = [
|
||||||
|
"h1:ML2D3UUZTM99yrll/EBXj7wBYMb8xmQgomqFNybEoxY=",
|
||||||
|
"zh:18fb7c31a08dde6bffa1a4d4a211e604d6d17eec7092fd59331b3db3c6f3742c",
|
||||||
|
"zh:1cd60761538289d4dd2a1086b3ae62a7b0bdd4b1a2f824e9a44e243413168dba",
|
||||||
|
"zh:2eb76f6fc8299b6820ff678c8252332cc3366e226b5ae2e61748fd2449c1ed92",
|
||||||
|
"zh:45e6f7ebd0bf48911d37060359a4f359b5743b3092e985295733990e406d0416",
|
||||||
|
"zh:4aa8ba912eae37975d2e983394d173e595ca34fc76b5bf220b37d0e99d76e98c",
|
||||||
|
"zh:58e0789923103a77d502a0a9fc3eb920625e8eb935ec2d4ac0d006aebd1d186c",
|
||||||
|
"zh:6df8aa85fb8865915537e946c19b02538ad188018a629759c213c6f03730f642",
|
||||||
|
"zh:6ed47bc00d0913a1d0880618fa1376115e9edab6b4a658c081061a7f0e4ca360",
|
||||||
|
"zh:c5b10ff4f33df7e4c29e8f1127d49845b561b37b57517e844fb0954d7923d65e",
|
||||||
|
"zh:d016510e14b738499f0db9d9b3aafe82fc6877fb4ab4e9f831fb68a8d70a1385",
|
||||||
|
"zh:d941f394069bbf24351b363da1c64383f487067aaee0a84f9b96476d4912e212",
|
||||||
|
"zh:ddf271dbc2632ae8ffa8de3972f243ee47d260cb2ac90aa784f2746d98e21a0f",
|
||||||
|
"zh:ed0caa3501c42f611b7e9622c9b1df69fd85dc25a3cd88d3076381829688cd62",
|
||||||
|
"zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597",
|
||||||
|
]
|
||||||
|
}
|
||||||
66
build/config/terraform/README.md
Normal file
66
build/config/terraform/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# build/config/terraform
|
||||||
|
|
||||||
|
Centralized Terraform config for Proxmox — provider, backend, and the module
|
||||||
|
calls that say what infrastructure exists. The counterpart to
|
||||||
|
`build/config/ansible`, which holds inventory and the shared roles.
|
||||||
|
|
||||||
|
## Division of labour
|
||||||
|
|
||||||
|
Terraform's job stops at the guest. It creates the LXC (template, cores,
|
||||||
|
memory, disk, IP) and nothing more; Ansible then installs the app into it via
|
||||||
|
the `lxc_app` role. That split is why Ansible is the common tool across both
|
||||||
|
platforms and Terraform is Proxmox-only:
|
||||||
|
|
||||||
|
| | Unraid | Proxmox |
|
||||||
|
|---|---|---|
|
||||||
|
| Provision the host | n/a — it already exists | Terraform (this directory) |
|
||||||
|
| Install the app | Ansible + `compose_stack` (Docker Compose) | Ansible + `lxc_app` (native + systemd) |
|
||||||
|
| App config | `src/<app>/common/vars.yml` + `src/<app>/ansible/unraid/vars.yml` | `src/<app>/common/vars.yml` + `src/<app>/ansible/proxmox/vars.yml` |
|
||||||
|
|
||||||
|
Per-app LXC specs live in `src/<app>/terraform/` as modules, called from
|
||||||
|
`main.tf` here.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
`backend "pg"` — specifically against the **CloudNativePG cluster on the k3s
|
||||||
|
Pis**, reached on its MetalLB address (`k3s_postgres_loadbalancer_ip`,
|
||||||
|
192.168.50.81). *Not* the shared Postgres this configuration itself
|
||||||
|
provisions on Proxmox, which would be circular: Terraform would need the
|
||||||
|
database to exist in order to create the container the database runs in.
|
||||||
|
State goes somewhere Terraform has no hand in building, which breaks the
|
||||||
|
cycle outright. Local state would make Terraform workstation-only; CI needs
|
||||||
|
to see the same state.
|
||||||
|
|
||||||
|
The connection string holds a password, so it is passed at init rather than
|
||||||
|
committed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
terraform init \
|
||||||
|
-backend-config="conn_str=postgres://terraform:$PG_PASSWORD@192.168.50.81:5432/terraform_state"
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap dependency this creates is on **k3s, not Unraid**: the cluster
|
||||||
|
and its CNPG instance must be up, with a `terraform_state` database and a
|
||||||
|
`terraform` role created on it, before `terraform init` works. That is the
|
||||||
|
one cross-platform dependency the rest of the repo avoids, and it is
|
||||||
|
deliberate — the alternative is a local-state-then-`init -migrate-state`
|
||||||
|
dance that has to be got right exactly once. Ordering is in
|
||||||
|
`docs/postgres-proxmox.md`.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Proxmox API token and the Postgres connection string both come from Vault
|
||||||
|
(`homelab/ci/proxmox`, `homelab/ci/terraform`), exported as environment
|
||||||
|
variables before running — the same pattern `group_vars/all.yml` uses for
|
||||||
|
`VAULT_ADDR` and friends. Nothing authenticating to anything is committed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export TF_VAR_proxmox_endpoint='https://turtle-proxmox-01.home.turtlesystems.co.uk:8006/'
|
||||||
|
export TF_VAR_proxmox_api_token='root@pam!terraform=<secret>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Not yet applied against anything. The provider version is intentionally
|
||||||
|
unpinned until the first `terraform init` — pin what it resolves and commit
|
||||||
|
`.terraform.lock.hcl` (which is not gitignored).
|
||||||
102
build/config/terraform/main.tf
Normal file
102
build/config/terraform/main.tf
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Per-app LXC modules are called from here, so there is one place that knows
|
||||||
|
# what infrastructure exists. Each module lives with its app in
|
||||||
|
# src/<app>/terraform/ and outputs the guest's address, which the Ansible
|
||||||
|
# side then picks up from the Proxmox API (inventory/proxmox.yml) rather than
|
||||||
|
# by wiring outputs into inventory by hand.
|
||||||
|
|
||||||
|
# The shared Postgres, and the first module here that is infrastructure other
|
||||||
|
# modules depend on rather than an app in its own right. Declared first for
|
||||||
|
# readability only — Terraform orders by dependency, not by position, and
|
||||||
|
# nothing else in this file references it. The ordering that does matter is
|
||||||
|
# on the Ansible side, where a guest's `db:` provisioning needs this cluster
|
||||||
|
# already answering; see the runbook in docs/postgres-proxmox.md.
|
||||||
|
module "postgres" {
|
||||||
|
source = "../../../src/shared/postgres/terraform"
|
||||||
|
|
||||||
|
node_name = var.proxmox_node
|
||||||
|
template_file_id = var.lxc_template_file_id
|
||||||
|
ssh_public_keys = var.ssh_public_keys
|
||||||
|
|
||||||
|
hostname = "postgres"
|
||||||
|
vm_id = 161
|
||||||
|
ip_address = "192.168.50.54/24"
|
||||||
|
gateway = "192.168.50.254"
|
||||||
|
|
||||||
|
cores = 2
|
||||||
|
memory = 2048
|
||||||
|
|
||||||
|
# Sized for every database this host will ever hold, not for the OS. The
|
||||||
|
# one-volume rule below means growth happens here rather than by adding a
|
||||||
|
# second disk: `terraform apply` turns an increase into a `pct resize` of
|
||||||
|
# the rootfs, which on ZFS is a refquota change — online, no data move, no
|
||||||
|
# filesystem grow step. It is one-way, though; shrinking is a replacement
|
||||||
|
# and `prevent_destroy` blocks it, so overshoot rather than creep upward.
|
||||||
|
disk_size = 256
|
||||||
|
|
||||||
|
# The ZFS pool, so the guest's volume can be replicated at all, and the
|
||||||
|
# second node to replicate it to. Both are the whole point of this module
|
||||||
|
# differing from the one below — see src/shared/postgres/terraform/main.tf.
|
||||||
|
datastore_id = "AppData"
|
||||||
|
replication_target_node = var.proxmox_replication_node
|
||||||
|
}
|
||||||
|
|
||||||
|
module "forgejo" {
|
||||||
|
source = "../../../src/forgejo/terraform"
|
||||||
|
|
||||||
|
# The second node, not `var.proxmox_node` — a deliberate split of the two
|
||||||
|
# guests across the cluster rather than an oversight. Written as a literal
|
||||||
|
# here for the same reason `hostname`/`vm_id`/`ip_address` are: it states
|
||||||
|
# where this one guest goes. `var.proxmox_replication_node` happens to hold
|
||||||
|
# the same string today, but it means "where Postgres replicates to", and
|
||||||
|
# borrowing it would tie Forgejo's placement to a decision about the
|
||||||
|
# database.
|
||||||
|
#
|
||||||
|
# Safe to change only while this container does not exist. Once it does, the
|
||||||
|
# provider treats `node_name` as a replacement — and `prevent_destroy` in
|
||||||
|
# the module turns that into a failed plan, which is the intended outcome:
|
||||||
|
# moving a live Forgejo between nodes is a Proxmox migration, not a
|
||||||
|
# `terraform apply`.
|
||||||
|
node_name = "turtle-proxmox-02"
|
||||||
|
template_file_id = var.lxc_template_file_id
|
||||||
|
ssh_public_keys = var.ssh_public_keys
|
||||||
|
|
||||||
|
hostname = "forgejo"
|
||||||
|
vm_id = 160
|
||||||
|
ip_address = "192.168.50.52/24"
|
||||||
|
gateway = "192.168.50.254"
|
||||||
|
|
||||||
|
cores = 2
|
||||||
|
memory = 2048
|
||||||
|
|
||||||
|
# Overrides the module default only in the sense of restating it; both are
|
||||||
|
# 256 GiB. Growing this is one-way, same as the Postgres guest above:
|
||||||
|
# `terraform apply` turns an increase into an online `pct resize` of the
|
||||||
|
# rootfs, but shrinking is a replacement and `prevent_destroy` blocks it.
|
||||||
|
# Sized generously because *everything* Forgejo owns is on this volume —
|
||||||
|
# repos, LFS, attachments, indexers, and the local database dumps.
|
||||||
|
disk_size = 256
|
||||||
|
|
||||||
|
# The ZFS pool, which exists on both nodes, rather than the module's
|
||||||
|
# `local-lvm` default — this cluster has no storage by that name. No
|
||||||
|
# replication job to go with it, unlike the Postgres guest above: Forgejo's
|
||||||
|
# off-box copy is the vzdump archive `pve_backup` writes to the NAS, which
|
||||||
|
# is a complete filesystem restore point precisely because nothing here is
|
||||||
|
# bind-mounted.
|
||||||
|
datastore_id = "AppData"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Consumed by the Proxmox nodes' host_vars, which can pin the vzdump job to
|
||||||
|
# explicit VMIDs. Kept as an output rather than restated there so the two
|
||||||
|
# can't drift.
|
||||||
|
output "backup_vmids" {
|
||||||
|
description = "VMIDs of the guests this configuration creates, for the vzdump job."
|
||||||
|
value = [module.postgres.vm_id, module.forgejo.vm_id]
|
||||||
|
}
|
||||||
|
|
||||||
|
# The address every app's `db.provision_host` and `DB_HOST` has to agree with.
|
||||||
|
# An output rather than something to look up in the Proxmox UI, because it is
|
||||||
|
# copied into several files by hand and this is the one authoritative copy.
|
||||||
|
output "postgres_address" {
|
||||||
|
description = "LAN address of the shared Postgres LXC."
|
||||||
|
value = module.postgres.ip_address
|
||||||
|
}
|
||||||
42
build/config/terraform/providers.tf
Normal file
42
build/config/terraform/providers.tf
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.6"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
proxmox = {
|
||||||
|
source = "bpg/proxmox"
|
||||||
|
# Deliberately unpinned until the first real `terraform init` — pin to
|
||||||
|
# whatever it resolves, and commit .terraform.lock.hcl (which is not
|
||||||
|
# gitignored) so everyone and CI get the same provider.
|
||||||
|
# version = "~> 0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# State in Postgres rather than a local file: CI and your workstation need
|
||||||
|
# to see the same state, and a local file makes Terraform workstation-only.
|
||||||
|
#
|
||||||
|
# Specifically the CloudNativePG cluster on k3s, reached at
|
||||||
|
# k3s_postgres_loadbalancer_ip — *not* the shared Postgres this
|
||||||
|
# configuration itself provisions on Proxmox. That would be circular:
|
||||||
|
# Terraform would need the database to exist in order to create the
|
||||||
|
# container the database runs in. Putting state on a cluster this
|
||||||
|
# configuration has no hand in building breaks the cycle outright, which is
|
||||||
|
# why it is worth the cross-platform dependency the rest of the repo
|
||||||
|
# otherwise avoids — `terraform apply` now needs the Pis up.
|
||||||
|
#
|
||||||
|
# Left empty on purpose — the connection string contains a password, so it
|
||||||
|
# is supplied at init time instead of being committed:
|
||||||
|
#
|
||||||
|
# terraform init \
|
||||||
|
# -backend-config="conn_str=postgres://terraform:$PG_PASSWORD@192.168.50.81:5432/terraform_state"
|
||||||
|
#
|
||||||
|
# Bootstrap ordering: the k3s cluster and its CNPG instance must exist (and
|
||||||
|
# a `terraform_state` database be created on it) before `terraform init`
|
||||||
|
# succeeds. See docs/postgres-proxmox.md.
|
||||||
|
backend "pg" {}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "proxmox" {
|
||||||
|
endpoint = var.proxmox_endpoint
|
||||||
|
api_token = var.proxmox_api_token
|
||||||
|
insecure = var.proxmox_insecure
|
||||||
|
}
|
||||||
78
build/config/terraform/variables.tf
Normal file
78
build/config/terraform/variables.tf
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
variable "proxmox_endpoint" {
|
||||||
|
description = "Proxmox VE API endpoint, e.g. https://turtle-proxmox-01.home.turtlesystems.co.uk:8006/"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "proxmox_api_token" {
|
||||||
|
description = <<-EOT
|
||||||
|
Proxmox API token in `USER@REALM!TOKENID=SECRET` form. Sourced from Vault
|
||||||
|
(homelab/ci/proxmox), never committed — pass via the TF_VAR_proxmox_api_token
|
||||||
|
environment variable.
|
||||||
|
EOT
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "proxmox_insecure" {
|
||||||
|
description = "Skip TLS verification — true while Proxmox has a self-signed cert."
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "proxmox_node" {
|
||||||
|
description = <<-EOT
|
||||||
|
Proxmox node guests are created on. Must be the node's **short** name as
|
||||||
|
the cluster knows it (`pvecm nodes`), never an FQDN, even though the API
|
||||||
|
endpoint is one.
|
||||||
|
|
||||||
|
pveproxy compares this against its own short hostname to decide whether a
|
||||||
|
request is local. An FQDN never matches, so it proxies the request onward
|
||||||
|
— to itself — over a connection it verifies against the cluster CA, which
|
||||||
|
a self-signed cert fails. The result is an HTTP 596 carrying an OpenSSL
|
||||||
|
`certificate verify failed`, which reads like a TLS misconfiguration on
|
||||||
|
this side and is not: `proxmox_insecure` governs the provider's own
|
||||||
|
connection and has no bearing on Proxmox's internal one.
|
||||||
|
EOT
|
||||||
|
type = string
|
||||||
|
default = "turtle-proxmox-02"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "proxmox_replication_node" {
|
||||||
|
description = <<-EOT
|
||||||
|
The other node in the cluster, which guests on a ZFS pool replicate to.
|
||||||
|
Only the shared Postgres module uses this today; it lives here rather than
|
||||||
|
in that module's own defaults because it names a node of this cluster, the
|
||||||
|
same as `proxmox_node` does.
|
||||||
|
|
||||||
|
Null disables replication. Set it explicitly even on a single-node
|
||||||
|
cluster — a null default is indistinguishable from an oversight, and the
|
||||||
|
failure mode is a database with no second copy that looks entirely
|
||||||
|
healthy.
|
||||||
|
EOT
|
||||||
|
type = string
|
||||||
|
default = "turtle-proxmox-01"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "lxc_template_file_id" {
|
||||||
|
description = <<-EOT
|
||||||
|
Container template every LXC module is built from, e.g.
|
||||||
|
`local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst`. Must already be
|
||||||
|
downloaded on the node — `pveam list local` shows what is there, `pveam
|
||||||
|
available`/`pveam download local <name>` fetches one. Not defaulted: the
|
||||||
|
exact filename moves with each point release, so a stale default would fail
|
||||||
|
at apply time rather than here.
|
||||||
|
EOT
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ssh_public_keys" {
|
||||||
|
description = <<-EOT
|
||||||
|
Public keys installed for root in every container, so Ansible can reach
|
||||||
|
them. The matching private key is in Vault at `homelab/ci/ssh` — see "SSH
|
||||||
|
access" in the top-level README.md. Public keys aren't secret, but they are
|
||||||
|
site-specific, so pass rather than commit:
|
||||||
|
|
||||||
|
export TF_VAR_ssh_public_keys='["'"$(cat ~/.ssh/unraid_ansible.pub)"'"]'
|
||||||
|
EOT
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
420
docs/authentik-migration.md
Normal file
420
docs/authentik-migration.md
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
# Migrating Authentik from Unraid to k3s
|
||||||
|
|
||||||
|
A one-time, hand-run cutover: move the Authentik database off Unraid onto the
|
||||||
|
cluster's CloudNativePG instance, bring Authentik up on k3s from
|
||||||
|
`src/authentik/`, verify it, then change DNS.
|
||||||
|
|
||||||
|
This is deliberately not automated. It runs once, most of its steps are
|
||||||
|
irreversible in the wrong order, and the verification between them is a
|
||||||
|
judgement call rather than a task result — the sort of thing a playbook makes
|
||||||
|
harder rather than easier. The repo half *is* automated: everything from
|
||||||
|
"deploy Authentik" onwards is `playbooks/k3s.yml`, and re-running it later is
|
||||||
|
an ordinary converge.
|
||||||
|
|
||||||
|
Read the whole thing before starting. The dump is taken with Authentik
|
||||||
|
stopped, so there's a service outage from step 5 to step 11 — budget an hour
|
||||||
|
and do it when nobody needs to log in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What moves, and what doesn't
|
||||||
|
|
||||||
|
| | Where it lives now | How it gets across |
|
||||||
|
|---|---|---|
|
||||||
|
| Users, groups, applications, providers, flows, tokens, certificates | Postgres | `pg_dump` \| `psql` (step 7) |
|
||||||
|
| Uploaded icons and flow backgrounds | `/media` on disk | `scp` + `kubectl cp` (step 9) |
|
||||||
|
| Session cookies | signed by `AUTHENTIK_SECRET_KEY` | carry the key over (step 2) |
|
||||||
|
| Cache / task queue | in-process | nothing — recent Authentik has no Redis, and the chart has no Redis dependency |
|
||||||
|
|
||||||
|
Authentik stores its certificates (including the signing keys OIDC/SAML
|
||||||
|
providers use) **in the database**, not in a `certs/` directory, so a
|
||||||
|
successful database restore carries them. That is what lets integrations like
|
||||||
|
Shelfarr's OIDC (`src/arr/`) keep working without being re-registered.
|
||||||
|
|
||||||
|
The one piece with no automatic path is anything you mounted into the Unraid
|
||||||
|
container by hand — custom templates, a `certs/` directory you populated
|
||||||
|
yourself. Step 1 is where you find out whether you have any.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gather the facts
|
||||||
|
|
||||||
|
Everything below needs these. Run on the Unraid box (web terminal, or SSH).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Which containers make up the current deployment, and what image/tag.
|
||||||
|
docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' | grep -i authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
Write down the **image tag** — that is the Authentik version, and step 3
|
||||||
|
depends on it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Every mount, so you can see where /media lives and spot anything unexpected.
|
||||||
|
docker inspect authentik-server \
|
||||||
|
--format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'
|
||||||
|
|
||||||
|
# The database connection it currently uses, and the secret key.
|
||||||
|
docker inspect authentik-server \
|
||||||
|
--format '{{range .Config.Env}}{{println .}}{{end}}' | grep -E 'POSTGRES|SECRET_KEY'
|
||||||
|
```
|
||||||
|
|
||||||
|
That last command tells you the thing this document can't assume: **which
|
||||||
|
Postgres the Unraid Authentik talks to**. Two possibilities, and they change
|
||||||
|
step 7:
|
||||||
|
|
||||||
|
- `AUTHENTIK_POSTGRESQL__HOST=shared-postgres` (or `192.168.50.1`) — it uses
|
||||||
|
the shared instance this repo already manages, `src/shared/postgres/`. The
|
||||||
|
superuser password is in Vault at `homelab/shared/postgres`.
|
||||||
|
- anything else (`authentik-postgresql`, `postgres`, a container name from
|
||||||
|
Authentik's own compose bundle) — the Unraid UI deployment brought its own
|
||||||
|
Postgres container. You'll need *its* credentials, which are in that
|
||||||
|
container's own environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker inspect <that-container> \
|
||||||
|
--format '{{range .Config.Env}}{{println .}}{{end}}' | grep POSTGRES
|
||||||
|
```
|
||||||
|
|
||||||
|
and it is probably not published on a host port, which step 7 handles.
|
||||||
|
|
||||||
|
Also note `AUTHENTIK_SECRET_KEY` from the output above — step 2 needs it
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Populate Vault
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/authentik \
|
||||||
|
AUTHENTIK_SECRET_KEY='<the value from step 1>' \
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD='<a new strong password>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Two different rules for those two values, and it's worth being clear about
|
||||||
|
why:
|
||||||
|
|
||||||
|
- **`AUTHENTIK_SECRET_KEY` must be the existing one.** It signs session
|
||||||
|
cookies. A fresh value doesn't break anything permanently, but it
|
||||||
|
invalidates every active session at the moment DNS flips — so instead of a
|
||||||
|
silent cutover, everyone gets logged out and the migration announces
|
||||||
|
itself. Copy it across.
|
||||||
|
- **`AUTHENTIK_POSTGRESQL__PASSWORD` can be new.** You are creating a new
|
||||||
|
role on a different Postgres; nothing in the dump references it. Generate a
|
||||||
|
fresh one.
|
||||||
|
|
||||||
|
If you're also using cert-manager's DNS-01 solver (step 4), populate its
|
||||||
|
credentials now too:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/k3s-cert-manager \
|
||||||
|
CLOUDFLARE_API_TOKEN='<token with Zone:DNS:Edit on the zone>'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Confirm the repo's values
|
||||||
|
|
||||||
|
Three values are guesses until you check them. All are flagged in-file.
|
||||||
|
|
||||||
|
**`src/authentik/common/vars.yml`**
|
||||||
|
|
||||||
|
- `AUTHENTIK_VERSION` — **set this to the image tag from step 1**, not to
|
||||||
|
whatever is newest. Authentik runs Django migrations on startup against
|
||||||
|
whatever schema it finds, and they are one-way: a newer Authentik pointed
|
||||||
|
at an older dump upgrades the schema silently, and if the cutover then has
|
||||||
|
to be rolled back, the Unraid instance can no longer read its own database.
|
||||||
|
Migrate first, upgrade second, in a separate commit.
|
||||||
|
- `AUTHENTIK_HOST` / `AUTHENTIK_URL` — the hostname you'll be moving. Must
|
||||||
|
match what's there today, or every OIDC/SAML redirect URI registered in
|
||||||
|
Authentik breaks.
|
||||||
|
|
||||||
|
**`build/config/ansible/inventory/group_vars/k3s_cluster.yml`**
|
||||||
|
|
||||||
|
- `k3s_cert_manager_solver` — empty by default, and
|
||||||
|
`roles/k3s_cert_manager` refuses to run until it's set. The commented
|
||||||
|
Cloudflare example in that file is the common case; for anything else take
|
||||||
|
the stanza from
|
||||||
|
[cert-manager's DNS-01 docs](https://cert-manager.io/docs/configuration/acme/dns01/).
|
||||||
|
|
||||||
|
While you're testing, point `k3s_cert_manager_acme_server` at Let's Encrypt
|
||||||
|
staging. Production allows five failed validations per hostname per hour, and
|
||||||
|
exhausting it means waiting rather than retrying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Deploy cert-manager and confirm the issuer works
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/ansible
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags cert-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check it actually registered — a `ClusterIssuer` is created whether or
|
||||||
|
not its solver is valid, so "the resource exists" proves nothing:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:KUBECONFIG = "$PWD\local\k3s\homelab-utils.kubeconfig"
|
||||||
|
kubectl get clusterissuer letsencrypt -o jsonpath='{.status.conditions[*].message}'
|
||||||
|
```
|
||||||
|
|
||||||
|
You want `The ACME account was registered with the ACME server`. Anything
|
||||||
|
else, fix it here — not after Authentik is deployed and waiting on a
|
||||||
|
certificate.
|
||||||
|
|
||||||
|
Worth knowing: with DNS-01, the certificate can be issued **before** DNS
|
||||||
|
points at the cluster. The challenge is a TXT record, not a request to your
|
||||||
|
web server, so cert-manager never needs to be reachable. That's why TLS is
|
||||||
|
sorted out in this step and not after the flip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Freeze the Unraid instance
|
||||||
|
|
||||||
|
Stop writes before dumping. Skipping this gives you a dump that's missing
|
||||||
|
whatever happened during it — usually invisible, occasionally a user account
|
||||||
|
that no longer exists.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker stop authentik-server authentik-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave its Postgres running — step 7 reads from it. **The outage starts
|
||||||
|
here.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Create the role and database on CNPG
|
||||||
|
|
||||||
|
The restore needs the `authentik` role to exist first so the dump's objects
|
||||||
|
land with the right ownership.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:KUBECONFIG = "$PWD\local\k3s\homelab-utils.kubeconfig"
|
||||||
|
kubectl -n shared-postgres exec -i shared-postgres-1 -- psql -v ON_ERROR_STOP=1 <<'SQL'
|
||||||
|
CREATE ROLE authentik LOGIN PASSWORD 'the-password-from-step-2';
|
||||||
|
CREATE DATABASE authentik OWNER authentik;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl exec` into a CNPG pod lands you as the `postgres` superuser over the
|
||||||
|
local socket, so bare `psql` needs no credentials.
|
||||||
|
|
||||||
|
You could instead let `roles/k3s_app` create these — its tasks are idempotent
|
||||||
|
and will run in step 8 regardless — but doing it by hand here keeps the
|
||||||
|
database creation and the restore adjacent, and avoids deploying Authentik
|
||||||
|
against an empty database first (which would have it run initial migrations
|
||||||
|
and build a schema the restore then has to fight).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dump and restore
|
||||||
|
|
||||||
|
Run the whole thing **inside the CNPG pod**, piping `pg_dump` straight into
|
||||||
|
`psql`. Three reasons this is better than dumping to a file on Windows:
|
||||||
|
|
||||||
|
- `pg_dump` must be at least the version of the server being dumped, and the
|
||||||
|
CNPG pod ships Postgres 18 client tools — dumping the Unraid Postgres 13
|
||||||
|
from there is the supported direction. Your workstation probably has no
|
||||||
|
`pg_dump` at all.
|
||||||
|
- PowerShell re-encodes bytes passing through `>` and `|`, which silently
|
||||||
|
corrupts a dump file. Nothing on the Windows filesystem means nothing to
|
||||||
|
corrupt.
|
||||||
|
- No intermediate file holding the entire user database in cleartext.
|
||||||
|
|
||||||
|
### If Authentik used the shared Postgres (published on `192.168.50.1:5432`)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:KUBECONFIG = "$PWD\local\k3s\homelab-utils.kubeconfig"
|
||||||
|
$srcPw = Read-Host 'Unraid postgres superuser password'
|
||||||
|
|
||||||
|
kubectl -n shared-postgres exec -i shared-postgres-1 -- `
|
||||||
|
env "PGPASSWORD=$srcPw" bash -c @'
|
||||||
|
set -euo pipefail
|
||||||
|
{ echo "SET ROLE authentik;"
|
||||||
|
pg_dump -h 192.168.50.1 -p 5432 -U postgres -d authentik \
|
||||||
|
--no-owner --no-privileges
|
||||||
|
} | psql -v ON_ERROR_STOP=1 -d authentik
|
||||||
|
'@
|
||||||
|
```
|
||||||
|
|
||||||
|
`SET ROLE authentik` before the dump stream, combined with `--no-owner`, is
|
||||||
|
what gets ownership right: the session creates every object as `authentik`,
|
||||||
|
so the application role owns its own schema. Without it, everything lands
|
||||||
|
owned by `postgres` and Authentik can read but not migrate.
|
||||||
|
|
||||||
|
`ON_ERROR_STOP=1` matters — without it `psql` reports success having skipped
|
||||||
|
every statement that failed.
|
||||||
|
|
||||||
|
### If it used its own Postgres container (not published)
|
||||||
|
|
||||||
|
Publish it temporarily, then use the command above with the container's own
|
||||||
|
credentials and port:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# on Unraid
|
||||||
|
docker run -d --rm --name pgbridge -p 55432:5432 \
|
||||||
|
--network container:<authentik-postgres-container> alpine/socat \
|
||||||
|
TCP-LISTEN:5432,fork TCP:127.0.0.1:5432
|
||||||
|
```
|
||||||
|
|
||||||
|
then `-h 192.168.50.1 -p 55432 -U <its user>` in the `pg_dump` above, and
|
||||||
|
`docker stop pgbridge` afterwards. Simpler alternative if you'd rather not:
|
||||||
|
`docker exec <container> pg_dump ... > /mnt/user/appdata/authentik.sql` on
|
||||||
|
Unraid, `scp` it to your workstation, and `kubectl exec -i ... -- psql -d
|
||||||
|
authentik < authentik.sql` — but a plain-SQL file through PowerShell
|
||||||
|
redirection is exactly the encoding hazard noted above, so run that last
|
||||||
|
command from WSL.
|
||||||
|
|
||||||
|
### Check the restore
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
kubectl -n shared-postgres exec -i shared-postgres-1 -- `
|
||||||
|
psql -d authentik -c '\dt' -c 'SELECT count(*) FROM authentik_core_user;'
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see a long table list and a user count matching what the old
|
||||||
|
instance had. An empty table list means the restore did nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deploy Authentik on k3s
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/ansible
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags apps
|
||||||
|
```
|
||||||
|
|
||||||
|
A green run means the manifests landed and the database was provisioned — not
|
||||||
|
that the workload came up. Watch it actually start:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
kubectl -n authentik get pods -w
|
||||||
|
```
|
||||||
|
|
||||||
|
The first start takes several minutes on a Pi: pulling the image, then the
|
||||||
|
worker checking migrations. Because the deployed version matches the dumped
|
||||||
|
version (step 3), it should find nothing to migrate.
|
||||||
|
|
||||||
|
If pods stay `Pending`, check the PVC — `local-path` uses
|
||||||
|
`WaitForFirstConsumer`, so a `Pending` PVC alongside a `Pending` pod is
|
||||||
|
normal only until the scheduler picks a node:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
kubectl -n authentik describe pvc authentik-media
|
||||||
|
kubectl -n authentik logs -l app.kubernetes.io/component=worker --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Copy `/media` across
|
||||||
|
|
||||||
|
Two hops rather than a pipe, again to keep binary data out of a PowerShell
|
||||||
|
pipeline:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# from the media path you noted in step 1
|
||||||
|
scp -r root@192.168.50.1:/mnt/user/appdata/authentik/media ./local/authentik-media
|
||||||
|
|
||||||
|
$pod = kubectl -n authentik get pod -l app.kubernetes.io/component=server `
|
||||||
|
-o jsonpath='{.items[0].metadata.name}'
|
||||||
|
kubectl -n authentik cp ./local/authentik-media/. "${pod}:/media"
|
||||||
|
```
|
||||||
|
|
||||||
|
`local/` is already gitignored, so the staging copy won't be committed.
|
||||||
|
|
||||||
|
Skip this entirely if you never uploaded custom icons or backgrounds —
|
||||||
|
Authentik falls back to its built-in assets, and the directory is empty on a
|
||||||
|
stock install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Verify before touching DNS
|
||||||
|
|
||||||
|
The hostname still resolves to Unraid, so test by overriding resolution
|
||||||
|
rather than changing it. `--resolve` sends the request to Traefik with the
|
||||||
|
right SNI and `Host` header:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -sv --resolve auth.turtlesystems.uk:443:192.168.50.80 `
|
||||||
|
https://auth.turtlesystems.uk/-/health/ready/
|
||||||
|
```
|
||||||
|
|
||||||
|
Check, in order:
|
||||||
|
|
||||||
|
1. The TLS certificate is the Let's Encrypt one, not Traefik's self-signed
|
||||||
|
default (`kubectl -n authentik get certificate` → `READY True`). If you
|
||||||
|
used the staging server in step 3, it will be untrusted — expected; switch
|
||||||
|
to production and re-run `--tags cert-manager` once satisfied.
|
||||||
|
2. `/-/health/ready/` returns 204.
|
||||||
|
3. Add a hosts-file entry pointing the name at `192.168.50.80` and log in
|
||||||
|
through a browser with a **real user account**. This is the check that
|
||||||
|
matters — it exercises the restored password hashes, the flows, and the
|
||||||
|
session cookie signed with the carried-over secret key.
|
||||||
|
4. Open an existing application from the Authentik dashboard and confirm the
|
||||||
|
OIDC/SAML round-trip still works (Shelfarr is the one this repo knows
|
||||||
|
about — see `src/arr/`).
|
||||||
|
|
||||||
|
Remove the hosts-file entry afterwards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Flip DNS
|
||||||
|
|
||||||
|
Point `auth.turtlesystems.uk` at `192.168.50.80` (Traefik's MetalLB address).
|
||||||
|
|
||||||
|
Lower the record's TTL a few hours beforehand if you can — it shortens the
|
||||||
|
window in which a rollback is still invisible to clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Viable until users start making changes in the new instance; after that,
|
||||||
|
rolling back loses whatever they did.
|
||||||
|
|
||||||
|
1. Point DNS back at Unraid.
|
||||||
|
2. `docker start authentik-server authentik-worker`.
|
||||||
|
|
||||||
|
The old database was only ever read, never written, so it's exactly as it was
|
||||||
|
at step 5. Nothing to restore.
|
||||||
|
|
||||||
|
Do **not** run both instances at once against their separate databases — they
|
||||||
|
diverge immediately, and there is no way to merge them afterwards. That is
|
||||||
|
the same "never declare an app on two platforms" rule from CLAUDE.md, and it
|
||||||
|
is why `src/authentik/ansible/unraid/` is a README rather than a compose file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After it has settled
|
||||||
|
|
||||||
|
Once you're confident, in this order:
|
||||||
|
|
||||||
|
1. **Delete the Unraid containers.** A UI action, not `state: absent` — this
|
||||||
|
repo never deployed them, so it has nothing to tear down. Keep the appdata
|
||||||
|
directory a while longer.
|
||||||
|
2. **Drop the old database**, if it was on the shared Postgres:
|
||||||
|
```sh
|
||||||
|
docker exec -it shared-postgres psql -U postgres -c 'DROP DATABASE authentik;'
|
||||||
|
```
|
||||||
|
Take a copy first if you want one; nothing else does.
|
||||||
|
3. **Upgrade Authentik**, now as an ordinary change: bump
|
||||||
|
`AUTHENTIK_VERSION` in `src/authentik/common/vars.yml`, read the release
|
||||||
|
notes for anything between your version and the target, and
|
||||||
|
`ansible-playbook playbooks/k3s.yml --tags apps -e only_apps=authentik`.
|
||||||
|
Do this as its own commit, so a bad upgrade is one `git revert` away from
|
||||||
|
the version you know worked.
|
||||||
|
|
||||||
|
## Backups, afterwards
|
||||||
|
|
||||||
|
Worth stating plainly: the cluster's CNPG has **no backups configured**
|
||||||
|
(CLAUDE.md → "Key decisions" — HA replication only). Before this migration,
|
||||||
|
Authentik's database sat on Unraid, inside whatever covers `/mnt/user/appdata`.
|
||||||
|
After it, the only redundancy is a streaming replica on another Pi, which
|
||||||
|
protects against a dead SD card and not against a bad migration, a dropped
|
||||||
|
table, or a mistake in this document.
|
||||||
|
|
||||||
|
The gap this leaves is real and this cutover widens it. CNPG's answer is a
|
||||||
|
`ScheduledBackup` to object storage or an NFS volume on the NAS; that's not
|
||||||
|
built here yet, and it's the obvious next piece of work after Authentik is
|
||||||
|
settled.
|
||||||
287
docs/forgejo-proxmox.md
Normal file
287
docs/forgejo-proxmox.md
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
# Bringing up Forgejo on Proxmox
|
||||||
|
|
||||||
|
A run-once bootstrap, same shape as `postgres-proxmox.md` and deliberately
|
||||||
|
downstream of it — Forgejo's database lives on the shared Postgres that
|
||||||
|
runbook creates, so none of this works until that one is finished.
|
||||||
|
|
||||||
|
The end state: an LXC on the `AppData` pool of **turtle-proxmox-02** (VMID
|
||||||
|
160, 192.168.50.52), running Forgejo natively under systemd, with its database
|
||||||
|
on the shared Postgres LXC at 192.168.50.54 and a nightly `pg_dump` landing on
|
||||||
|
its own disk in time for the node's 02:00 vzdump.
|
||||||
|
|
||||||
|
## Why turtle-proxmox-02
|
||||||
|
|
||||||
|
Nothing forces it. Postgres is on `-01`, so putting Forgejo on `-02` splits
|
||||||
|
the two guests across the cluster: a node going down takes one of them with
|
||||||
|
it rather than both. Note what that does *not* buy — Forgejo with its database
|
||||||
|
unreachable is not a working forge, so this is about not losing both
|
||||||
|
filesystems at once, not about staying up.
|
||||||
|
|
||||||
|
It is set as a literal `node_name` on the `forgejo` module in
|
||||||
|
`build/config/terraform/main.tf`, not from `var.proxmox_node`. **Change it
|
||||||
|
only before the container exists**: the provider treats `node_name` as a
|
||||||
|
replacement, and `prevent_destroy` in `src/forgejo/terraform/main.tf` turns
|
||||||
|
that into a failed plan. Moving a live Forgejo is a Proxmox migration
|
||||||
|
(`pct migrate`) followed by editing the literal to match, not a
|
||||||
|
`terraform apply`.
|
||||||
|
|
||||||
|
## 1. Secrets
|
||||||
|
|
||||||
|
`homelab/forgejo` needs five keys. Four of them may already exist from the
|
||||||
|
Unraid stack; **`LFS_JWT_SECRET` almost certainly does not** — the Compose
|
||||||
|
deployment let Forgejo generate it, and the native install renders `app.ini`
|
||||||
|
in full with `INSTALL_LOCK = true`, so there is no first boot for Forgejo to
|
||||||
|
invent one on.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv get kv/homelab/forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
If `LFS_JWT_SECRET` is missing, generate one and patch it in without
|
||||||
|
disturbing the others:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv patch kv/homelab/forgejo \
|
||||||
|
LFS_JWT_SECRET="$(docker run --rm codeberg.org/forgejo/forgejo:10 forgejo generate secret)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Full key table in `vault-secrets.md`. `install.yml` asserts on all five before
|
||||||
|
it touches the container, so a missing one fails on the first task with a
|
||||||
|
message naming it rather than three tasks later on an undefined variable.
|
||||||
|
|
||||||
|
## 2. The container template
|
||||||
|
|
||||||
|
The template must be present on **turtle-proxmox-02**, not just on `-01` where
|
||||||
|
Postgres was built. If `StorageOne` is not shared across the cluster this is a
|
||||||
|
separate download:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@turtle-proxmox-02 'pveam list StorageOne'
|
||||||
|
# if the ubuntu-26.04 template is absent:
|
||||||
|
ssh root@turtle-proxmox-02 'pveam update && pveam download StorageOne ubuntu-26.04-standard_26.04-1_amd64.tar.zst'
|
||||||
|
```
|
||||||
|
|
||||||
|
Whatever is there has to match `lxc_template_file_id` exactly — the filename
|
||||||
|
moves with each point release, which is why that variable has no default.
|
||||||
|
|
||||||
|
## 3. Terraform
|
||||||
|
|
||||||
|
State is on the CNPG cluster, so the Pis have to be up (see
|
||||||
|
`postgres-proxmox.md` § "Why this order").
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/terraform
|
||||||
|
terraform init -backend-config=... # as per postgres-proxmox.md
|
||||||
|
terraform plan -out=deploy.plan -target=module.forgejo
|
||||||
|
terraform apply deploy.plan
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the plan before applying. It should be **one resource to add** and
|
||||||
|
nothing to change or destroy; anything touching `module.postgres` — in
|
||||||
|
particular a *replacement* — means stop and work out why, because that guest
|
||||||
|
is live.
|
||||||
|
|
||||||
|
Note `deploy.plan` embeds the state and every input variable, `sensitive` ones
|
||||||
|
included, so it holds the Proxmox API token in the clear. `.gitignore` covers
|
||||||
|
`*.plan`; delete it once applied rather than leaving it in the tree.
|
||||||
|
|
||||||
|
Confirm the guest exists and Ansible can see it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@turtle-proxmox-02 'pct list | grep 160'
|
||||||
|
cd ../ansible && ansible-inventory --list --yaml proxmox_guests | grep -A2 forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
If it is absent from the inventory but present in `pct list`, the `terraform`
|
||||||
|
tag is missing — that filter is what tells this repo's guests from hand-made
|
||||||
|
ones, and a guest without it is silently never deployed to.
|
||||||
|
|
||||||
|
## 4. Install Forgejo into it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/ansible
|
||||||
|
ansible-playbook playbooks/proxmox.yml -e only_stacks=forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
This provisions the `forgejo` database and role on 192.168.50.54, then
|
||||||
|
installs the binary, `app.ini`, the systemd unit and the dump timer. The
|
||||||
|
database step runs from **your controller**, not from inside the container
|
||||||
|
(`delegate_to: localhost`), so 192.168.50.54:5432 has to be reachable from
|
||||||
|
wherever you run this.
|
||||||
|
|
||||||
|
Check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@192.168.50.52 'systemctl is-active forgejo && systemctl list-timers forgejo-dbdump.timer'
|
||||||
|
curl -sI http://192.168.50.52:3000/ | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4b. The first admin user
|
||||||
|
|
||||||
|
A fresh install has **no way in**: `INSTALL_LOCK = true` skips the setup
|
||||||
|
wizard, which is where the first admin would normally be created, and
|
||||||
|
`DISABLE_REGISTRATION = true` means you cannot self-register either (both in
|
||||||
|
`src/forgejo/ansible/proxmox/templates/app.ini.j2`). Nothing in `install.yml`
|
||||||
|
creates a user, so the account has to be made from the CLI inside the
|
||||||
|
container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@192.168.50.52
|
||||||
|
|
||||||
|
sudo -u git forgejo admin user create --admin --username <you> --email <you>@turtlesystems.co.uk --password '<strong-password>' --config /etc/forgejo/app.ini --work-path /var/lib/forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things that are easy to get wrong:
|
||||||
|
|
||||||
|
- **`sudo -u git`, not root.** `RUN_USER` is `git`; running the CLI as root
|
||||||
|
leaves root-owned files under `/var/lib/forgejo` that Forgejo then cannot
|
||||||
|
write. `app.ini` is `0640 root:git`, so the `git` user can read it.
|
||||||
|
- **`--config` is not optional.** Forgejo looks for `custom/conf/app.ini`
|
||||||
|
under the work path by default; this install puts it at
|
||||||
|
`/etc/forgejo/app.ini` (`FORGEJO_CONFIG_DIR` in `vars.yml`). Without the
|
||||||
|
flag the CLI reads a config that isn't there and never reaches the
|
||||||
|
database.
|
||||||
|
- Recent versions default `--must-change-password` to true, so expect a
|
||||||
|
forced change on first web login. Pass `--must-change-password=false` to
|
||||||
|
skip it. Confirm the flags for the pinned `FORGEJO_RELEASE` with
|
||||||
|
`forgejo admin user create --help` rather than assuming.
|
||||||
|
|
||||||
|
Then log in at `http://192.168.50.52:3000/user/login` — by IP, because
|
||||||
|
`ROOT_URL` is `https://git.turtlesystems.uk` and nothing resolves there until
|
||||||
|
the cutover in step 7. The login form works over the IP; some links and
|
||||||
|
redirects Forgejo renders will point at the not-yet-live hostname.
|
||||||
|
|
||||||
|
**Skip this step if you are restoring a database in step 6.** Users live in
|
||||||
|
the database, so a dump brings its own admin back and an account created here
|
||||||
|
is overwritten by the restore. Create one only if this is genuinely an empty
|
||||||
|
forge — or after the restore, if the dump turns out to have no usable admin.
|
||||||
|
|
||||||
|
## 5. Node backups
|
||||||
|
|
||||||
|
VMID 160 is on `-02`, so it is `-02`'s vzdump job that covers it.
|
||||||
|
`pve_backup_all: true` in `group_vars/proxmox_nodes.yml` means no edit is
|
||||||
|
needed — but the job has to actually exist on that node:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ansible-playbook playbooks/pve_host.yml --limit turtle-proxmox-02
|
||||||
|
ssh root@turtle-proxmox-02 'cat /etc/pve/jobs.cfg'
|
||||||
|
```
|
||||||
|
|
||||||
|
The 01:30 dump and the 02:00 vzdump are a pair. Move one, move the other —
|
||||||
|
see `src/forgejo/ansible/proxmox/README.md`.
|
||||||
|
|
||||||
|
## 6. Repository data
|
||||||
|
|
||||||
|
**Ansible does not move any of this.** A fresh install is a working, empty
|
||||||
|
forge; the repositories are a separate restore into
|
||||||
|
`/var/lib/forgejo/data/forgejo-repositories`.
|
||||||
|
|
||||||
|
The original instructions here were an `rsync` off nas2, which no longer
|
||||||
|
exists as a machine to read from — whatever repository data survives has to
|
||||||
|
come from wherever it went when that host was retired. After restoring:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@192.168.50.52 'chown -R git:git /var/lib/forgejo/data && systemctl restart forgejo'
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore the database the same way, from whatever dump you have, before
|
||||||
|
starting Forgejo against it — an empty database with populated repositories
|
||||||
|
gives back every file and no issues, pull requests, users or permissions.
|
||||||
|
|
||||||
|
## 7. Cutover
|
||||||
|
|
||||||
|
Last, once the above is verified:
|
||||||
|
|
||||||
|
- Point `git.turtlesystems.uk` at this container.
|
||||||
|
- **SSH clone URLs move from port 2222 to 22.** The Unraid stack published
|
||||||
|
2222 to dodge the host's own sshd; this container has its own IP and uses
|
||||||
|
its own sshd, with Forgejo managing the `git` user's `authorized_keys`.
|
||||||
|
Every existing remote needs editing — there is no redirect for this.
|
||||||
|
- The Forgejo Actions runner registration is tied to the instance. Re-register
|
||||||
|
it against the new host, or CI stops running (`.forgejo/workflows/`).
|
||||||
|
|
||||||
|
## 8. Sign-in through Authentik (optional)
|
||||||
|
|
||||||
|
Forgejo supports OIDC, and `app.ini` is already set up for it — but only the
|
||||||
|
*policy* half. The provider itself cannot be configured from `app.ini`:
|
||||||
|
Forgejo keeps authentication sources in its **database**. That makes this the
|
||||||
|
one part of Forgejo's configuration Ansible does not own, and the reason it is
|
||||||
|
a runbook step rather than a task in `install.yml`. It is run once and then
|
||||||
|
carried forward by the nightly `pg_dump`, the same as every other row in that
|
||||||
|
database.
|
||||||
|
|
||||||
|
**Do this after step 7, not before.** The redirect URI has to match `ROOT_URL`
|
||||||
|
(`https://git.turtlesystems.uk`), so DNS and TLS must already be live.
|
||||||
|
Configuring it against `http://192.168.50.52:3000` means doing it twice.
|
||||||
|
|
||||||
|
### On the Authentik side
|
||||||
|
|
||||||
|
Create an OAuth2/OIDC **Provider** plus an **Application** for it, as for any
|
||||||
|
other app on `auth.turtlesystems.uk`. The redirect URI is:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://git.turtlesystems.uk/user/oauth2/authentik/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
The last path segment is the *name of the auth source in Forgejo*, not a fixed
|
||||||
|
string — it has to match the `--name` below. Store the generated client secret
|
||||||
|
in Vault (`vault-secrets.md` → `OIDC_CLIENT_SECRET`).
|
||||||
|
|
||||||
|
### On the Forgejo side
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@192.168.50.52
|
||||||
|
|
||||||
|
sudo -u git forgejo admin auth add-oauth --name authentik --provider openidConnect --key <client-id> --secret <client-secret> --auto-discover-url https://auth.turtlesystems.uk/application/o/forgejo/.well-known/openid-configuration --config /etc/forgejo/app.ini --work-path /var/lib/forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
Same `sudo -u git` and `--config` requirements as step 4b, for the same
|
||||||
|
reasons. Verify the flag names against the pinned `FORGEJO_RELEASE` with
|
||||||
|
`forgejo admin auth add-oauth --help` before running — and list what exists
|
||||||
|
afterwards with `forgejo admin auth list`, which is also how you find the `id`
|
||||||
|
for `update-oauth` if the secret is ever rotated.
|
||||||
|
|
||||||
|
No restart is needed: the source is a database row, not a file Forgejo reads
|
||||||
|
at boot.
|
||||||
|
|
||||||
|
### What the app.ini side already does
|
||||||
|
|
||||||
|
Set in `src/forgejo/ansible/proxmox/templates/app.ini.j2`, so a redeploy keeps
|
||||||
|
them — the comments there carry the detail:
|
||||||
|
|
||||||
|
| Setting | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `DISABLE_REGISTRATION = false` | It blocks OIDC auto-registration too, not just the local signup form. Left `true`, Authentik logins authenticate and are then refused an account. |
|
||||||
|
| `ALLOW_ONLY_EXTERNAL_REGISTRATION = true` | Restores "no self-service signup" without blocking Authentik. |
|
||||||
|
| `ENABLE_AUTO_REGISTRATION = true` | Creates the Forgejo account on first successful SSO login. |
|
||||||
|
| `ACCOUNT_LINKING = auto` | Attaches the OIDC identity to an existing local account with the same email. |
|
||||||
|
|
||||||
|
`ACCOUNT_LINKING` is the one that matters on the first login: if the
|
||||||
|
break-glass admin from step 4b uses the same email address as your Authentik
|
||||||
|
account, signing in through Authentik lands you *in that admin account*. With
|
||||||
|
linking off you would arrive as a second, unprivileged user and have to
|
||||||
|
promote it from the CLI.
|
||||||
|
|
||||||
|
### Keep the local admin
|
||||||
|
|
||||||
|
Do not delete the step 4b account or convert it to SSO-only. The login path is
|
||||||
|
now Forgejo → Authentik → CloudNativePG → a healthy k3s cluster, and any of
|
||||||
|
those failing takes SSO down with it — including the cases you would most want
|
||||||
|
to log into the forge to investigate. This is the same reasoning
|
||||||
|
`roles/k3s_traefik` uses for keeping the port-forward-only dashboard route
|
||||||
|
alive alongside the Authentik-published one.
|
||||||
|
|
||||||
|
### Git over HTTPS
|
||||||
|
|
||||||
|
Users who arrive through Authentik have no Forgejo password, so HTTPS clones
|
||||||
|
need a personal access token. SSH keys are unaffected — and since step 7 put
|
||||||
|
git-over-SSH on port 22 with Forgejo managing the `git` user's
|
||||||
|
`authorized_keys`, SSH is the smoother default to point people at.
|
||||||
|
|
||||||
|
## Rolling back
|
||||||
|
|
||||||
|
There is no `state: absent` for `lxc_app` — removal would need a per-app
|
||||||
|
`uninstall.yml` and Forgejo has none. To back out: stop the service
|
||||||
|
(`systemctl stop forgejo`), point DNS back at whatever was serving before, and
|
||||||
|
leave the container in place. Destroying it needs the `prevent_destroy` block
|
||||||
|
removed by hand first, which is deliberate.
|
||||||
237
docs/postgres-proxmox.md
Normal file
237
docs/postgres-proxmox.md
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
# Bringing up the shared Postgres on Proxmox
|
||||||
|
|
||||||
|
A run-once bootstrap, in the order the dependencies actually demand. Each step
|
||||||
|
exists because the one after it cannot start without it; if you already have a
|
||||||
|
piece, skip it and check the assertion at the end of the section.
|
||||||
|
|
||||||
|
The end state: a Debian LXC on the `AppData` ZFS pool of `turtle-proxmox-01`,
|
||||||
|
running Postgres 17, replicated every five minutes to `turtle-proxmox-02`, and
|
||||||
|
answering on 192.168.50.54:5432 for every app that declares a `db:`.
|
||||||
|
|
||||||
|
## Why this order
|
||||||
|
|
||||||
|
Two chains have to be satisfied before `terraform apply` will run at all:
|
||||||
|
|
||||||
|
- **State.** Terraform stores state in Postgres. Storing it in the database
|
||||||
|
this configuration provisions would be circular, so it goes on the
|
||||||
|
CloudNativePG cluster on the k3s Pis instead — a cluster Terraform has no
|
||||||
|
hand in building. That cluster therefore has to be up first.
|
||||||
|
- **Inventory.** `playbooks/proxmox.yml` targets `proxmox_guests`, a group
|
||||||
|
that only exists because of the API-backed dynamic inventory. Ansible cannot
|
||||||
|
install into a container Terraform has just made until it can see it.
|
||||||
|
|
||||||
|
## 1. The k3s cluster and its CNPG instance
|
||||||
|
|
||||||
|
Needed only for Terraform state. If the cluster is already up, confirm the
|
||||||
|
LoadBalancer answers and move on.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/ansible
|
||||||
|
ansible-playbook playbooks/k3s.yml --tags services
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, against `k3s_postgres_loadbalancer_ip` (192.168.50.81), create the state
|
||||||
|
database and its role. This is the only hand-run SQL in the whole flow —
|
||||||
|
everything else provisions itself:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE ROLE terraform LOGIN PASSWORD '<generate-a-strong-password>';
|
||||||
|
CREATE DATABASE terraform_state OWNER terraform;
|
||||||
|
```
|
||||||
|
|
||||||
|
Store the resulting connection string at `homelab/ci/terraform` in Vault (see
|
||||||
|
`vault-secrets.md`), then check it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
psql "postgres://terraform:$PG_PASSWORD@192.168.50.81:5432/terraform_state" -c '\conninfo'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Secrets
|
||||||
|
|
||||||
|
`homelab/shared/postgres` is the same path the Unraid and k3s instances
|
||||||
|
already use — one superuser identity for the "shared postgres" concept
|
||||||
|
wherever it runs. If it is populated, nothing to do:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv get kv/homelab/shared/postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
`homelab/ci/proxmox` needs a Proxmox API token, and `homelab/ci/ssh` the key
|
||||||
|
pair whose public half Terraform installs into the container.
|
||||||
|
|
||||||
|
## 3. The container template
|
||||||
|
|
||||||
|
On the node, once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pveam available | grep debian-12
|
||||||
|
pveam download local debian-12-standard_12.7-1_amd64.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Terraform
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/terraform
|
||||||
|
terraform init -backend-config="conn_str=$PG_CONN_STR"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pin the provider to whatever this resolves and commit the resulting
|
||||||
|
`.terraform.lock.hcl`. Then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export TF_VAR_proxmox_endpoint='https://turtle-proxmox-01.home.turtlesystems.co.uk:8006/'
|
||||||
|
export TF_VAR_proxmox_api_token='...' # homelab/ci/proxmox
|
||||||
|
export TF_VAR_lxc_template_file_id='local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst'
|
||||||
|
export TF_VAR_ssh_public_keys='["'"$(cat ~/.ssh/unraid_ansible.pub)"'"]'
|
||||||
|
|
||||||
|
terraform apply -target=module.postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
`-target` on purpose: the root module also defines the Forgejo LXC, and
|
||||||
|
bringing that up is a separate decision with its own migration story
|
||||||
|
(`inventory/host_vars/forgejo.yml`). Drop the flag once you want both.
|
||||||
|
|
||||||
|
If the apply 403s on `changing feature flags (except nesting) is only allowed
|
||||||
|
for root@pam`, the container already exists without nesting enabled and the
|
||||||
|
`ansible@pam` token cannot add it. Set it as root on the node, then re-plan —
|
||||||
|
it will read clean, and Postgres's systemd unit needs it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@turtle-proxmox-01.home.turtlesystems.co.uk 'pct set 161 --features nesting=1'
|
||||||
|
ssh root@turtle-proxmox-01.home.turtlesystems.co.uk 'pct reboot 161'
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the replication job exists — this is the step most easily missed,
|
||||||
|
because a container with no job looks identical in the storage view:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@turtle-proxmox-01 'pvesr status'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Install Postgres into it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/ansible
|
||||||
|
ansible-inventory -i inventory/proxmox.yml --graph # the guest should appear
|
||||||
|
ansible-playbook playbooks/proxmox.yml -e only_stacks=postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Then from the controller, confirming both that it listens on the LAN and that
|
||||||
|
the Vault password took:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
psql "postgres://postgres:$PGPASSWORD@192.168.50.54:5432/postgres" -c 'SELECT version();'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Node backups
|
||||||
|
|
||||||
|
Replication covers a dead node, not a dropped table. `roles/pve_backup` writes
|
||||||
|
vzdump archives to the NAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ansible-playbook playbooks/pve_host.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Growing the disk
|
||||||
|
|
||||||
|
Everything lives on the rootfs — that is what makes a replication snapshot
|
||||||
|
atomic (`src/shared/postgres/terraform/README.md`), so there is no second
|
||||||
|
volume to add when space runs short. Raise `disk_size` on the `postgres`
|
||||||
|
module in `build/config/terraform/main.tf` and apply.
|
||||||
|
|
||||||
|
Check the pool has the room first. Terraform will not: ZFS lets an apply
|
||||||
|
overcommit the pool happily, and the failure surfaces later as a write inside
|
||||||
|
the guest hitting ENOSPC.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@turtle-proxmox-01 'zpool list AppData; zfs list -o name,used,avail,refquota -r AppData'
|
||||||
|
ssh root@turtle-proxmox-02 'zpool list AppData' # the target needs the room too
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd build/config/terraform
|
||||||
|
terraform plan # expect an in-place update to disk.size, NOT a replacement
|
||||||
|
terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
A **replacement** in that plan means something other than the size changed —
|
||||||
|
`prevent_destroy` will refuse it, which is the point. Never work around it
|
||||||
|
here; replacing this container destroys every database on it.
|
||||||
|
|
||||||
|
The resize itself is a `pct resize` of the rootfs, which on ZFS-backed storage
|
||||||
|
is a refquota change rather than a partition operation. It applies to a
|
||||||
|
running guest, ships no data, and needs nothing done inside the container
|
||||||
|
afterwards — no `resize2fs`, no Postgres restart. Confirm from inside:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@192.168.50.54 'df -h /'
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things the larger number does *not* change: replication traffic, which is
|
||||||
|
a function of what actually gets written rather than of the quota, and vzdump
|
||||||
|
archive size, which covers used data only. Both track the databases, not the
|
||||||
|
headroom.
|
||||||
|
|
||||||
|
Shrinking is not available — it is a replacement, and `prevent_destroy` blocks
|
||||||
|
it — so overshoot rather than raising this every few months.
|
||||||
|
|
||||||
|
## Failing over
|
||||||
|
|
||||||
|
Replication makes the far copy a volume, not a running guest, so failover is
|
||||||
|
deliberate. Planned, with both nodes up, is an ordinary migration — fast,
|
||||||
|
because replication means only the delta has to ship:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@turtle-proxmox-01 'pct migrate 161 turtle-proxmox-02 --restart'
|
||||||
|
```
|
||||||
|
|
||||||
|
Unplanned, with the source node gone, means telling Proxmox to run the guest
|
||||||
|
from the replicated volume, and it loses every transaction committed since the
|
||||||
|
last successful send — up to `replication_schedule`, five minutes. Check what
|
||||||
|
you are about to accept first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh root@turtle-proxmox-02 'pvesr status' # look at "Last Sync"
|
||||||
|
```
|
||||||
|
|
||||||
|
Automating this is what a HA resource would add, and it is deliberately not
|
||||||
|
configured: automatic failover on a two-node cluster with no third vote is a
|
||||||
|
good way to get both nodes deciding they are the survivor.
|
||||||
|
|
||||||
|
## Afterwards: repointing clients
|
||||||
|
|
||||||
|
The address is written down in several files that cannot discover it. The
|
||||||
|
authoritative copy is `ip_address` on the `postgres` module in
|
||||||
|
`build/config/terraform/main.tf`, published as the `postgres_address` output.
|
||||||
|
|
||||||
|
| Where | What |
|
||||||
|
|---|---|
|
||||||
|
| `inventory/host_vars/<guest>.yml` | `db.provision_host` on each app that declares one |
|
||||||
|
| `src/<app>/ansible/proxmox/vars.yml` | that app's own `DB_HOST` |
|
||||||
|
| `src/<app>/ansible/unraid/vars.yml` | ditto, for anything still on Unraid — no `unraid_shared` network alias reaches this host |
|
||||||
|
|
||||||
|
Apps on the k3s cluster keep using the in-cluster CloudNativePG instance and
|
||||||
|
are unaffected.
|
||||||
|
|
||||||
|
## Migrating an existing database onto it
|
||||||
|
|
||||||
|
Per database, not `pg_dumpall` — the roles are recreated by the `db:` block on
|
||||||
|
each app's own entry, so only the data needs moving:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pg_dump -h <old-host> -U postgres -Fc forgejo > forgejo.dump
|
||||||
|
# create the role and database by deploying the app once with its `db:` block,
|
||||||
|
# then:
|
||||||
|
pg_restore -h 192.168.50.54 -U postgres -d forgejo --no-owner --role=forgejo forgejo.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-owner --role=` rather than a straight restore: the dump carries
|
||||||
|
ownership from the old cluster, and the role provisioning in `lxc_app` /
|
||||||
|
`compose_stack` has already created the owner here with a Vault-managed
|
||||||
|
password. Restoring ownership from the dump would fight it.
|
||||||
|
|
||||||
|
Stop the application before dumping. A dump of a live database is consistent
|
||||||
|
as of its start, so anything written during it is silently absent from the
|
||||||
|
restore.
|
||||||
551
docs/vault-secrets.md
Normal file
551
docs/vault-secrets.md
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
# Vault secret layout
|
||||||
|
|
||||||
|
KV v2 mount: `kv` (matches `vault_kv_mount` in
|
||||||
|
`build/config/ansible/inventory/group_vars/all.yml`).
|
||||||
|
One path per stack, fetched whole via `community.hashi_vault.vault_kv2_get`.
|
||||||
|
|
||||||
|
Paths are prefixed `homelab/`, not `unraid/`: an app may run as a Compose
|
||||||
|
stack on Unraid or natively in a Proxmox LXC, and its secrets are the same
|
||||||
|
either way. If you populated the old `unraid/*` paths before this rename,
|
||||||
|
move them and delete the originals:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for p in shared/postgres forgejo ci/ssh; do
|
||||||
|
vault kv get -format=json "kv/unraid/$p" \
|
||||||
|
| jq -r '.data.data | to_entries | map("\(.key)=\(.value)") | .[]' \
|
||||||
|
| xargs vault kv put "kv/homelab/$p"
|
||||||
|
vault kv metadata delete "kv/unraid/$p"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Vault CLI](https://developer.hashicorp.com/vault/docs/install) installed
|
||||||
|
on whatever machine you're running these commands from (your workstation
|
||||||
|
during bootstrap — not the Unraid box, and not the same thing as the
|
||||||
|
Ansible controller needing `hvac`, though in practice it's the same WSL
|
||||||
|
shell for both).
|
||||||
|
- `VAULT_ADDR` set to your Vault instance:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export VAULT_ADDR='https://vault.example.internal:8200'
|
||||||
|
```
|
||||||
|
|
||||||
|
If Vault is using a self-signed cert, either trust it properly or, for a
|
||||||
|
homelab-only instance, set `export VAULT_SKIP_VERIFY=true` — don't do this
|
||||||
|
against anything reachable outside your LAN.
|
||||||
|
|
||||||
|
## Login
|
||||||
|
|
||||||
|
Log in interactively once per shell session (or whenever the token expires);
|
||||||
|
the resulting token is cached to `~/.vault-token` and picked up automatically
|
||||||
|
by later `vault` commands and by `VAULT_TOKEN` if you export it.
|
||||||
|
|
||||||
|
Token auth (simplest, fine for a single-operator homelab):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault login
|
||||||
|
# prompts for the token
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using a different auth backend, log in with that method instead —
|
||||||
|
adjust to whatever your Vault instance actually has enabled:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault login -method=userpass username=<your-username>
|
||||||
|
vault login -method=oidc # opens a browser
|
||||||
|
vault login -method=ldap username=<your-username>
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the login worked and check the token's TTL:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault token lookup
|
||||||
|
```
|
||||||
|
|
||||||
|
## One-time setup: enable the KV v2 mount
|
||||||
|
|
||||||
|
Only needed once, the first time this Vault instance is used for this repo
|
||||||
|
(skip if `kv/` already exists — check with `vault secrets list`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault secrets enable -path=kv -version=2 kv
|
||||||
|
```
|
||||||
|
|
||||||
|
## `homelab/shared/postgres`
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `POSTGRES_SUPERUSER` | Superuser name (matches `vars.yml` default `postgres`, but Vault is authoritative since it's paired with the password below) |
|
||||||
|
| `POSTGRES_SUPERUSER_PASSWORD` | Superuser password |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/shared/postgres \
|
||||||
|
POSTGRES_SUPERUSER=postgres \
|
||||||
|
POSTGRES_SUPERUSER_PASSWORD='<generate-a-strong-password>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Also seeds the k3s cluster's shared Postgres (`roles/k3s_postgres`,
|
||||||
|
CloudNativePG) — a separate physical instance from the Unraid/Proxmox ones,
|
||||||
|
but the same superuser identity, same path. See README.md → "Postgres
|
||||||
|
(CloudNativePG)".
|
||||||
|
|
||||||
|
## `homelab/forgejo`
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DB_PASSWORD` | Password for the `forgejo` Postgres role (Ansible creates the role with this password) |
|
||||||
|
| `SECRET_KEY` | Forgejo `security.SECRET_KEY` |
|
||||||
|
| `INTERNAL_TOKEN` | Forgejo `security.INTERNAL_TOKEN` |
|
||||||
|
| `JWT_SECRET` | Forgejo `oauth2.JWT_SECRET` |
|
||||||
|
| `LFS_JWT_SECRET` | Forgejo `server.LFS_JWT_SECRET` — **Proxmox only**, see below |
|
||||||
|
| `OIDC_CLIENT_SECRET` | Authentik OAuth2 provider secret — **optional**, see below |
|
||||||
|
|
||||||
|
Generate the Forgejo secrets once and store them, rather than letting Forgejo
|
||||||
|
auto-generate on first boot — that keeps a from-scratch redeploy (fresh data
|
||||||
|
volume) reproducible instead of silently rotating tokens:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm codeberg.org/forgejo/forgejo:10 forgejo generate secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Run that four times (once per secret) and write them all together with the
|
||||||
|
DB password:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/forgejo \
|
||||||
|
DB_PASSWORD='<generate-a-strong-password>' \
|
||||||
|
SECRET_KEY='<output-of-generate-secret>' \
|
||||||
|
INTERNAL_TOKEN='<output-of-generate-secret>' \
|
||||||
|
JWT_SECRET='<output-of-generate-secret>' \
|
||||||
|
LFS_JWT_SECRET='<output-of-generate-secret>'
|
||||||
|
```
|
||||||
|
|
||||||
|
`LFS_JWT_SECRET` is only consumed by the Proxmox native install, which renders
|
||||||
|
`app.ini` in full and sets `INSTALL_LOCK` — nothing is left for Forgejo to
|
||||||
|
generate on first boot, and a value it invented for itself would be overwritten
|
||||||
|
on the next deploy anyway. The Unraid Compose stack doesn't reference it; the
|
||||||
|
key being present just means one extra unused line in the rendered `.env`. If
|
||||||
|
you only run the Unraid stack today, add it before the Proxmox cutover —
|
||||||
|
`src/forgejo/ansible/proxmox/install.yml` asserts on it rather than failing
|
||||||
|
three tasks later with an undefined-variable error:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv patch kv/homelab/forgejo LFS_JWT_SECRET='<output-of-generate-secret>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `OIDC_CLIENT_SECRET`
|
||||||
|
|
||||||
|
Only needed if Forgejo signs users in through Authentik (step 8 of
|
||||||
|
`docs/forgejo-proxmox.md`). Unlike every other key on this path it is **not
|
||||||
|
read by Ansible** — nothing templates it, and `install.yml` does not assert on
|
||||||
|
it, so its absence is not an error. Forgejo keeps authentication sources in
|
||||||
|
its database, not in `app.ini`, so the value is consumed once by a
|
||||||
|
`forgejo admin auth add-oauth` run and lives in the database from then on.
|
||||||
|
|
||||||
|
It is kept here anyway, rather than only in Authentik, for the same reason as
|
||||||
|
the rest: a rebuild that restores from a `pg_dump` gets the auth source back
|
||||||
|
with the dump, but a rebuild from *nothing* has to re-run that command, and
|
||||||
|
this is where it looks for the value. Generated by Authentik when you create
|
||||||
|
the provider, not by you:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv patch kv/homelab/forgejo OIDC_CLIENT_SECRET='<from-the-authentik-provider>'
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching client ID is not a secret and is not stored here — it is an
|
||||||
|
argument to that one command, recorded in the runbook step alongside it.
|
||||||
|
|
||||||
|
## `homelab/arr`
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `SECRET_KEY_BASE` | Signs sessions and cookies |
|
||||||
|
| `ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY` | Encrypts every credential Shelfarr stores — indexer API keys, download client passwords, OIDC secrets |
|
||||||
|
| `ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY` | As above, for values Shelfarr needs to query on |
|
||||||
|
| `ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT` | As above |
|
||||||
|
| `SHELFARR_SETTING_OIDC_CLIENT_SECRET` | Client secret of the Authentik provider for Shelfarr |
|
||||||
|
| `PROWLARR_API_KEY` | Prowlarr's API key, for anything integrating with it |
|
||||||
|
|
||||||
|
One path for the whole stack, not one per container — `compose_stack` fetches
|
||||||
|
`vault_path` once and renders every key into a single `.env` that both
|
||||||
|
services read, so Prowlarr's key belongs here rather than under a path of its
|
||||||
|
own.
|
||||||
|
|
||||||
|
Shelfarr's entrypoint generates the first four on first run if they're unset, into
|
||||||
|
`/rails/storage/.secret_key_base` and `/rails/storage/.encryption_keys` — i.e.
|
||||||
|
into the appdata volume. Set them explicitly for the same reason Forgejo's
|
||||||
|
three secrets are fixed above: a from-scratch redeploy on a fresh volume would
|
||||||
|
otherwise generate new ones, and everything encrypted with the old values
|
||||||
|
becomes unreadable.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/arr \
|
||||||
|
SECRET_KEY_BASE="$(openssl rand -hex 64)" \
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="$(openssl rand -base64 32)" \
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="$(openssl rand -base64 32)" \
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Set them **before the first start**, and set the three encryption keys as a
|
||||||
|
group — the entrypoint only falls back to its generated file when the primary
|
||||||
|
key is absent, so a partial set silently mixes provided and generated values.
|
||||||
|
Changing them after Shelfarr has stored anything means re-entering every
|
||||||
|
credential through Admin → Settings.
|
||||||
|
|
||||||
|
> **Do not add `RAILS_MASTER_KEY` here.** Despite the name it is not a
|
||||||
|
> generatable secret: it decrypts the `config/credentials.yml.enc` compiled
|
||||||
|
> into the upstream image, so only upstream's own key works. Supplying a value
|
||||||
|
> of your own aborts startup with
|
||||||
|
> `ActiveSupport::MessageEncryptor::InvalidMessage` during `db:prepare`.
|
||||||
|
|
||||||
|
The OIDC client secret is the one key here that isn't generated — copy it off
|
||||||
|
the Authentik provider:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv patch kv/homelab/arr \
|
||||||
|
SHELFARR_SETTING_OIDC_CLIENT_SECRET='<authentik-client-secret>'
|
||||||
|
```
|
||||||
|
|
||||||
|
`SHELFARR_SETTING_` is how upstream maps environment variables onto the
|
||||||
|
Admin → Settings store; the non-secret half of the OIDC config lives in
|
||||||
|
`src/arr/common/vars.yml`. Both halves have to be named in
|
||||||
|
`docker-compose.yml`'s `environment:` — `compose_stack` renders every Vault
|
||||||
|
key and every `env_defaults` entry into `.env`, but Compose reads `.env` only
|
||||||
|
to interpolate `${...}` in the compose file. A var that nothing references
|
||||||
|
never reaches the container, and Shelfarr just carries on with OIDC off.
|
||||||
|
|
||||||
|
Prowlarr's API key is generated the same way as Shelfarr's secrets, and for
|
||||||
|
the same reason — left unset, Prowlarr writes one of its own into
|
||||||
|
`/config/config.xml` on first start, where the only way to find it is the UI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv patch kv/homelab/arr PROWLARR_API_KEY="$(openssl rand -hex 16)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Prowlarr binds config-file settings to environment as `PROWLARR__` plus the
|
||||||
|
section and key, so this reaches it as `PROWLARR__AUTH__APIKEY`. Releases
|
||||||
|
before the auth section was split read the same value as `PROWLARR__APIKEY`;
|
||||||
|
an unrecognised variable is ignored rather than fatal, so the symptom of the
|
||||||
|
wrong form is a key in the UI that doesn't match Vault, not a container that
|
||||||
|
won't start.
|
||||||
|
|
||||||
|
## `homelab/k3s-homelab-utils`
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `K3S_TOKEN` | Cluster join token — shared by the server and every agent |
|
||||||
|
|
||||||
|
Not app config — this is what makes `playbooks/k3s.yml` reproducible.
|
||||||
|
`k3s server` normally invents a token on first install and writes it to
|
||||||
|
`/var/lib/rancher/k3s/server/node-token`; every agent then has to be told that
|
||||||
|
value. Pinning it in Vault instead means a full rebuild (wipe both SD cards,
|
||||||
|
reinstall) reproduces the same cluster identity, because both the control
|
||||||
|
plane and every worker fetch the *same* value independently rather than one
|
||||||
|
generating it and handing it to the other.
|
||||||
|
|
||||||
|
Generate it once, before the first run of `playbooks/k3s.yml`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/k3s-homelab-utils \
|
||||||
|
K3S_TOKEN="$(openssl rand -hex 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Changing this after the cluster is up means re-running the playbook against
|
||||||
|
every node — the server re-issues its cert bundle around the new token and
|
||||||
|
every agent needs to reconnect with it, which is disruptive enough that
|
||||||
|
there's no automatic rotation path here, only a manual one.
|
||||||
|
|
||||||
|
## `homelab/authentik`
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `AUTHENTIK_SECRET_KEY` | Signs session cookies |
|
||||||
|
| `AUTHENTIK_POSTGRESQL__PASSWORD` | Password for the `authentik` role on the cluster's shared Postgres |
|
||||||
|
|
||||||
|
The first app on the k3s cluster, deployed by `roles/k3s_app` from the
|
||||||
|
`k3s_apps:` list in `inventory/group_vars/k3s_cluster.yml`. Both keys are
|
||||||
|
named for the environment variables Authentik itself reads, because
|
||||||
|
`roles/k3s_app` passes Vault keys through to the app's Kubernetes Secret
|
||||||
|
verbatim — so one value at `AUTHENTIK_POSTGRESQL__PASSWORD` serves both the
|
||||||
|
database provisioning step and the running app, with nothing restated.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/authentik \
|
||||||
|
AUTHENTIK_SECRET_KEY='<carried over from the Unraid deployment>' \
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD="$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
`AUTHENTIK_SECRET_KEY` is the exception to "generate a fresh secret" — it
|
||||||
|
must be copied from the existing Unraid deployment, not invented. It signs
|
||||||
|
session cookies, so a new value logs every user out at the moment DNS moves.
|
||||||
|
See `docs/authentik-migration.md`, which is the only reason this path has a
|
||||||
|
"copy the old value" step at all; a from-scratch install would generate both.
|
||||||
|
|
||||||
|
## `homelab/k3s-cert-manager`
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `CLOUDFLARE_API_TOKEN` | DNS provider API token for cert-manager's ACME DNS-01 solver |
|
||||||
|
|
||||||
|
Consumed by `roles/k3s_cert_manager`, which renders every key at this path
|
||||||
|
into a Secret in the `cert-manager` namespace — so the key name above is a
|
||||||
|
convention, not a requirement: store whatever your DNS provider needs and
|
||||||
|
reference that name from `k3s_cert_manager_solver` in
|
||||||
|
`inventory/group_vars/k3s_cluster.yml`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/k3s-cert-manager \
|
||||||
|
CLOUDFLARE_API_TOKEN='<token with Zone:DNS:Edit on the zone>'
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS-01 rather than HTTP-01 because HTTP-01 needs Let's Encrypt to reach the
|
||||||
|
cluster from the internet on port 80, and this LAN deliberately isn't
|
||||||
|
reachable. Scope the token to the single zone it manages — cert-manager only
|
||||||
|
ever needs to write `_acme-challenge` TXT records.
|
||||||
|
|
||||||
|
## `homelab/ci/ssh`
|
||||||
|
|
||||||
|
Not consumed by `compose_stack` — this is the SSH private key Ansible itself
|
||||||
|
connects to the Unraid hosts with. It lives in Vault rather than as a
|
||||||
|
Forgejo Actions secret so there's one place secrets come from, not two; see
|
||||||
|
README.md "SSH access" for generating the key pair and installing the public
|
||||||
|
half on each host.
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PRIVATE_KEY` | OpenSSH private key `ansible_user` (root) authenticates with |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# `@path` reads the value from a file, which handles the embedded newlines
|
||||||
|
# in a private key correctly — don't try to inline it as a normal KEY=value.
|
||||||
|
# Use $HOME, not `~`: this is a plain command argument, not a shell
|
||||||
|
# assignment, so bash won't tilde-expand a `~` sitting after the `@` — it'd
|
||||||
|
# get passed through literally and vault would look for a file named `~`.
|
||||||
|
vault kv put kv/homelab/ci/ssh PRIVATE_KEY=@"$HOME/.ssh/unraid_ansible"
|
||||||
|
```
|
||||||
|
|
||||||
|
## `homelab/ci/ssh-k3s`
|
||||||
|
|
||||||
|
The SSH private key Ansible connects to the 4 k3s Pis with. A separate key
|
||||||
|
pair from `homelab/ci/ssh` above, deliberately — the two host groups don't
|
||||||
|
share a trust boundary, so a leaked key for one shouldn't also be a working
|
||||||
|
key for the other. Same shape as the Unraid key otherwise:
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PRIVATE_KEY` | OpenSSH private key the `ansible` user (per `inventory/hosts.yml` → `k3s_cluster`) authenticates with |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/k3s_ansible -C "ansible@homelab-utils" -N ""
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the `ansible` user on each of the 4 Pis with NOPASSWD sudo and install
|
||||||
|
the public half as its `authorized_keys` — see README.md "SSH access". Then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/ci/ssh-k3s PRIVATE_KEY=@"$HOME/.ssh/k3s_ansible"
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike the Unraid key above, nothing needs loading into `ssh-agent` to use
|
||||||
|
this: `playbooks/k3s_ssh_key.yml` reads this path and writes the key to
|
||||||
|
`local/k3s/homelab-utils.key` on the controller, and both `playbooks/k3s.yml`
|
||||||
|
and `playbooks/k3s_maintenance.yml` import it as their first play. A manual
|
||||||
|
run needs only `VAULT_ADDR`/`VAULT_TOKEN` in the environment (the "Login"
|
||||||
|
section above — no AppRole involvement yet, both k3s playbooks being
|
||||||
|
manual-only for now). See README.md "SSH access" for the fallback flags if
|
||||||
|
Vault is unreachable.
|
||||||
|
|
||||||
|
## `homelab/ci/proxmox`
|
||||||
|
|
||||||
|
Proxmox VE API token, used by two things: the `community.proxmox` dynamic
|
||||||
|
inventory plugin (`build/config/ansible/inventory/proxmox.yml`), and the
|
||||||
|
Terraform provider that creates LXCs. A token rather than a password — same
|
||||||
|
reasoning as the SSH key, a password means an interactive prompt or a
|
||||||
|
plaintext secret on disk.
|
||||||
|
|
||||||
|
Create the user and its token on either node (ACLs are cluster-wide), then
|
||||||
|
grant it a role. This is more involved than it looks, and the failure modes
|
||||||
|
give errors that don't obviously point at permissions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pveum user add ansible@pam
|
||||||
|
pveum user token add ansible@pam terraform --privsep 0
|
||||||
|
|
||||||
|
pveum role add TerraformProv -privs "Datastore.Allocate \
|
||||||
|
Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit \
|
||||||
|
Pool.Allocate SDN.Use Sys.Audit Sys.Console Sys.Modify \
|
||||||
|
VM.Allocate VM.Audit VM.Clone VM.Console VM.Migrate VM.PowerMgmt \
|
||||||
|
VM.Replicate \
|
||||||
|
VM.Config.CPU VM.Config.Disk VM.Config.HWType VM.Config.Memory \
|
||||||
|
VM.Config.Network VM.Config.Options"
|
||||||
|
|
||||||
|
pveum acl modify / --users 'ansible@pam' --roles TerraformProv
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things that otherwise cost an afternoon:
|
||||||
|
|
||||||
|
- **`--privsep 0` is load-bearing.** A token created with privilege separation
|
||||||
|
on (the UI default) carries its *own* ACL and ignores what the user was
|
||||||
|
granted, so `pveum acl modify --users` looks like it did nothing. Either
|
||||||
|
turn it off as above, or grant the token as well with
|
||||||
|
`--tokens 'ansible@pam!terraform'`. With a purpose-made user rather than
|
||||||
|
`root@pam` there is nothing gained by maintaining both layers — least
|
||||||
|
privilege is already enforced on the user.
|
||||||
|
- **Privilege names are version-sensitive.** `VM.Monitor` appears in most
|
||||||
|
guides for this and is rejected outright by current PVE (it is a QEMU
|
||||||
|
monitor privilege, meaningless for LXC). `SDN.Use` is required from PVE 8.2
|
||||||
|
on, where attaching a container to `vmbr0` is gated on
|
||||||
|
`/sdn/zones/localnetwork/vmbr0`. Check against your own node rather than
|
||||||
|
against any list, this one included: `pvesh get /access/roles/Administrator`
|
||||||
|
prints the complete valid set.
|
||||||
|
- **`VM.Replicate` is there for the shared Postgres**, whose module creates a
|
||||||
|
`pvesr` job (`replication_target_node` — see
|
||||||
|
`src/shared/postgres/terraform/`). A module without replication won't need
|
||||||
|
it.
|
||||||
|
|
||||||
|
Verify the grant landed before reaching for Terraform — this answers the
|
||||||
|
token's effective permissions, not the user's:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pveum user permissions ansible@pam --token terraform
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `terraform apply` failure here is an HTTP 403 naming the exact missing
|
||||||
|
privilege, so the recovery loop is `pveum role modify TerraformProv --privs
|
||||||
|
"<name>" --append` and re-run. Note `--append`: without it, `--privs` replaces
|
||||||
|
the whole list rather than adding to it.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PROXMOX_URL` | API endpoint, e.g. `https://turtle-proxmox-01.home.turtlesystems.co.uk:8006/` |
|
||||||
|
| `PROXMOX_USER` | Token owner, e.g. `ansible@pam` |
|
||||||
|
| `PROXMOX_TOKEN_ID` | Token ID (the part after `!`) |
|
||||||
|
| `PROXMOX_TOKEN_SECRET` | Token secret — shown once at creation |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/ci/proxmox \
|
||||||
|
PROXMOX_URL='https://turtle-proxmox-01.home.turtlesystems.co.uk:8006/' \
|
||||||
|
PROXMOX_USER='ansible@pam' \
|
||||||
|
PROXMOX_TOKEN_ID='terraform' \
|
||||||
|
PROXMOX_TOKEN_SECRET='<shown-once-at-creation>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ansible's inventory plugin reads these from the environment, so export them
|
||||||
|
before running anything that touches `proxmox_guests`. Terraform wants the
|
||||||
|
token in one combined string instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export TF_VAR_proxmox_api_token="${PROXMOX_USER}!${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## `homelab/ci/terraform`
|
||||||
|
|
||||||
|
Connection string for the Postgres database Terraform keeps its state in
|
||||||
|
(`backend "pg"` — see `build/config/terraform/README.md`). Separate from the
|
||||||
|
superuser credentials above: Terraform gets its own role, scoped to its own
|
||||||
|
database.
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PG_CONN_STR` | `postgres://terraform:<password>@192.168.50.81:5432/terraform_state` |
|
||||||
|
|
||||||
|
The host is the **CloudNativePG cluster on k3s**
|
||||||
|
(`k3s_postgres_loadbalancer_ip`), not either shared Postgres this repo
|
||||||
|
deploys — Terraform cannot keep its state in a database it provisions itself.
|
||||||
|
Create the role and database once, against that cluster, then store the
|
||||||
|
string:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/ci/terraform \
|
||||||
|
PG_CONN_STR='postgres://terraform:<generate-a-strong-password>@192.168.50.81:5432/terraform_state'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifying what was written
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv get kv/homelab/shared/postgres
|
||||||
|
vault kv get kv/homelab/forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
`vault kv put` (used above) replaces the whole secret at that path — to add
|
||||||
|
or change a single key without touching the others, use `vault kv patch`
|
||||||
|
instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv patch kv/homelab/forgejo DB_PASSWORD='<new-password>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a new stack's secrets
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault kv put kv/homelab/<stack-name> KEY1=value1 KEY2=value2
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference the same path as `vault_path` in the stack's entry under
|
||||||
|
`build/config/ansible/inventory/host_vars/<host>.yml`.
|
||||||
|
|
||||||
|
## AppRole for CI (Forgejo Actions)
|
||||||
|
|
||||||
|
The bootstrap commands above use your own human token. The Forgejo Actions
|
||||||
|
runner (`.forgejo/workflows/deploy.yml`) authenticates as an AppRole instead,
|
||||||
|
scoped to read-only on the `kv/homelab/*` paths. One-time setup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Policy: read-only on every stack's secrets
|
||||||
|
vault policy write unraid-deploy - <<'EOF'
|
||||||
|
path "kv/data/homelab/*" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 2. Enable AppRole auth (skip if already enabled — check `vault auth list`)
|
||||||
|
vault auth enable approle
|
||||||
|
|
||||||
|
# 3. Create the role, bound to that policy
|
||||||
|
vault write auth/approle/role/unraid-deploy \
|
||||||
|
token_policies="unraid-deploy" \
|
||||||
|
token_ttl=15m \
|
||||||
|
token_max_ttl=1h
|
||||||
|
|
||||||
|
# 4. Fetch the role ID (stable, not secret on its own)
|
||||||
|
vault read auth/approle/role/unraid-deploy/role-id
|
||||||
|
|
||||||
|
# 5. Generate a secret ID (treat as a secret — shown once)
|
||||||
|
vault write -f auth/approle/role/unraid-deploy/secret-id
|
||||||
|
```
|
||||||
|
|
||||||
|
Store the `role_id` and `secret_id` from steps 4–5 as `VAULT_ROLE_ID` and
|
||||||
|
`VAULT_SECRET_ID` in the Forgejo repo's Actions secrets (alongside
|
||||||
|
`VAULT_ADDR`) — that's what `deploy.yml` and
|
||||||
|
`build/config/ansible/inventory/group_vars/all.yml`
|
||||||
|
(`vault_auth_method: approle`) expect.
|
||||||
|
|
||||||
|
These never live in a file in this repo, in either mode:
|
||||||
|
|
||||||
|
- **CI:** Forgejo repo → Settings → Actions → Secrets. `deploy.yml` reads
|
||||||
|
them from there via `${{ secrets.VAULT_ROLE_ID }}` /
|
||||||
|
`${{ secrets.VAULT_SECRET_ID }}` and exports them as job env vars.
|
||||||
|
- **Manual runs** (bootstrap, or any ad hoc `ansible-playbook` invocation):
|
||||||
|
export them as shell env vars first — `group_vars/all.yml` picks them up
|
||||||
|
via `lookup('env', ...)`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export VAULT_AUTH_METHOD=approle
|
||||||
|
export VAULT_ROLE_ID='<from step 4>'
|
||||||
|
export VAULT_SECRET_ID='<from step 5>'
|
||||||
|
```
|
||||||
|
|
||||||
|
(Bootstrap can also just use your own human token instead — leave
|
||||||
|
`VAULT_AUTH_METHOD` unset, it defaults to `token`, and `vault login` from
|
||||||
|
the "Login" section above is enough.)
|
||||||
|
|
||||||
|
Secret IDs can be regenerated (step 5) and old ones revoked without touching
|
||||||
|
the role itself if one ever leaks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault write auth/approle/role/unraid-deploy/secret-id-accessor/destroy \
|
||||||
|
secret_id_accessor='<accessor-from-step-5-output>'
|
||||||
|
```
|
||||||
22
src/arr/ansible/proxmox/README.md
Normal file
22
src/arr/ansible/proxmox/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# ansible/proxmox/ — arr
|
||||||
|
|
||||||
|
Not built. Shelfarr runs as a Compose stack on nas1 (`../unraid/`), and there
|
||||||
|
is no reason to move it — this folder exists because every app keeps the same
|
||||||
|
shape whether or not both platforms are in use.
|
||||||
|
|
||||||
|
A native install would be a poor fit here in a way most apps aren't. Shelfarr
|
||||||
|
is a Rails app shipping two containers' worth of runtime (Ruby, a bundled
|
||||||
|
Solid Queue worker, and a .NET companion for the Libation sidecar), with no
|
||||||
|
released binary or package — upstream distributes container images only. Where
|
||||||
|
`forgejo` drops a single Go binary and a systemd unit into an LXC,
|
||||||
|
reproducing this natively means building the Rails app from source on the
|
||||||
|
guest.
|
||||||
|
|
||||||
|
If it ever needs to move, the realistic option is Docker inside a privileged
|
||||||
|
LXC rather than the `lxc_app` role, which would be a new deployment primitive
|
||||||
|
for this repo — decide that deliberately rather than by extending the role.
|
||||||
|
|
||||||
|
What wouldn't change: `../../common/vars.yml` already holds the portable
|
||||||
|
config (version, published port), and the Vault path stays `homelab/arr`.
|
||||||
|
Only paths and PUID/PGID are Unraid-specific, and those live in
|
||||||
|
`../unraid/vars.yml`.
|
||||||
101
src/arr/ansible/unraid/.env.example
Normal file
101
src/arr/ansible/unraid/.env.example
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Reference only — real values are rendered by Ansible from vars.yml (non-
|
||||||
|
# secret) and Vault path homelab/arr (secret). Do not fill this in and
|
||||||
|
# deploy it directly.
|
||||||
|
|
||||||
|
# Non-secret (see ../../common/vars.yml)
|
||||||
|
SHELFARR_VERSION=2026.08.05.1
|
||||||
|
SHELFARR_HTTP_PORT=5056
|
||||||
|
|
||||||
|
# Non-secret (see vars.yml) — check the media paths against your own shares
|
||||||
|
SHELFARR_DATA_PATH=/mnt/user/appdata/arr/data
|
||||||
|
SHELFARR_AUDIOBOOKS_PATH=/mnt/user/Media/AudioBooks
|
||||||
|
SHELFARR_EBOOKS_PATH=/mnt/user/Media/Books
|
||||||
|
SHELFARR_DOWNLOADS_PATH=/mnt/user/downloads
|
||||||
|
PUID=99
|
||||||
|
PGID=100
|
||||||
|
CHOWN_ON_START=auto
|
||||||
|
|
||||||
|
# Docker network to join, and it must already exist on the target host —
|
||||||
|
# Compose declares it `external`, so it will not create it. Defined in
|
||||||
|
# Unraid's Docker settings, not by any stack in this repo.
|
||||||
|
SHELFARR_NETWORK=caddy-net
|
||||||
|
|
||||||
|
# Non-secret, supplied by the compose_stack role rather than either vars.yml —
|
||||||
|
# the path it copies ansible/unraid/icon.png to on the host, for the
|
||||||
|
# net.unraid.docker.icon label. Empty when the stack ships no icon.png, which
|
||||||
|
# just leaves Unraid's question-mark placeholder. Override it in vars.yml to
|
||||||
|
# point at a hosted PNG instead.
|
||||||
|
STACK_ICON=/mnt/user/appdata/icons/arr.png
|
||||||
|
|
||||||
|
# Secret — Vault key: SECRET_KEY_BASE
|
||||||
|
# Signs sessions and cookies. Generate with:
|
||||||
|
# openssl rand -hex 64
|
||||||
|
SECRET_KEY_BASE=
|
||||||
|
|
||||||
|
# Secret — Vault keys: ACTIVE_RECORD_ENCRYPTION_*
|
||||||
|
# Encrypt every credential Shelfarr stores (indexer API keys, download client
|
||||||
|
# passwords). Generate each with:
|
||||||
|
# openssl rand -base64 32
|
||||||
|
# Set these before the first start, and set all three together. Changing them
|
||||||
|
# later makes everything already encrypted with the old values unreadable.
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
|
||||||
|
|
||||||
|
# Non-secret (see ../../common/vars.yml) — OIDC against Authentik.
|
||||||
|
# Upstream maps environment onto the settings store by SHELFARR_SETTING_ plus
|
||||||
|
# the uppercased setting key. There is no redirect-URI variable: the callback
|
||||||
|
# is fixed at <shelfarr-url>/auth/oidc/callback and is registered on the
|
||||||
|
# Authentik provider.
|
||||||
|
SHELFARR_SETTING_OIDC_ENABLED=true
|
||||||
|
SHELFARR_SETTING_OIDC_PROVIDER_NAME=Authentik
|
||||||
|
SHELFARR_SETTING_OIDC_ISSUER=https://auth.turtlesystems.uk/application/o/shelfarr/
|
||||||
|
SHELFARR_SETTING_OIDC_CLIENT_ID=
|
||||||
|
SHELFARR_SETTING_OIDC_SCOPES=openid profile email
|
||||||
|
SHELFARR_SETTING_OIDC_AUTO_CREATE_USERS=true
|
||||||
|
SHELFARR_SETTING_OIDC_DEFAULT_ROLE=user
|
||||||
|
SHELFARR_SETTING_OIDC_LINK_EXISTING_USERS=true
|
||||||
|
SHELFARR_SETTING_OIDC_AUTO_REDIRECT=false
|
||||||
|
|
||||||
|
# Secret — Vault key: SHELFARR_SETTING_OIDC_CLIENT_SECRET
|
||||||
|
# The client secret of the Authentik provider for Shelfarr.
|
||||||
|
SHELFARR_SETTING_OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Do NOT set RAILS_MASTER_KEY. It decrypts the credentials file built into the
|
||||||
|
# image, so only upstream's key works — a self-generated one aborts startup
|
||||||
|
# with ActiveSupport::MessageEncryptor::InvalidMessage.
|
||||||
|
|
||||||
|
# Only if the Libation companion is enabled — see docker-compose.yml
|
||||||
|
# LIBATION_CONFIG_PATH=/mnt/user/appdata/arr/libation/config
|
||||||
|
# LIBATION_BOOKS_PATH=/mnt/user/appdata/arr/libation/books
|
||||||
|
# LIBATION_CONTROL_PATH=/mnt/user/appdata/arr/libation/control
|
||||||
|
|
||||||
|
# Only if running behind a reverse proxy at a sub-path, e.g. /arr
|
||||||
|
# RAILS_RELATIVE_URL_ROOT=/
|
||||||
|
|
||||||
|
# --- Prowlarr ---------------------------------------------------------------
|
||||||
|
|
||||||
|
# Non-secret (see ../../common/vars.yml). LinuxServer tags are the upstream
|
||||||
|
# version plus their build suffix (1.37.0.5076-ls117); pin to a full one read
|
||||||
|
# off the registry rather than leaving this at latest.
|
||||||
|
PROWLARR_VERSION=latest
|
||||||
|
PROWLARR_HTTP_PORT=9696
|
||||||
|
|
||||||
|
# Non-secret, supplied by the compose_stack role — the path it copies
|
||||||
|
# icon-prowlarr.png to, for the prowlarr container's net.unraid.docker.icon
|
||||||
|
# label. Per-service, so it's separate from STACK_ICON above, which is the
|
||||||
|
# stack's own icon.png and labels shelfarr.
|
||||||
|
STACK_ICON_PROWLARR=/mnt/user/appdata/icons/arr-prowlarr.png
|
||||||
|
|
||||||
|
# Non-secret (see vars.yml)
|
||||||
|
PROWLARR_CONFIG_PATH=/mnt/user/appdata/arr/prowlarr
|
||||||
|
TZ=Europe/London
|
||||||
|
|
||||||
|
# Secret — Vault key: PROWLARR_API_KEY
|
||||||
|
# Bound to Prowlarr's config.xml via PROWLARR__AUTH__APIKEY, so the key is
|
||||||
|
# known before first boot and anything integrating with Prowlarr can be
|
||||||
|
# configured from the same Vault path. Generate with:
|
||||||
|
# openssl rand -hex 16
|
||||||
|
# Leave it out and Prowlarr generates its own on first start — then the value
|
||||||
|
# only exists in the UI and in /config/config.xml.
|
||||||
|
PROWLARR_API_KEY=
|
||||||
252
src/arr/ansible/unraid/docker-compose.yml
Normal file
252
src/arr/ansible/unraid/docker-compose.yml
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
services:
|
||||||
|
shelfarr:
|
||||||
|
image: ghcr.io/pedro-revez-silva/shelfarr:${SHELFARR_VERSION}
|
||||||
|
container_name: arr
|
||||||
|
restart: unless-stopped
|
||||||
|
# Unraid's Docker page normally reads icon, WebUI link and console shell
|
||||||
|
# from the dockerMan template that created the container. A Compose stack
|
||||||
|
# has no template, so 6.10+ falls back to these labels; without them the
|
||||||
|
# container renders as a question mark with no WebUI or Console entry.
|
||||||
|
#
|
||||||
|
# STACK_ICON comes from the compose_stack role (a path under
|
||||||
|
# /mnt/user/appdata/icons, or a URL if vars.yml overrides it). `[IP]` is
|
||||||
|
# substituted by the webgui with the host's address; the port has to be
|
||||||
|
# the published one, hence SHELFARR_HTTP_PORT rather than the container's
|
||||||
|
# 80. If you drop the `ports:` mapping below and reach Shelfarr only
|
||||||
|
# through Caddy, replace the whole value with the proxied URL — the
|
||||||
|
# webgui has no way to know about the reverse proxy.
|
||||||
|
labels:
|
||||||
|
net.unraid.docker.icon: ${STACK_ICON}
|
||||||
|
net.unraid.docker.webui: "http://[IP]:${SHELFARR_HTTP_PORT}/"
|
||||||
|
net.unraid.docker.shell: bash
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
environment:
|
||||||
|
# Runs the Solid Queue background worker inside the Puma process rather
|
||||||
|
# than as a second container. Upstream's own compose does this; Shelfarr
|
||||||
|
# is a single-user-scale app and a separate worker buys nothing here.
|
||||||
|
SOLID_QUEUE_IN_PUMA: "1"
|
||||||
|
PUID: ${PUID}
|
||||||
|
PGID: ${PGID}
|
||||||
|
CHOWN_ON_START: ${CHOWN_ON_START}
|
||||||
|
# Deliberately NOT RAILS_MASTER_KEY. That variable decrypts the
|
||||||
|
# config/credentials.yml.enc baked into the image at build time, so only
|
||||||
|
# upstream's own key works — setting it to a generated value aborts boot
|
||||||
|
# with ActiveSupport::MessageEncryptor::InvalidMessage.
|
||||||
|
#
|
||||||
|
# These four are the ones the entrypoint actually treats as overridable,
|
||||||
|
# and pinning them is what stops a fresh appdata volume from orphaning
|
||||||
|
# stored credentials (same reasoning as forgejo's SECRET_KEY). Left
|
||||||
|
# unset, the entrypoint generates them into /rails/storage/.secret_key_base
|
||||||
|
# and /rails/storage/.encryption_keys — fine until that directory is lost.
|
||||||
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||||
|
# Encrypt every credential Shelfarr stores (indexer API keys, download
|
||||||
|
# client passwords). All three must be set together; the entrypoint only
|
||||||
|
# falls back to its generated file when the primary key is absent.
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: ${ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY}
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: ${ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY}
|
||||||
|
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: ${ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT}
|
||||||
|
# --- OIDC (Authentik) ------------------------------------------------
|
||||||
|
#
|
||||||
|
# Upstream maps environment onto the Admin → Settings store by
|
||||||
|
# `SHELFARR_SETTING_` + the uppercased setting key, so these ten are the
|
||||||
|
# whole of the SSO config; there is no redirect-URI variable, the
|
||||||
|
# callback path is fixed at /auth/oidc/callback and is registered on the
|
||||||
|
# Authentik side.
|
||||||
|
#
|
||||||
|
# Every one has to be named here. The .env `compose_stack` renders holds
|
||||||
|
# them already, but Compose reads .env only to interpolate `${...}` in
|
||||||
|
# this file — it does not inject it into the container. An unreferenced
|
||||||
|
# var reaches the host's Compose process and stops there, which is why
|
||||||
|
# OIDC stayed off with the values apparently set.
|
||||||
|
SHELFARR_SETTING_OIDC_ENABLED: ${SHELFARR_SETTING_OIDC_ENABLED}
|
||||||
|
SHELFARR_SETTING_OIDC_PROVIDER_NAME: ${SHELFARR_SETTING_OIDC_PROVIDER_NAME}
|
||||||
|
SHELFARR_SETTING_OIDC_ISSUER: ${SHELFARR_SETTING_OIDC_ISSUER}
|
||||||
|
SHELFARR_SETTING_OIDC_CLIENT_ID: ${SHELFARR_SETTING_OIDC_CLIENT_ID}
|
||||||
|
# Secret — Vault key of the same name, not either vars.yml.
|
||||||
|
SHELFARR_SETTING_OIDC_CLIENT_SECRET: ${SHELFARR_SETTING_OIDC_CLIENT_SECRET}
|
||||||
|
SHELFARR_SETTING_OIDC_SCOPES: ${SHELFARR_SETTING_OIDC_SCOPES}
|
||||||
|
SHELFARR_SETTING_OIDC_AUTO_CREATE_USERS: ${SHELFARR_SETTING_OIDC_AUTO_CREATE_USERS}
|
||||||
|
SHELFARR_SETTING_OIDC_DEFAULT_ROLE: ${SHELFARR_SETTING_OIDC_DEFAULT_ROLE}
|
||||||
|
SHELFARR_SETTING_OIDC_LINK_EXISTING_USERS: ${SHELFARR_SETTING_OIDC_LINK_EXISTING_USERS}
|
||||||
|
SHELFARR_SETTING_OIDC_AUTO_REDIRECT: ${SHELFARR_SETTING_OIDC_AUTO_REDIRECT}
|
||||||
|
volumes:
|
||||||
|
- ${SHELFARR_DATA_PATH}:/rails/storage
|
||||||
|
- ${SHELFARR_AUDIOBOOKS_PATH}:/audiobooks
|
||||||
|
- ${SHELFARR_EBOOKS_PATH}:/ebooks
|
||||||
|
- ${SHELFARR_DOWNLOADS_PATH}:/downloads
|
||||||
|
ports:
|
||||||
|
- "${SHELFARR_HTTP_PORT}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:80/up"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Indexer manager for the rest of the stack. Shelfarr doesn't talk to
|
||||||
|
# trackers itself — it asks Prowlarr, which owns the indexer definitions and
|
||||||
|
# their credentials. Both are on `proxy`, so Shelfarr reaches it as
|
||||||
|
# `http://prowlarr:9696` (Admin → Settings → Indexers) rather than via nas1's
|
||||||
|
# LAN IP and the published port.
|
||||||
|
prowlarr:
|
||||||
|
image: lscr.io/linuxserver/prowlarr:${PROWLARR_VERSION}
|
||||||
|
container_name: prowlarr
|
||||||
|
restart: unless-stopped
|
||||||
|
# Same reasoning as the labels on `shelfarr` above — a Compose stack has no
|
||||||
|
# dockerMan template, so without these the container is a question mark
|
||||||
|
# with no WebUI entry. STACK_ICON_PROWLARR rather than STACK_ICON: labels
|
||||||
|
# are per-service, and ${STACK_ICON} is the stack's own icon.png, which is
|
||||||
|
# Shelfarr's. This one comes from the committed icon-prowlarr.png in this
|
||||||
|
# directory — see README.md → "Icons on the Unraid Docker page".
|
||||||
|
labels:
|
||||||
|
net.unraid.docker.icon: ${STACK_ICON_PROWLARR}
|
||||||
|
net.unraid.docker.webui: "http://[IP]:${PROWLARR_HTTP_PORT}/"
|
||||||
|
# Alpine-based, so `sh` — same as forgejo, not `bash` like the
|
||||||
|
# Debian-based shelfarr above.
|
||||||
|
net.unraid.docker.shell: sh
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
environment:
|
||||||
|
PUID: ${PUID}
|
||||||
|
PGID: ${PGID}
|
||||||
|
# LinuxServer's init sets the container clock from this. It matters more
|
||||||
|
# here than for Shelfarr: Prowlarr timestamps search history and applies
|
||||||
|
# per-indexer rate limits, and both read wrong at UTC.
|
||||||
|
TZ: ${TZ}
|
||||||
|
# Prowlarr binds its config file to environment via `PROWLARR__` plus the
|
||||||
|
# config section and key, double-underscore separated. Pinning the API
|
||||||
|
# key means Shelfarr's indexer config can be written from the same Vault
|
||||||
|
# path instead of being copied by hand out of the UI after first boot —
|
||||||
|
# the same argument as SECRET_KEY_BASE above, applied to a value that is
|
||||||
|
# otherwise generated into /config/config.xml on first start.
|
||||||
|
#
|
||||||
|
# Older releases read this as PROWLARR__APIKEY (no section). If the key
|
||||||
|
# in the UI doesn't match Vault after a deploy, that's which form this
|
||||||
|
# image wants; an unrecognised variable is ignored silently rather than
|
||||||
|
# failing the container, so the symptom is a generated key, not a crash.
|
||||||
|
PROWLARR__AUTH__APIKEY: ${PROWLARR_API_KEY}
|
||||||
|
volumes:
|
||||||
|
# config.xml, the indexer definitions, and Prowlarr's own SQLite
|
||||||
|
# database. This directory *is* the application state — same standing as
|
||||||
|
# SHELFARR_DATA_PATH, back it up the same way.
|
||||||
|
- ${PROWLARR_CONFIG_PATH}:/config
|
||||||
|
ports:
|
||||||
|
- "${PROWLARR_HTTP_PORT}:9696"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9696/ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# `external: true` means "attach to a network that already exists; don't
|
||||||
|
# create it, don't delete it on `down`". Without it Compose would create a
|
||||||
|
# *new* network named `arr_<key>` — the deploy would still succeed and
|
||||||
|
# Caddy would silently fail to resolve `arr`. `name:` is what stops the
|
||||||
|
# project-name prefix being applied.
|
||||||
|
#
|
||||||
|
# The local alias is a fixed `proxy` so the compose file stays static; which
|
||||||
|
# real network it maps to is a per-host decision in vars.yml
|
||||||
|
# (SHELFARR_NETWORK). Same shape as forgejo's `shared` → `unraid_shared`.
|
||||||
|
#
|
||||||
|
# The alias MUST be a literal, not `${SHELFARR_NETWORK}:`. Compose
|
||||||
|
# interpolates values, never mapping keys, so a variable used as the key
|
||||||
|
# stays the literal string `${SHELFARR_NETWORK}` while the service's
|
||||||
|
# `networks:` entry (a list value) expands to `caddy-net` — and the two no
|
||||||
|
# longer match:
|
||||||
|
# service "arr" refers to undefined network caddy-net
|
||||||
|
# `name:` below is a value, so it interpolates normally. That asymmetry is
|
||||||
|
# the whole reason for the alias indirection.
|
||||||
|
#
|
||||||
|
# caddy-net is defined in Unraid's Docker settings on nas1 rather than by a
|
||||||
|
# stack in this repo, so nothing here has to deploy before Shelfarr does.
|
||||||
|
# But it must exist on the target host — an absent external network fails
|
||||||
|
# the deploy outright, which is why `unraid_shared` (created by the shared
|
||||||
|
# Postgres stack, and that runs on nas2) is deliberately not used here.
|
||||||
|
# Shelfarr is SQLite-only and needs no database anyway.
|
||||||
|
proxy:
|
||||||
|
name: ${SHELFARR_NETWORK}
|
||||||
|
external: true
|
||||||
|
|
||||||
|
# Now that Caddy and Shelfarr share a network, Caddy should proxy to
|
||||||
|
# `arr:80` — the container name on a user-defined network — rather than
|
||||||
|
# to nas1's LAN IP and the published port. The `ports:` mapping above is kept
|
||||||
|
# only for direct access that bypasses the proxy; drop it if you don't want
|
||||||
|
# 5056 reachable on the LAN.
|
||||||
|
#
|
||||||
|
# Reaching the download client and indexers stays a runtime concern, not a
|
||||||
|
# compose one: they're configured in Admin → Settings. Anything not on
|
||||||
|
# caddy-net is addressed via nas1's LAN IP and its published port
|
||||||
|
# (e.g. http://192.168.50.1:8080), which works whichever network it's on.
|
||||||
|
|
||||||
|
# --- Import layout ---------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The three media binds above follow upstream's documented layout, which works
|
||||||
|
# regardless of how your shares are arranged. The cost: /downloads and
|
||||||
|
# /audiobooks are separate mounts inside the container, and rename() across
|
||||||
|
# mounts returns EXDEV even when both sit on the same Unraid filesystem — so
|
||||||
|
# every import is a full copy rather than an instant move.
|
||||||
|
#
|
||||||
|
# If downloads and the library live under one share, replace the three binds
|
||||||
|
# with a single one and set the paths in Admin → Settings to match:
|
||||||
|
#
|
||||||
|
# - ${SHELFARR_MEDIA_PATH}:/media # e.g. /mnt/user/media
|
||||||
|
#
|
||||||
|
# Admin → Settings: /media/downloads, /media/audiobooks, /media/ebooks
|
||||||
|
#
|
||||||
|
# One mount means one filesystem, so imports become atomic moves and hardlinks
|
||||||
|
# work. Worth doing if your download client is already writing under the same
|
||||||
|
# share.
|
||||||
|
|
||||||
|
# --- Libation companion (Audible backup, beta) -----------------------------
|
||||||
|
#
|
||||||
|
# Left out for now; purely additive. To enable it: uncomment the service and
|
||||||
|
# the `volumes:` block below, add these three to the main service's
|
||||||
|
# environment, and add the two mounts to its `volumes:`, then redeploy. The
|
||||||
|
# appdata bind survives the container recreate, so nothing is lost.
|
||||||
|
#
|
||||||
|
# environment:
|
||||||
|
# SHELFARR_LIBATION_URL: http://shelfarr-libation:8080
|
||||||
|
# SHELFARR_LIBATION_TOKEN_FILE: /run/shelfarr-libation/token
|
||||||
|
# SHELFARR_LIBATION_IMPORT_ROOT: /imports/libation
|
||||||
|
# volumes:
|
||||||
|
# - ${LIBATION_BOOKS_PATH}:/imports/libation:ro
|
||||||
|
# - ${LIBATION_CONTROL_PATH}:/run/shelfarr-libation:ro
|
||||||
|
#
|
||||||
|
# Note the two services must then share a user-defined network so the main
|
||||||
|
# container can resolve `shelfarr-libation` by name — the default bridge has no
|
||||||
|
# DNS between containers.
|
||||||
|
#
|
||||||
|
# shelfarr-libation:
|
||||||
|
# image: ghcr.io/pedro-revez-silva/shelfarr-libation:${SHELFARR_VERSION}
|
||||||
|
# container_name: shelfarr-libation
|
||||||
|
# restart: unless-stopped
|
||||||
|
# expose:
|
||||||
|
# - "8080"
|
||||||
|
# environment:
|
||||||
|
# PUID: ${PUID}
|
||||||
|
# PGID: ${PGID}
|
||||||
|
# CHOWN_ON_START: ${CHOWN_ON_START}
|
||||||
|
# LIBATION_FILES_DIR: /config
|
||||||
|
# LIBATION_BOOKS_DIR: /data
|
||||||
|
# LIBATION_IN_PROGRESS_DIR: /config/in-progress
|
||||||
|
# COMPANION_STATE_DIR: /config/shelfarr-companion
|
||||||
|
# COMPANION_TOKEN_FILE: /control/token
|
||||||
|
# ASPNETCORE_URLS: http://0.0.0.0:8080
|
||||||
|
# volumes:
|
||||||
|
# - ${LIBATION_CONFIG_PATH}:/config
|
||||||
|
# - ${LIBATION_BOOKS_PATH}:/data
|
||||||
|
# - ${LIBATION_CONTROL_PATH}:/control
|
||||||
|
# healthcheck:
|
||||||
|
# test: ["CMD", "/companion/Shelfarr.Libation.Companion", "--healthcheck"]
|
||||||
|
# interval: 30s
|
||||||
|
# timeout: 10s
|
||||||
|
# retries: 3
|
||||||
|
# start_period: 40s
|
||||||
|
#
|
||||||
|
# Upstream uses named volumes for libation_config/books/control. Bind mounts
|
||||||
|
# under /mnt/user/appdata/arr/ instead, to match every other stack here
|
||||||
|
# and so the data is visible on the array rather than buried in Docker's
|
||||||
|
# storage. Add the paths to ansible/unraid/vars.yml when enabling.
|
||||||
BIN
src/arr/ansible/unraid/icon-prowlarr.png
Normal file
BIN
src/arr/ansible/unraid/icon-prowlarr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
src/arr/ansible/unraid/icon-shelfarr.png
Normal file
BIN
src/arr/ansible/unraid/icon-shelfarr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
59
src/arr/ansible/unraid/vars.yml
Normal file
59
src/arr/ansible/unraid/vars.yml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
# Unraid-specific overrides, merged over ../../common/vars.yml (these win).
|
||||||
|
env_defaults:
|
||||||
|
# SQLite lives here — all four production databases (primary, cache, queue,
|
||||||
|
# cable) plus Active Storage. Shelfarr has no external database, so unlike
|
||||||
|
# forgejo this directory *is* the application state, and it is also where
|
||||||
|
# everything configured in Admin → Settings is kept. Back it up accordingly.
|
||||||
|
SHELFARR_DATA_PATH: /mnt/user/appdata/arr/data
|
||||||
|
|
||||||
|
# CHECK THESE AGAINST YOUR OWN SHARES before the first deploy — they are
|
||||||
|
# conventional Unraid layouts, not something read off nas1.
|
||||||
|
#
|
||||||
|
# `/mnt/user/...`, never `/mnt/cache/...` or `/mnt/disk1/...`: the latter
|
||||||
|
# bypass the user-share layer and strand files where the mover won't find
|
||||||
|
# them.
|
||||||
|
#
|
||||||
|
# See the note in docker-compose.yml about keeping downloads and the library
|
||||||
|
# on one mount — as three separate binds, imports are copies rather than
|
||||||
|
# instant moves.
|
||||||
|
SHELFARR_AUDIOBOOKS_PATH: /mnt/user/Media/Books/audio
|
||||||
|
SHELFARR_EBOOKS_PATH: /mnt/user/Media/Books/ebooks
|
||||||
|
SHELFARR_DOWNLOADS_PATH: /mnt/user/downloads
|
||||||
|
|
||||||
|
# 99:100 is nobody:users — what Unraid owns share content as. Upstream
|
||||||
|
# defaults to 1000:1000, which is a normal Linux desktop user and wrong
|
||||||
|
# here; files written as 1000 are invisible to everything else on the box.
|
||||||
|
PUID: "99"
|
||||||
|
PGID: "100"
|
||||||
|
|
||||||
|
# `auto` chowns only when it has to. `always` would fight Unraid's own
|
||||||
|
# permissions handling on every restart; `never` risks a container that
|
||||||
|
# can't write to its own appdata after a share rebuild.
|
||||||
|
CHOWN_ON_START: auto
|
||||||
|
|
||||||
|
# Prowlarr's config.xml, indexer definitions and SQLite database. Sibling of
|
||||||
|
# SHELFARR_DATA_PATH under the same stack directory — one appdata folder per
|
||||||
|
# stack, one subdirectory per container.
|
||||||
|
PROWLARR_CONFIG_PATH: /mnt/user/appdata/arr/prowlarr
|
||||||
|
|
||||||
|
# Container clock. Prowlarr timestamps its search history and enforces
|
||||||
|
# per-indexer rate limits against it; Shelfarr takes its time from the Rails
|
||||||
|
# default and doesn't read this.
|
||||||
|
TZ: Europe/London
|
||||||
|
|
||||||
|
# The stack's Docker network — both containers join it, which is how
|
||||||
|
# Shelfarr resolves `prowlarr` by name.
|
||||||
|
SHELFARR_NETWORK: caddy-net
|
||||||
|
|
||||||
|
# --- Unraid Docker page presentation (net.unraid.docker.* labels) --------
|
||||||
|
#
|
||||||
|
# STACK_ICON is normally left to the compose_stack role, which points it at
|
||||||
|
# /mnt/user/appdata/icons/<stack>.png when this directory contains an
|
||||||
|
# icon.png. Uncomment to use a hosted icon instead of committing one:
|
||||||
|
#
|
||||||
|
# STACK_ICON: https://example.org/arr.png
|
||||||
|
#
|
||||||
|
# PNG only — Unraid renders its placeholder for SVG and nothing at all for
|
||||||
|
# WebP, and a URL it can't reach when the Docker page renders fails the same
|
||||||
|
# way, which is the argument for committing the file.
|
||||||
64
src/arr/common/vars.yml
Normal file
64
src/arr/common/vars.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
# Portable config — values that hold regardless of where Shelfarr runs.
|
||||||
|
# Platform-specific values (filesystem paths, the PUID/PGID the container
|
||||||
|
# writes as) live in ../ansible/<platform>/vars.yml and are merged over these
|
||||||
|
# at deploy time.
|
||||||
|
env_defaults:
|
||||||
|
# Pinned rather than `latest`. Upstream tags releases YYYY.MM.DD.N and the
|
||||||
|
# image tag carries no leading `v`, unlike the git tag — `v2026.08.05.1` is
|
||||||
|
# the release, `2026.08.05.1` is the image. One tag pins both the app image
|
||||||
|
# and the (currently unused) Libation companion.
|
||||||
|
SHELFARR_VERSION: "2026.08.09.1"
|
||||||
|
|
||||||
|
# Published port. 5056 is upstream's convention; the container listens on 80
|
||||||
|
# internally, which is left at its default rather than being made
|
||||||
|
# configurable — there is nothing else in the container to collide with.
|
||||||
|
SHELFARR_HTTP_PORT: "5056"
|
||||||
|
|
||||||
|
# SECRET_KEY_BASE and the three ACTIVE_RECORD_ENCRYPTION_* values are
|
||||||
|
# secrets — sourced from Vault (homelab/arr), never set here. The
|
||||||
|
# encryption trio is not just a session key: it encrypts every credential
|
||||||
|
# Shelfarr stores (indexer API keys, download client passwords), so losing
|
||||||
|
# it means re-entering all of them.
|
||||||
|
#
|
||||||
|
# RAILS_MASTER_KEY is deliberately absent. It belongs to the
|
||||||
|
# config/credentials.yml.enc compiled into the image, so any value you
|
||||||
|
# generate yourself fails to decrypt it and the container won't boot. See
|
||||||
|
# the note in ../ansible/unraid/docker-compose.yml.
|
||||||
|
|
||||||
|
SHELFARR_SETTING_OIDC_ENABLED: "true"
|
||||||
|
SHELFARR_SETTING_OIDC_PROVIDER_NAME: Authentik
|
||||||
|
SHELFARR_SETTING_OIDC_ISSUER: https://auth.turtlesystems.uk/application/o/shelfarr/
|
||||||
|
SHELFARR_SETTING_OIDC_CLIENT_ID: jf1IKMxxYjgZxZikvONdFfOTauFrFiC4AYmJEbmN
|
||||||
|
SHELFARR_SETTING_OIDC_SCOPES: openid profile email
|
||||||
|
SHELFARR_SETTING_OIDC_AUTO_CREATE_USERS: "true"
|
||||||
|
SHELFARR_SETTING_OIDC_DEFAULT_ROLE: user
|
||||||
|
SHELFARR_SETTING_OIDC_LINK_EXISTING_USERS: "true"
|
||||||
|
SHELFARR_SETTING_OIDC_AUTO_REDIRECT: "false"
|
||||||
|
|
||||||
|
# --- Prowlarr --------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# LinuxServer's tags are the upstream version plus their own build suffix
|
||||||
|
# (`1.37.0.5076-ls117`), and the suffix can't be derived from the release
|
||||||
|
# number — so a pin has to be read off the registry rather than guessed.
|
||||||
|
# `latest` until then; check
|
||||||
|
# https://github.com/linuxserver/docker-prowlarr/pkgs/container/prowlarr and
|
||||||
|
# replace this with the full tag, which is what every other version in this
|
||||||
|
# repo does.
|
||||||
|
PROWLARR_VERSION: latest
|
||||||
|
|
||||||
|
# Published port. 9696 is upstream's default and what the container listens
|
||||||
|
# on internally; kept the same on the host so the WebUI label, the LAN URL
|
||||||
|
# and the docs all read alike.
|
||||||
|
PROWLARR_HTTP_PORT: "9696"
|
||||||
|
|
||||||
|
# PROWLARR_API_KEY is a secret — Vault (homelab/arr), never set here.
|
||||||
|
# Generate a 32-character hex string: openssl rand -hex 16
|
||||||
|
# Prowlarr generates its own into /config/config.xml if this is left empty,
|
||||||
|
# which works fine but means the value is only discoverable from the UI.
|
||||||
|
|
||||||
|
# STACK_ICON_PROWLARR is supplied by the compose_stack role, from the
|
||||||
|
# committed ../ansible/unraid/icon-prowlarr.png — not set here. Uncomment to
|
||||||
|
# use a hosted icon instead, exactly as with STACK_ICON:
|
||||||
|
#
|
||||||
|
# STACK_ICON_PROWLARR: https://example.org/prowlarr.png
|
||||||
11
src/arr/terraform/README.md
Normal file
11
src/arr/terraform/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# terraform/ — arr
|
||||||
|
|
||||||
|
Not built, and not expected to be. Terraform's only job in this repo is
|
||||||
|
creating Proxmox LXCs; Shelfarr runs as a Compose stack on nas1, where the
|
||||||
|
host already exists.
|
||||||
|
|
||||||
|
This folder is a placeholder so the app keeps the same
|
||||||
|
`common/` + `ansible/` + `terraform/` shape as every other one. If Shelfarr
|
||||||
|
ever moves to Proxmox it would need an LXC defined here and wired into
|
||||||
|
`build/config/terraform/main.tf` — see `../ansible/proxmox/README.md` first
|
||||||
|
for why that move is less straightforward than it looks.
|
||||||
130
src/authentik/ansible/kubernetes/helmchart.yaml.j2
Normal file
130
src/authentik/ansible/kubernetes/helmchart.yaml.j2
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_app) — do not edit on the node.
|
||||||
|
|
||||||
|
A HelmChart CR for k3s's bundled helm-controller, the same mechanism every
|
||||||
|
cluster service here already uses (roles/k3s_metallb, k3s_monitoring,
|
||||||
|
k3s_postgres, k3s_cert_manager). The difference is only in who owns the
|
||||||
|
file: those roles each carry their own template, whereas this one belongs
|
||||||
|
to the app and roles/k3s_app renders whatever *.yaml.j2 it finds here.
|
||||||
|
|
||||||
|
This is the k3s analogue of src/<app>/ansible/unraid/docker-compose.yml.
|
||||||
|
It is not static the way the compose files are — Helm values have no
|
||||||
|
${VAR} interpolation to defer to a rendered .env, so config is substituted
|
||||||
|
at render time from `app_config` (common/vars.yml + this platform's
|
||||||
|
vars.yml). What survives from the Unraid convention is the part that
|
||||||
|
matters: no secret is ever interpolated into this file. Secrets are
|
||||||
|
referenced by secretKeyRef against the Secret roles/k3s_app renders from
|
||||||
|
Vault, so this manifest stays 0644 on the node and safe to read.
|
||||||
|
|
||||||
|
No Redis anywhere below, deliberately: recent Authentik no longer requires
|
||||||
|
it, and the chart carries no Redis dependency (its only conditional
|
||||||
|
subchart is Bitnami Postgres, which is disabled here in favour of the
|
||||||
|
cluster's shared CNPG instance).
|
||||||
|
-#}
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChart
|
||||||
|
metadata:
|
||||||
|
name: authentik
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
chart: authentik
|
||||||
|
repo: https://charts.goauthentik.io
|
||||||
|
# Chart version and app version are the same string upstream, so
|
||||||
|
# AUTHENTIK_VERSION pins both. See common/vars.yml — this must match the
|
||||||
|
# version the database was dumped from at cutover time.
|
||||||
|
version: "{{ app_config.AUTHENTIK_VERSION }}"
|
||||||
|
targetNamespace: {{ app_config.K8S_NAMESPACE }}
|
||||||
|
createNamespace: true
|
||||||
|
valuesContent: |-
|
||||||
|
global:
|
||||||
|
# Explicit `env` entries rather than an `envFrom: [secretRef]`, for a
|
||||||
|
# precedence reason rather than a stylistic one: the chart builds its
|
||||||
|
# own env for the values under `authentik:` below, and container `env`
|
||||||
|
# deterministically wins over anything arriving via `envFrom`, whereas
|
||||||
|
# two envFrom sources resolve by list order the chart controls. This
|
||||||
|
# way the non-secret half stays readable as ordinary Helm values and
|
||||||
|
# the secret half is unambiguously authoritative.
|
||||||
|
env:
|
||||||
|
# Signs session cookies. Carried over from the Unraid deployment, not
|
||||||
|
# generated — a new value logs every user out the moment DNS flips.
|
||||||
|
- name: AUTHENTIK_SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: authentik-secrets
|
||||||
|
key: AUTHENTIK_SECRET_KEY
|
||||||
|
- name: AUTHENTIK_POSTGRESQL__PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: authentik-secrets
|
||||||
|
key: AUTHENTIK_POSTGRESQL__PASSWORD
|
||||||
|
|
||||||
|
authentik:
|
||||||
|
log_level: info
|
||||||
|
# Off: this is a homelab, and the default ships crash reports to
|
||||||
|
# Sentry.
|
||||||
|
error_reporting:
|
||||||
|
enabled: false
|
||||||
|
postgresql:
|
||||||
|
host: {{ app_config.DB_HOST }}
|
||||||
|
port: {{ app_config.DB_PORT }}
|
||||||
|
name: {{ app_config.DB_NAME }}
|
||||||
|
user: {{ app_config.DB_USER }}
|
||||||
|
# password comes from global.env above, never from here.
|
||||||
|
|
||||||
|
# The chart's bundled Bitnami Postgres. Off — this cluster has a shared
|
||||||
|
# CNPG instance (roles/k3s_postgres) and an app getting its own database
|
||||||
|
# server would defeat the point of having one.
|
||||||
|
postgresql:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
replicas: 1
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: {{ app_config.SERVER_MEMORY_REQUEST }}
|
||||||
|
limits:
|
||||||
|
memory: {{ app_config.SERVER_MEMORY_LIMIT }}
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
ingressClassName: {{ app_config.INGRESS_CLASS }}
|
||||||
|
annotations:
|
||||||
|
# cert-manager watches Ingresses for this annotation and creates
|
||||||
|
# the Certificate (and hence the Secret named under tls: below) on
|
||||||
|
# its own — no Certificate resource of ours to keep in sync.
|
||||||
|
cert-manager.io/cluster-issuer: {{ app_config.CERT_ISSUER }}
|
||||||
|
hosts:
|
||||||
|
- {{ app_config.AUTHENTIK_HOST }}
|
||||||
|
tls:
|
||||||
|
- secretName: authentik-tls
|
||||||
|
hosts:
|
||||||
|
- {{ app_config.AUTHENTIK_HOST }}
|
||||||
|
|
||||||
|
# /media — uploaded icons and flow backgrounds, the only Authentik
|
||||||
|
# state that isn't in Postgres. The same PVC is mounted by the worker
|
||||||
|
# below; see vars.yml for why one ReadWriteOnce volume across two pods
|
||||||
|
# is fine here and what it costs.
|
||||||
|
volumes:
|
||||||
|
- name: media
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: authentik-media
|
||||||
|
volumeMounts:
|
||||||
|
- name: media
|
||||||
|
mountPath: /media
|
||||||
|
|
||||||
|
worker:
|
||||||
|
replicas: 1
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: {{ app_config.WORKER_MEMORY_REQUEST }}
|
||||||
|
limits:
|
||||||
|
memory: {{ app_config.WORKER_MEMORY_LIMIT }}
|
||||||
|
volumes:
|
||||||
|
- name: media
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: authentik-media
|
||||||
|
volumeMounts:
|
||||||
|
- name: media
|
||||||
|
mountPath: /media
|
||||||
33
src/authentik/ansible/kubernetes/media-pvc.yaml.j2
Normal file
33
src/authentik/ansible/kubernetes/media-pvc.yaml.j2
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{#
|
||||||
|
Managed by Ansible (roles/k3s_app) — do not edit on the node.
|
||||||
|
|
||||||
|
Authentik's /media volume, claimed here rather than left to the chart: the
|
||||||
|
chart has no key that creates a PVC, only `volumes:`/`volumeMounts:` that
|
||||||
|
reference one, so something has to declare it.
|
||||||
|
|
||||||
|
Owning it separately turns out to be the safer arrangement anyway. A PVC
|
||||||
|
created by the chart would carry Helm's ownership metadata and be a
|
||||||
|
candidate for deletion whenever the HelmChart CR is uninstalled or the
|
||||||
|
chart's templates change shape across an upgrade. This one is a plain
|
||||||
|
manifest whose lifecycle is its own, so `state: absent` on the app — which
|
||||||
|
deletes the manifests and lets k3s garbage-collect them — is the only thing
|
||||||
|
that touches it, and roles/k3s_app/tasks/remove.yml documents PVCs as
|
||||||
|
deliberately surviving a default teardown.
|
||||||
|
|
||||||
|
WaitForFirstConsumer on the local-path StorageClass means this stays
|
||||||
|
Pending until the first Authentik pod is scheduled; that is the provisioner
|
||||||
|
working as intended, not a failure, and it's what pins the volume — and
|
||||||
|
therefore both pods — to a single worker Pi.
|
||||||
|
-#}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: authentik-media
|
||||||
|
namespace: {{ app_config.K8S_NAMESPACE }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: {{ app_config.MEDIA_STORAGE_CLASS }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ app_config.MEDIA_STORAGE_SIZE }}
|
||||||
63
src/authentik/ansible/kubernetes/vars.yml
Normal file
63
src/authentik/ansible/kubernetes/vars.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
---
|
||||||
|
# Kubernetes-specific overrides, merged over ../../common/vars.yml (these
|
||||||
|
# win). Consumed by roles/k3s_app — the same "portable config + platform
|
||||||
|
# overrides" split compose_stack and lxc_app apply, on the third platform.
|
||||||
|
env_defaults:
|
||||||
|
K8S_NAMESPACE: authentik
|
||||||
|
|
||||||
|
# --- Database ---------------------------------------------------------
|
||||||
|
#
|
||||||
|
# CNPG's read-write Service, by in-cluster DNS. This is the *primary*, and
|
||||||
|
# it follows a failover — CNPG re-points the selector when it promotes the
|
||||||
|
# replica, so the name stays correct without Authentik being restarted.
|
||||||
|
#
|
||||||
|
# Deliberately not the LoadBalancer address (192.168.50.81): that exists so
|
||||||
|
# the Ansible controller can provision databases from off-cluster, and
|
||||||
|
# sending in-cluster traffic out to the LAN and back would put MetalLB's
|
||||||
|
# L2 speaker in the path of every query for no benefit. See
|
||||||
|
# roles/k3s_app/tasks/deploy.yml, which uses the LB address for exactly the
|
||||||
|
# one thing that needs it.
|
||||||
|
DB_HOST: shared-postgres-rw.shared-postgres.svc.cluster.local
|
||||||
|
DB_PORT: "5432"
|
||||||
|
|
||||||
|
# --- Ingress ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# k3s's bundled Traefik, already running on 192.168.50.80 (MetalLB's first
|
||||||
|
# pool address). Nothing here pins that IP — the Ingress attaches to the
|
||||||
|
# class and Traefik's own Service owns the address.
|
||||||
|
INGRESS_CLASS: traefik
|
||||||
|
|
||||||
|
# The ClusterIssuer roles/k3s_cert_manager creates. Must match
|
||||||
|
# k3s_cert_manager_issuer_name in inventory/group_vars/k3s_cluster.yml.
|
||||||
|
CERT_ISSUER: letsencrypt
|
||||||
|
|
||||||
|
# --- Storage ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# /media holds uploaded application icons and flow backgrounds — the things
|
||||||
|
# that make the login page look like yours rather than stock. Small, but
|
||||||
|
# the one piece of Authentik's state that isn't in Postgres, so it has to
|
||||||
|
# be copied across at cutover (docs/authentik-migration.md) or the migration
|
||||||
|
# is visibly incomplete.
|
||||||
|
#
|
||||||
|
# ReadWriteOnce on k3s's local-path provisioner, and both the server and
|
||||||
|
# worker pods mount it. That works without any affinity rules of our own:
|
||||||
|
# local-path PVs carry node affinity, so once the volume binds to whichever
|
||||||
|
# worker the first pod lands on, the scheduler is obliged to place the
|
||||||
|
# second pod on that same node — and RWO permits multiple pods per node.
|
||||||
|
# The cost is that both pods are pinned to one Pi and neither can be
|
||||||
|
# rescheduled while it's down. Acceptable for a homelab SSO that is already
|
||||||
|
# a single logical instance; the fix, if it ever matters, is
|
||||||
|
# ReadWriteMany-capable storage, not a second PVC.
|
||||||
|
MEDIA_STORAGE_SIZE: 2Gi
|
||||||
|
MEDIA_STORAGE_CLASS: local-path
|
||||||
|
|
||||||
|
# --- Sizing -----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Pi 4, 3.8Gi usable, already running CNPG + Prometheus + MetalLB. The
|
||||||
|
# worker gets more headroom than the server because it's the half that runs
|
||||||
|
# migrations on startup and processes outposts/policies in the background;
|
||||||
|
# it is the one that gets OOM-killed if these are set naively equal.
|
||||||
|
SERVER_MEMORY_REQUEST: 384Mi
|
||||||
|
SERVER_MEMORY_LIMIT: 768Mi
|
||||||
|
WORKER_MEMORY_REQUEST: 512Mi
|
||||||
|
WORKER_MEMORY_LIMIT: 1Gi
|
||||||
15
src/authentik/ansible/proxmox/README.md
Normal file
15
src/authentik/ansible/proxmox/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Authentik on Proxmox — placeholder
|
||||||
|
|
||||||
|
Not deployed on Proxmox. Authentik runs on the k3s cluster
|
||||||
|
(`../kubernetes/`), having moved off a hand-made Unraid container — see
|
||||||
|
`docs/authentik-migration.md`.
|
||||||
|
|
||||||
|
To add it here later: `vars.yml` of Proxmox-specific overrides, `install.yml`
|
||||||
|
of install steps, and templates for the app's config and systemd unit, plus
|
||||||
|
`src/authentik/terraform/` defining its LXC. See `src/forgejo/ansible/proxmox/`
|
||||||
|
for a worked example, and CLAUDE.md → "Adding a new app" — in particular that
|
||||||
|
an app must only ever be live on one platform at a time.
|
||||||
|
|
||||||
|
This folder is kept empty-but-present on purpose: every app carries the same
|
||||||
|
`common/` + `ansible/` + `terraform/` shape whether or not each platform is
|
||||||
|
in use, so adding one later doesn't mean restructuring.
|
||||||
23
src/authentik/ansible/unraid/README.md
Normal file
23
src/authentik/ansible/unraid/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Authentik on Unraid — deliberately not managed here
|
||||||
|
|
||||||
|
Authentik **does** currently run on Unraid, but it was created by hand
|
||||||
|
through the Unraid UI, not by this repo. There is no `docker-compose.yml`
|
||||||
|
here and there should not be one: adding it would put Authentik in `nas01`'s
|
||||||
|
`stacks:` list, and CLAUDE.md → "Adding a new app" is explicit that an app
|
||||||
|
must never be declared on two platforms at once — they would share a Vault
|
||||||
|
path and a database, and two live deployments would corrupt each other's
|
||||||
|
state.
|
||||||
|
|
||||||
|
The k3s deployment under `../kubernetes/` is where Authentik is going. See
|
||||||
|
`docs/authentik-migration.md` for the cutover: dump the database off the
|
||||||
|
Unraid Postgres, restore onto the cluster's CNPG instance, bring Authentik up
|
||||||
|
on k3s on the *same* version, verify, then move DNS.
|
||||||
|
|
||||||
|
Once DNS has moved and the k3s instance has been trusted for long enough,
|
||||||
|
stop and delete the hand-made Unraid container. That teardown is a UI action,
|
||||||
|
not a `state: absent` run — this repo never deployed it, so it has nothing to
|
||||||
|
tear down.
|
||||||
|
|
||||||
|
This folder stays as a placeholder rather than being deleted, per CLAUDE.md:
|
||||||
|
the shape is the point, and Authentik moving back to Unraid one day should
|
||||||
|
mean writing a compose file here, not restructuring the app.
|
||||||
38
src/authentik/common/vars.yml
Normal file
38
src/authentik/common/vars.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
# Portable config — values that hold regardless of where Authentik runs.
|
||||||
|
# Platform-specific values (namespace, storage class, ingress class, how the
|
||||||
|
# database is reached) live in ../ansible/<platform>/vars.yml and are merged
|
||||||
|
# over these at deploy time.
|
||||||
|
env_defaults:
|
||||||
|
# >>> CONFIRM BEFORE THE CUTOVER <<<
|
||||||
|
#
|
||||||
|
# This must match the version currently running on Unraid at the moment
|
||||||
|
# the database is dumped. Authentik runs its Django migrations on startup
|
||||||
|
# against whatever schema it finds, and those migrations are one-way: a
|
||||||
|
# newer Authentik pointed at an older dump will silently upgrade the
|
||||||
|
# schema, and there is no downgrade path if the cutover then has to be
|
||||||
|
# rolled back. Deploy on the *same* version, confirm it works, flip DNS,
|
||||||
|
# and only then bump this in a separate commit.
|
||||||
|
#
|
||||||
|
# The chart version and the app version are the same string upstream (see
|
||||||
|
# ../ansible/kubernetes/helmchart.yaml.j2, which uses this for both), so
|
||||||
|
# this is the only place a version is written down.
|
||||||
|
AUTHENTIK_VERSION: "2026.5.4"
|
||||||
|
|
||||||
|
AUTHENTIK_HOST: auth.turtlesystems.uk
|
||||||
|
AUTHENTIK_URL: "https://auth.turtlesystems.uk"
|
||||||
|
|
||||||
|
# Authentik's own HTTP port inside the container. Not the published port —
|
||||||
|
# on k3s the Ingress terminates and forwards here; there is no host port.
|
||||||
|
AUTHENTIK_HTTP_PORT: "9000"
|
||||||
|
|
||||||
|
DB_NAME: authentik
|
||||||
|
DB_USER: authentik
|
||||||
|
# AUTHENTIK_SECRET_KEY and AUTHENTIK_POSTGRESQL__PASSWORD are secrets —
|
||||||
|
# sourced from Vault (homelab/authentik), never set here.
|
||||||
|
#
|
||||||
|
# AUTHENTIK_SECRET_KEY specifically must be carried over from the existing
|
||||||
|
# Unraid deployment rather than generated fresh: it signs session cookies,
|
||||||
|
# so a new value logs every user out at the moment DNS flips, and the
|
||||||
|
# cutover stops looking like the no-op it's meant to be. See
|
||||||
|
# docs/authentik-migration.md.
|
||||||
11
src/authentik/terraform/README.md
Normal file
11
src/authentik/terraform/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Authentik Terraform — placeholder
|
||||||
|
|
||||||
|
No Terraform for Authentik. It runs on the k3s cluster, and the k3s cluster
|
||||||
|
is 4 Raspberry Pis that already exist — there is no guest for Terraform to
|
||||||
|
provision, the same reason `homelab-utils` has no `src/<app>/terraform/`
|
||||||
|
entries of its own (CLAUDE.md → "K3s cluster").
|
||||||
|
|
||||||
|
Terraform would only come into this if Authentik moved to Proxmox, where it
|
||||||
|
would need a module defining its LXC (template, cores, memory, disk, IP),
|
||||||
|
called from `build/config/terraform/main.tf`. See `src/forgejo/terraform/`
|
||||||
|
for the shape.
|
||||||
94
src/forgejo/ansible/proxmox/README.md
Normal file
94
src/forgejo/ansible/proxmox/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# ansible/proxmox/ — forgejo
|
||||||
|
|
||||||
|
Native install into the Terraform-provisioned LXC (`../../terraform/`). Run by
|
||||||
|
the shared `lxc_app` role, which merges `../../common/vars.yml` with `vars.yml`
|
||||||
|
here, fetches `homelab/forgejo` from Vault, provisions the database, then
|
||||||
|
includes `install.yml`.
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `vars.yml` | Proxmox-specific overrides — paths, service user, exact release, LAN address of the shared Postgres |
|
||||||
|
| `install.yml` | Packages, `git` user, directory layout, binary download, config/unit rendering, dump schedule |
|
||||||
|
| `templates/app.ini.j2` | Forgejo's config — the equivalent of the `FORGEJO__section__KEY` env vars in `../unraid/docker-compose.yml` |
|
||||||
|
| `templates/forgejo.service.j2` | systemd unit, replacing Compose's `restart: unless-stopped` |
|
||||||
|
| `templates/forgejo-dbdump.*` | Nightly `pg_dump`, script + service + timer |
|
||||||
|
| `templates/pgpass.j2` | Credentials for that dump, mode 0600 |
|
||||||
|
|
||||||
|
## How this is backed up
|
||||||
|
|
||||||
|
Nothing is bind-mounted in from the NAS. Every path Forgejo writes to —
|
||||||
|
repositories, LFS, attachments, indexers, queues, sessions — is left at its
|
||||||
|
default under `APP_DATA_PATH` on the container's own disk, so a vzdump of the
|
||||||
|
guest is a complete copy of its filesystem state. Proxmox excludes bind mounts
|
||||||
|
from vzdump, so an NFS-mounted repository directory would have been the one
|
||||||
|
thing missing from the backup that was made to protect it.
|
||||||
|
|
||||||
|
The off-box copy is the vzdump job in
|
||||||
|
`build/config/ansible/roles/pve_backup`, writing to an NFS storage on the NAS.
|
||||||
|
|
||||||
|
**The database is the other half.** Postgres runs elsewhere — the shared
|
||||||
|
Postgres LXC at 192.168.50.54, a different guest and therefore a different
|
||||||
|
archive — so a restored container would come back with every repository and no
|
||||||
|
issues, pull requests, users or permissions. `forgejo-dbdump.timer` runs
|
||||||
|
`pg_dump` into `FORGEJO_BACKUP_PATH` on the container's own disk, which puts
|
||||||
|
the dump inside the same vzdump archive as the repositories it belongs to. One
|
||||||
|
artifact, one restore.
|
||||||
|
|
||||||
|
That only holds if the dump finishes before the backup window, so the two
|
||||||
|
schedules are a pair:
|
||||||
|
|
||||||
|
| | Set in | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| Database dump | `vars.yml` → `FORGEJO_DB_DUMP_ONCALENDAR` | 01:30 |
|
||||||
|
| vzdump job | `group_vars/proxmox_nodes.yml` → `pve_backup_schedule` | 02:00 |
|
||||||
|
|
||||||
|
Move one and move the other. Retention is independent: `pg_dump` output is
|
||||||
|
pruned inside the container by `FORGEJO_BACKUP_KEEP_DAYS`, while the archives
|
||||||
|
themselves are pruned by the storage's `prune-backups` settings — Proxmox
|
||||||
|
prunes archives, not their contents.
|
||||||
|
|
||||||
|
### Restoring
|
||||||
|
|
||||||
|
1. Restore the container from the archive (`pct restore`, or the UI).
|
||||||
|
2. Recreate the database and role if they're gone —
|
||||||
|
`ansible-playbook playbooks/proxmox.yml -e only_stacks=forgejo` does it
|
||||||
|
idempotently.
|
||||||
|
3. Load the newest dump from `/var/lib/forgejo/backups`:
|
||||||
|
```sh
|
||||||
|
systemctl stop forgejo
|
||||||
|
zcat /var/lib/forgejo/backups/forgejo-<stamp>.sql.gz \
|
||||||
|
| psql -h <postgres-host> -U forgejo -d forgejo
|
||||||
|
systemctl start forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading Forgejo
|
||||||
|
|
||||||
|
`FORGEJO_VERSION` in `../../common/vars.yml` is `10` — a Docker tag, a rolling
|
||||||
|
pointer at the latest 10.x, which is all the Compose stack needs. A native
|
||||||
|
install downloads one specific artifact, so `vars.yml` here carries
|
||||||
|
`FORGEJO_RELEASE` with the exact version. Bump it and redeploy: the new binary
|
||||||
|
lands alongside the running one, the `/usr/local/bin/forgejo` symlink flips,
|
||||||
|
and the service restarts. The old binary stays on disk, so rolling back is
|
||||||
|
editing `FORGEJO_RELEASE` and redeploying.
|
||||||
|
|
||||||
|
## Differences from the Unraid stack
|
||||||
|
|
||||||
|
- **SSH port 22, not 2222.** On Unraid, 2222 was a published Docker port
|
||||||
|
dodging the host's own sshd. Here the container has its own IP and its own
|
||||||
|
sshd, which Forgejo uses directly (`START_SSH_SERVER = false`) — it manages
|
||||||
|
the `git` user's `authorized_keys` rather than running its own server. This
|
||||||
|
changes the SSH clone URLs Forgejo advertises, so existing remotes need
|
||||||
|
updating after a migration.
|
||||||
|
- **`INSTALL_LOCK = true`.** `app.ini` is rendered in full from Vault, so the
|
||||||
|
web installer is skipped entirely. Nothing is left for Forgejo to generate
|
||||||
|
on first boot — which is why `LFS_JWT_SECRET` has to exist in Vault here
|
||||||
|
even though the Compose stack lets Forgejo invent one.
|
||||||
|
- **`DB_HOST` is a LAN address**, not the `shared-postgres` Docker network
|
||||||
|
alias — there's no `unraid_shared` network to resolve it through.
|
||||||
|
|
||||||
|
## One platform at a time
|
||||||
|
|
||||||
|
Both platforms use the same Vault path and the same database, so they must not
|
||||||
|
run at once. This was why `host_vars/forgejo.yml` carried an empty `apps:`
|
||||||
|
list for so long; nas2 has since been retired, the list is live, and the
|
||||||
|
bring-up runbook is `docs/forgejo-proxmox.md`.
|
||||||
190
src/forgejo/ansible/proxmox/install.yml
Normal file
190
src/forgejo/ansible/proxmox/install.yml
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
---
|
||||||
|
# App-specific install steps, included by the `lxc_app` role after it has
|
||||||
|
# merged ../../common/vars.yml with ./vars.yml into `app_config` and fetched
|
||||||
|
# `vault_secrets` from the app's Vault path. The role handles enabling and
|
||||||
|
# starting the service once this file has put the unit in place.
|
||||||
|
#
|
||||||
|
# Template paths are absolute rather than relative: this file is included from
|
||||||
|
# a role, so a bare `src:` would be looked up against that role's templates/
|
||||||
|
# directory, not this app's.
|
||||||
|
|
||||||
|
# A native install renders app.ini in full and sets INSTALL_LOCK, so nothing
|
||||||
|
# is left for Forgejo to generate on first boot. A missing key would otherwise
|
||||||
|
# surface as an undefined-variable error three tasks later.
|
||||||
|
- name: Check that every required Vault key is present
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- vault_secrets.DB_PASSWORD is defined
|
||||||
|
- vault_secrets.SECRET_KEY is defined
|
||||||
|
- vault_secrets.INTERNAL_TOKEN is defined
|
||||||
|
- vault_secrets.JWT_SECRET is defined
|
||||||
|
- vault_secrets.LFS_JWT_SECRET is defined
|
||||||
|
fail_msg: >-
|
||||||
|
Vault path {{ app.vault_path }} is missing one or more of DB_PASSWORD,
|
||||||
|
SECRET_KEY, INTERNAL_TOKEN, JWT_SECRET, LFS_JWT_SECRET. See
|
||||||
|
docs/vault-secrets.md — LFS_JWT_SECRET is needed by the native install
|
||||||
|
even though the Unraid Compose stack lets Forgejo generate it.
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Install packages Forgejo needs
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- git
|
||||||
|
- gzip
|
||||||
|
- ca-certificates
|
||||||
|
# pg_dump for the nightly database dump at the end of this file. The
|
||||||
|
# distro client and the shared Postgres LXC (17, from the PGDG archive)
|
||||||
|
# are close enough that either direction works, but the supported one is
|
||||||
|
# a client at least as new as the server — check this if the dump ever
|
||||||
|
# starts failing after a Postgres major upgrade over there.
|
||||||
|
- postgresql-client
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
- name: Create the Forgejo service group
|
||||||
|
ansible.builtin.group:
|
||||||
|
name: "{{ app_config.FORGEJO_USER }}"
|
||||||
|
system: true
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Create the Forgejo service user
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ app_config.FORGEJO_USER }}"
|
||||||
|
group: "{{ app_config.FORGEJO_USER }}"
|
||||||
|
system: true
|
||||||
|
# A real shell, not nologin: this account is also the SSH login clients
|
||||||
|
# use for git@host:owner/repo.git.
|
||||||
|
shell: /bin/bash
|
||||||
|
home: "{{ app_config.FORGEJO_HOME }}"
|
||||||
|
create_home: true
|
||||||
|
comment: Forgejo
|
||||||
|
|
||||||
|
- name: Create Forgejo directories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.owner | default(app_config.FORGEJO_USER) }}"
|
||||||
|
group: "{{ item.group | default(app_config.FORGEJO_USER) }}"
|
||||||
|
mode: "{{ item.mode }}"
|
||||||
|
loop:
|
||||||
|
- { path: "{{ app_config.FORGEJO_HOME }}", mode: "0750" }
|
||||||
|
- { path: "{{ app_config.FORGEJO_DATA_PATH }}", mode: "0750" }
|
||||||
|
- { path: "{{ app_config.FORGEJO_LOG_PATH }}", mode: "0750" }
|
||||||
|
# Forgejo writes the git user's authorized_keys here itself.
|
||||||
|
- { path: "{{ app_config.FORGEJO_HOME }}/.ssh", mode: "0700" }
|
||||||
|
# Nightly database dumps. On the container's own disk on purpose — that is
|
||||||
|
# what gets them into the vzdump archive.
|
||||||
|
- { path: "{{ app_config.FORGEJO_BACKUP_PATH }}", mode: "0700" }
|
||||||
|
# Config is root-owned and group-readable by Forgejo: it holds the
|
||||||
|
# database password and the three signing secrets, and Forgejo has no
|
||||||
|
# business rewriting it — everything in it is rendered from Vault below.
|
||||||
|
- { path: "{{ app_config.FORGEJO_CONFIG_DIR }}", owner: root, mode: "0750" }
|
||||||
|
- { path: "{{ app_config.FORGEJO_INSTALL_DIR }}", owner: root, group: root, mode: "0755" }
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.path }}"
|
||||||
|
|
||||||
|
- name: Set Forgejo release facts
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
forgejo_artifact: >-
|
||||||
|
forgejo-{{ app_config.FORGEJO_RELEASE }}-linux-{{ app_config.FORGEJO_ARCH }}
|
||||||
|
forgejo_release_url: >-
|
||||||
|
https://codeberg.org/forgejo/forgejo/releases/download/v{{ app_config.FORGEJO_RELEASE }}
|
||||||
|
|
||||||
|
- name: Download the Forgejo binary
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "{{ forgejo_release_url }}/{{ forgejo_artifact }}"
|
||||||
|
dest: "{{ app_config.FORGEJO_INSTALL_DIR }}/{{ forgejo_artifact }}"
|
||||||
|
# Checksum from the release's own .sha256 file. Ansible fetches it and
|
||||||
|
# matches the line for this artifact — which is why `dest` keeps the
|
||||||
|
# upstream filename rather than being renamed to plain `forgejo`.
|
||||||
|
checksum: "sha256:{{ forgejo_release_url }}/{{ forgejo_artifact }}.sha256"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
register: forgejo_binary
|
||||||
|
|
||||||
|
# Versioned file plus a symlink, rather than overwriting one path: an upgrade
|
||||||
|
# lands the new binary alongside the running one and flips the link, so a
|
||||||
|
# failed download can't leave a half-written executable in place.
|
||||||
|
- name: Link the current Forgejo binary onto PATH
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "{{ app_config.FORGEJO_INSTALL_DIR }}/{{ forgejo_artifact }}"
|
||||||
|
dest: /usr/local/bin/forgejo
|
||||||
|
state: link
|
||||||
|
register: forgejo_link
|
||||||
|
|
||||||
|
- name: Render Forgejo configuration
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ app_local_dir }}/ansible/proxmox/templates/app.ini.j2"
|
||||||
|
dest: "{{ app_config.FORGEJO_CONFIG_PATH }}"
|
||||||
|
owner: root
|
||||||
|
group: "{{ app_config.FORGEJO_USER }}"
|
||||||
|
mode: "0640"
|
||||||
|
register: forgejo_config
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install the Forgejo systemd unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ app_local_dir }}/ansible/proxmox/templates/forgejo.service.j2"
|
||||||
|
dest: /etc/systemd/system/forgejo.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
register: forgejo_unit
|
||||||
|
|
||||||
|
# `lxc_app` starts the service after this file, but starting an already-running
|
||||||
|
# service is a no-op — it would not pick up a changed app.ini or a new binary.
|
||||||
|
- name: Restart Forgejo if its binary or configuration changed
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: forgejo
|
||||||
|
state: restarted
|
||||||
|
daemon_reload: true
|
||||||
|
when: forgejo_binary.changed or forgejo_link.changed
|
||||||
|
or forgejo_config.changed or forgejo_unit.changed
|
||||||
|
|
||||||
|
# --- Database dump -------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The vzdump job on the Proxmox node captures this container's filesystem, but
|
||||||
|
# Forgejo's database is not in it — Postgres runs elsewhere, in the shared
|
||||||
|
# Postgres LXC at 192.168.50.54, which is a *different guest* and so a
|
||||||
|
# different archive. Restoring the container alone would give back every repository with
|
||||||
|
# no issues, pull requests, users or permissions. So dump the database into the
|
||||||
|
# container's own filesystem on a schedule that finishes before the vzdump
|
||||||
|
# window, and the one archive holds both halves.
|
||||||
|
|
||||||
|
- name: Render the Postgres password file for the dump job
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ app_local_dir }}/ansible/proxmox/templates/pgpass.j2"
|
||||||
|
dest: "{{ app_config.FORGEJO_HOME }}/.pgpass"
|
||||||
|
owner: "{{ app_config.FORGEJO_USER }}"
|
||||||
|
group: "{{ app_config.FORGEJO_USER }}"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install the database dump script
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ app_local_dir }}/ansible/proxmox/templates/forgejo-dbdump.sh.j2"
|
||||||
|
dest: /usr/local/bin/forgejo-dbdump
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Install the database dump systemd units
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ app_local_dir }}/ansible/proxmox/templates/{{ item }}.j2"
|
||||||
|
dest: "/etc/systemd/system/{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- forgejo-dbdump.service
|
||||||
|
- forgejo-dbdump.timer
|
||||||
|
register: forgejo_dbdump_units
|
||||||
|
|
||||||
|
- name: Enable the nightly database dump timer
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: forgejo-dbdump.timer
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
daemon_reload: "{{ forgejo_dbdump_units.changed }}"
|
||||||
116
src/forgejo/ansible/proxmox/templates/app.ini.j2
Normal file
116
src/forgejo/ansible/proxmox/templates/app.ini.j2
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
{#
|
||||||
|
Rendered by Ansible from src/forgejo/common/vars.yml merged with
|
||||||
|
../vars.yml (as `app_config`), plus Vault path homelab/forgejo (as
|
||||||
|
`vault_secrets`). The native-install equivalent of the
|
||||||
|
FORGEJO__section__KEY environment variables in ../../unraid/docker-compose.yml.
|
||||||
|
|
||||||
|
Do not edit this file on the host — the next deploy overwrites it. It is
|
||||||
|
root-owned and group-readable by Forgejo precisely so Forgejo cannot rewrite
|
||||||
|
it behind Ansible's back.
|
||||||
|
|
||||||
|
Note what is NOT configured here: every path Forgejo stores state under is
|
||||||
|
left at its default beneath APP_DATA_PATH — indexers, queues, sessions,
|
||||||
|
avatars, packages. That is deliberate. All of it sitting on the container's
|
||||||
|
own disk is what makes a vzdump of this guest a complete copy of Forgejo's
|
||||||
|
filesystem state.
|
||||||
|
#}
|
||||||
|
APP_NAME = Forgejo
|
||||||
|
RUN_USER = {{ app_config.FORGEJO_USER }}
|
||||||
|
RUN_MODE = prod
|
||||||
|
WORK_PATH = {{ app_config.FORGEJO_WORK_PATH }}
|
||||||
|
|
||||||
|
[server]
|
||||||
|
PROTOCOL = http
|
||||||
|
DOMAIN = {{ app_config.FORGEJO_DOMAIN }}
|
||||||
|
ROOT_URL = {{ app_config.FORGEJO_ROOT_URL }}
|
||||||
|
HTTP_ADDR = 0.0.0.0
|
||||||
|
HTTP_PORT = {{ app_config.FORGEJO_HTTP_PORT }}
|
||||||
|
APP_DATA_PATH = {{ app_config.FORGEJO_DATA_PATH }}
|
||||||
|
{# The container's own sshd serves git over SSH, so Forgejo only advertises
|
||||||
|
the address and manages the git user's authorized_keys. On Unraid this was
|
||||||
|
Forgejo's built-in server behind a published Docker port. #}
|
||||||
|
SSH_DOMAIN = {{ app_config.FORGEJO_DOMAIN }}
|
||||||
|
SSH_PORT = {{ app_config.FORGEJO_SSH_PORT }}
|
||||||
|
START_SSH_SERVER = false
|
||||||
|
DISABLE_SSH = false
|
||||||
|
LFS_START_SERVER = true
|
||||||
|
LFS_JWT_SECRET = {{ vault_secrets.LFS_JWT_SECRET }}
|
||||||
|
|
||||||
|
[database]
|
||||||
|
DB_TYPE = postgres
|
||||||
|
HOST = {{ app_config.DB_HOST }}:{{ app_config.DB_PORT }}
|
||||||
|
NAME = {{ app_config.DB_NAME }}
|
||||||
|
USER = {{ app_config.DB_USER }}
|
||||||
|
PASSWD = {{ vault_secrets.DB_PASSWORD }}
|
||||||
|
{# The shared Postgres publishes a plain port on the LAN and has no TLS
|
||||||
|
configured — see src/shared/postgres/ansible/unraid/docker-compose.yml.
|
||||||
|
Change both together if that ever gains a certificate. #}
|
||||||
|
SSL_MODE = disable
|
||||||
|
|
||||||
|
[repository]
|
||||||
|
ROOT = {{ app_config.FORGEJO_DATA_PATH }}/forgejo-repositories
|
||||||
|
|
||||||
|
[security]
|
||||||
|
{# Fully configured from Vault, so skip the web installer entirely — without
|
||||||
|
this, a fresh container serves the setup wizard on first boot and would
|
||||||
|
write its own app.ini over this one. #}
|
||||||
|
INSTALL_LOCK = true
|
||||||
|
SECRET_KEY = {{ vault_secrets.SECRET_KEY }}
|
||||||
|
INTERNAL_TOKEN = {{ vault_secrets.INTERNAL_TOKEN }}
|
||||||
|
|
||||||
|
[oauth2]
|
||||||
|
JWT_SECRET = {{ vault_secrets.JWT_SECRET }}
|
||||||
|
|
||||||
|
[oauth2_client]
|
||||||
|
{# Policy for signing in through an external OIDC provider (Authentik). The
|
||||||
|
provider itself is NOT configured here and cannot be: Forgejo keeps
|
||||||
|
authentication sources in its database, added once with
|
||||||
|
`forgejo admin auth add-oauth` — see step 8 of docs/forgejo-proxmox.md.
|
||||||
|
This section only decides what happens to a user who has just
|
||||||
|
authenticated there. #}
|
||||||
|
|
||||||
|
{# Create a Forgejo account on first successful OIDC login. Without this,
|
||||||
|
Authentik authenticates the user and Forgejo then has nobody to log in as.
|
||||||
|
Gated by the registration settings below, not independent of them. #}
|
||||||
|
ENABLE_AUTO_REGISTRATION = true
|
||||||
|
|
||||||
|
{# Attach an OIDC identity to an existing local account when the email
|
||||||
|
matches, rather than creating a second one alongside it. This is what lets
|
||||||
|
the break-glass admin from step 4b become the same account you arrive as
|
||||||
|
through Authentik, instead of demoting you to a fresh unprivileged user on
|
||||||
|
first SSO login. `login` would ask the user to confirm by entering their
|
||||||
|
local password; `auto` links silently, which is only safe because
|
||||||
|
Authentik is the sole source of verified addresses here. #}
|
||||||
|
ACCOUNT_LINKING = auto
|
||||||
|
|
||||||
|
{# Take the Forgejo username from the provider's preferred_username claim.
|
||||||
|
The alternative, `userid`, would name accounts after Authentik's opaque
|
||||||
|
subject UUID. #}
|
||||||
|
USERNAME = nickname
|
||||||
|
|
||||||
|
[service]
|
||||||
|
{# INSTALL_LOCK skips the wizard, which is also where these would have been
|
||||||
|
chosen.
|
||||||
|
|
||||||
|
These two are a pair and the combination is deliberate. DISABLE_REGISTRATION
|
||||||
|
cannot stay `true` once SSO is wanted: it blocks OIDC auto-registration as
|
||||||
|
well as the local signup form, so Authentik logins authenticate correctly
|
||||||
|
and are then refused an account — which reads like a broken provider rather
|
||||||
|
than a policy setting. ALLOW_ONLY_EXTERNAL_REGISTRATION restores exactly
|
||||||
|
the property that was wanted: no self-service signup, accounts only through
|
||||||
|
a configured provider.
|
||||||
|
|
||||||
|
Safe to have in place before the Authentik source exists. With no external
|
||||||
|
provider configured, "only external registration" permits nothing, so this
|
||||||
|
is not a window during which the forge is open — flipping the first value
|
||||||
|
alone would have been. #}
|
||||||
|
DISABLE_REGISTRATION = false
|
||||||
|
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
|
||||||
|
REQUIRE_SIGNIN_VIEW = false
|
||||||
|
|
||||||
|
[log]
|
||||||
|
{# journald, via systemd capturing stdout — `journalctl -u forgejo`. ROOT_PATH
|
||||||
|
still matters: some subsystems write their own files regardless. #}
|
||||||
|
MODE = console
|
||||||
|
LEVEL = info
|
||||||
|
ROOT_PATH = {{ app_config.FORGEJO_LOG_PATH }}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{# Rendered by Ansible — see ../install.yml. #}
|
||||||
|
[Unit]
|
||||||
|
Description=Dump the Forgejo database into this container's filesystem
|
||||||
|
Documentation=file://{{ app_config.FORGEJO_CONFIG_PATH }}
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User={{ app_config.FORGEJO_USER }}
|
||||||
|
Group={{ app_config.FORGEJO_USER }}
|
||||||
|
ExecStart=/usr/local/bin/forgejo-dbdump
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ReadWritePaths={{ app_config.FORGEJO_BACKUP_PATH }}
|
||||||
50
src/forgejo/ansible/proxmox/templates/forgejo-dbdump.sh.j2
Normal file
50
src/forgejo/ansible/proxmox/templates/forgejo-dbdump.sh.j2
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#!/bin/bash
|
||||||
|
{# Rendered by Ansible — see ../install.yml. Do not edit on the host. #}
|
||||||
|
#
|
||||||
|
# Dumps the Forgejo database into this container's filesystem, where the
|
||||||
|
# Proxmox vzdump job will pick it up along with everything else. Postgres runs
|
||||||
|
# elsewhere (shared, on Unraid), so without this a restored container would
|
||||||
|
# have every repository and no issues, pull requests, users or permissions.
|
||||||
|
#
|
||||||
|
# Run by forgejo-dbdump.timer, not by hand — though running it by hand is
|
||||||
|
# harmless and is the quickest way to check the credentials work.
|
||||||
|
|
||||||
|
# pipefail matters more than usual here: pg_dump feeds gzip, and without it a
|
||||||
|
# failed dump still exits 0 through gzip and gets published as a valid-looking
|
||||||
|
# but truncated archive.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly BACKUP_DIR="{{ app_config.FORGEJO_BACKUP_PATH }}"
|
||||||
|
readonly KEEP_DAYS="{{ app_config.FORGEJO_BACKUP_KEEP_DAYS }}"
|
||||||
|
|
||||||
|
export PGPASSFILE="{{ app_config.FORGEJO_HOME }}/.pgpass"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
dest="${BACKUP_DIR}/{{ app_config.DB_NAME }}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
|
||||||
|
|
||||||
|
# Write to .part and rename only on success, so a dump interrupted by the
|
||||||
|
# vzdump window (or a reboot) never leaves a partial file that looks complete
|
||||||
|
# to the retention sweep below.
|
||||||
|
pg_dump \
|
||||||
|
--host="{{ app_config.DB_HOST }}" \
|
||||||
|
--port="{{ app_config.DB_PORT }}" \
|
||||||
|
--username="{{ app_config.DB_USER }}" \
|
||||||
|
--dbname="{{ app_config.DB_NAME }}" \
|
||||||
|
--format=plain \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
| gzip -9 > "${dest}.part"
|
||||||
|
|
||||||
|
mv "${dest}.part" "${dest}"
|
||||||
|
|
||||||
|
# Retention is here rather than in the storage's prune settings because these
|
||||||
|
# live inside the container: Proxmox prunes backup archives, not their
|
||||||
|
# contents. Only complete dumps are counted, so a stale .part never displaces
|
||||||
|
# a good one.
|
||||||
|
find "${BACKUP_DIR}" -maxdepth 1 -type f -name '*.sql.gz' \
|
||||||
|
-mtime "+${KEEP_DAYS}" -delete
|
||||||
|
find "${BACKUP_DIR}" -maxdepth 1 -type f -name '*.sql.gz.part' \
|
||||||
|
-mtime +1 -delete
|
||||||
|
|
||||||
|
echo "wrote ${dest}"
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{# Rendered by Ansible — see ../install.yml. #}
|
||||||
|
[Unit]
|
||||||
|
Description=Nightly Forgejo database dump
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
{# Must land before the vzdump window on the Proxmox node
|
||||||
|
(build/config/ansible/inventory/host_vars/pve.yml). The archive is only a
|
||||||
|
complete restore point if the dump inside it is from the same night. #}
|
||||||
|
OnCalendar={{ app_config.FORGEJO_DB_DUMP_ONCALENDAR }}
|
||||||
|
{# Catch up after a reboot rather than skipping a night silently. #}
|
||||||
|
Persistent=true
|
||||||
|
{# No randomised delay: the gap to the backup window is the whole point. #}
|
||||||
|
AccuracySec=1min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
33
src/forgejo/ansible/proxmox/templates/forgejo.service.j2
Normal file
33
src/forgejo/ansible/proxmox/templates/forgejo.service.j2
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{# Rendered by Ansible — see ../install.yml. The native-install replacement for
|
||||||
|
Compose's `restart: unless-stopped`. #}
|
||||||
|
[Unit]
|
||||||
|
Description=Forgejo
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ app_config.FORGEJO_USER }}
|
||||||
|
Group={{ app_config.FORGEJO_USER }}
|
||||||
|
WorkingDirectory={{ app_config.FORGEJO_WORK_PATH }}
|
||||||
|
ExecStart=/usr/local/bin/forgejo web --config {{ app_config.FORGEJO_CONFIG_PATH }}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2s
|
||||||
|
Environment=USER={{ app_config.FORGEJO_USER }}
|
||||||
|
Environment=HOME={{ app_config.FORGEJO_HOME }}
|
||||||
|
Environment=GITEA_WORK_DIR={{ app_config.FORGEJO_WORK_PATH }}
|
||||||
|
|
||||||
|
# HTTP_PORT is 3000, so no CAP_NET_BIND_SERVICE is needed. Forgejo does need
|
||||||
|
# to write the git user's authorized_keys and its own data, so the filesystem
|
||||||
|
# is not made read-only wholesale.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=false
|
||||||
|
ReadWritePaths={{ app_config.FORGEJO_HOME }}
|
||||||
|
{# Restarting mid-push should not orphan git subprocesses. #}
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=30s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue