236 lines
10 KiB
YAML
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]
|