Versioning, déploiement et mise à jour automatique

Gestion des versions :
- Fichier VERSION (1.0.0) comme source de vérité
- config/update.php : URL Gitea, AUTO_UPDATE (false par défaut), rétention des sauvegardes

Artisan commands :
- app:check-update  : interroge l'API Gitea, cache Redis 1h, déclenche app:update si AUTO_UPDATE=true
- app:update        : télécharge l'archive, sauvegarde pg_dump, rsync, composer install, migrate, reload php-fpm
- app:rollback      : liste les sauvegardes et restaure via psql

UpdateService :
- Téléchargement via Http::sink() (streaming, pas de charge mémoire)
- Sauvegarde pg_dump dans storage/app/backups/ avant chaque mise à jour
- Rechargement php-fpm gracieux (kill -USR2 1) sans downtime
- Purge automatique des anciennes sauvegardes (configurable)

Docker (refactor pour volume-mount) :
- Dockerfile : runtime seulement (PHP + extensions + composer + rsync + pg_client)
  Le code n'est plus copié dans l'image → les mises à jour ne nécessitent pas de rebuild
- entrypoint.sh : composer install + key:generate + caches au démarrage du container
- docker-compose.prod.yml : montage du code comme volume (.:/var/www/html)

Scripts de déploiement :
- bin/build-release.sh : rsync + tar.gz + sha256, exclut vendor/node_modules/tests
- install.sh : guide d'installation Docker complète (première mise en service)

Interface admin :
- Bandeau "mise à jour disponible" dans le dashboard admin (version courante + cible)
- Badge version + icône "à jour" en pied de tableau de bord
- Commande à copier-coller pour appliquer depuis le container

