Wrap DB migrations in an automatic DB dump and restore process to ensure database integrity.
This commit is contained in:
parent
eac7970fe2
commit
87b7c28a95
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,10 +49,7 @@ final class InitializeCommand extends CommandAbstract
|
|||
|
||||
$this->runCommand(
|
||||
$output,
|
||||
'migrations:migrate',
|
||||
[
|
||||
'--allow-no-migration' => true,
|
||||
]
|
||||
'azuracast:setup:migrate'
|
||||
);
|
||||
|
||||
$io->newLine();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue