236 lines
10 KiB
YAML

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