170 lines
7.6 KiB
YAML
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
|