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