homelab/build/config/ansible/roles/k3s_app/tasks/deploy.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

170 lines
7.6 KiB
YAML

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