From 7706457322cea9220f0c58e136be2c6527c974d0 Mon Sep 17 00:00:00 2001 From: Buster Neece Date: Sun, 3 Sep 2023 12:10:40 -0500 Subject: [PATCH] Add a "azuracast:setup:rollback" CLI command to roll back to the DB migration associated with a given stable release. --- config/cli.php | 1 + .../Command/AbstractDatabaseCommand.php | 52 ++++++ src/Console/Command/MigrateDbCommand.php | 64 +------ src/Console/Command/RollbackDbCommand.php | 161 ++++++++++++++++++ src/Entity/Attributes/StableMigration.php | 19 +++ .../Migration/Version20220605052847.php | 2 + .../Migration/Version20220611123923.php | 2 + .../Migration/Version20220626171758.php | 2 + .../Migration/Version20220724223136.php | 2 + .../Migration/Version20221008043751.php | 2 + .../Migration/Version20221102125558.php | 2 + .../Migration/Version20221110212745.php | 2 + .../Migration/Version20230102192652.php | 5 +- .../Migration/Version20230410210554.php | 5 + .../Migration/Version20230602095822.php | 6 + .../Migration/Version20230803181406.php | 5 + 16 files changed, 272 insertions(+), 60 deletions(-) create mode 100644 src/Console/Command/RollbackDbCommand.php create mode 100644 src/Entity/Attributes/StableMigration.php diff --git a/config/cli.php b/config/cli.php index aa175e1b6..2ac08e7c2 100644 --- a/config/cli.php +++ b/config/cli.php @@ -27,6 +27,7 @@ return function (App\Event\BuildConsoleCommands $event) { 'azuracast:config:migrate' => Command\MigrateConfigCommand::class, 'azuracast:setup:migrate' => Command\MigrateDbCommand::class, 'azuracast:setup:fixtures' => Command\SetupFixturesCommand::class, + 'azuracast:setup:rollback' => Command\RollbackDbCommand::class, 'azuracast:setup' => Command\SetupCommand::class, 'azuracast:radio:restart' => Command\RestartRadioCommand::class, 'azuracast:sync:nowplaying' => Command\Sync\NowPlayingCommand::class, diff --git a/src/Console/Command/AbstractDatabaseCommand.php b/src/Console/Command/AbstractDatabaseCommand.php index 09bf897ea..d49738f7a 100644 --- a/src/Console/Command/AbstractDatabaseCommand.php +++ b/src/Console/Command/AbstractDatabaseCommand.php @@ -7,8 +7,11 @@ namespace App\Console\Command; use App\Console\Command\Traits\PassThruProcess; use App\Container\EntityManagerAwareTrait; use App\Container\EnvironmentAwareTrait; +use App\Entity\StorageLocation; +use Exception; use RuntimeException; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; abstract class AbstractDatabaseCommand extends CommandAbstract { @@ -91,4 +94,53 @@ abstract class AbstractDatabaseCommand extends CommandAbstract $commandEnvVars ); } + + protected function saveOrRestoreDatabase( + SymfonyStyle $io, + ): string { + $io->section(__('Backing up initial database state...')); + + $tempDir = StorageLocation::DEFAULT_BACKUPS_PATH; + $dbDumpPath = $tempDir . '/pre_migration_db.sql'; + + $fs = new Filesystem(); + + if ($fs->exists($dbDumpPath)) { + $io->info([ + __('We detected a database restore file from a previous (possibly failed) migration.'), + __('Attempting to restore that now...'), + ]); + + $this->restoreDatabaseDump($io, $dbDumpPath); + } else { + $this->dumpDatabase($io, $dbDumpPath); + } + + return $dbDumpPath; + } + + protected function tryEmergencyRestore( + SymfonyStyle $io, + string $dbDumpPath + ): int { + $io->section(__('Attempting to roll back to previous database state...')); + + try { + $this->restoreDatabaseDump($io, $dbDumpPath); + + $io->warning([ + __('Your database was restored due to a failed migration.'), + __('Please report this bug to our developers.'), + ]); + return 0; + } catch (Exception $e) { + $io->error( + sprintf( + __('Restore failed: %s'), + $e->getMessage() + ) + ); + return 1; + } + } } diff --git a/src/Console/Command/MigrateDbCommand.php b/src/Console/Command/MigrateDbCommand.php index 66587a8a8..05770b4be 100644 --- a/src/Console/Command/MigrateDbCommand.php +++ b/src/Console/Command/MigrateDbCommand.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Console\Command; -use App\Entity\StorageLocation; use Exception; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -41,42 +40,11 @@ final class MigrateDbCommand extends AbstractDatabaseCommand } // Back up current DB state. - $io->section(__('Backing up initial database state...')); - - $tempDir = StorageLocation::DEFAULT_BACKUPS_PATH; - $dbDumpPath = $tempDir . '/pre_migration_db.sql'; - - $fs = new Filesystem(); - - if ($fs->exists($dbDumpPath)) { - $io->info([ - __('We detected a database restore file from a previous (possibly failed) migration.'), - __('Attempting to restore that now...'), - ]); - - try { - $this->restoreDatabaseDump($io, $dbDumpPath); - } catch (Exception $e) { - $io->error( - sprintf( - __('Restore failed: %s'), - $e->getMessage() - ) - ); - return 1; - } - } else { - try { - $this->dumpDatabase($io, $dbDumpPath); - } catch (Exception $e) { - $io->error( - sprintf( - __('Initial backup failed: %s'), - $e->getMessage() - ) - ); - return 1; - } + try { + $dbDumpPath = $this->saveOrRestoreDatabase($io); + } catch (Exception $e) { + $io->error($e->getMessage()); + return 1; } // Attempt DB migration. @@ -99,27 +67,9 @@ final class MigrateDbCommand extends AbstractDatabaseCommand ) ); - $io->section(__('Attempting to roll back to previous database state...')); - - try { - $this->restoreDatabaseDump($io, $dbDumpPath); - - $io->warning([ - __('Your database was restored due to a failed migration.'), - __('Please report this bug to our developers.'), - ]); - return 0; - } catch (Exception $e) { - $io->error( - sprintf( - __('Restore failed: %s'), - $e->getMessage() - ) - ); - return 1; - } + return $this->tryEmergencyRestore($io, $dbDumpPath); } finally { - $fs->remove($dbDumpPath); + (new Filesystem())->remove($dbDumpPath); } $io->newLine(); diff --git a/src/Console/Command/RollbackDbCommand.php b/src/Console/Command/RollbackDbCommand.php new file mode 100644 index 000000000..6e592a1bb --- /dev/null +++ b/src/Console/Command/RollbackDbCommand.php @@ -0,0 +1,161 @@ +addArgument('version', InputArgument::REQUIRED); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title(__('Roll Back Database')); + + // Pull migration corresponding to the stable version specified. + try { + $version = $input->getArgument('version'); + $migrationVersion = $this->findMigration($version); + } catch (Throwable $e) { + $io->error($e->getMessage()); + return 1; + } + + $this->runCommand( + $output, + 'migrations:sync-metadata-storage' + ); + + // Attempt DB migration. + $io->section(__('Running database migrations...')); + + // Back up current DB state. + try { + $dbDumpPath = $this->saveOrRestoreDatabase($io); + } catch (Exception $e) { + $io->error($e->getMessage()); + return 1; + } + + try { + $io->info($migrationVersion); + + $this->runCommand( + $output, + 'migrations:migrate', + [ + '--allow-no-migration' => true, + 'version' => $migrationVersion, + ] + ); + } catch (Exception $e) { + // Rollback to the DB dump from earlier. + $io->error( + sprintf( + __('Database migration failed: %s'), + $e->getMessage() + ) + ); + + return $this->tryEmergencyRestore($io, $dbDumpPath); + } finally { + (new Filesystem())->remove($dbDumpPath); + } + + $io->newLine(); + $io->success( + sprintf( + __('Database rolled back to stable release version "%s".'), + $version + ) + ); + return 0; + } + + protected function findMigration(string $version): string + { + $version = trim($version); + + if (empty($version)) { + throw new InvalidArgumentException('No version specified.'); + } + + $versionParts = explode('.', $version); + if (3 !== count($versionParts)) { + throw new InvalidArgumentException( + 'Invalid version specified. Version must be in the form of x.x.x, i.e. 0.19.0.' + ); + } + + $migrationsDir = $this->environment->getBaseDirectory() . '/src/Entity/Migration'; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($migrationsDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + $migrationFiles = []; + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + // Skip dotfiles + $fileName = $file->getBasename('.php'); + if ($fileName == $file->getBasename()) { + continue; + } + + $className = 'App\\Entity\\Migration\\' . $fileName; + $migrationFiles[$fileName] = $className; + } + + $migrationFiles = array_reverse($migrationFiles); + + /** @var class-string $migrationClassName */ + foreach ($migrationFiles as $migrationClassName) { + $reflClass = new ReflectionClass($migrationClassName); + $reflAttrs = $reflClass->getAttributes(StableMigration::class); + + foreach ($reflAttrs as $reflAttrInfo) { + /** @var StableMigration $reflAttr */ + $reflAttr = $reflAttrInfo->newInstance(); + + if ($version === $reflAttr->version) { + return $migrationClassName; + } + } + } + + throw new InvalidArgumentException( + 'No migration found for the specified version. Make sure to specify a version after 0.17.0.' + ); + } +} diff --git a/src/Entity/Attributes/StableMigration.php b/src/Entity/Attributes/StableMigration.php new file mode 100644 index 000000000..5de63b00e --- /dev/null +++ b/src/Entity/Attributes/StableMigration.php @@ -0,0 +1,19 @@ +