Files
mesreleves-php/app/Services/UpdateService.php
T
yann64 236d37976c Compatibilité MySQL + suppression de Redis comme dépendance requise
DbCompat (app/Support/DbCompat.php) :
- like()           → ilike (pgsql) ou like (mysql)
- jsonRegexRaw()   → data::text ~* ? (pgsql) ou CAST(data AS CHAR) REGEXP ? (mysql)
- ftsRaw()         → to_tsvector/plainto_tsquery (pgsql) ou null/fallback LIKE (mysql)
- generatedJsonCol()       → syntaxe colonne générée JSON selon le SGBD
- generatedJsonNestedCol() → idem pour champs imbriqués

Migrations :
- create_releves_table : JSON/JSONB selon SGBD, colonnes générées adaptées,
  index GIN uniquement pour PostgreSQL

Controllers :
- LieuController (search + index) : ilike → DbCompat::like()
- Admin\UserController (index)     : ilike → DbCompat::like()
- RechercheController              : FTS + regex → DbCompat, fallback LIKE MySQL
- ExportController                 : regex → DbCompat::jsonRegexRaw()

UpdateService :
- backupDatabase()  : pg_dump (pgsql) ou mysqldump (mysql)
- restoreBackup()   : psql (pgsql) ou mysql (mysql)

Docker :
- docker-compose.yml       : suppression Redis (plus requis)
- docker-compose.mysql.yml : nouveau fichier pour dev MySQL
- docker-compose.prod.yml  : suppression Redis, DB_IMAGE configurable,
  CACHE_STORE/SESSION_DRIVER/QUEUE_CONNECTION → database par défaut

.env.example :
- DB_PORT commenté avec les deux valeurs (5432/3306)
- CACHE_STORE et QUEUE_CONNECTION commentés (database par défaut)
- Redis marqué optionnel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 18:13:42 +02:00

315 lines
11 KiB
PHP

<?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}");
}
$driver = config('database.default');
$conn = config("database.connections.{$driver}");
if ($driver === 'pgsql') {
$cmd = sprintf(
'PGPASSWORD=%s psql -h %s -p %s -U %s %s < %s',
escapeshellarg($conn['password']),
escapeshellarg($conn['host']),
(int) ($conn['port'] ?? 5432),
escapeshellarg($conn['username']),
escapeshellarg($conn['database']),
escapeshellarg($path),
);
} else {
$cmd = sprintf(
'mysql -h %s -P %s -u %s %s %s < %s',
escapeshellarg($conn['host']),
(int) ($conn['port'] ?? 3306),
escapeshellarg($conn['username']),
$conn['password'] ? '-p' . escapeshellarg($conn['password']) : '',
escapeshellarg($conn['database']),
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}";
$driver = config('database.default');
$conn = config("database.connections.{$driver}");
if ($driver === 'pgsql') {
$cmd = sprintf(
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s > %s',
escapeshellarg($conn['password']),
escapeshellarg($conn['host']),
(int) ($conn['port'] ?? 5432),
escapeshellarg($conn['username']),
escapeshellarg($conn['database']),
escapeshellarg($path),
);
} else {
$cmd = sprintf(
'mysqldump -h %s -P %s -u %s %s %s > %s',
escapeshellarg($conn['host']),
(int) ($conn['port'] ?? 3306),
escapeshellarg($conn['username']),
$conn['password'] ? '-p' . escapeshellarg($conn['password']) : '',
escapeshellarg($conn['database']),
escapeshellarg($path),
);
}
$this->exec($cmd);
if (! file_exists($path) || filesize($path) === 0) {
throw new RuntimeException("La sauvegarde de la base de données a échoué ({$driver}).");
}
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';
}
}