Planification :
- routes/console.php : Schedule::command('app:check-update')->daily()
- .env.example : variables GITEA_*, AUTO_UPDATE, UPDATE_BACKUPS_TO_KEEP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 17:38:39 +02:00
parent c790691200
commit ba7fe10329
15 changed files with 756 additions and 23 deletions
@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Services\UpdateService;
use Illuminate\Console\Command;
class ApplyUpdateCommand extends Command
{
protected $signature = 'app:update
{--force : Pas de confirmation interactive}
{--check : Vérifie uniquement, sans appliquer}';
protected $description = 'Télécharge et applique la dernière mise à jour disponible';
public function handle(UpdateService $updates): int
{
$installed = $updates->getInstalledVersion();
$this->line("Version installée : <info>v{$installed}</info>");
$this->line("Vérification des mises à jour...");
$latest = $updates->fetchLatestRelease(useCache: false);
if (! $latest) {
$this->error("Impossible de contacter le serveur de mises à jour.");
return self::FAILURE;
}
if (! version_compare($latest['version'], $installed, '>')) {
$this->info("L'application est déjà à jour (v{$installed}).");
return self::SUCCESS;
}
$this->info("Mise à jour disponible : v{$installed} → <comment>v{$latest['version']}</comment>");
if ($latest['body']) {
$this->line($latest['body']);
}
if ($this->option('check')) {
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm('Procéder à la mise à jour ?', true)) {
return self::SUCCESS;
}
$this->line("");
try {
$updates->applyUpdate($latest, fn ($msg) => $this->line($msg));
} catch (\Exception $e) {
$this->error("La mise à jour a échoué : " . $e->getMessage());
$this->warn("Vérifiez les sauvegardes dans storage/app/backups/ pour une restauration manuelle.");
return self::FAILURE;
}
return self::SUCCESS;
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use App\Services\UpdateService;
use Illuminate\Console\Command;
class CheckUpdateCommand extends Command
{
protected $signature = 'app:check-update {--force : Ignore le cache et interroge Gitea directement}';
protected $description = 'Vérifie si une nouvelle version de MesRelevés est disponible';
public function handle(UpdateService $updates): int
{
$installed = $updates->getInstalledVersion();
$this->line("Version installée : <info>v{$installed}</info>");
$this->line("Interrogation du serveur de mises à jour...");
$latest = $updates->fetchLatestRelease(useCache: ! $this->option('force'));
if (! $latest) {
$this->warn("Impossible de contacter le serveur de mises à jour.");
return self::FAILURE;
}
if (version_compare($latest['version'], $installed, '>')) {
$this->info("Nouvelle version disponible : v{$latest['version']}");
if ($latest['body']) {
$this->line("<comment>Notes de version :</comment>");
$this->line($latest['body']);
}
$this->line("");
$this->line("Pour mettre à jour : <comment>php artisan app:update</comment>");
if (config('update.auto_update')) {
$this->line("AUTO_UPDATE=true détecté — application automatique...");
$this->call('app:update', ['--force' => true]);
}
return self::SUCCESS;
}
$this->info("L'application est à jour (v{$installed}).");
return self::SUCCESS;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Console\Commands;
use App\Services\UpdateService;
use Illuminate\Console\Command;
class RollbackCommand extends Command
{
protected $signature = 'app:rollback
{--list : Liste les sauvegardes disponibles}';
protected $description = 'Restaure la base de données depuis une sauvegarde pré-mise à jour';
public function handle(UpdateService $updates): int
{
$backups = $updates->listBackups();
if (empty($backups)) {
$this->warn("Aucune sauvegarde disponible dans storage/app/backups/.");
return self::FAILURE;
}
// --list : afficher la liste et sortir
if ($this->option('list')) {
$rows = array_map(fn ($b) => [
basename($b),
$this->humanSize(filesize($b)),
date('d/m/Y H:i:s', filemtime($b)),
], $backups);
$this->table(['Fichier', 'Taille', 'Date'], $rows);
return self::SUCCESS;
}
// Sélection interactive
$choices = array_map('basename', $backups);
$choice = $this->choice('Choisir la sauvegarde à restaurer :', $choices, 0);
$path = storage_path("app/backups/{$choice}");
$this->warn("Sauvegarde sélectionnée : {$choice}");
$this->warn("ATTENTION : cette opération remplace la base de données actuelle.");
if (! $this->confirm('Continuer ?')) {
$this->line("Annulé.");
return self::SUCCESS;
}
try {
$updates->restoreBackup($path);
$this->info("Base de données restaurée avec succès.");
} catch (\Exception $e) {
$this->error("Restauration échouée : " . $e->getMessage());
return self::FAILURE;
}
return self::SUCCESS;
}
private function humanSize(int $bytes): string
{
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 1) . ' Mo';
if ($bytes >= 1_024) return round($bytes / 1_024, 0) . ' Ko';
return $bytes . ' o';
}
}
@@ -7,12 +7,18 @@ use App\Http\Controllers\Controller;
use App\Models\Releve;
use App\Models\Source;
use App\Models\User;
use App\Services\UpdateService;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View
public function index(UpdateService $updates): View
{
$installedVersion = $updates->getInstalledVersion();
$latestRelease = $updates->fetchLatestRelease(); // depuis le cache Redis
$updateAvailable = $latestRelease
&& version_compare($latestRelease['version'], $installedVersion, '>');
// Compteurs sources par statut
$sourcesByStatus = Source::selectRaw('status, count(*) as total')
->groupBy('status')
@@ -54,7 +60,8 @@ class DashboardController extends Controller
return view('admin.dashboard', compact(
'sourcesByStatus', 'totalSources', 'totalReleves',
'usersByRole', 'totalUsers',
'sourcesAValider', 'relevesRecents', 'activiteMensuelle'
'sourcesAValider', 'relevesRecents', 'activiteMensuelle',
'installedVersion', 'latestRelease', 'updateAvailable'
));
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class UpdateService
{
private const CACHE_KEY = 'update.latest_release';
private const CACHE_TTL = 3600; // 1 heure
// ─── Lecture de version ──────────────────────────────────────────────────
public function getInstalledVersion(): string
{
$file = base_path('VERSION');
return file_exists($file) ? trim(file_get_contents($file)) : '0.0.0';
}
// ─── Vérification distante ───────────────────────────────────────────────
public function fetchLatestRelease(bool $useCache = true): ?array
{
if ($useCache && Cache::has(self::CACHE_KEY)) {
return Cache::get(self::CACHE_KEY);
}
$url = sprintf(
'%s/api/v1/repos/%s/%s/releases/latest',
rtrim(config('update.gitea_url'), '/'),
config('update.gitea_owner'),
config('update.gitea_repo'),
);
try {
$response = Http::timeout(10)->get($url);
if (! $response->successful()) {
return null;
}
$data = $response->json();
$version = ltrim($data['tag_name'] ?? '', 'v');
$downloadUrl = null;
foreach ($data['assets'] ?? [] as $asset) {
if (str_ends_with($asset['name'] ?? '', '.tar.gz')) {
$downloadUrl = $asset['browser_download_url'];
break;
}
}
if (! $version || ! $downloadUrl) {
return null;
}
$release = [
'version' => $version,
'tag' => $data['tag_name'],
'name' => $data['name'] ?? "v{$version}",
'download_url' => $downloadUrl,
'published_at' => $data['published_at'] ?? null,
'body' => $data['body'] ?? '',
];
Cache::put(self::CACHE_KEY, $release, self::CACHE_TTL);
return $release;
} catch (\Exception $e) {
Log::warning('UpdateService: impossible de contacter le serveur de mises à jour.', [
'error' => $e->getMessage(),
]);
return null;
}
}
public function getLatestIfNewer(): ?array
{
$latest = $this->fetchLatestRelease();
if (! $latest) {
return null;
}
return version_compare($latest['version'], $this->getInstalledVersion(), '>') ? $latest : null;
}
// ─── Téléchargement ─────────────────────────────────────────────────────
public function download(array $release): string
{
$path = sys_get_temp_dir() . "/mesreleves-{$release['version']}.tar.gz";
$response = Http::timeout(300)->sink($path)->get($release['download_url']);
if (! $response->successful() || ! file_exists($path) || filesize($path) === 0) {
@unlink($path);
throw new RuntimeException("Téléchargement échoué (HTTP {$response->status()}).");
}
return $path;
}
// ─── Application de la mise à jour ──────────────────────────────────────
public function applyUpdate(array $release, callable $log = null): void
{
$log ??= static fn ($msg) => null;
$version = $release['version'];
$appPath = base_path();
// 1. Sauvegarde BDD
$log("Sauvegarde de la base de données...");
$backupPath = $this->backupDatabase($version);
$log("{$backupPath}");
// 2. Téléchargement
$log("Téléchargement de mesreleves-{$version}.tar.gz...");
$archivePath = $this->download($release);
$log("{$archivePath} (" . $this->humanSize(filesize($archivePath)) . ")");
// 3. Maintenance
$log("Activation du mode maintenance...");
Artisan::call('down');
try {
// 4. Extraction
$log("Extraction de l'archive...");
$tmpDir = sys_get_temp_dir() . "/mesreleves-update-{$version}";
if (is_dir($tmpDir)) {
$this->exec("rm -rf " . escapeshellarg($tmpDir));
}
mkdir($tmpDir, 0755, true);
$this->exec("tar -xzf " . escapeshellarg($archivePath) . " -C " . escapeshellarg($tmpDir));
$dirs = glob($tmpDir . '/*/');
if (empty($dirs)) {
throw new RuntimeException("L'archive est vide ou mal formée.");
}
$srcDir = rtrim($dirs[0], '/');
// 5. Synchronisation des fichiers (préserve .env, storage, vendor)
$log("Synchronisation des fichiers...");
$this->exec(sprintf(
'rsync -a --delete --exclude=".env" --exclude="storage/" --exclude="vendor/" %s %s',
escapeshellarg($srcDir . '/'),
escapeshellarg($appPath . '/'),
));
// 6. Dépendances Composer (prod)
$log("Installation des dépendances (composer)...");
$this->exec('composer install --no-dev --optimize-autoloader --quiet', $appPath);
// 7. Mise à jour du fichier VERSION
file_put_contents($appPath . '/VERSION', $version . PHP_EOL);
// 8. Migrations
$log("Migration de la base de données...");
Artisan::call('migrate', ['--force' => true]);
$log(trim(Artisan::output()));
// 9. Nettoyage et optimisation des caches
$log("Optimisation...");
Artisan::call('optimize:clear');
Artisan::call('optimize');
// 10. Rechargement php-fpm (opcache)
$log("Rechargement php-fpm...");
$this->reloadPhpFpm();
// 11. Purge des anciennes sauvegardes
$this->pruneBackups();
// 12. Nettoyage temporaire
$this->exec("rm -rf " . escapeshellarg($tmpDir));
@unlink($archivePath);
Cache::forget(self::CACHE_KEY);
$log("✓ Mise à jour vers v{$version} réussie.");
} catch (\Exception $e) {
$log("✗ Erreur : " . $e->getMessage());
$log(" La base de données peut être restaurée depuis : {$backupPath}");
throw $e;
} finally {
Artisan::call('up');
}
}
// ─── Sauvegardes ────────────────────────────────────────────────────────
public function listBackups(): array
{
$dir = storage_path('app/backups');
$backups = glob($dir . '/db-*.sql') ?: [];
rsort($backups);
return $backups;
}
public function restoreBackup(string $path): void
{
if (! file_exists($path)) {
throw new RuntimeException("Fichier introuvable : {$path}");
}
$host = config('database.connections.pgsql.host');
$port = config('database.connections.pgsql.port', 5432);
$db = config('database.connections.pgsql.database');
$user = config('database.connections.pgsql.username');
$password = config('database.connections.pgsql.password');
$cmd = sprintf(
'PGPASSWORD=%s psql -h %s -p %s -U %s %s < %s',
escapeshellarg($password),
escapeshellarg($host),
(int) $port,
escapeshellarg($user),
escapeshellarg($db),
escapeshellarg($path),
);
Artisan::call('down');
$this->exec($cmd);
Artisan::call('up');
}
// ─── Méthodes privées ───────────────────────────────────────────────────
private function backupDatabase(string $version): string
{
$dir = storage_path('app/backups');
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$filename = "db-before-{$version}-" . date('Ymd-His') . ".sql";
$path = "{$dir}/{$filename}";
$host = config('database.connections.pgsql.host');
$port = config('database.connections.pgsql.port', 5432);
$db = config('database.connections.pgsql.database');
$user = config('database.connections.pgsql.username');
$password = config('database.connections.pgsql.password');
$cmd = sprintf(
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s > %s',
escapeshellarg($password),
escapeshellarg($host),
(int) $port,
escapeshellarg($user),
escapeshellarg($db),
escapeshellarg($path),
);
$this->exec($cmd);
if (! file_exists($path) || filesize($path) === 0) {
throw new RuntimeException("La sauvegarde PostgreSQL a échoué (pg_dump disponible dans le container ?)");
}
return $path;
}
private function pruneBackups(): void
{
$backups = $this->listBackups();
$keep = (int) config('update.backups_to_keep', 5);
foreach (array_slice($backups, $keep) as $old) {
@unlink($old);
}
}
private function reloadPhpFpm(): void
{
// Envoie USR2 au process php-fpm (rechargement gracieux sans downtime)
// PID 1 = php-fpm master dans le container Docker
@shell_exec('kill -USR2 1 2>/dev/null || true');
}
private function exec(string $cmd, ?string $cwd = null): string
{
$fullCmd = $cwd ? "cd " . escapeshellarg($cwd) . " && {$cmd}" : $cmd;
$output = shell_exec("{$fullCmd} 2>&1") ?? '';
return $output;
}
private function humanSize(int $bytes): string
{
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 1) . ' Mo';
if ($bytes >= 1_024) return round($bytes / 1_024, 0) . ' Ko';
return $bytes . ' o';
}
}