homelab/build/config/ansible/playbooks/k3s.yml
Russell Seymour 1250c9cef6
Some checks are pending
deploy / deploy (push) Waiting to run
Initial checkin of code for managing homelab
2026-08-24 20:52:36 +01:00

241 lines
9.8 KiB
YAML

---
# 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.