diff --git a/playbook/deploy-sa-wellness.yml b/playbook/deploy-sa-wellness.yml new file mode 100644 index 0000000..bf1255f --- /dev/null +++ b/playbook/deploy-sa-wellness.yml @@ -0,0 +1,129 @@ +--- +# 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." diff --git a/playbook/provision-sa-wellness-prod.yml b/playbook/provision-sa-wellness-prod.yml new file mode 100644 index 0000000..bf2ae1a --- /dev/null +++ b/playbook/provision-sa-wellness-prod.yml @@ -0,0 +1,59 @@ +--- +# Provision the sa-wellness PRODUCTION LXC (Gate 3, Semaphore-run, one-time). +# Additions-only to sam/semaphore.git /playbook. Semaphore provisions PROD (not dev/staging). +# After this runs once, playbook/deploy-sa-wellness.yml deploys/rolls versioned images into it. +# +# Optional extra-vars: +# node: proxmox node to create the LXC on (default pve4) +# vmid: prod VMID (default 130 — 1xx range; pick the lowest free if taken) +# ip: static CIDR (default 192.168.122.130/24) +# +# Run: -e "vmid=130 ip=192.168.122.130/24" + +- name: Provision sa-wellness-prod LXC + hosts: "{{ node | default('pve4') }}" + gather_facts: false + vars: + vmid: "{{ vmid | default('130') }}" + ctip: "{{ ip | default('192.168.122.130/24') }}" + ctname: "sa-wellness-prod" + template: "local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst" + tasks: + - name: Create the prod LXC if absent (nesting for docker, onboot, tagged) + ansible.builtin.shell: | + set -e + if [ -n "$(pct list | awk '$0 ~ /{{ ctname }}/ {print $1}')" ]; then + echo "exists"; exit 0 + fi + pct create {{ vmid }} {{ template }} \ + --hostname {{ ctname }} \ + --cores 2 --memory 2048 --swap 512 \ + --rootfs local-lvm:16 \ + --net0 name=eth0,bridge=vmbr0,ip={{ ctip }},gw=192.168.122.1,type=veth \ + --nameserver 192.168.122.1 \ + --features nesting=1,keyctl=1 --unprivileged 1 \ + --onboot 1 --tags 'sa-wellness;production;docker;api' --start 1 + sleep 8 + register: create_out + changed_when: "'exists' not in create_out.stdout" + + - name: Install docker inside the prod LXC (idempotent) + ansible.builtin.shell: | + pct exec {{ vmid }} -- bash -lc ' + command -v docker >/dev/null 2>&1 && exit 0 + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq && apt-get install -y -qq ca-certificates curl gnupg + install -m0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + . /etc/os-release + echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $VERSION_CODENAME stable" > /etc/apt/sources.list.d/docker.list + apt-get update -qq && apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin + mkdir -p /etc/docker + echo "{\"insecure-registries\":[\"192.168.122.103:3000\"]}" > /etc/docker/daemon.json + systemctl restart docker' + changed_when: false + + - name: Report + ansible.builtin.debug: + msg: "Provisioned {{ ctname }} (VMID {{ vmid }}, {{ ctip }}). Next: run deploy-sa-wellness.yml -e version=vX.Y.Z"