<?php
namespace App\Command\Tools;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
use Symfony\Component\Filesystem\Filesystem;
#[AsCommand(
name: 'tools:backup',
description: 'Crée une sauvegarde complète (base MySQL + fichiers) avec rétention automatique et upload vers pCloud.'
)]
class BackupCommand extends Command
{
public function __construct(
private readonly string $projectDir,
private readonly ?string $databaseUrl = null,
private readonly ?string $backupLocalDir = null,
private readonly ?string $backupFilesDirs = null,
private int $backupRetentionDays,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('tag', null, InputOption::VALUE_OPTIONAL, 'Tag de sauvegarde (manual, nightly...)', 'manual');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$timestamp = (new \DateTimeImmutable())->format('Ymd-His');
$tag = $input->getOption('tag');
$backupRoot = $this->resolvePath($this->backupLocalDir ?? '%kernel.project_dir%/var/backups');
$sessionDir = $backupRoot . '/' . $timestamp . '-' . $tag;
(new Filesystem())->mkdir($sessionDir);
$output->writeln("<info>[1/6]</info> Création du dossier de sauvegarde : <comment>$sessionDir</comment>");
// 1) Dump MySQL
[$host, $port, $db, $user, $pass, $charset] = $this->parseMysqlUrl($this->databaseUrl ?? '');
$sqlFile = $sessionDir . "/{$timestamp}.sql";
$output->writeln("<info>[2/6]</info> Dump de la base MySQL…");
$env = ['MYSQL_PWD' => $pass];
$cmd = [
'mysqldump',
'-h', $host,
'-P', (string)$port,
'-u', $user,
'--single-transaction',
'--routines',
'--triggers',
'--events',
'--default-character-set=' . ($charset ?: 'utf8mb4'),
];
// Auth spéciale si BACKUP_MYSQL_DEFAULT_AUTH est défini
$defaultAuth = $_ENV['BACKUP_MYSQL_DEFAULT_AUTH'] ?? null;
if ($defaultAuth) {
$cmd[] = '--default-auth=' . $defaultAuth;
}
$cmd[] = $db;
$process = new Process($cmd, null, $env, null, 1800);
$process->run();
if (!$process->isSuccessful()) {
$output->writeln("<error>Échec du dump MySQL : {$process->getErrorOutput()}</error>");
return Command::FAILURE;
}
file_put_contents($sqlFile, $process->getOutput());
$output->writeln("<comment>→ Dump enregistré : $sqlFile</comment>");
// 2) Archive des fichiers
$archive = $sessionDir . "/{$timestamp}-files.tar.gz";
$paths = array_filter(
array_map('trim', explode(',', $this->resolvePath($this->backupFilesDirs ?? 'public/uploads')))
);
if ($paths) {
$output->writeln("<info>[3/6]</info> Archivage des fichiers…");
$tar = new Process(array_merge(['tar', 'czf', $archive], $paths), $this->projectDir, null, null, 1800);
$tar->run();
if (!$tar->isSuccessful()) {
$output->writeln("<error>Échec de la création de l’archive : {$tar->getErrorOutput()}</error>");
return Command::FAILURE;
}
$output->writeln("<comment>→ Archive enregistrée : $archive</comment>");
} else {
$output->writeln("<comment>Aucun dossier à archiver.</comment>");
}
// 3) SHA256SUMS
$output->writeln("<info>[4/6]</info> Calcul des sommes de contrôle…");
$files = array_filter([$sqlFile, $archive]);
$checksums = [];
foreach ($files as $file) {
if (is_file($file)) {
$checksums[basename($file)] = hash_file('sha256', $file);
}
}
file_put_contents($sessionDir . '/SHA256SUMS', $this->formatChecksums($checksums));
$output->writeln("<comment>→ Fichier SHA256SUMS généré</comment>");
// 4) Purge locale sur le VPS
$output->writeln("<info>[5/6]</info> Vérification de la rétention locale…");
$retentionDays = $this->backupRetentionDays;
$this->purgeOldBackups($backupRoot, $retentionDays, $output);
// 5) Upload vers pCloud + publication de la rétention pour Windows
$output->writeln("<info>[6/6]</info> Transfert vers pCloud…");
// Base distante (configurable) & nom d’app pour séparer les projets
$remoteBase = $_SERVER['BACKUP_REMOTE_BASE'] ?? $_ENV['BACKUP_REMOTE_BASE'] ?? 'pcloud:backups';
$appName = $_SERVER['APP_NAME'] ?? $_ENV['APP_NAME'] ?? 'app';
// Dossier de la session sur pCloud : pcloud:backups/APP_NAME/2025...
$remoteSession = sprintf(
'%s/%s/%s',
rtrim($remoteBase, '/'),
$appName,
basename($sessionDir)
);
// Dossier meta pour publier la rétention : pcloud:backups/APP_NAME/__meta/RETENTION_DAYS
$metaDirLocal = $backupRoot . '/__meta';
(new Filesystem())->mkdir($metaDirLocal);
file_put_contents($metaDirLocal . '/RETENTION_DAYS', (string)$retentionDays);
$remoteMeta = sprintf('%s/%s/__meta', rtrim($remoteBase, '/'), $appName);
// Envoi de la session
$uploadSession = new Process([
'rclone',
'copy',
$sessionDir,
$remoteSession,
'--verbose',
]);
$uploadSession->run();
if ($uploadSession->isSuccessful()) {
$output->writeln("<comment>✔ Sauvegarde copiée sur pCloud ($remoteSession)</comment>");
} else {
$output->writeln("<error>Échec du transfert pCloud (session) : {$uploadSession->getErrorOutput()}</error>");
}
// Publication de la rétention
$remoteMetaDir = sprintf('%s/%s/__meta', rtrim($remoteBase, '/'), $appName);
$remoteMetaFile = $remoteMetaDir . '/RETENTION_DAYS';
// s'assurer que le dossier distant existe
$mkdirMeta = new Process(['rclone', 'mkdir', $remoteMetaDir]);
$mkdirMeta->run();
if (!$mkdirMeta->isSuccessful()) {
$output->writeln("<error>Échec création du dossier meta sur pCloud : {$mkdirMeta->getErrorOutput()}</error>");
}
// écrire le fichier distant en forçant l'overwrite
$uploadMeta = new Process(['rclone', 'rcat', '--verbose', $remoteMetaFile]);
$uploadMeta->setInput((string) $retentionDays . "\n");
$uploadMeta->run();
if ($uploadMeta->isSuccessful()) {
// vérification (source de vérité)
$check = new Process(['rclone', 'cat', $remoteMetaFile]);
$check->run();
$actual = trim($check->getOutput());
if ($actual === (string) $retentionDays) {
$output->writeln("<comment>✔ Rétention publiée sur pCloud ($remoteMetaFile = $actual)</comment>");
} else {
$output->writeln("<error>Rétention NON mise à jour sur pCloud. Attendu=$retentionDays, obtenu=$actual</error>");
}
} else {
$output->writeln("<error>Échec de la publication de la rétention sur pCloud : {$uploadMeta->getErrorOutput()}</error>");
}
$output->writeln("<info>✅ Sauvegarde terminée avec succès.</info>");
return Command::SUCCESS;
}
private function purgeOldBackups(string $backupRoot, int $retentionDays, OutputInterface $output): void
{
if (!is_dir($backupRoot)) {
return;
}
$threshold = (new \DateTimeImmutable())->modify("-{$retentionDays} days");
$dirs = array_filter(glob($backupRoot . '/*'), 'is_dir');
foreach ($dirs as $dir) {
$basename = basename($dir);
if (preg_match('/^(\d{8}-\d{6})-/', $basename, $m)) {
try {
$created = \DateTimeImmutable::createFromFormat('Ymd-His', $m[1]);
if ($created < $threshold) {
$output->writeln("🗑️ Suppression ancienne sauvegarde locale : <comment>$basename</comment>");
(new Filesystem())->remove($dir);
}
} catch (\Exception) {
// ignore invalid names
}
}
}
}
private function resolvePath(string $path): string
{
return str_replace('%kernel.project_dir%', $this->projectDir, $path);
}
private function parseMysqlUrl(string $url): array
{
$p = parse_url($url);
if (!$p) {
throw new \RuntimeException('DATABASE_URL invalide.');
}
parse_str($p['query'] ?? '', $q);
return [
$p['host'] ?? '127.0.0.1',
$p['port'] ?? 3306,
ltrim($p['path'] ?? '/symfony', '/'),
$p['user'] ?? 'root',
$p['pass'] ?? '',
$q['charset'] ?? 'utf8mb4',
];
}
private function formatChecksums(array $map): string
{
$lines = [];
foreach ($map as $file => $sha) {
$lines[] = sprintf('%s %s', $sha, $file);
}
return implode(PHP_EOL, $lines) . PHP_EOL;
}
}