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:
@@ -63,3 +63,13 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# ── Mises à jour automatiques ─────────────────────────────────────────────────
|
||||||
|
# Serveur Gitea hébergeant les releases publiques
|
||||||
|
GITEA_URL=https://git.barbel.synology.me
|
||||||
|
GITEA_OWNER=CGL
|
||||||
|
GITEA_REPO=mesreleves-php
|
||||||
|
# Si true, la mise à jour est appliquée automatiquement lors de la vérification planifiée
|
||||||
|
AUTO_UPDATE=false
|
||||||
|
# Nombre de sauvegardes PostgreSQL conservées avant suppression
|
||||||
|
UPDATE_BACKUPS_TO_KEEP=5
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\Releve;
|
||||||
use App\Models\Source;
|
use App\Models\Source;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\UpdateService;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
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
|
// Compteurs sources par statut
|
||||||
$sourcesByStatus = Source::selectRaw('status, count(*) as total')
|
$sourcesByStatus = Source::selectRaw('status, count(*) as total')
|
||||||
->groupBy('status')
|
->groupBy('status')
|
||||||
@@ -54,7 +60,8 @@ class DashboardController extends Controller
|
|||||||
return view('admin.dashboard', compact(
|
return view('admin.dashboard', compact(
|
||||||
'sourcesByStatus', 'totalSources', 'totalReleves',
|
'sourcesByStatus', 'totalSources', 'totalReleves',
|
||||||
'usersByRole', 'totalUsers',
|
'usersByRole', 'totalUsers',
|
||||||
'sourcesAValider', 'relevesRecents', 'activiteMensuelle'
|
'sourcesAValider', 'relevesRecents', 'activiteMensuelle',
|
||||||
|
'installedVersion', 'latestRelease', 'updateAvailable'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# MesRelevés — Script de construction d'une archive de release
|
||||||
|
#
|
||||||
|
# Usage : bin/build-release.sh
|
||||||
|
#
|
||||||
|
# Prérequis : node/npm (pour les assets), rsync, tar
|
||||||
|
# Produit : mesreleves-X.Y.Z.tar.gz + mesreleves-X.Y.Z.tar.gz.sha256
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Se positionner à la racine du projet
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
ARCHIVE="mesreleves-${VERSION}.tar.gz"
|
||||||
|
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
|
echo " MesRelevés — Build de la release v${VERSION}"
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
|
|
||||||
|
# ── 1. Vérifications ──────────────────────────────────────────────────────────
|
||||||
|
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "AVERTISSEMENT : des modifications non commitées seront incluses."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Build des assets frontend ─────────────────────────────────────────────
|
||||||
|
echo "→ Build des assets frontend (npm run build)..."
|
||||||
|
npm run build --silent
|
||||||
|
|
||||||
|
# ── 3. Copie dans un répertoire temporaire (sans vendor, sans fichiers dev) ──
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
BUILDDIR="${TMPDIR}/mesreleves-${VERSION}"
|
||||||
|
trap 'rm -rf "${TMPDIR}"' EXIT
|
||||||
|
|
||||||
|
echo "→ Copie des fichiers dans ${BUILDDIR}..."
|
||||||
|
rsync -a \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
--exclude='vendor/' \
|
||||||
|
--exclude='storage/logs/' \
|
||||||
|
--exclude='storage/framework/cache/' \
|
||||||
|
--exclude='storage/framework/sessions/' \
|
||||||
|
--exclude='storage/framework/views/' \
|
||||||
|
--exclude='tests/' \
|
||||||
|
--exclude='phpunit.xml' \
|
||||||
|
--exclude='.editorconfig' \
|
||||||
|
--exclude='.gitattributes' \
|
||||||
|
--exclude='.gitignore' \
|
||||||
|
--exclude='*.tar.gz' \
|
||||||
|
--exclude='*.sha256' \
|
||||||
|
--exclude='.phpunit.result.cache' \
|
||||||
|
. "${BUILDDIR}/"
|
||||||
|
|
||||||
|
# ── 4. Création de l'archive ─────────────────────────────────────────────────
|
||||||
|
echo "→ Création de l'archive ${ARCHIVE}..."
|
||||||
|
tar -czf "${ARCHIVE}" -C "${TMPDIR}" "mesreleves-${VERSION}/"
|
||||||
|
|
||||||
|
# ── 5. Somme de contrôle SHA-256 ─────────────────────────────────────────────
|
||||||
|
sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256"
|
||||||
|
CHECKSUM=$(awk '{print $1}' "${ARCHIVE}.sha256")
|
||||||
|
|
||||||
|
# ── Résumé ────────────────────────────────────────────────────────────────────
|
||||||
|
SIZE=$(du -sh "${ARCHIVE}" | cut -f1)
|
||||||
|
echo ""
|
||||||
|
echo "✓ Archive créée : ${ARCHIVE} (${SIZE})"
|
||||||
|
echo " SHA-256 : ${CHECKSUM}"
|
||||||
|
echo ""
|
||||||
|
echo "Prochaines étapes :"
|
||||||
|
echo " 1. git tag v${VERSION} && git push origin v${VERSION}"
|
||||||
|
echo " 2. Créer une release sur Gitea (tag v${VERSION})"
|
||||||
|
echo " 3. Joindre ${ARCHIVE} et ${ARCHIVE}.sha256 à la release"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
* URL de base du serveur Gitea hébergeant les releases.
|
||||||
|
* L'API consultée : {gitea_url}/api/v1/repos/{owner}/{repo}/releases/latest
|
||||||
|
*/
|
||||||
|
'gitea_url' => env('GITEA_URL', 'https://git.barbel.synology.me'),
|
||||||
|
'gitea_owner' => env('GITEA_OWNER', 'CGL'),
|
||||||
|
'gitea_repo' => env('GITEA_REPO', 'mesreleves-php'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Si true, la mise à jour est téléchargée et appliquée automatiquement
|
||||||
|
* lors de la vérification quotidienne planifiée.
|
||||||
|
* Par défaut false : seule une notification admin est générée.
|
||||||
|
*/
|
||||||
|
'auto_update' => env('AUTO_UPDATE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nombre de sauvegardes PostgreSQL à conserver dans storage/app/backups/.
|
||||||
|
*/
|
||||||
|
'backups_to_keep' => env('UPDATE_BACKUPS_TO_KEEP', 5),
|
||||||
|
];
|
||||||
@@ -26,6 +26,9 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
|
# Le code est monté depuis l'hôte : les mises à jour ne nécessitent pas de rebuild image
|
||||||
|
- .:/var/www/html
|
||||||
|
# Le storage est un volume nommé (uploads, logs, sessions — persisté indépendamment du code)
|
||||||
- app_storage:/var/www/html/storage
|
- app_storage:/var/www/html/storage
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
@@ -39,8 +42,8 @@ services:
|
|||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- app_storage:/var/www/html/storage:ro
|
|
||||||
- ./public:/var/www/html/public:ro
|
- ./public:/var/www/html/public:ro
|
||||||
|
- app_storage:/var/www/html/storage:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+9
-15
@@ -1,29 +1,23 @@
|
|||||||
FROM php:8.5-fpm-alpine
|
FROM php:8.5-fpm-alpine
|
||||||
|
|
||||||
|
# Runtime : extensions PHP + outils nécessaires aux mises à jour
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
postgresql-dev \
|
postgresql-dev \
|
||||||
redis \
|
postgresql-client \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
|
rsync \
|
||||||
&& docker-php-ext-install pdo pdo_pgsql opcache
|
&& docker-php-ext-install pdo pdo_pgsql opcache
|
||||||
|
|
||||||
# Installer Composer
|
# Composer (runtime, pas de copie du code dans l'image)
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Entrypoint : composer install + caches au démarrage du container
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
COPY composer.json composer.lock ./
|
|
||||||
RUN composer install --no-dev --optimize-autoloader --no-scripts
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN composer run-script post-autoload-dump \
|
|
||||||
&& npm ci \
|
|
||||||
&& npm run build \
|
|
||||||
&& rm -rf node_modules \
|
|
||||||
&& php artisan config:cache \
|
|
||||||
&& php artisan route:cache \
|
|
||||||
&& php artisan view:cache \
|
|
||||||
&& chown -R www-data:www-data storage bootstrap/cache
|
|
||||||
|
|
||||||
EXPOSE 9000
|
EXPOSE 9000
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["php-fpm"]
|
CMD ["php-fpm"]
|
||||||
|
|||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Installer les dépendances Composer si nécessaire
|
||||||
|
if [ ! -f vendor/autoload.php ] || [ composer.lock -nt vendor/autoload.php ] 2>/dev/null; then
|
||||||
|
echo "[entrypoint] composer install --no-dev ..."
|
||||||
|
composer install --no-dev --optimize-autoloader --quiet
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Générer la clé applicative si absente
|
||||||
|
if grep -q "^APP_KEY=$" .env 2>/dev/null; then
|
||||||
|
echo "[entrypoint] Génération de la clé applicative..."
|
||||||
|
php artisan key:generate --force
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Caches (silencieux si pas encore de .env complet)
|
||||||
|
php artisan config:cache 2>/dev/null || true
|
||||||
|
php artisan route:cache 2>/dev/null || true
|
||||||
|
php artisan view:cache 2>/dev/null || true
|
||||||
|
|
||||||
|
# Permissions storage
|
||||||
|
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
|
||||||
|
chmod -R 775 storage bootstrap/cache 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[entrypoint] Démarrage php-fpm..."
|
||||||
|
exec "$@"
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# MesRelevés — Installation initiale (déploiement Docker)
|
||||||
|
#
|
||||||
|
# Usage : ./install.sh
|
||||||
|
#
|
||||||
|
# Prérequis : Docker + Docker Compose v2
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE="docker compose -f docker-compose.prod.yml"
|
||||||
|
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
echo " MesRelevés — Installation"
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
|
||||||
|
# ── Prérequis ─────────────────────────────────────────────────────────────────
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo "Erreur : Docker n'est pas installé."; exit 1; }
|
||||||
|
docker compose version >/dev/null 2>&1 || { echo "Erreur : Docker Compose v2 requis."; exit 1; }
|
||||||
|
|
||||||
|
# ── Fichier .env ──────────────────────────────────────────────────────────────
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo ""
|
||||||
|
echo "→ Fichier .env créé depuis .env.example."
|
||||||
|
echo " Éditez .env avec vos paramètres (DB_PASSWORD, MAIL_*, APP_URL, etc.)"
|
||||||
|
echo " puis relancez ce script."
|
||||||
|
echo ""
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérification DB_PASSWORD obligatoire
|
||||||
|
if grep -qE "^DB_PASSWORD=$" .env; then
|
||||||
|
echo "Erreur : DB_PASSWORD n'est pas défini dans .env."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Structure storage ─────────────────────────────────────────────────────────
|
||||||
|
mkdir -p storage/{app/backups,framework/{cache,sessions,views},logs}
|
||||||
|
mkdir -p bootstrap/cache
|
||||||
|
|
||||||
|
# ── Démarrage des services ────────────────────────────────────────────────────
|
||||||
|
echo "→ Construction de l'image Docker..."
|
||||||
|
$COMPOSE build
|
||||||
|
|
||||||
|
echo "→ Démarrage des services (db, redis, app, nginx)..."
|
||||||
|
$COMPOSE up -d
|
||||||
|
|
||||||
|
echo "→ Attente de la base de données..."
|
||||||
|
until $COMPOSE exec -T db pg_isready -U "${DB_USERNAME:-mesreleves}" >/dev/null 2>&1; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo " Base de données prête."
|
||||||
|
|
||||||
|
# Attendre que l'entrypoint termine (composer install, caches)
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
# ── Migrations ────────────────────────────────────────────────────────────────
|
||||||
|
echo "→ Migration de la base de données..."
|
||||||
|
$COMPOSE exec app php artisan migrate --force
|
||||||
|
|
||||||
|
# ── Lien storage ──────────────────────────────────────────────────────────────
|
||||||
|
$COMPOSE exec app php artisan storage:link 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Tâche planifiée (cron sur l'hôte) ────────────────────────────────────────
|
||||||
|
CRON_CMD="* * * * * cd $(pwd) && docker compose -f docker-compose.prod.yml exec -T app php artisan schedule:run >> /dev/null 2>&1"
|
||||||
|
echo ""
|
||||||
|
echo "→ Pour activer les tâches planifiées (vérification automatique des mises à jour),"
|
||||||
|
echo " ajoutez cette ligne à votre crontab (crontab -e) :"
|
||||||
|
echo ""
|
||||||
|
echo " ${CRON_CMD}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Résumé ────────────────────────────────────────────────────────────────────
|
||||||
|
VERSION=$(cat VERSION 2>/dev/null || echo "?")
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
echo "✓ MesRelevés v${VERSION} installé avec succès !"
|
||||||
|
echo ""
|
||||||
|
echo " URL : $(grep APP_URL .env | cut -d= -f2)"
|
||||||
|
echo ""
|
||||||
|
echo "Commandes utiles :"
|
||||||
|
echo " Voir les logs : docker compose -f docker-compose.prod.yml logs -f app"
|
||||||
|
echo " Vérifier updates : docker compose -f docker-compose.prod.yml exec app php artisan app:check-update"
|
||||||
|
echo " Mettre à jour : docker compose -f docker-compose.prod.yml exec app php artisan app:update"
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
@@ -5,6 +5,32 @@
|
|||||||
|
|
||||||
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
||||||
|
|
||||||
|
{{-- Bandeau mise à jour disponible --}}
|
||||||
|
@if($updateAvailable)
|
||||||
|
<div class="bg-indigo-50 border border-indigo-300 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-indigo-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-indigo-800">
|
||||||
|
Mise à jour disponible : v{{ $latestRelease['version'] }}
|
||||||
|
<span class="ml-2 font-normal text-indigo-600">(installé : v{{ $installedVersion }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-indigo-600 mt-0.5 font-mono">
|
||||||
|
docker compose exec app php artisan app:update
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($latestRelease['published_at'])
|
||||||
|
<span class="text-xs text-indigo-400 whitespace-nowrap">
|
||||||
|
Publié {{ \Carbon\Carbon::parse($latestRelease['published_at'])->diffForHumans() }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Compteurs globaux --}}
|
{{-- Compteurs globaux --}}
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
@php
|
@php
|
||||||
@@ -135,5 +161,17 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{-- Version --}}
|
||||||
|
<div class="flex items-center justify-end gap-2 text-xs text-gray-400 pt-2">
|
||||||
|
<span>MesRelevés v{{ $installedVersion }}</span>
|
||||||
|
@if(! $updateAvailable)
|
||||||
|
<span class="inline-flex items-center gap-1 text-green-600">
|
||||||
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
à jour
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
+8
-5
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
// Vérification quotidienne des mises à jour disponibles.
|
||||||
$this->comment(Inspiring::quote());
|
// Si AUTO_UPDATE=true dans .env, la mise à jour est aussi appliquée automatiquement.
|
||||||
})->purpose('Display an inspiring quote');
|
// Pour activer : ajouter à crontab (voir install.sh)
|
||||||
|
Schedule::command('app:check-update')
|
||||||
|
->daily()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|||||||
Reference in New Issue
Block a user