From 6428b30bbb03aae26ae92a4d2b7b2d453503aafd Mon Sep 17 00:00:00 2001 From: Imugiii Date: Tue, 23 Jun 2026 09:28:40 +0000 Subject: [PATCH] feat(devops): CD push-based vers Vision (prod) + preuve de version Sur push main, le workflow Deploy ouvre une session SSH vers Vision ou une forced command lance scripts/deploy.sh : le runner (Stark, sans socket Docker) ne pilote pas Docker, il delegue a l'hote distant. La cle CI ne peut declencher que le deploiement (forced command + options no-*, cle d'hote epinglee, BatchMode). deploy.sh gagne un mode non-interactif (DEPLOY_YES), grave src/VERSION (SHA + date) et alimente deploy.log. GET /api/health expose version + deployed_at lus depuis src/VERSION : apres un deploiement, la sonde reflete le nouveau commit -> preuve verifiable du CD cote app. Mise en place cote Vision + secrets forge documentes dans docs/architecture/deployment.md. Revue compliance : 1 must_fix integre (BatchMode). --- .forgejo/workflows/deploy.yml | 45 ++++++++ .gitignore | 3 + docs/architecture/deployment.md | 107 ++++++++++++++++++ scripts/deploy.sh | 35 ++++-- src/app/Controllers/HealthController.php | 47 +++++++- .../Unit/Controllers/HealthControllerTest.php | 72 ++++++++++++ 6 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 .forgejo/workflows/deploy.yml create mode 100644 docs/architecture/deployment.md create mode 100644 tests/Unit/Controllers/HealthControllerTest.php diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..c8a2e75 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,45 @@ +name: Deploy +# Deploiement continu (CD) vers Vision (prod) a chaque release sur main. +# +# Topologie : le runner tourne sur Stark (dev) et n'a pas le socket Docker. Il ne +# pilote donc PAS Docker lui-meme : il OUVRE une session SSH vers Vision (prod, hote +# distinct) ou une forced command (cote Vision) lance scripts/deploy.sh. La cle CI ne +# peut ainsi declencher QUE le deploiement, rien d'autre. +# +# main n'est alimentee que par des PR dev->main deja passees par la CI : le code +# deploye a donc deja ete teste. Voir docs/architecture/deployment.md pour la mise en +# place cote Vision (utilisateur deploy, forced command) et les secrets a creer. + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: docker + steps: + - name: Install SSH client + run: | + apt-get update -qq + apt-get install -y -qq openssh-client >/dev/null + - name: Deploy to Vision over SSH + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ vars.DEPLOY_USER }} + run: | + set -eu + install -d -m 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + # Cle d'hote epinglee (pas de TOFU) : la connexion echoue si Vision ne + # presente pas la cle attendue. + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts + # Aucune commande passee : la forced command cote Vision lance deploy.sh. + # BatchMode : pas de prompt interactif (un echec d'auth echoue vite au lieu + # de pendre le job) ; ConnectTimeout borne l'attente si Vision est injoignable. + ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o BatchMode=yes -o ConnectTimeout=15 \ + "$DEPLOY_USER@$DEPLOY_HOST" diff --git a/.gitignore b/.gitignore index 3f40d20..83261e8 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ Thumbs.db *.log /logs/ +# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) === +/src/VERSION + # === Data / Uploads / Backups === # /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron) # et tout futur artefact run-time (caches persistes, logs). diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md new file mode 100644 index 0000000..3c637ee --- /dev/null +++ b/docs/architecture/deployment.md @@ -0,0 +1,107 @@ +# Deploiement continu (CD) — Wakdo + +Ce document decrit le deploiement automatique vers la production et la mise en place +a faire une seule fois cote serveur. Il complete `scripts/deploy.sh` et +`.forgejo/workflows/deploy.yml`. + +## Topologie + +| Hote | Role | +|---|---| +| **Thanos** (`git.acadenice.com`) | Forge : depot Git + Forgejo Actions | +| **Stark** | Environnement de dev ; heberge le runner Forgejo | +| **Vision** | Production : la stack Wakdo y tourne, cible du deploiement | + +Le runner (sur Stark) n'a pas acces au socket Docker, par choix de securite : un job +CI ne peut pas piloter Docker sur son hote. Le deploiement vers Vision se fait donc +par SSH — ce qui correspond au schema normal d'un deploiement vers un hote distant. + +## Flux + +``` +merge dev -> main (release, deja passee par la CI sur la PR) + │ + ▼ +Forgejo Actions: workflow Deploy (.forgejo/workflows/deploy.yml) + │ ssh deploy@vision (sans commande : forced command cote Vision) + ▼ +Vision: scripts/deploy.sh (git ff-only -> VERSION + deploy.log -> compose build/up) + │ + ▼ +GET /api/health renvoie le nouveau SHA ← preuve du deploiement +``` + +## Ce qui est automatise (dans le depot) + +- `.forgejo/workflows/deploy.yml` : sur push `main`, ouvre la session SSH vers Vision. +- `scripts/deploy.sh` : recupere `main` (fast-forward), ecrit le marqueur de version + (`src/VERSION`) et une ligne dans `deploy.log`, reconstruit et recree la stack. + Mode non-interactif via `DEPLOY_YES=1`. +- `GET /api/health` expose `version` (SHA) et `deployed_at` (date), lus depuis + `src/VERSION`. + +## Mise en place cote Vision (une fois) + +Prerequis : Docker + docker compose, le depot clone (ex. `/srv/wakdo`), un `.env` de +prod renseigne et un `docker-compose.prod.yml` propre a l'hote. + +1. Creer un utilisateur dedie au deploiement, membre du groupe `docker` : + ```bash + sudo useradd -m -G docker deploy + ``` +2. Lui donner le depot (ou ajuster les droits du clone existant) : + ```bash + sudo chown -R deploy:deploy /srv/wakdo + ``` +3. Autoriser la cle CI avec une **forced command** : la cle ne peut lancer que le + deploiement, aucune autre commande. Dans `~deploy/.ssh/authorized_keys` : + ``` + command="cd /srv/wakdo && DEPLOY_YES=1 scripts/deploy.sh main",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...CLE_PUBLIQUE... deploy@wakdo-ci + ``` + `deploy.sh` ne lit pas `$SSH_ORIGINAL_COMMAND` : meme si un appel SSH tentait de + passer une autre commande, elle serait ignoree. + +## Generer la cle et la connaitre cote forge + +Sur un poste de confiance : +```bash +ssh-keygen -t ed25519 -f wakdo-deploy -C "deploy@wakdo-ci" -N "" +# wakdo-deploy -> cle PRIVEE (secret de la forge, ci-dessous) +# wakdo-deploy.pub -> cle PUBLIQUE (authorized_keys de Vision, etape 3) + +ssh-keyscan -t ed25519 # -> contenu du secret DEPLOY_KNOWN_HOSTS +``` + +## Secrets et variables a creer sur la forge + +Depot -> Settings -> Actions -> Secrets / Variables : + +| Type | Nom | Valeur | +|---|---|---| +| Secret | `DEPLOY_SSH_KEY` | contenu de la cle privee `wakdo-deploy` | +| Secret | `DEPLOY_KNOWN_HOSTS` | sortie de `ssh-keyscan` (cle d'hote de Vision) | +| Secret | `DEPLOY_HOST` | nom/IP de Vision | +| Variable | `DEPLOY_USER` | `deploy` | + +## Verification + +1. Faire une release (`dev -> main`). +2. Suivre le workflow **Deploy** dans l'interface de la forge (il se declenche au push + sur `main`). +3. Interroger la sonde et lire la version deployee : + ```bash + curl -s https:///api/health + # { ... "version": "", "deployed_at": "" } + ``` + Le `version` correspond au HEAD de `main` apres la release — preuve que Vision a ete + mise a jour sans intervention manuelle. + +## Notes de securite + +- Cle SSH dediee au seul deploiement, **forced command** + options `no-*` qui retirent + shell, tunnels et forwarding. +- Cle d'hote **epinglee** (`DEPLOY_KNOWN_HOSTS`, `StrictHostKeyChecking=yes`) : pas de + confiance a la premiere connexion. +- Secrets stockes cote forge, hors du depot. `.env` et `docker-compose.prod.yml` + restent gitignores. +- Le runner n'a pas le socket Docker : un job ne peut pas agir sur Docker localement. diff --git a/scripts/deploy.sh b/scripts/deploy.sh index e36a134..765d844 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -52,25 +52,40 @@ if ! command -v docker >/dev/null 2>&1; then fi echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE" -printf 'Confirmer le deploiement en production ? [oui/NON] ' -read -r answer -if [ "$answer" != "oui" ]; then - echo "deploy: annule." - exit 1 +# Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced +# command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut +# influencer ni la branche ni le compose, seulement declencher CE script. +if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then + echo "deploy: confirmation automatique (DEPLOY_YES)." +else + printf 'Confirmer le deploiement en production ? [oui/NON] ' + read -r answer + if [ "$answer" != "oui" ]; then + echo "deploy: annule." + exit 1 + fi fi -echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)" +echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)" git fetch --prune "$REMOTE" "$BRANCH" git checkout "$BRANCH" git merge --ff-only "$REMOTE/$BRANCH" -echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)" +echo "[2/5] marqueur de version (preuve CD cote app)" +SHA="$(git rev-parse --short HEAD)" +NOW="$(date --iso-8601=seconds)" +# Sous src/ pour etre visible dans le conteneur (mount ./src -> /var/www/html), +# lu a chaud par GET /api/health. Journal d'historique a la racine du depot. +printf '%s %s\n' "$SHA" "$NOW" > src/VERSION +printf '[%s] deploy %s (branche %s)\n' "$NOW" "$SHA" "$BRANCH" >> deploy.log + +echo "[3/5] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)" docker compose -f "$COMPOSE_FILE" build --pull -echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)" +echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)" docker compose -f "$COMPOSE_FILE" up -d -echo "[4/4] etat des services" +echo "[5/5] etat des services" docker compose -f "$COMPOSE_FILE" ps -echo "Deploiement termine." +echo "Deploiement termine ($SHA)." diff --git a/src/app/Controllers/HealthController.php b/src/app/Controllers/HealthController.php index d9e7750..74bccba 100644 --- a/src/app/Controllers/HealthController.php +++ b/src/app/Controllers/HealthController.php @@ -12,9 +12,13 @@ use App\Core\Response; * Sonde de sante. GET /api/health. * * Le comptage des categories prouve la chaine complete (autoloader -> routeur - * -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. + * -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi + * la version deployee (SHA + date), ecrite par scripts/deploy.sh : c'est la preuve + * cote app du CD (apres un deploiement, ce champ reflete le nouveau commit). + * + * Non-final : seam de test (la sous-classe redirige versionFilePath sur une fixture). */ -final class HealthController extends Controller +class HealthController extends Controller { /** * @param array $params @@ -35,6 +39,8 @@ final class HealthController extends Controller $httpStatus = 503; } + $version = $this->readVersion(); + return $this->json( [ 'status' => $dbStatus === 'ok' ? 'ok' : 'degraded', @@ -42,8 +48,45 @@ final class HealthController extends Controller 'php_version' => PHP_VERSION, 'db' => $dbStatus, 'categories' => $categories, + 'version' => $version['version'], + 'deployed_at' => $version['deployed_at'], ], $httpStatus, ); } + + /** + * Chemin du marqueur de version. Sous le mount du code (./src -> /var/www/html), + * donc lisible a chaud par l'app sans rebuild. + */ + protected function versionFilePath(): string + { + return dirname(__DIR__, 2) . '/VERSION'; + } + + /** + * Lit "SHAdate" ecrit par deploy.sh. Absence toleree (dev / avant 1er + * deploiement) : les deux champs retombent a null. + * + * @return array{version: ?string, deployed_at: ?string} + */ + private function readVersion(): array + { + $path = $this->versionFilePath(); + if (!is_file($path) || !is_readable($path)) { + return ['version' => null, 'deployed_at' => null]; + } + + $line = trim((string) @file_get_contents($path)); + if ($line === '') { + return ['version' => null, 'deployed_at' => null]; + } + + $parts = explode(' ', $line, 2); + + return [ + 'version' => $parts[0] !== '' ? $parts[0] : null, + 'deployed_at' => isset($parts[1]) && $parts[1] !== '' ? $parts[1] : null, + ]; + } } diff --git a/tests/Unit/Controllers/HealthControllerTest.php b/tests/Unit/Controllers/HealthControllerTest.php new file mode 100644 index 0000000..7e51d1f --- /dev/null +++ b/tests/Unit/Controllers/HealthControllerTest.php @@ -0,0 +1,72 @@ +versionPath; + } +} + +/** + * La sonde expose la version deployee (SHA + date) pour prouver le CD : apres un + * deploiement, GET /api/health doit refleter le nouveau commit. Le test n'a pas de + * base : l'appel DB echoue et degrade le statut, mais les champs version restent + * presents (ils sont independants de la BDD). + */ +final class HealthControllerTest extends TestCase +{ + private function controller(string $versionPath): TestHealthController + { + $request = new Request('GET', '/api/health', [], [], '', '203.0.113.5'); + $c = new TestHealthController($request, new Config(), new Database(new Config())); + $c->versionPath = $versionPath; + + return $c; + } + + public function testExposesDeployedVersionWhenFilePresent(): void + { + $fixture = tempnam(sys_get_temp_dir(), 'wakdo_version_'); + file_put_contents($fixture, "3dee190 2026-06-23T14:02:11+02:00\n"); + + try { + $body = $this->controller($fixture)->index()->body(); + $payload = json_decode($body, true); + + self::assertSame('3dee190', $payload['version']); + self::assertSame('2026-06-23T14:02:11+02:00', $payload['deployed_at']); + } finally { + @unlink($fixture); + } + } + + public function testVersionNullWhenFileAbsent(): void + { + $missing = sys_get_temp_dir() . '/wakdo_version_does_not_exist_' . getmypid(); + @unlink($missing); + + $body = $this->controller($missing)->index()->body(); + $payload = json_decode($body, true); + + self::assertNull($payload['version']); + self::assertNull($payload['deployed_at']); + } +}