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