ops: un deploiement qui refuse de partir casse, et qui se verifie
OPS-011, en partie. Ce que le workflow garantit maintenant : - rien ne part d un arbre casse. La suite, ruff check et ruff format tournent sur le runner de deploiement avant tout envoi. Une CI verte sur GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce que la branche contient a cet instant ; - seuls les fichiers nommes partent. La charge est une liste blanche — app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf exclusions. C est par cette porte que clear_db.py, la suite de tests et les definitions de CI se sont retrouves sur le noeud de production ; - le deploiement est verifie. /health est interroge pendant deux minutes apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant, un arbre a moitie televerse etait un deploiement vert. Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est pas atomique. Le miroir se fait sur place, donc pendant le transfert la production execute un melange de deux versions. En cherchant a fermer ce point, un defaut a part entiere est apparu. Les contrats etaient ranges a os.getcwd()/documents et leur chemin absolu ecrit en base. La racine de stockage suivait donc le repertoire depuis lequel le processus avait ete lance : redemarrer le serveur ailleurs envoie les nouveaux contrats dans un nouvel arbre et rend les anciens illisibles — la base continuant d affirmer qu ils sont la, la panne se manifeste par un 500 au telechargement, pas par quelque chose d actionnable. app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu et continuent de resoudre : aucune migration de donnees n est necessaire, donc ce changement n attend pas Alembic. C etait aussi le troisieme pre-requis de la bascule par repertoires de version. Les deux autres sont hors d atteinte d ici — la commande de demarrage Pterodactyl doit pointer sur current/, et les repertoires partages doivent etre installes sur le noeud. Les deux sont decrits dans docs/deployment.md, avec la procedure de retour arriere qui manquait. 511 tests.
This commit is contained in:
@@ -6,6 +6,34 @@ on:
|
||||
# branches:
|
||||
# - main # Optional: Run automatically on pushes to the main branch
|
||||
|
||||
# OPS-011 — what this workflow now guarantees, and what it still does not.
|
||||
#
|
||||
# Guaranteed:
|
||||
# - nothing is uploaded unless the test suite and the linters pass;
|
||||
# - only files on an explicit allowlist are uploaded, so a new file at the
|
||||
# repository root does not reach production by default. That is how
|
||||
# clear_db.py — a script that DROPs every table and recreates
|
||||
# admin/password — got there in the first place;
|
||||
# - after the upload, /health is polled until it answers healthy, and the
|
||||
# job fails loudly if it does not. Before, a half-uploaded tree was a
|
||||
# green deployment.
|
||||
#
|
||||
# NOT guaranteed — the switch is not atomic. Files are mirrored in place, so
|
||||
# for the length of the transfer production runs a mixture of two versions.
|
||||
# Closing that needs a release-directory layout, which has three
|
||||
# prerequisites, two of which cannot be done from here:
|
||||
#
|
||||
# 1. the Pterodactyl startup command must run the app from `current/`
|
||||
# rather than from the server root, and the server must be restarted on
|
||||
# switch — a panel change;
|
||||
# 2. `documents/`, `logs/` and `.env` must live beside the releases, not
|
||||
# inside one. DOCUMENTS_ROOT exists for this (app/storage.py);
|
||||
# 3. contract paths must be relative to that root, so the switch does not
|
||||
# strand them. Done: new rows are relative, old absolute ones still
|
||||
# resolve.
|
||||
#
|
||||
# docs/deployment.md carries the design and the rollback procedure.
|
||||
|
||||
jobs:
|
||||
deploy-to-sftp:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -16,51 +44,98 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install lftp and ssh
|
||||
run: sudo apt-get update && sudo apt-get install -y lftp openssh-client
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
# The gate. This runner is not the GitHub one, so a green CI over
|
||||
# there proves nothing about what is about to be shipped from here:
|
||||
# the deploy is triggered by hand, on whatever the branch holds.
|
||||
- name: Refuse to deploy a broken tree
|
||||
run: |
|
||||
python -m pytest -q
|
||||
python -m ruff check .
|
||||
python -m ruff format --check .
|
||||
|
||||
# An allowlist, not a list of exclusions. The previous form mirrored
|
||||
# the whole working tree minus nine globs, so every file added to the
|
||||
# repository shipped to production unless someone remembered to
|
||||
# exclude it. This inverts the default: a new top-level file has to be
|
||||
# named here to reach the server.
|
||||
- name: Assemble the release payload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p payload
|
||||
cp -r app payload/
|
||||
cp requirements.txt wsgi.py payload/
|
||||
# Compiled catalogues are versioned deliberately: the deployment is
|
||||
# a file mirror with no build step (docs/translations.md).
|
||||
find payload -name '__pycache__' -type d -prune -exec rm -rf {} +
|
||||
find payload -name '*.pyc' -delete
|
||||
echo "Shipping $(find payload -type f | wc -l) files:"
|
||||
find payload -maxdepth 2 -type d | sort
|
||||
|
||||
- name: Set up SSH Private Key
|
||||
env:
|
||||
# Binds the secret to a secure environment variable
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
# Uses the environment variable, so the raw key is never printed in the execution log
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
|
||||
- name: Push files via SFTP with progress
|
||||
run: |
|
||||
# The mirror command below uses the -R (reverse) flag
|
||||
# to push from local './' to remote './'
|
||||
# Connection is made using 'open' inside the execution block to enforce SSH key usage
|
||||
#
|
||||
# --exclude-glob entries: the previous command mirrored the entire
|
||||
# working tree, so CI definitions, the test suite and clear_db.py --
|
||||
# a script that DELETEs every table and recreates admin/password --
|
||||
# were all shipped to the production node.
|
||||
#
|
||||
# --delete is deliberately NOT used. Uploaded contracts, logs and the
|
||||
# server's own .env live under the deployment root and are absent
|
||||
# from the repository; deleting anything not present locally would
|
||||
# destroy them. Stale files therefore accumulate: switching to an
|
||||
# atomic timestamped-directory deploy is tracked as OPS-011.
|
||||
# destroy them. Stale files therefore still accumulate — that is the
|
||||
# other half of what the release-directory layout would fix.
|
||||
lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \
|
||||
set sftp:auto-confirm yes; \
|
||||
set net:max-retries 5; \
|
||||
set net:timeout 30; \
|
||||
set cmd:fail-exit yes; \
|
||||
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
|
||||
mirror -R --verbose --parallel=4 \
|
||||
--exclude-glob .git/ \
|
||||
--exclude-glob .github/ \
|
||||
--exclude-glob .gitea/ \
|
||||
--exclude-glob .venv/ \
|
||||
--exclude-glob venv/ \
|
||||
--exclude-glob tests/ \
|
||||
--exclude-glob audit/ \
|
||||
--exclude-glob .ai/ \
|
||||
--exclude-glob __pycache__/ \
|
||||
--exclude-glob clear_db.py \
|
||||
./ ./; \
|
||||
quit"
|
||||
mirror -R --verbose --parallel=4 ./payload/ ./; \
|
||||
quit"
|
||||
|
||||
# Without this a deployment that left the site 500ing reported success,
|
||||
# and the first person to hear about it was a user. /health checks the
|
||||
# database connection and reports whether the Discord bot thread is
|
||||
# alive (OPS-012).
|
||||
# HEALTH_URL is carried as a secret rather than as a variable. It is
|
||||
# not secret — it is the public site — but `secrets` is the context
|
||||
# this runner is already known to support, and a smoke test that fails
|
||||
# to run because of an unsupported expression is worse than none.
|
||||
- name: Smoke test
|
||||
if: ${{ secrets.HEALTH_URL != '' }}
|
||||
env:
|
||||
HEALTH_URL: ${{ secrets.HEALTH_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The app is restarted by the panel, not by this workflow, so the
|
||||
# first few probes are expected to fail or answer from the old
|
||||
# process. Two minutes, then give up.
|
||||
for attempt in $(seq 1 24); do
|
||||
body=$(curl -fsS --max-time 10 "$HEALTH_URL" 2>/dev/null) || body=''
|
||||
if echo "$body" | grep -q '"status": *"healthy"'; then
|
||||
echo "Healthy after ${attempt} attempt(s):"
|
||||
echo "$body"
|
||||
exit 0
|
||||
fi
|
||||
echo "attempt ${attempt}: not healthy yet"
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::/health never reported healthy. The deployment is live and may be broken — see the rollback procedure in docs/deployment.md."
|
||||
exit 1
|
||||
|
||||
- name: Warn when no health check is configured
|
||||
if: ${{ secrets.HEALTH_URL == '' }}
|
||||
run: |
|
||||
echo "::warning::HEALTH_URL is not set, so this deployment was not verified. Set it to https://<host>/health in the repository secrets."
|
||||
|
||||
Reference in New Issue
Block a user