homelab/build/config/ansible/roles/compose_stack/tasks/deploy.yml
Russell Seymour 3cd33d350e
Some checks failed
deploy / deploy (push) Has been cancelled
Added Jellyfin deployment to the repo
2026-08-31 20:14:43 +01:00

208 lines
9.2 KiB
YAML

---
# 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"
# Bind-mount source directories the stack needs to exist *with a specific
# owner* before `docker compose up`. Opt-in per stack (`appdata:` on the
# host_vars entry) and normally unset: the LinuxServer images (arr, jellyfin)
# start as root and chown their own /config on boot, and postgres/forgejo run
# as root outright, so for those Docker creating a missing bind path as
# root:root is fine. A rootless image (watchstate runs as uid 99 and refuses
# to start if it can't write /config) has nothing to do the chown, so the
# directory has to arrive already owned correctly — otherwise the first
# `up` creates it as root:root and the container exits.
#
# `recurse` defaults on, matching upstream's `chown -R`, to also fix a
# directory that a previous run already created wrong. Keep the list to small
# state/config dirs; don't point it at a media share.
- name: Ensure stack appdata directories exist with the right ownership
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner | default(omit) }}"
group: "{{ item.group | default(omit) }}"
mode: "{{ item.mode | default('0750') }}"
recurse: "{{ item.recurse | default(true) }}"
loop: "{{ stack.appdata | default([]) }}"
loop_control:
label: "{{ item.path }}"
- 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