--- # Production deploy playbook for sa-wellness (multi-tenant wellness SaaS API). # Additions-only to sam/semaphore.git /playbook — derived from playbook/deploy-webapp.yml. # Semaphore runs this to promote a registry image to production (Gate 3). # # Required extra-vars: # version: immutable image tag to deploy, e.g. v0.1.0 — passing a PRIOR tag = rollback. # Optional: # node: proxmox node hosting the prod LXC (default pve4) # port: API container port (default 3001) # # Deploy: -e "version=v0.1.0" # Rollback: re-run with a prior version tag, e.g. -e "version=v0.1.0" # # Secrets (injected by Semaphore from Infisical as env, never committed / printed): # REGISTRY_USER, REGISTRY_TOKEN — Gitea OCI registry pull creds # SA_DB_PASSWORD — production PostgreSQL password # SA_JWT_SIGNING_KEY — >= 32 chars; app fails closed otherwise # SA_CORS_ORIGINS — exact comma-separated production origin(s) - name: Deploy sa-wellness:{{ version | default('UNSET') }} to production hosts: "{{ node | default('pve4') }}" gather_facts: false vars: registry: "192.168.122.103:3000" prefix: "sa-wellness" image: "192.168.122.103:3000/sam/sa-wellness:{{ version }}" ctname: "sa-wellness-prod" cport: "{{ port | default('3001') }}" net: "sa-net" dburl: "postgresql://sa:{{ lookup('env','SA_DB_PASSWORD') }}@sa-db:5432/sa_wellness" tasks: - name: Require an immutable version argument ansible.builtin.assert: that: - version is defined - version | length > 0 - version is match('^v[0-9]+\.[0-9]+\.[0-9]+$') fail_msg: "version must be an immutable vMAJOR.MINOR.PATCH tag (pass -e version=vX.Y.Z). Rollback = a prior tag." - name: Require production secrets are present ansible.builtin.assert: that: - lookup('env','SA_JWT_SIGNING_KEY') | length >= 32 - lookup('env','SA_DB_PASSWORD') | length > 0 - lookup('env','SA_CORS_ORIGINS') | length > 0 fail_msg: "SA_JWT_SIGNING_KEY (>=32), SA_DB_PASSWORD, and SA_CORS_ORIGINS must be injected by Semaphore." no_log: true - name: Ensure the prod LXC exists (provisioned by Semaphore before first deploy) ansible.builtin.shell: | set -e if [ -z "$(pct list | awk '$0 ~ /{{ ctname }}/ {print $1}')" ]; then echo "prod LXC {{ ctname }} must be provisioned by Semaphore before first deploy"; exit 3 fi changed_when: false - name: Resolve the prod LXC VMID ansible.builtin.shell: "pct list | awk '$0 ~ /{{ ctname }}/ {print $1}'" register: vmid_out changed_when: false - name: Set VMID fact ansible.builtin.set_fact: vmid: "{{ vmid_out.stdout | trim }}" - name: Log in to the Gitea registry inside the prod LXC (creds never printed) ansible.builtin.shell: > pct exec {{ vmid }} -- bash -lc 'echo "{{ lookup("env","REGISTRY_TOKEN") }}" | docker login {{ registry }} -u "{{ lookup("env","REGISTRY_USER") }}" --password-stdin' no_log: true - name: Ensure docker network exists ansible.builtin.shell: "pct exec {{ vmid }} -- docker network create {{ net }} 2>/dev/null || true" changed_when: false - name: Ensure PostgreSQL is running (persistent volume) ansible.builtin.shell: > pct exec {{ vmid }} -- bash -lc 'docker ps --filter name=sa-db --filter status=running -q | grep -q . || docker run -d --name sa-db --network {{ net }} --restart unless-stopped -e POSTGRES_USER=sa -e POSTGRES_PASSWORD="{{ lookup("env","SA_DB_PASSWORD") }}" -e POSTGRES_DB=sa_wellness -v sa-db-data:/var/lib/postgresql/data postgres:16-alpine' no_log: true - name: Wait for PostgreSQL readiness ansible.builtin.shell: > pct exec {{ vmid }} -- bash -lc 'for i in $(seq 1 30); do docker exec sa-db pg_isready -U sa >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1' changed_when: false - name: Pull the versioned image ansible.builtin.shell: "pct exec {{ vmid }} -- docker pull {{ image }}" - name: Run migrations for this version (app does not auto-migrate) ansible.builtin.shell: > pct exec {{ vmid }} -- docker run --rm --network {{ net }} -e DATABASE_URL="{{ dburl }}" --entrypoint node {{ image }} dist/db/migrate.js no_log: true - name: Stop/remove previous app container (idempotent) ansible.builtin.shell: "pct exec {{ vmid }} -- docker rm -f sa-app 2>/dev/null || true" changed_when: false - name: Run the versioned API container (non-root image; secrets via env) ansible.builtin.shell: > pct exec {{ vmid }} -- docker run -d --name sa-app --network {{ net }} --restart unless-stopped -e DATABASE_URL="{{ dburl }}" -e JWT_SIGNING_KEY="{{ lookup('env','SA_JWT_SIGNING_KEY') }}" -e CORS_ORIGINS="{{ lookup('env','SA_CORS_ORIGINS') }}" -e PORT={{ cport }} -p {{ cport }}:{{ cport }} {{ image }} no_log: true - name: Verify liveness (/healthz 200) ansible.builtin.shell: > pct exec {{ vmid }} -- bash -lc 'for i in $(seq 1 15); do [ "$(curl -s -o /dev/null -w %{http_code} http://127.0.0.1:{{ cport }}/healthz)" = "200" ] && exit 0; sleep 1; done; exit 1' changed_when: false - name: Verify readiness (/readyz 200 — database reachable) ansible.builtin.shell: > pct exec {{ vmid }} -- bash -lc 'for i in $(seq 1 15); do [ "$(curl -s -o /dev/null -w %{http_code} http://127.0.0.1:{{ cport }}/readyz)" = "200" ] && exit 0; sleep 1; done; exit 1' changed_when: false - name: Record deploy result ansible.builtin.debug: msg: "Deployed {{ image }} to {{ ctname }} (VMID {{ vmid }}, port {{ cport }}). Rollback: re-run with a prior version tag."