add: sa-wellness prod provision + versioned deploy playbooks (Gate 3)
Additions-only. provision-sa-wellness-prod.yml creates the sa-wellness-prod LXC with docker (Semaphore provisions prod). deploy-sa-wellness.yml promotes an immutable vX.Y.Z registry image: requires version arg (rollback = prior tag), runs PostgreSQL + one-time migrations, runs the non-root API, verifies /healthz + /readyz 200. Secrets (REGISTRY_*, SA_DB_PASSWORD, SA_JWT_SIGNING_KEY, SA_CORS_ORIGINS) injected by Semaphore from Infisical, never committed.
This commit is contained in:
parent
5625ff89a1
commit
b000a132ac
129
playbook/deploy-sa-wellness.yml
Normal file
129
playbook/deploy-sa-wellness.yml
Normal file
@ -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."
|
||||
59
playbook/provision-sa-wellness-prod.yml
Normal file
59
playbook/provision-sa-wellness-prod.yml
Normal file
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user