Ctrl K
ComunidadGuías…

ansible/deploy.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
---
# myNextScreen — Full Deployment Playbook (GHCR pull model)
# Usage: ansible-playbook -i hosts.ini deploy.yml -K
#
# Images are built + published to GHCR by CI (.github/workflows/ci.yml).
# This playbook pulls the published images and runs them via rootless Podman
# Quadlets. Build nothing locally.
#
# Required variables (set in hosts.ini, group_vars, or -e):
#   domain_name:        Base domain (e.g., mynextscreen.example.com)
#   jwt_access_secret:  Strong random secret for signing access JWTs (vault it)
#   secrets_encryption_key: 32-byte base64 key that encrypts every stored secret
#                       (openssl rand -base64 32; vault it, back it up apart from DB dumps)
#   service_user:       Linux user that runs Podman (default: mynextscreen)
#   mynextscreen_image_tag:  GHCR tag to deploy (default: the version in package.json)
#   ghcr_username:      GHCR user for `podman login` (default: image owner)
#   ghcr_token:         GHCR PAT (read:packages). Omit if packages are public.
#   vault_mynextscreen_postgres_password: Postgres password (vault it; no default)

- name: Deploy to server
  hosts: servers
  become: true
  vars:
    service_user: mynextscreen
    service_user_home: "/home/{{ service_user }}"
    quadlet_dir: "{{ service_user_home }}/.config/containers/systemd"
    image_registry: "ghcr.io/flo-schilli/mynextscreen"
    # Pinned to the version of the checkout being deployed, not `latest`:
    # combined with AutoUpdate=registry, a moving tag meant production drifted
    # to whatever CI pushed last, with no reproducible rollback target.
    # Override with -e mynextscreen_image_tag=1.2.3 to deploy a different release.
    mynextscreen_image_tag: "{{ (lookup('file', playbook_dir + '/../package.json') | from_json).version }}"
    ghcr_username: flo-schilli
    # Fail-closed: must be provided via Ansible Vault or --extra-vars.
    mynextscreen_postgres_password: "{{ vault_mynextscreen_postgres_password | mandatory }}"
    app_dirs:
      - "{{ service_user_home }}/app"
      - "{{ service_user_home }}/app/data"
      - "{{ service_user_home }}/app/media"
      - "{{ service_user_home }}/app/backups"
    app_images:
      - backend
      - frontend
      - player
    services:
      - mynextscreen-redis
      - mynextscreen-postgres
      - mynextscreen-backend
      - mynextscreen-frontend
      - mynextscreen-player

  pre_tasks:
    # Secrets are never stored in plaintext: the backend refuses to boot without
    # this key, so fail here — before anything on the host changes.
    - name: Require SECRETS_ENCRYPTION_KEY (32 bytes, base64)
      ansible.builtin.assert:
        that:
          - secrets_encryption_key is defined
          - secrets_encryption_key is match('^[A-Za-z0-9+/]{43}=$')
        fail_msg: >-
          secrets_encryption_key is missing or not 32 bytes base64.
          Generate it with `openssl rand -base64 32`, store it in Ansible Vault
          and back it up separately from database dumps. Existing plaintext
          secrets are encrypted automatically on the next backend start.
        quiet: true
      no_log: true

  tasks:
    # ─── System packages ──────────────────────────────────────
    - name: Install required packages
      ansible.builtin.dnf:
        name:
          - podman
          - firewalld
          - caddy
          - fail2ban
          - sqlite
          - acl
          - net-tools
        state: present

    - name: Ensure firewalld is running
      ansible.builtin.systemd:
        name: firewalld
        state: started
        enabled: true

    - name: Open firewall ports for HTTP/HTTPS
      ansible.posix.firewalld:
        service: "{{ item }}"
        permanent: true
        immediate: true
        state: enabled
      loop:
        - http
        - https

    # ─── Service user ─────────────────────────────────────────
    - name: Create service user
      ansible.builtin.user:
        name: "{{ service_user }}"
        shell: /bin/bash
        create_home: true
        state: present

    - name: Enable linger for service user
      ansible.builtin.command:
        cmd: loginctl enable-linger {{ service_user }}
      changed_when: true

    # ─── Directory structure ──────────────────────────────────
    - name: Create application directories
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        owner: "{{ service_user }}"
        group: "{{ service_user }}"
        # Backups hold the full database — password hashes, API keys and the
        # per-org SMTP credentials — so they are owner-only, unlike the rest.
        mode: "{{ '0700' if item.endswith('/backups') else '0755' }}"
      loop: "{{ app_dirs }}"

    - name: Create Quadlet directory
      ansible.builtin.file:
        path: "{{ quadlet_dir }}"
        state: directory
        owner: "{{ service_user }}"
        group: "{{ service_user }}"
        mode: "0755"

    - name: Create Caddy log directory
      ansible.builtin.file:
        path: /var/log/caddy
        state: directory
        owner: caddy
        group: caddy
        mode: "0755"
        setype: httpd_log_t

    # ─── SELinux for Caddy logs ───────────────────────────────
    - name: Install SELinux policy tools
      ansible.builtin.dnf:
        name:
          - policycoreutils-python-utils
        state: present

    - name: Set SELinux file context for Caddy logs
      community.general.sefcontext:
        target: "/var/log/caddy(/.*)?"
        setype: httpd_log_t
        state: present

    - name: Apply SELinux file context to Caddy log directory
      ansible.builtin.command:
        cmd: restorecon -Rv /var/log/caddy/
      changed_when: true

    # Built in a root-owned private directory rather than in /tmp: compiling a
    # policy module from a world-readable file inside a world-writable directory
    # invites a symlink race on hosts with other local accounts.
    - name: Create SELinux policy build directory
      ansible.builtin.file:
        path: /var/lib/mynextscreen-selinux
        state: directory
        owner: root
        group: root
        mode: "0700"

    - name: Copy Caddy SELinux policy source
      ansible.builtin.copy:
        src: "{{ playbook_dir }}/templates/caddy_log.te"
        dest: /var/lib/mynextscreen-selinux/caddy_log.te
        owner: root
        group: root
        mode: "0600"

    - name: Check if Caddy log SELinux module is already loaded
      ansible.builtin.command:
        cmd: semodule -l
      register: semodule_list
      changed_when: false

    - name: Compile and install Caddy SELinux policy module
      ansible.builtin.shell: |
        cd /var/lib/mynextscreen-selinux
        checkmodule -M -m -o caddy_log.mod caddy_log.te
        semodule_package -o caddy_log.pp -m caddy_log.mod
        semodule -i caddy_log.pp
      when: "'caddy_log' not in semodule_list.stdout"

    # ─── Database backup ──────────────────────────────────────
    - name: Check if PostgreSQL container is running
      ansible.builtin.command: podman container exists mynextscreen-postgres
      register: pg_running
      changed_when: false
      failed_when: false

    - name: Backup existing PostgreSQL database (pg_dump custom format)
      ansible.builtin.shell: >-
        podman exec mynextscreen-postgres pg_dump -U mynextscreen -d mynextscreen -Fc
        > "{{ service_user_home }}/app/backups/mynextscreen_{{ ansible_date_time.iso8601_basic_short }}.dump"
      args:
        executable: /bin/bash
      when: pg_running.rc == 0
      changed_when: true

    - name: Restrict database dump permissions
      ansible.builtin.file:
        path: "{{ service_user_home }}/app/backups"
        state: directory
        recurse: true
        owner: "{{ service_user }}"
        group: "{{ service_user }}"
        mode: u=rwX,g=,o=
      when: pg_running.rc == 0

    # ─── Quadlet files and env ────────────────────────────────
    - name: Copy static Quadlet files
      ansible.builtin.copy:
        src: "{{ playbook_dir }}/quadlets/{{ item }}"
        dest: "{{ quadlet_dir }}/{{ item }}"
        owner: "{{ service_user }}"
        group: "{{ service_user }}"
        mode: "0644"
      loop:
        - mynextscreen.network
        - mynextscreen-redis.container
        - mynextscreen-postgres.container

    - name: Template application Quadlet files (pin GHCR image tag)
      ansible.builtin.template:
        src: "{{ playbook_dir }}/templates/{{ item }}.j2"
        dest: "{{ quadlet_dir }}/{{ item }}"
        owner: "{{ service_user }}"
        group: "{{ service_user }}"
        mode: "0644"
      loop:
        - mynextscreen-backend.container
        - mynextscreen-frontend.container
        - mynextscreen-player.container

    - name: Generate environment file
      ansible.builtin.template:
        src: "{{ playbook_dir }}/templates/env.mynextscreen.j2"
        dest: "{{ quadlet_dir }}/.env.mynextscreen"
        owner: "{{ service_user }}"
        group: "{{ service_user }}"
        mode: "0600"

    - name: Generate PostgreSQL environment file
      ansible.builtin.template:
        src: "{{ playbook_dir }}/templates/env.mynextscreen-postgres.j2"
        dest: "{{ quadlet_dir }}/.env.mynextscreen-postgres"
        owner: "{{ service_user }}"
        group: "{{ service_user }}"
        mode: "0600"

    # ─── Stop services before image update ────────────────────
    - name: Get service user UID (for container ops)
      ansible.builtin.command:
        cmd: id -u {{ service_user }}
      register: uid_result_early
      changed_when: false

    - name: Stop Podman services before image update
      ansible.builtin.command:
        cmd: systemctl --user stop {{ item }}
      become: true
      become_user: "{{ service_user }}"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}"
        DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ uid_result_early.stdout }}/bus"
      loop: "{{ services | reverse | list }}"
      failed_when: false

    - name: Remove old containers
      ansible.builtin.command:
        cmd: podman rm -f {{ item }}
      become: true
      become_user: "{{ service_user }}"
      loop:
        - mynextscreen-player
        - mynextscreen-frontend
        - mynextscreen-backend
      failed_when: false

    # ─── Pull images from GHCR ────────────────────────────────
    - name: Log in to GHCR
      ansible.builtin.command:
        cmd: podman login ghcr.io -u {{ ghcr_username }} --password-stdin
        stdin: "{{ ghcr_token }}"
      become: true
      become_user: "{{ service_user }}"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}"
      when: ghcr_token is defined and ghcr_token | length > 0
      no_log: true

    - name: Pull application images from GHCR
      ansible.builtin.command:
        cmd: podman pull {{ image_registry }}/{{ item }}:{{ mynextscreen_image_tag }}
      become: true
      become_user: "{{ service_user }}"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}"
      loop: "{{ app_images }}"

    - name: Pull Redis image
      ansible.builtin.command:
        cmd: podman pull docker.io/library/redis:7-alpine
      become: true
      become_user: "{{ service_user }}"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}"

    - name: Pull PostgreSQL image
      ansible.builtin.command:
        cmd: podman pull docker.io/library/postgres:16-alpine
      become: true
      become_user: "{{ service_user }}"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}"

    # ─── Caddy configuration ─────────────────────────────────
    - name: Ensure Caddyfile.d directory exists
      ansible.builtin.file:
        path: /etc/caddy/Caddyfile.d
        state: directory
        owner: root
        group: root
        mode: "0755"

    - name: Generate Caddyfile
      ansible.builtin.template:
        src: "{{ playbook_dir }}/templates/Caddyfile.j2"
        dest: "/etc/caddy/Caddyfile.d/{{ caddy_site_prefix | default('02') }}_{{ domain_name }}.caddyfile"
        owner: root
        group: root
        mode: "0644"
      notify: Reload Caddy

    # ─── Fail2ban ─────────────────────────────────────────────
    - name: Deploy fail2ban filter for Caddy
      ansible.builtin.template:
        src: "{{ playbook_dir }}/templates/fail2ban-caddy-filter.conf.j2"
        dest: /etc/fail2ban/filter.d/caddy-auth.conf
        owner: root
        group: root
        mode: "0644"
      notify: Restart fail2ban

    - name: Deploy fail2ban jail for Caddy
      ansible.builtin.template:
        src: "{{ playbook_dir }}/templates/fail2ban-caddy-jail.conf.j2"
        dest: /etc/fail2ban/jail.d/caddy-auth.conf
        owner: root
        group: root
        mode: "0644"
      notify: Restart fail2ban

    # ─── Start services ───────────────────────────────────────
    - name: Get service user UID
      ansible.builtin.command:
        cmd: id -u {{ service_user }}
      register: uid_result
      changed_when: false

    - name: Set service user UID fact
      ansible.builtin.set_fact:
        service_user_uid: "{{ uid_result.stdout }}"

    - name: Reload systemd user daemon
      ansible.builtin.command:
        cmd: systemctl --user daemon-reload
      become: true
      become_user: "{{ service_user }}"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ service_user_uid }}"
        DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ service_user_uid }}/bus"

    - name: Restart Podman services in order
      ansible.builtin.command:
        cmd: systemctl --user restart {{ item }}
      become: true
      become_user: "{{ service_user }}"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ service_user_uid }}"
        DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ service_user_uid }}/bus"
      loop: "{{ services }}"

    - name: Enable Caddy
      ansible.builtin.systemd:
        name: caddy
        state: started
        enabled: true

    - name: Enable fail2ban
      ansible.builtin.systemd:
        name: fail2ban
        state: started
        enabled: true

  handlers:
    - name: Reload Caddy
      ansible.builtin.systemd:
        name: caddy
        state: reloaded

    - name: Restart fail2ban
      ansible.builtin.systemd:
        name: fail2ban
        state: restarted