Wrap DB migrations in an automatic DB dump and restore process to ensure database integrity.

This commit is contained in:
Buster Neece 2022-09-16 18:59:46 -05:00
parent eac7970fe2
commit 87b7c28a95
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
9 changed files with 245 additions and 96 deletions

View File

@ -7,6 +7,13 @@ release channel, you can take advantage of these new features and fixes.
## Code Quality/Technical Changes
- **Smarter database migrations:** A common source of problems with AzuraCast upgrades is experiencing a faulty or
interrupted database migration, leaving your database in a state that we can't automatically recover from. While we
can't wrap database changes in transactions due to our use of MariaDB, we can do the next best thing, which is to take
an automatic snapshot of your database just prior to the migration and roll back to that automatically upon failure.
This even applies if the entire update process is stopped and restarted, where the original database will be restored
on the second update attempt.
- In a previous version, we added a rule that would prevent stations from starting up unless they had at least one
active playlist with at least one music file to play. Several stations reported unique edge cases that didn't work
with this configuration, so it has been removed.

View File

@ -24,6 +24,7 @@ return function (App\Event\BuildConsoleCommands $event) {
'azuracast:cache:clear' => Command\ClearCacheCommand::class,
'azuracast:setup:initialize' => Command\InitializeCommand::class,
'azuracast:config:migrate' => Command\MigrateConfigCommand::class,
'azuracast:setup:migrate' => Command\MigrateDbCommand::class,
'azuracast:setup:fixtures' => Command\SetupFixturesCommand::class,
'azuracast:setup' => Command\SetupCommand::class,
'azuracast:radio:restart' => Command\RestartRadioCommand::class,

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Console\Command;
use App\Console\Command\Traits\PassThruProcess;
use App\Environment;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractDatabaseCommand extends CommandAbstract
{
use PassThruProcess;
public function __construct(
protected Environment $environment,
protected EntityManagerInterface $em
) {
parent::__construct();
}
protected function getDatabaseSettingsAsCliFlags(): array
{
$connSettings = $this->environment->getDatabaseSettings();
$commandEnvVars = [
'DB_DATABASE' => $connSettings['dbname'],
'DB_USERNAME' => $connSettings['user'],
'DB_PASSWORD' => $connSettings['password'],
];
$commandFlags = [
'--user=$DB_USERNAME',
'--password=$DB_PASSWORD',
];
if (isset($connSettings['unix_socket'])) {
$commandFlags[] = '--socket=$DB_SOCKET';
$commandEnvVars['DB_SOCKET'] = $connSettings['unix_socket'];
} else {
$commandFlags[] = '--host=$DB_HOST';
$commandFlags[] = '--port=$DB_PORT';
$commandEnvVars['DB_HOST'] = $connSettings['host'];
$commandEnvVars['DB_PORT'] = $connSettings['port'];
}
return [$commandFlags, $commandEnvVars];
}
protected function dumpDatabase(
SymfonyStyle $io,
string $path
): void {
[$commandFlags, $commandEnvVars] = $this->getDatabaseSettingsAsCliFlags();
$commandFlags[] = '--add-drop-table';
$commandFlags[] = '--default-character-set=UTF8MB4';
$commandEnvVars['DB_DEST'] = $path;
$this->passThruProcess(
$io,
'mysqldump ' . implode(' ', $commandFlags) . ' $DB_DATABASE > $DB_DEST',
dirname($path),
$commandEnvVars
);
}
protected function restoreDatabaseDump(
SymfonyStyle $io,
string $path
): void {
if (!file_exists($path)) {
throw new \RuntimeException('Database backup file not found!');
}
$conn = $this->em->getConnection();
// Drop all preloaded tables prior to running a DB dump backup.
$conn->executeQuery('SET FOREIGN_KEY_CHECKS = 0');
foreach ($conn->fetchFirstColumn('SHOW TABLES') as $table) {
$conn->executeQuery('DROP TABLE IF EXISTS ' . $conn->quoteIdentifier($table));
}
$conn->executeQuery('SET FOREIGN_KEY_CHECKS = 1');
[$commandFlags, $commandEnvVars] = $this->getDatabaseSettingsAsCliFlags();
$commandEnvVars['DB_DUMP'] = $path;
$this->passThruProcess(
$io,
'mysql ' . implode(' ', $commandFlags) . ' $DB_DATABASE < $DB_DUMP',
dirname($path),
$commandEnvVars
);
}
}

View File

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Command\Backup;
use App\Console\Command\CommandAbstract;
use App\Console\Command\Traits\PassThruProcess;
use App\Environment;
use Doctrine\ORM\EntityManagerInterface;
abstract class AbstractBackupCommand extends CommandAbstract
{
use PassThruProcess;
public function __construct(
protected Environment $environment,
protected EntityManagerInterface $em
) {
parent::__construct();
}
protected function getDatabaseSettingsAsCliFlags(): array
{
$connSettings = $this->environment->getDatabaseSettings();
$commandEnvVars = [
'DB_DATABASE' => $connSettings['dbname'],
'DB_USERNAME' => $connSettings['user'],
'DB_PASSWORD' => $connSettings['password'],
];
$commandFlags = [
'--user=$DB_USERNAME',
'--password=$DB_PASSWORD',
];
if (isset($connSettings['unix_socket'])) {
$commandFlags[] = '--socket=$DB_SOCKET';
$commandEnvVars['DB_SOCKET'] = $connSettings['unix_socket'];
} else {
$commandFlags[] = '--host=$DB_HOST';
$commandFlags[] = '--port=$DB_PORT';
$commandEnvVars['DB_HOST'] = $connSettings['host'];
$commandEnvVars['DB_PORT'] = $connSettings['port'];
}
return [$commandFlags, $commandEnvVars];
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Console\Command\Backup;
use App\Console\Command\AbstractDatabaseCommand;
use App\Entity;
use App\Environment;
use Doctrine\ORM\EntityManagerInterface;
@ -23,7 +24,7 @@ use const PATHINFO_EXTENSION;
name: 'azuracast:backup',
description: 'Back up the AzuraCast database and statistics (and optionally media).',
)]
final class BackupCommand extends AbstractBackupCommand
final class BackupCommand extends AbstractDatabaseCommand
{
public function __construct(
Environment $environment,
@ -113,20 +114,7 @@ final class BackupCommand extends AbstractBackupCommand
$io->section(__('Backing up MariaDB...'));
$path_db_dump = $tmp_dir_mariadb . '/db.sql';
[$commandFlags, $commandEnvVars] = $this->getDatabaseSettingsAsCliFlags();
$commandFlags[] = '--add-drop-table';
$commandFlags[] = '--default-character-set=UTF8MB4';
$commandEnvVars['DB_DEST'] = $path_db_dump;
$this->passThruProcess(
$io,
'mysqldump ' . implode(' ', $commandFlags) . ' $DB_DATABASE > $DB_DEST',
$tmp_dir_mariadb,
$commandEnvVars
);
$this->dumpDatabase($io, $path_db_dump);
$files_to_backup[] = $path_db_dump;
$io->newLine();

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Console\Command\Backup;
use App\Console\Command\AbstractDatabaseCommand;
use App\Entity\StorageLocation;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
@ -19,7 +20,7 @@ use const PATHINFO_EXTENSION;
name: 'azuracast:restore',
description: 'Restore a backup previously generated by AzuraCast.',
)]
final class RestoreCommand extends AbstractBackupCommand
final class RestoreCommand extends AbstractDatabaseCommand
{
protected function configure(): void
{
@ -132,33 +133,15 @@ final class RestoreCommand extends AbstractBackupCommand
$io->section('Importing database...');
$tmp_dir_mariadb = '/tmp/azuracast_backup_mariadb';
$path_db_dump = $tmp_dir_mariadb . '/db.sql';
if (!file_exists($path_db_dump)) {
$io->getErrorStyle()->error('Database backup file not found!');
try {
$path_db_dump = $tmp_dir_mariadb . '/db.sql';
$this->restoreDatabaseDump($io, $path_db_dump);
} catch (\Exception $e) {
$io->getErrorStyle()->error($e->getMessage());
return 1;
}
$conn = $this->em->getConnection();
// Drop all preloaded tables prior to running a DB dump backup.
$conn->executeQuery('SET FOREIGN_KEY_CHECKS = 0');
foreach ($conn->fetchFirstColumn('SHOW TABLES') as $table) {
$conn->executeQuery('DROP TABLE IF EXISTS ' . $conn->quoteIdentifier($table));
}
$conn->executeQuery('SET FOREIGN_KEY_CHECKS = 1');
[$commandFlags, $commandEnvVars] = $this->getDatabaseSettingsAsCliFlags();
$commandEnvVars['DB_DUMP'] = $path_db_dump;
$this->passThruProcess(
$io,
'mysql ' . implode(' ', $commandFlags) . ' $DB_DATABASE < $DB_DUMP',
$tmp_dir_mariadb,
$commandEnvVars
);
(new Filesystem())->remove($tmp_dir_mariadb);
$io->newLine();

View File

@ -10,16 +10,16 @@ use Symfony\Component\Console\Output\OutputInterface;
abstract class CommandAbstract extends Command
{
protected function runCommand(OutputInterface $output, string $command_name, array $command_args = []): void
protected function runCommand(OutputInterface $output, string $command_name, array $command_args = []): int
{
$command = $this->getApplication()?->find($command_name);
if (null === $command) {
return;
throw new \RuntimeException(sprintf('Command %s not found.', $command_name));
}
$input = new ArrayInput(['command' => $command_name] + $command_args);
$input->setInteractive(false);
$command->run($input, $output);
return $command->run($input, $output);
}
}

View File

@ -49,10 +49,7 @@ final class InitializeCommand extends CommandAbstract
$this->runCommand(
$output,
'migrations:migrate',
[
'--allow-no-migration' => true,
]
'azuracast:setup:migrate'
);
$io->newLine();

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Console\Command;
use App\Entity\StorageLocation;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
#[AsCommand(
name: 'azuracast:setup:migrate',
description: 'Migrate the database to the latest revision.',
)]
final class MigrateDbCommand extends AbstractDatabaseCommand
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title(__('Database Migrations'));
if (
0 === $this->runCommand(
new NullOutput(),
'migrations:up-to-date'
)
) {
$io->success(__('Database is already up to date!'));
return 0;
}
// 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;
}
}
// Attempt DB migration.
$io->section(__('Running database migrations...'));
try {
$this->runCommand(
$output,
'migrations:migrate',
[
'--allow-no-migration' => true,
]
);
} catch (\Exception $e) {
// Rollback to the DB dump from earlier.
$io->error(
sprintf(
__('Database migration failed: %s'),
$e->getMessage()
)
);
$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;
}
} finally {
$fs->remove($dbDumpPath);
}
$io->newLine();
$io->success(
__('Database migration completed!')
);
return 0;
}
}