feat(devops): CD push-based vers Vision (prod) + preuve de version #94
6 changed files with 297 additions and 12 deletions
45
.forgejo/workflows/deploy.yml
Normal file
45
.forgejo/workflows/deploy.yml
Normal 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
3
.gitignore
vendored
|
|
@ -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).
|
||||
|
|
|
|||
107
docs/architecture/deployment.md
Normal file
107
docs/architecture/deployment.md
Normal 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.
|
||||
|
|
@ -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)."
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
tests/Unit/Controllers/HealthControllerTest.php
Normal file
72
tests/Unit/Controllers/HealthControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue