--- # playbooks/docker/deploy_plex.yml # # Purpose: # Deploy Plex Media Server as a Swarm stack, pinned to swarm-manager-1 which # hosts the media volumes and hardware transcoding devices. # # Architecture: # Plex listens on port 32400. Traefik on Heimdall routes inbound HTTPS for # plex.castaldifamily.com via traefik-kop, which reads deploy.labels from # the Swarm service and publishes routes into Redis. # Media is served from bind-mounted host paths; config persists under # /mnt/homelab/apps/plex. # # Pre-requisites: # - Swarm must be active; swarm-manager-1 (10.0.0.211) must be reachable. # - proxy-net overlay must exist (deploy_traefik_kop.yml must have run). # - traefik-kop must be running on Swarm. # - vault_plex_claim must be present in group_vars/vault/all.yml: # ansible-vault encrypt_string 'claim-XXXX' --name 'vault_plex_claim' # - Media paths on swarm-manager-1 must be mounted: # /mnt/media/tvshows # /mnt/media/movies # - community.docker collection installed: # ansible-galaxy collection install -r requirements.yml # # Usage: # Normal deploy: # ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_plex.yml # # Validate only (preflight and syntax checks — no changes applied to Swarm): # ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_plex.yml \ # -e "stack_validate_only=true" # # Tear down: # ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_plex.yml \ # -e "plex_deploy_state=absent" # # Verification after deploy: # docker stack services plex # docker service ps plex_plex # docker exec redis redis-cli keys 'traefik/*plex*' # curl -sf https://plex.castaldifamily.com/web/index.html | head -5 - name: Deploy Plex Media Server Swarm stack hosts: swarm_managers become: true gather_facts: false vars_files: - ../../group_vars/all.yml tasks: # -------------------------------------------------- # STEP 0: Assert required secrets are present # WHY: If vault_plex_claim is missing or still holds the placeholder value, # the stack template renders with an empty PLEX_CLAIM and Plex starts # unclaimed — a silent failure. Catching it here produces a clear, # actionable error before any Swarm state is touched. # -------------------------------------------------- - name: Assert vault_plex_claim is defined and non-empty ansible.builtin.assert: that: - vault_plex_claim is defined - vault_plex_claim | length > 0 fail_msg: >- vault_plex_claim is not defined or is empty. Encrypt your Plex claim token with: ansible-vault encrypt_string 'claim-XXXX' --name 'vault_plex_claim' then add the result to group_vars/vault/all.yml. when: inventory_hostname == groups['swarm_managers'][0] - name: Assert vault_plex_claim is not the placeholder literal ansible.builtin.assert: that: - vault_plex_claim != 'claim-XXXX' fail_msg: >- vault_plex_claim contains the placeholder value 'claim-XXXX'. Replace it with a real token from https://www.plex.tv/claim/ when: inventory_hostname == groups['swarm_managers'][0] # -------------------------------------------------- # STEP 1: Assert Swarm is active and reachable # WHY: Fail fast before touching the stack; the role also validates this # but an early assert here produces a cleaner error message. # -------------------------------------------------- - name: Collect Swarm manager state ansible.builtin.command: > docker info --format '{{ "{{" }}.Swarm.LocalNodeState{{ "}}" }}|{{ "{{" }}.Swarm.ControlAvailable{{ "}}" }}' register: _swarm_info changed_when: false when: inventory_hostname == groups['swarm_managers'][0] - name: Assert target is an active Swarm manager ansible.builtin.assert: that: # WHY exact equality: search('active') matches 'inactive' as a substring. # The format string yields 'active|true' only for a healthy manager. - _swarm_info.stdout == 'active|true' fail_msg: >- {{ inventory_hostname }} must be an active Swarm manager. Expected 'active|true', got '{{ _swarm_info.stdout | default('unknown') }}'. when: inventory_hostname == groups['swarm_managers'][0] # -------------------------------------------------- # STEP 1b: Validate Docker Engine version and hardware device availability # WHY: Device passthrough requires Docker >= 20.10. Missing devices fall # back to CPU transcoding silently — warn here for operator visibility. # These checks are NON-BLOCKING: deploy proceeds regardless of result. # -------------------------------------------------- - name: Get Docker Engine version on placement node ansible.builtin.command: docker info --format '{{ "{{" }}.ServerVersion{{ "}}" }}' register: _docker_ver changed_when: false when: inventory_hostname == groups['swarm_managers'][0] - name: Warn if Docker Engine is below 20.10 (device passthrough may fail) ansible.builtin.debug: msg: >- WARNING: Docker Engine {{ _docker_ver.stdout }} may not support Swarm device passthrough. Required: >= 20.10. Hardware transcoding may be unavailable; CPU transcoding will be used as fallback. when: - inventory_hostname == groups['swarm_managers'][0] - _docker_ver.stdout is version('20.10', '<') - name: Stat GPU device nodes on placement node ansible.builtin.stat: path: "{{ item }}" register: _device_stat loop: - /dev/renderD128 - /dev/dri when: inventory_hostname == groups['swarm_managers'][0] - name: Warn on missing GPU device nodes (CPU fallback will be used) ansible.builtin.debug: msg: >- WARNING: Device {{ item.item }} not present on {{ inventory_hostname }}. Plex will fall back to CPU transcoding. loop: "{{ _device_stat.results }}" when: - inventory_hostname == groups['swarm_managers'][0] - not item.stat.exists # -------------------------------------------------- # STEP 2: Verify media bind-mount paths exist on placement node # WHY: A missing media path causes Plex to start but serve no content. # Catch this before deploy to prevent a misleading "success" state. # -------------------------------------------------- - name: Stat required media paths on placement node ansible.builtin.stat: path: "{{ item }}" register: _media_path_stat loop: - /mnt/media/tvshows - /mnt/media/movies when: inventory_hostname == groups['swarm_managers'][0] - name: Assert media paths are present ansible.builtin.assert: that: - item.stat.exists fail_msg: >- Required media path '{{ item.item }}' does not exist on {{ inventory_hostname }}. Mount or create the path before deploying Plex. loop: "{{ _media_path_stat.results }}" when: inventory_hostname == groups['swarm_managers'][0] # -------------------------------------------------- # STEP 3: Deploy Plex stack # WHY swarm_stack_deploy role: handles template render, compose config # validation, external-network pre-check, directory creation, and # idempotent docker stack deploy with prune and registry auth. # -------------------------------------------------- - name: Deploy Plex stack ansible.builtin.include_role: name: swarm_stack_deploy vars: stack_name: "plex" stack_compose_src: "{{ playbook_dir }}/../../templates/stacks/plex.stack.yml" # WHY plex_deploy_state (not stack_state): using stack_state here would # create a Jinja2 self-reference loop — the role stores stack_state as # a template string, then any evaluation of stack_state recurses into # itself. plex_deploy_state is never internally defined, so # | default('present') always resolves cleanly. stack_state: "{{ plex_deploy_state | default('present') }}" stack_required_external_networks: - proxy-net stack_required_directories: - /mnt/homelab/apps/plex/data when: inventory_hostname == groups['swarm_managers'][0] # -------------------------------------------------- # STEP 4: Wait for service to reach desired replica count # WHY: Confirms the scheduler placed and started the task successfully, # rather than leaving the caller to check manually. # -------------------------------------------------- - name: Wait for Plex service to converge ansible.builtin.command: > docker service ls --filter name=plex_plex --format '{{ "{{" }}.Replicas{{ "}}" }}' register: _plex_replicas retries: 12 delay: 10 until: _plex_replicas.stdout is search('1/1') changed_when: false when: - inventory_hostname == groups['swarm_managers'][0] - plex_deploy_state | default('present') == 'present' - not ansible_check_mode tags: [verify] - name: Report deployment result ansible.builtin.debug: msg: - "================================================" - "Plex deployment complete." - "================================================" - "Stack : plex" - "Manager : {{ inventory_hostname }} ({{ ansible_host | default('') }})" - "Port : 32400" - "URL : https://plex.castaldifamily.com" - "Config : /mnt/homelab/apps/plex/data" - "Media : /mnt/media/tvshows, /mnt/media/movies" - "------------------------------------------------" - "Verify route keys in Traefik Redis:" - " docker exec redis redis-cli keys 'traefik/*plex*'" - "================================================" when: inventory_hostname == groups['swarm_managers'][0] tags: [always]