src/Command/Tools/BackupCommand.php line 19

Open in your IDE?
  1. <?php
  2. namespace App\Command\Tools;
  3. use Symfony\Component\Console\Attribute\AsCommand;
  4. use Symfony\Component\Console\Command\Command;
  5. use Symfony\Component\Console\Input\InputInterface;
  6. use Symfony\Component\Console\Input\InputOption;
  7. use Symfony\Component\Console\Output\OutputInterface;
  8. use Symfony\Component\Process\Process;
  9. use Symfony\Component\Filesystem\Filesystem;
  10. #[AsCommand(
  11. name: 'tools:backup',
  12. description: 'Crée une sauvegarde complète (base MySQL + fichiers) avec rétention automatique et upload vers pCloud.'
  13. )]
  14. class BackupCommand extends Command
  15. {
  16. public function __construct(
  17. private readonly string $projectDir,
  18. private readonly ?string $databaseUrl = null,
  19. private readonly ?string $backupLocalDir = null,
  20. private readonly ?string $backupFilesDirs = null,
  21. private int $backupRetentionDays,
  22. ) {
  23. parent::__construct();
  24. }
  25. protected function configure(): void
  26. {
  27. $this
  28. ->addOption('tag', null, InputOption::VALUE_OPTIONAL, 'Tag de sauvegarde (manual, nightly...)', 'manual');
  29. }
  30. protected function execute(InputInterface $input, OutputInterface $output): int
  31. {
  32. $timestamp = (new \DateTimeImmutable())->format('Ymd-His');
  33. $tag = $input->getOption('tag');
  34. $backupRoot = $this->resolvePath($this->backupLocalDir ?? '%kernel.project_dir%/var/backups');
  35. $sessionDir = $backupRoot . '/' . $timestamp . '-' . $tag;
  36. (new Filesystem())->mkdir($sessionDir);
  37. $output->writeln("<info>[1/6]</info> Création du dossier de sauvegarde : <comment>$sessionDir</comment>");
  38. // 1) Dump MySQL
  39. [$host, $port, $db, $user, $pass, $charset] = $this->parseMysqlUrl($this->databaseUrl ?? '');
  40. $sqlFile = $sessionDir . "/{$timestamp}.sql";
  41. $output->writeln("<info>[2/6]</info> Dump de la base MySQL…");
  42. $env = ['MYSQL_PWD' => $pass];
  43. $cmd = [
  44. 'mysqldump',
  45. '-h', $host,
  46. '-P', (string)$port,
  47. '-u', $user,
  48. '--single-transaction',
  49. '--routines',
  50. '--triggers',
  51. '--events',
  52. '--default-character-set=' . ($charset ?: 'utf8mb4'),
  53. ];
  54. // Auth spéciale si BACKUP_MYSQL_DEFAULT_AUTH est défini
  55. $defaultAuth = $_ENV['BACKUP_MYSQL_DEFAULT_AUTH'] ?? null;
  56. if ($defaultAuth) {
  57. $cmd[] = '--default-auth=' . $defaultAuth;
  58. }
  59. $cmd[] = $db;
  60. $process = new Process($cmd, null, $env, null, 1800);
  61. $process->run();
  62. if (!$process->isSuccessful()) {
  63. $output->writeln("<error>Échec du dump MySQL : {$process->getErrorOutput()}</error>");
  64. return Command::FAILURE;
  65. }
  66. file_put_contents($sqlFile, $process->getOutput());
  67. $output->writeln("<comment>→ Dump enregistré : $sqlFile</comment>");
  68. // 2) Archive des fichiers
  69. $archive = $sessionDir . "/{$timestamp}-files.tar.gz";
  70. $paths = array_filter(
  71. array_map('trim', explode(',', $this->resolvePath($this->backupFilesDirs ?? 'public/uploads')))
  72. );
  73. if ($paths) {
  74. $output->writeln("<info>[3/6]</info> Archivage des fichiers…");
  75. $tar = new Process(array_merge(['tar', 'czf', $archive], $paths), $this->projectDir, null, null, 1800);
  76. $tar->run();
  77. if (!$tar->isSuccessful()) {
  78. $output->writeln("<error>Échec de la création de l’archive : {$tar->getErrorOutput()}</error>");
  79. return Command::FAILURE;
  80. }
  81. $output->writeln("<comment>→ Archive enregistrée : $archive</comment>");
  82. } else {
  83. $output->writeln("<comment>Aucun dossier à archiver.</comment>");
  84. }
  85. // 3) SHA256SUMS
  86. $output->writeln("<info>[4/6]</info> Calcul des sommes de contrôle…");
  87. $files = array_filter([$sqlFile, $archive]);
  88. $checksums = [];
  89. foreach ($files as $file) {
  90. if (is_file($file)) {
  91. $checksums[basename($file)] = hash_file('sha256', $file);
  92. }
  93. }
  94. file_put_contents($sessionDir . '/SHA256SUMS', $this->formatChecksums($checksums));
  95. $output->writeln("<comment>→ Fichier SHA256SUMS généré</comment>");
  96. // 4) Purge locale sur le VPS
  97. $output->writeln("<info>[5/6]</info> Vérification de la rétention locale…");
  98. $retentionDays = $this->backupRetentionDays;
  99. $this->purgeOldBackups($backupRoot, $retentionDays, $output);
  100. // 5) Upload vers pCloud + publication de la rétention pour Windows
  101. $output->writeln("<info>[6/6]</info> Transfert vers pCloud…");
  102. // Base distante (configurable) & nom d’app pour séparer les projets
  103. $remoteBase = $_SERVER['BACKUP_REMOTE_BASE'] ?? $_ENV['BACKUP_REMOTE_BASE'] ?? 'pcloud:backups';
  104. $appName = $_SERVER['APP_NAME'] ?? $_ENV['APP_NAME'] ?? 'app';
  105. // Dossier de la session sur pCloud : pcloud:backups/APP_NAME/2025...
  106. $remoteSession = sprintf(
  107. '%s/%s/%s',
  108. rtrim($remoteBase, '/'),
  109. $appName,
  110. basename($sessionDir)
  111. );
  112. // Dossier meta pour publier la rétention : pcloud:backups/APP_NAME/__meta/RETENTION_DAYS
  113. $metaDirLocal = $backupRoot . '/__meta';
  114. (new Filesystem())->mkdir($metaDirLocal);
  115. file_put_contents($metaDirLocal . '/RETENTION_DAYS', (string)$retentionDays);
  116. $remoteMeta = sprintf('%s/%s/__meta', rtrim($remoteBase, '/'), $appName);
  117. // Envoi de la session
  118. $uploadSession = new Process([
  119. 'rclone',
  120. 'copy',
  121. $sessionDir,
  122. $remoteSession,
  123. '--verbose',
  124. ]);
  125. $uploadSession->run();
  126. if ($uploadSession->isSuccessful()) {
  127. $output->writeln("<comment>✔ Sauvegarde copiée sur pCloud ($remoteSession)</comment>");
  128. } else {
  129. $output->writeln("<error>Échec du transfert pCloud (session) : {$uploadSession->getErrorOutput()}</error>");
  130. }
  131. // Publication de la rétention
  132. $remoteMetaDir = sprintf('%s/%s/__meta', rtrim($remoteBase, '/'), $appName);
  133. $remoteMetaFile = $remoteMetaDir . '/RETENTION_DAYS';
  134. // s'assurer que le dossier distant existe
  135. $mkdirMeta = new Process(['rclone', 'mkdir', $remoteMetaDir]);
  136. $mkdirMeta->run();
  137. if (!$mkdirMeta->isSuccessful()) {
  138. $output->writeln("<error>Échec création du dossier meta sur pCloud : {$mkdirMeta->getErrorOutput()}</error>");
  139. }
  140. // écrire le fichier distant en forçant l'overwrite
  141. $uploadMeta = new Process(['rclone', 'rcat', '--verbose', $remoteMetaFile]);
  142. $uploadMeta->setInput((string) $retentionDays . "\n");
  143. $uploadMeta->run();
  144. if ($uploadMeta->isSuccessful()) {
  145. // vérification (source de vérité)
  146. $check = new Process(['rclone', 'cat', $remoteMetaFile]);
  147. $check->run();
  148. $actual = trim($check->getOutput());
  149. if ($actual === (string) $retentionDays) {
  150. $output->writeln("<comment>✔ Rétention publiée sur pCloud ($remoteMetaFile = $actual)</comment>");
  151. } else {
  152. $output->writeln("<error>Rétention NON mise à jour sur pCloud. Attendu=$retentionDays, obtenu=$actual</error>");
  153. }
  154. } else {
  155. $output->writeln("<error>Échec de la publication de la rétention sur pCloud : {$uploadMeta->getErrorOutput()}</error>");
  156. }
  157. $output->writeln("<info>✅ Sauvegarde terminée avec succès.</info>");
  158. return Command::SUCCESS;
  159. }
  160. private function purgeOldBackups(string $backupRoot, int $retentionDays, OutputInterface $output): void
  161. {
  162. if (!is_dir($backupRoot)) {
  163. return;
  164. }
  165. $threshold = (new \DateTimeImmutable())->modify("-{$retentionDays} days");
  166. $dirs = array_filter(glob($backupRoot . '/*'), 'is_dir');
  167. foreach ($dirs as $dir) {
  168. $basename = basename($dir);
  169. if (preg_match('/^(\d{8}-\d{6})-/', $basename, $m)) {
  170. try {
  171. $created = \DateTimeImmutable::createFromFormat('Ymd-His', $m[1]);
  172. if ($created < $threshold) {
  173. $output->writeln("🗑️ Suppression ancienne sauvegarde locale : <comment>$basename</comment>");
  174. (new Filesystem())->remove($dir);
  175. }
  176. } catch (\Exception) {
  177. // ignore invalid names
  178. }
  179. }
  180. }
  181. }
  182. private function resolvePath(string $path): string
  183. {
  184. return str_replace('%kernel.project_dir%', $this->projectDir, $path);
  185. }
  186. private function parseMysqlUrl(string $url): array
  187. {
  188. $p = parse_url($url);
  189. if (!$p) {
  190. throw new \RuntimeException('DATABASE_URL invalide.');
  191. }
  192. parse_str($p['query'] ?? '', $q);
  193. return [
  194. $p['host'] ?? '127.0.0.1',
  195. $p['port'] ?? 3306,
  196. ltrim($p['path'] ?? '/symfony', '/'),
  197. $p['user'] ?? 'root',
  198. $p['pass'] ?? '',
  199. $q['charset'] ?? 'utf8mb4',
  200. ];
  201. }
  202. private function formatChecksums(array $map): string
  203. {
  204. $lines = [];
  205. foreach ($map as $file => $sha) {
  206. $lines[] = sprintf('%s %s', $sha, $file);
  207. }
  208. return implode(PHP_EOL, $lines) . PHP_EOL;
  209. }
  210. }