--- # 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 /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-.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_ 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_ only exists when the matching icon-.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