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'; } }