feat(devops): CD push-based vers Vision (prod) + preuve de version
All checks were successful
CI / secret-scan (push) Successful in 13s
CI / php-lint (push) Successful in 26s
CI / static-tests (push) Successful in 56s
CI / js-tests (push) Successful in 32s
CI / secret-scan (pull_request) Successful in 11s
CI / php-lint (pull_request) Successful in 21s
CI / static-tests (pull_request) Successful in 50s
CI / js-tests (pull_request) Successful in 29s

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).
This commit is contained in:
Imugiii 2026-06-23 09:28:40 +00:00
parent 193a15abf7
commit 6428b30bbb
6 changed files with 297 additions and 12 deletions

View file

@ -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"

3
.gitignore vendored
View file

@ -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).

View file

@ -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 <hote-vision> # -> 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://<fqdn-admin-prod>/api/health
# { ... "version": "<sha>", "deployed_at": "<date>" }
```
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.

View file

@ -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
# 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)."

View file

@ -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<string, string> $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 "SHA<espace>date" 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,
];
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Controllers;
use PHPUnit\Framework\TestCase;
use App\Controllers\HealthController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
/**
* Sous-classe de test : pointe le fichier VERSION sur une fixture temporaire,
* pour couvrir l'exposition de la version deployee sans dependre d'un deploiement
* reel (le fichier est ecrit par scripts/deploy.sh sur l'hote, jamais en test).
*/
final class TestHealthController extends HealthController
{
public string $versionPath = '';
protected function versionFilePath(): string
{
return $this->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']);
}
}