Implement zero-downtime backups + nightly backups (#1574)
* Create new backup and restore commands allowing live backups. * Switch migrate script to use new backup method. * Avoid loading fixtures, ensure directories exist when restarting stations. * Include album art in media backup. * First portion of automated backup management code. * Further backup page work; add download/delete functionality. * Implement automatic backups and "manual run" page. * Switch automatic backup filename to match text. * Add new locales. * Add restore instructions and ability to view latest backup log.
This commit is contained in:
parent
54c9e52259
commit
16fc2c54bc
|
@ -95,12 +95,12 @@
|
|||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/AzuraCast/azuracore.git",
|
||||
"reference": "f4f25ddeaa4b941154947a88f8509fa37649163a"
|
||||
"reference": "4b84a6051127772dc0abc53518270cfc206e0ae5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/AzuraCast/azuracore/zipball/f4f25ddeaa4b941154947a88f8509fa37649163a",
|
||||
"reference": "f4f25ddeaa4b941154947a88f8509fa37649163a",
|
||||
"url": "https://api.github.com/repos/AzuraCast/azuracore/zipball/4b84a6051127772dc0abc53518270cfc206e0ae5",
|
||||
"reference": "4b84a6051127772dc0abc53518270cfc206e0ae5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -150,7 +150,7 @@
|
|||
}
|
||||
],
|
||||
"description": "A lightweight core application framework.",
|
||||
"time": "2019-05-07T14:25:24+00:00"
|
||||
"time": "2019-05-23T01:44:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "azuracast/azuraforms",
|
||||
|
|
|
@ -66,6 +66,8 @@ return function (\Azura\EventDispatcher $dispatcher)
|
|||
new Command\SetAdministrator,
|
||||
new Command\ListSettings,
|
||||
new Command\SetSetting,
|
||||
new Command\Backup,
|
||||
new Command\Restore,
|
||||
]);
|
||||
}, 0);
|
||||
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
use App\Entity;
|
||||
|
||||
return [
|
||||
'groups' => [
|
||||
|
||||
'backup' => [
|
||||
'use_grid' => true,
|
||||
|
||||
'elements' => [
|
||||
|
||||
Entity\Settings::BACKUP_ENABLED => [
|
||||
'toggle',
|
||||
[
|
||||
'label' => __('Run Automatic Nightly Backups'),
|
||||
'description' => __('Enable to have AzuraCast automatically run nightly backups at the time specified.'),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => false,
|
||||
'form_group_class' => 'col-md-6',
|
||||
]
|
||||
],
|
||||
|
||||
Entity\Settings::BACKUP_TIME => [
|
||||
'PlaylistTime',
|
||||
[
|
||||
'label' => __('Scheduled Backup Time'),
|
||||
'description' => __('The time (in UTC) to run the automated backup, if enabled.'),
|
||||
'form_group_class' => 'col-md-6',
|
||||
]
|
||||
],
|
||||
|
||||
Entity\Settings::BACKUP_EXCLUDE_MEDIA => [
|
||||
'toggle',
|
||||
[
|
||||
'label' => __('Exclude Media from Backups'),
|
||||
'description' => __('Excluding media from automated backups will save space, but you should make sure to back up your media elsewhere.'),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => false,
|
||||
'form_group_class' => 'col-md-6',
|
||||
]
|
||||
],
|
||||
|
||||
Entity\Settings::BACKUP_KEEP_COPIES => [
|
||||
'number',
|
||||
[
|
||||
'label' => __('Number of Backup Copies to Keep'),
|
||||
'description' => __('Copies older than the specified number of days will automatically be deleted. Set to zero to disable automatic deletion.'),
|
||||
'min' => 0,
|
||||
'max' => 365,
|
||||
'default' => 0,
|
||||
'form_group_class' => 'col-md-6',
|
||||
]
|
||||
],
|
||||
|
||||
],
|
||||
],
|
||||
|
||||
'submit' => [
|
||||
'elements' => [
|
||||
'submit' => [
|
||||
'submit',
|
||||
[
|
||||
'type' => 'submit',
|
||||
'label' => __('Save Changes'),
|
||||
'class' => 'btn btn-lg btn-primary',
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
use App\Entity;
|
||||
|
||||
return [
|
||||
'elements' => [
|
||||
|
||||
'path' => [
|
||||
'text',
|
||||
[
|
||||
'label' => __('Backup Filename'),
|
||||
'description' => __('Optional absolute or relative path where the backup file should be located.'),
|
||||
]
|
||||
],
|
||||
|
||||
'exclude_media' => [
|
||||
'toggle',
|
||||
[
|
||||
'label' => __('Exclude Media from Backup'),
|
||||
'description' => __('This will produce a significantly smaller backup, but you should make sure to back up your media elsewhere.'),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => false,
|
||||
]
|
||||
],
|
||||
|
||||
'submit' => [
|
||||
'submit',
|
||||
[
|
||||
'type' => 'submit',
|
||||
'label' => __('Save Changes'),
|
||||
'class' => 'btn btn-lg btn-primary',
|
||||
]
|
||||
],
|
||||
|
||||
],
|
||||
];
|
|
@ -33,6 +33,11 @@ return function(\App\Event\BuildAdminMenu $e) {
|
|||
'url' => $router->named('admin:logs:index'),
|
||||
'permission' => Acl::GLOBAL_LOGS,
|
||||
],
|
||||
'backups' => [
|
||||
'label' => __('Backups'),
|
||||
'url' => $router->named('admin:backups:index'),
|
||||
'permission' => Acl::GLOBAL_BACKUPS,
|
||||
]
|
||||
],
|
||||
],
|
||||
'users' => [
|
||||
|
@ -73,4 +78,4 @@ return function(\App\Event\BuildAdminMenu $e) {
|
|||
],
|
||||
]
|
||||
]);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<?php
|
||||
// An array of message queue types and the DI classes responsible for handling them.
|
||||
return [
|
||||
\App\Message\AddNewMedia::class => \App\Sync\Task\Media::class,
|
||||
\App\Message\ReprocessMedia::class => \App\Sync\Task\Media::class,
|
||||
\App\Message\AddNewMediaMessage::class => \App\Sync\Task\Media::class,
|
||||
\App\Message\ReprocessMediaMessage::class => \App\Sync\Task\Media::class,
|
||||
|
||||
\App\Message\UpdateNowPlayingMessage::class => \App\Sync\Task\NowPlaying::class,
|
||||
\App\Message\UpdateNowPlayingMessage::class => \App\Sync\Task\NowPlaying::class,
|
||||
|
||||
\App\Message\BackupMessage::class => \App\Sync\Task\Backup::class,
|
||||
];
|
||||
|
|
|
@ -39,6 +39,26 @@ return function(App $app)
|
|||
|
||||
})->add([Middleware\Permissions::class, Acl::GLOBAL_API_KEYS]);
|
||||
|
||||
$this->group('/backups', function() {
|
||||
/** @var App $this */
|
||||
|
||||
$this->get('', Controller\Admin\BackupsController::class)
|
||||
->setName('admin:backups:index');
|
||||
|
||||
$this->map(['GET', 'POST'], '/configure', Controller\Admin\BackupsController::class.':configureAction')
|
||||
->setName('admin:backups:configure');
|
||||
|
||||
$this->map(['GET', 'POST'], '/run', Controller\Admin\BackupsController::class.':runAction')
|
||||
->setName('admin:backups:run');
|
||||
|
||||
$this->get('/delete/{path}', Controller\Admin\BackupsController::class.':downloadAction')
|
||||
->setName('admin:backups:download');
|
||||
|
||||
$this->get('/delete/{path}/{csrf}', Controller\Admin\BackupsController::class.':deleteAction')
|
||||
->setName('admin:backups:delete');
|
||||
|
||||
})->add([Middleware\Permissions::class, Acl::GLOBAL_BACKUPS]);
|
||||
|
||||
$this->map(['GET', 'POST'], '/branding', Controller\Admin\BrandingController::class.':indexAction')
|
||||
->setName('admin:branding:index')
|
||||
->add([Middleware\Permissions::class, Acl::GLOBAL_SETTINGS]);
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
version: '2.2'
|
||||
|
||||
services:
|
||||
influxdb:
|
||||
volumes:
|
||||
- ./migration/influxdb:/tmp/migration
|
||||
|
||||
web:
|
||||
volumes:
|
||||
- ../stations:/tmp/migration
|
||||
|
||||
mariadb:
|
||||
volumes:
|
||||
- ./migration/database.sql:/tmp/database.sql
|
|
@ -18,23 +18,11 @@ fi
|
|||
|
||||
BASE_DIR=`pwd`
|
||||
|
||||
mkdir -p ${BASE_DIR}/migration
|
||||
mkdir -p ${BASE_DIR}/migration/influxdb
|
||||
# Create backup from existing installation.
|
||||
chmod a+x bin/azuracast
|
||||
./bin/azuracast azuracast:backup --exclude-media ./migration.tar.gz
|
||||
|
||||
# Dump MySQL data into fixtures folder
|
||||
MYSQL_USERNAME=`awk -F "=" '/db_username/ {print $2}' env.ini | tr -d ' '`
|
||||
MYSQL_PASSWORD=`awk -F "=" '/db_password/ {print $2}' env.ini | tr -d ' '`
|
||||
|
||||
mysqldump --add-drop-table -u$MYSQL_USERNAME -p$MYSQL_PASSWORD azuracast > migration/database.sql
|
||||
|
||||
read -n 1 -s -r -p "MySQL exported. Press any key to continue (Export InfluxDB)..."
|
||||
|
||||
# Dump InfluxDB data
|
||||
mkdir -p /var/azuracast/migration
|
||||
|
||||
influxd backup -database stations ${BASE_DIR}/migration/influxdb
|
||||
|
||||
read -n 1 -s -r -p "InfluxDB exported. Press any key to continue (Install Docker)..."
|
||||
read -n 1 -s -r -p "Database backed up. Press any key to continue (Install Docker)..."
|
||||
|
||||
# Install Docker
|
||||
wget -qO- https://get.docker.com/ | sh
|
||||
|
@ -55,30 +43,22 @@ read -n 1 -s -r -p "Uninstall complete. Press any key to continue (Install Azura
|
|||
|
||||
# Spin up Docker
|
||||
docker-compose pull
|
||||
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml up -d
|
||||
sleep 5
|
||||
|
||||
sleep 15
|
||||
# Copy media.
|
||||
docker-compose run --user="azuracast" --rm \
|
||||
-v /var/azuracast/stations:/tmp/migration \
|
||||
mv /tmp/migration/* /var/azuracast/stations
|
||||
|
||||
# Run Docker AzuraCast-specific installer
|
||||
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml run --rm influxdb import_folder /tmp/migration/
|
||||
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml exec mariadb import_file /tmp/database.sql
|
||||
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml run --user="azuracast" --rm web azuracast_migrate_stations /tmp/migration
|
||||
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml run --user="azuracast" --rm web azuracast_install
|
||||
|
||||
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml down
|
||||
docker-compose up -d
|
||||
|
||||
# Docker cleanup
|
||||
docker-compose rm -f
|
||||
|
||||
docker volume prune -f
|
||||
docker rmi $(docker images | grep "none" | awk '/ / { print $3 }')
|
||||
# Copy all other settings.
|
||||
chmod a+x docker.sh
|
||||
./docker.sh restore ./migration.tar.gz
|
||||
|
||||
read -n 1 -s -r -p "Docker is running. Press any key to continue (cleanup)..."
|
||||
|
||||
# Codebase cleanup
|
||||
rm -rf /var/azuracast/stations
|
||||
|
||||
find -maxdepth 1 ! -name migration ! -name . ! -name docker-compose.yml \
|
||||
find -maxdepth 1 ! -name migration.tar.gz ! -name . ! -name docker-compose.yml \
|
||||
! -name docker.sh ! -name .env ! -name azuracast.env ! -name plugins \
|
||||
-exec rm -rv {} \;
|
||||
|
|
48
docker.sh
48
docker.sh
|
@ -182,6 +182,7 @@ backup() {
|
|||
BACKUP_PATH=${1:-"./backup.tar.gz"}
|
||||
BACKUP_DIR=$(cd `dirname "$BACKUP_PATH"` && pwd)
|
||||
BACKUP_FILENAME=`basename "$BACKUP_PATH"`
|
||||
shift
|
||||
|
||||
cd $APP_BASE_DIR
|
||||
|
||||
|
@ -190,25 +191,52 @@ backup() {
|
|||
curl -L https://raw.githubusercontent.com/AzuraCast/AzuraCast/master/.env > .env
|
||||
fi
|
||||
|
||||
docker-compose down
|
||||
|
||||
docker run --rm -v $BACKUP_DIR:/backup \
|
||||
-v azuracast_db_data:/azuracast/db \
|
||||
-v azuracast_influx_data:/azuracast/influx \
|
||||
-v azuracast_station_data:/azuracast/stations \
|
||||
busybox tar zcvf /backup/$BACKUP_FILENAME /azuracast
|
||||
|
||||
docker-compose up -d
|
||||
docker-compose run --rm --user="azuracast" \
|
||||
-v $BACKUP_DIR:/backup \
|
||||
web azuracast_cli azuracast:backup /backup/$BACKUP_FILENAME $*
|
||||
}
|
||||
|
||||
#
|
||||
# Restore the Docker volumes from a .tar.gz file.
|
||||
# Restore an AzuraCast backup into Docker.
|
||||
# Usage:
|
||||
# ./docker.sh restore [/custom/backup/dir/custombackupname.tar.gz]
|
||||
#
|
||||
restore() {
|
||||
APP_BASE_DIR=$(pwd)
|
||||
|
||||
BACKUP_PATH=${1:-"./backup.tar.gz"}
|
||||
BACKUP_DIR=$(cd `dirname "$BACKUP_PATH"` && pwd)
|
||||
BACKUP_FILENAME=`basename "$BACKUP_PATH"`
|
||||
shift
|
||||
|
||||
cd $APP_BASE_DIR
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Writing default .env file..."
|
||||
curl -L https://raw.githubusercontent.com/AzuraCast/AzuraCast/master/.env > .env
|
||||
fi
|
||||
|
||||
if [ -f $BACKUP_PATH ]; then
|
||||
docker-compose run --rm --user="azuracast" \
|
||||
-v $BACKUP_DIR:/backup \
|
||||
web azuracast_restore /backup/$BACKUP_FILENAME $*
|
||||
|
||||
docker-compose up -d
|
||||
else
|
||||
echo "File $BACKUP_PATH does not exist in this directory. Nothing to restore."
|
||||
exit 1
|
||||
fi
|
||||
echo 'test'
|
||||
}
|
||||
|
||||
#
|
||||
# Restore the Docker volumes from a legacy backup format .tar.gz file.
|
||||
# Usage:
|
||||
# ./docker.sh restore [/custom/backup/dir/custombackupname.tar.gz]
|
||||
#
|
||||
restore-legacy() {
|
||||
APP_BASE_DIR=$(pwd)
|
||||
|
||||
BACKUP_PATH=${1:-"./backup.tar.gz"}
|
||||
BACKUP_DIR=$(cd `dirname "$BACKUP_PATH"` && pwd)
|
||||
BACKUP_FILENAME=`basename "$BACKUP_PATH"`
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -14,6 +14,7 @@ class Acl
|
|||
public const GLOBAL_PERMISSIONS = 'administer permissions';
|
||||
public const GLOBAL_STATIONS = 'administer stations';
|
||||
public const GLOBAL_CUSTOM_FIELDS = 'administer custom fields';
|
||||
public const GLOBAL_BACKUPS = 'administer backups';
|
||||
|
||||
public const STATION_ALL = 'administer all';
|
||||
public const STATION_VIEW = 'view station management';
|
||||
|
@ -188,6 +189,7 @@ class Acl
|
|||
self::GLOBAL_PERMISSIONS => __('Administer %s', __('Permissions')),
|
||||
self::GLOBAL_STATIONS => __('Administer %s', __('Stations')),
|
||||
self::GLOBAL_CUSTOM_FIELDS => __('Administer %s', __('Custom Fields')),
|
||||
self::GLOBAL_BACKUPS => __('Administer %s', __('Backups')),
|
||||
],
|
||||
'station' => [
|
||||
self::STATION_ALL => __('All Permissions'),
|
||||
|
|
|
@ -0,0 +1,222 @@
|
|||
<?php
|
||||
namespace App\Console\Command;
|
||||
|
||||
use App\Entity;
|
||||
use Azura\Console\Command\CommandAbstract;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Monolog\Logger;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class Backup extends CommandAbstract
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('azuracast:backup')
|
||||
->setDescription(
|
||||
'Back up the AzuraCast database and statistics (and optionally media).'
|
||||
)
|
||||
->addArgument(
|
||||
'path',
|
||||
InputArgument::OPTIONAL,
|
||||
'The absolute (or relative to /var/azuracast/backups) path to generate the backup.',
|
||||
''
|
||||
)
|
||||
->addOption(
|
||||
'exclude-media',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Exclude media from the backup.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$destination_path = $input->getArgument('path');
|
||||
if (empty($destination_path)) {
|
||||
$destination_path = 'manual_backup_'.gmdate('Ymd_Hi').'.tar.gz';
|
||||
}
|
||||
if ('/' !== $destination_path[0]) {
|
||||
$destination_path = \App\Sync\Task\Backup::BASE_DIR.'/'.$destination_path;
|
||||
}
|
||||
|
||||
$include_media = !(bool)$input->getOption('exclude-media');
|
||||
|
||||
$files_to_backup = [];
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('AzuraCast Backup');
|
||||
$io->writeln('Please wait while a backup is generated...');
|
||||
|
||||
// Create temp directories
|
||||
$io->section('Creating temporary directories...');
|
||||
|
||||
$tmp_dir_mariadb = '/tmp/azuracast_backup_mariadb';
|
||||
if (!mkdir($tmp_dir_mariadb) && !is_dir($tmp_dir_mariadb)) {
|
||||
$io->error(sprintf('Directory "%s" was not created', $tmp_dir_mariadb));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$tmp_dir_influxdb = '/tmp/azuracast_backup_influxdb';
|
||||
if (!mkdir($tmp_dir_influxdb) && !is_dir($tmp_dir_influxdb)) {
|
||||
$io->error(sprintf('Directory "%s" was not created', $tmp_dir_influxdb));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
|
||||
// Back up MariaDB
|
||||
$io->section('Backing up MariaDB...');
|
||||
|
||||
$path_db_dump = $tmp_dir_mariadb.'/db.sql';
|
||||
|
||||
/** @var EntityManager $em */
|
||||
$em = $this->get(EntityManager::class);
|
||||
$conn = $em->getConnection();
|
||||
|
||||
$process = $this->passThruProcess(
|
||||
$io,
|
||||
'mysqldump --host=$DB_HOST --user=$DB_USERNAME --password=$DB_PASSWORD --add-drop-table --default-character-set=UTF8MB4 $DB_DATABASE > $DB_DEST',
|
||||
$tmp_dir_mariadb,
|
||||
[
|
||||
'DB_HOST' => $conn->getHost(),
|
||||
'DB_DATABASE' => $conn->getDatabase(),
|
||||
'DB_USERNAME' => $conn->getUsername(),
|
||||
'DB_PASSWORD' => $conn->getPassword(),
|
||||
'DB_DEST' => $path_db_dump,
|
||||
]
|
||||
);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->getErrorStyle()->error('An error occurred with MariaDB.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$files_to_backup[] = $path_db_dump;
|
||||
$io->newLine();
|
||||
|
||||
// Back up InfluxDB
|
||||
$io->section('Backing up InfluxDB...');
|
||||
|
||||
/** @var \InfluxDB\Database $influxdb */
|
||||
$influxdb = $this->get(\InfluxDB\Database::class);
|
||||
$influxdb_client = $influxdb->getClient();
|
||||
|
||||
$process = $this->passThruProcess($io, [
|
||||
'influxd',
|
||||
'backup',
|
||||
'-database', 'stations',
|
||||
'-portable',
|
||||
'-host',
|
||||
$influxdb_client->getHost().':8088',
|
||||
$tmp_dir_influxdb,
|
||||
], $tmp_dir_influxdb);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->getErrorStyle()->error('An error occurred with InfluxDB.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$files_to_backup[] = $tmp_dir_influxdb;
|
||||
$io->newLine();
|
||||
|
||||
// Include station media if specified.
|
||||
if ($include_media) {
|
||||
$stations = $em->createQuery(/** @lang DQL */'SELECT s FROM App\Entity\Station s')
|
||||
->execute();
|
||||
|
||||
foreach($stations as $station) {
|
||||
/** @var Entity\Station $station */
|
||||
|
||||
$media_dir = $station->getRadioMediaDir();
|
||||
if (!in_array($media_dir, $files_to_backup, true)) {
|
||||
$files_to_backup[] = $media_dir;
|
||||
}
|
||||
|
||||
$art_dir = $station->getRadioAlbumArtDir();
|
||||
if (!in_array($art_dir, $files_to_backup, true)) {
|
||||
$files_to_backup[] = $art_dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compress backup files.
|
||||
$io->section('Creating backup archive...');
|
||||
|
||||
// Strip leading slashes from backup paths.
|
||||
$files_to_backup = array_map(function($val) {
|
||||
if (0 === strpos($val, '/')) {
|
||||
return substr($val, 1);
|
||||
}
|
||||
return $val;
|
||||
}, $files_to_backup);
|
||||
|
||||
$process = $this->passThruProcess($io, array_merge([
|
||||
'tar',
|
||||
'zcvf',
|
||||
$destination_path
|
||||
], $files_to_backup),'/');
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->getErrorStyle()->error('An error occurred with the archive process.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->success([
|
||||
'Backup complete!',
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function passThruProcess(SymfonyStyle $io, $cmd, $cwd = null, array $env = []): Process
|
||||
{
|
||||
if (is_array($cmd)) {
|
||||
$process = new Process($cmd, $cwd);
|
||||
} else {
|
||||
$process = Process::fromShellCommandline($cmd, $cwd);
|
||||
}
|
||||
|
||||
$stdout = [];
|
||||
$stderr = [];
|
||||
|
||||
$process->run(function($type, $data) use ($process, $io, &$stdout, &$stderr) {
|
||||
if ($process::ERR === $type) {
|
||||
$io->getErrorStyle()->write($data);
|
||||
$stderr[] = $data;
|
||||
} else {
|
||||
$io->write($data);
|
||||
$stdout[] = $data;
|
||||
}
|
||||
}, $env);
|
||||
|
||||
if (!empty($stderr) || !empty($stdout)) {
|
||||
/** @var Logger $logger */
|
||||
$logger = $this->get(Logger::class);
|
||||
|
||||
if (!empty($stdout)) {
|
||||
$logger->debug('Backup process output', [
|
||||
'cmd' => $cmd,
|
||||
'output' => $stdout,
|
||||
]);
|
||||
}
|
||||
if (!empty($stderr)) {
|
||||
$logger->error('Backup process error', [
|
||||
'cmd' => $cmd,
|
||||
'output' => $stderr,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $process;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
namespace App\Console\Command;
|
||||
|
||||
use App\Entity;
|
||||
use App\Utilities;
|
||||
use Azura\Console\Command\CommandAbstract;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class Restore extends CommandAbstract
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('azuracast:restore')
|
||||
->setDescription(
|
||||
'Restore a backup previously generated by AzuraCast.'
|
||||
)
|
||||
->addArgument(
|
||||
'path',
|
||||
InputArgument::REQUIRED,
|
||||
'The absolute (or relative to /var/azuracast/backups) path of the backup to restore.'
|
||||
)
|
||||
->addOption('restore', null, InputOption::VALUE_NONE, 'Unused.')
|
||||
->addOption('release', null, InputOption::VALUE_NONE, 'Used for updating only to a tagged release.');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('AzuraCast Restore');
|
||||
$io->writeln('Please wait while the backup is restored...');
|
||||
|
||||
$archive_path = $input->getArgument('path');
|
||||
if ('/' !== $archive_path[0]) {
|
||||
$archive_path = \App\Sync\Task\Backup::BASE_DIR.$archive_path;
|
||||
}
|
||||
|
||||
if (!file_exists($archive_path)) {
|
||||
$io->getErrorStyle()->error(sprintf('Backup path %s not found!', $archive_path));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract tar.gz archive
|
||||
$io->section('Extracting backup file...');
|
||||
|
||||
$process = $this->passThruProcess($io, [
|
||||
'tar',
|
||||
'zxvf',
|
||||
$archive_path
|
||||
],'/');
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->getErrorStyle()->error('An error occurred with the archive extraction.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
|
||||
// Handle DB dump
|
||||
$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!');
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** @var EntityManager $em */
|
||||
$em = $this->get(EntityManager::class);
|
||||
$conn = $em->getConnection();
|
||||
|
||||
$process = $this->passThruProcess(
|
||||
$io,
|
||||
'mysql --host=$DB_HOST --user=$DB_USERNAME --password=$DB_PASSWORD $DB_DATABASE < $DB_DUMP',
|
||||
$tmp_dir_mariadb,
|
||||
[
|
||||
'DB_HOST' => $conn->getHost(),
|
||||
'DB_DATABASE' => $conn->getDatabase(),
|
||||
'DB_USERNAME' => $conn->getUsername(),
|
||||
'DB_PASSWORD' => $conn->getPassword(),
|
||||
'DB_DUMP' => $path_db_dump,
|
||||
]
|
||||
);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->getErrorStyle()->error('An error occurred with MariaDB.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
Utilities::rmdirRecursive($tmp_dir_mariadb);
|
||||
$io->newLine();
|
||||
|
||||
// Handle InfluxDB import
|
||||
$tmp_dir_influxdb = '/tmp/azuracast_backup_influxdb';
|
||||
|
||||
if (!is_dir($tmp_dir_influxdb)) {
|
||||
$io->getErrorStyle()->error('InfluxDB backup file not found!');
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** @var \InfluxDB\Database $influxdb */
|
||||
$influxdb = $this->get(\InfluxDB\Database::class);
|
||||
$influxdb_client = $influxdb->getClient();
|
||||
|
||||
$process = $this->passThruProcess($io, [
|
||||
'influxd',
|
||||
'restore',
|
||||
'-portable',
|
||||
'-host',
|
||||
$influxdb_client->getHost().':8088',
|
||||
$tmp_dir_influxdb,
|
||||
], $tmp_dir_influxdb);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->getErrorStyle()->error('An error occurred with InfluxDB.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
Utilities::rmdirRecursive($tmp_dir_influxdb);
|
||||
$io->newLine();
|
||||
|
||||
// Update from current version to latest.
|
||||
$io->section('Running standard updates...');
|
||||
|
||||
$this->runCommand($output, 'azuracast:setup', ['--update' => true]);
|
||||
|
||||
$io->success([
|
||||
'Restore complete!',
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function passThruProcess(SymfonyStyle $io, $cmd, $cwd = null, array $env = []): Process
|
||||
{
|
||||
if (is_array($cmd)) {
|
||||
$process = new Process($cmd, $cwd);
|
||||
} else {
|
||||
$process = Process::fromShellCommandline($cmd, $cwd);
|
||||
}
|
||||
|
||||
$process->run(function($type, $data) use ($process, $io) {
|
||||
if ($process::ERR === $type) {
|
||||
$io->getErrorStyle()->write($data);
|
||||
} else {
|
||||
$io->write($data);
|
||||
}
|
||||
}, $env);
|
||||
|
||||
return $process;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\Repository\SettingsRepository;
|
||||
use App\Entity\Settings;
|
||||
use App\Form\Form;
|
||||
use App\Form\SettingsForm;
|
||||
use App\Http\Request;
|
||||
use App\Http\Response;
|
||||
use App\Sync\Task\Backup;
|
||||
use League\Flysystem\Adapter\Local;
|
||||
use League\Flysystem\Filesystem;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class BackupsController
|
||||
{
|
||||
/** @var SettingsForm */
|
||||
protected $settings_form;
|
||||
|
||||
/** @var SettingsRepository */
|
||||
protected $settings_repo;
|
||||
|
||||
/** @var Form */
|
||||
protected $backup_run_form;
|
||||
|
||||
/** @var Backup */
|
||||
protected $backup_task;
|
||||
|
||||
/** @var Filesystem */
|
||||
protected $backup_fs;
|
||||
|
||||
/** @var string */
|
||||
protected $csrf_namespace = 'admin_backups';
|
||||
|
||||
/**
|
||||
* @param SettingsForm $settings_form
|
||||
* @param Form $backup_run_form
|
||||
* @param Backup $backup_task
|
||||
*
|
||||
* @see \App\Provider\AdminProvider
|
||||
*/
|
||||
public function __construct(
|
||||
SettingsForm $settings_form,
|
||||
Form $backup_run_form,
|
||||
Backup $backup_task
|
||||
)
|
||||
{
|
||||
$this->settings_form = $settings_form;
|
||||
$this->settings_repo = $settings_form->getEntityRepository();
|
||||
|
||||
$this->backup_run_form = $backup_run_form;
|
||||
|
||||
$this->backup_task = $backup_task;
|
||||
$this->backup_fs = new Filesystem(new Local(Backup::BASE_DIR));
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response): ResponseInterface
|
||||
{
|
||||
return $request->getView()->renderToResponse($response, 'admin/backups/index', [
|
||||
'backups' => $this->backup_fs->listContents('', false),
|
||||
'is_enabled' => (bool)$this->settings_repo->getSetting(Settings::BACKUP_ENABLED, false),
|
||||
'last_run' => $this->settings_repo->getSetting(Settings::BACKUP_LAST_RUN, 0),
|
||||
'last_result' => $this->settings_repo->getSetting(Settings::BACKUP_LAST_RESULT, 0),
|
||||
'last_output' => $this->settings_repo->getSetting(Settings::BACKUP_LAST_OUTPUT, ''),
|
||||
'csrf' => $request->getSession()->getCsrf()->generate($this->csrf_namespace),
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureAction(Request $request, Response $response): ResponseInterface
|
||||
{
|
||||
if (false !== $this->settings_form->process($request)) {
|
||||
$request->getSession()->flash(__('Changes saved.'), 'green');
|
||||
return $response->withRedirect($request->getRouter()->fromHere('admin:backups:index'));
|
||||
}
|
||||
|
||||
return $request->getView()->renderToResponse($response, 'system/form_page', [
|
||||
'form' => $this->settings_form,
|
||||
'render_mode' => 'edit',
|
||||
'title' => __('Configure Backups'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function runAction(Request $request, Response $response): ResponseInterface
|
||||
{
|
||||
// Handle submission.
|
||||
if ($request->isPost() && $this->backup_run_form->isValid($request->getParsedBody())) {
|
||||
$data = $this->backup_run_form->getValues();
|
||||
|
||||
[$result_code, $result_output] = $this->backup_task->runBackup($data['path'], $data['exclude_media']);
|
||||
|
||||
$is_successful = (0 === $result_code);
|
||||
|
||||
return $request->getView()->renderToResponse($response, 'admin/backups/run', [
|
||||
'title' => __('Run Manual Backup'),
|
||||
'path' => $data['path'],
|
||||
'is_successful' => $is_successful,
|
||||
'output' => $result_output,
|
||||
]);
|
||||
}
|
||||
|
||||
return $request->getView()->renderToResponse($response, 'system/form_page', [
|
||||
'form' => $this->backup_run_form,
|
||||
'render_mode' => 'edit',
|
||||
'title' => __('Run Manual Backup'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadAction(Request $request, Response $response, $path): ResponseInterface
|
||||
{
|
||||
$path = $this->getFilePath($path);
|
||||
|
||||
$fh = $this->backup_fs->readStream($path);
|
||||
$file_meta = $this->backup_fs->getMetadata($path);
|
||||
|
||||
try {
|
||||
$file_mime = $this->backup_fs->getMimetype($path);
|
||||
} catch(\Exception $e) {
|
||||
$file_mime = 'application/octet-stream';
|
||||
}
|
||||
|
||||
return $response
|
||||
->withNoCache()
|
||||
->withHeader('Content-Type', $file_mime)
|
||||
->withHeader('Content-Length', $file_meta['size'])
|
||||
->withHeader('Content-Disposition', sprintf('attachment; filename=%s',
|
||||
strpos('MSIE', $_SERVER['HTTP_REFERER']) ? rawurlencode($path) : "\"$path\""))
|
||||
->withHeader('X-Accel-Buffering', 'no')
|
||||
->withBody(new \Slim\Http\Stream($fh));
|
||||
}
|
||||
|
||||
public function deleteAction(Request $request, Response $response, $path, $csrf_token): ResponseInterface
|
||||
{
|
||||
$request->getSession()->getCsrf()->verify($csrf_token, $this->csrf_namespace);
|
||||
|
||||
$path = $this->getFilePath($path);
|
||||
$this->backup_fs->delete($path);
|
||||
|
||||
$request->getSession()->flash('<b>' . __('%s deleted.', __('Backup')) . '</b>', 'green');
|
||||
return $response->withRedirect($request->getRouter()->named('admin:backups:index'));
|
||||
}
|
||||
|
||||
protected function getFilePath($raw_path)
|
||||
{
|
||||
$path = base64_decode($raw_path);
|
||||
$path = basename($path);
|
||||
|
||||
if (!$this->backup_fs->has($path)) {
|
||||
throw new \App\Exception\NotFound(__('%s not found.', 'Backup'));
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,14 +1,35 @@
|
|||
<?php
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\SettingsForm;
|
||||
use App\Http\Request;
|
||||
use App\Http\Response;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class BrandingController extends SettingsController
|
||||
class BrandingController
|
||||
{
|
||||
/** @var SettingsForm */
|
||||
protected $form;
|
||||
|
||||
/**
|
||||
* @param SettingsForm $form
|
||||
*
|
||||
* @see \App\Provider\AdminProvider
|
||||
*/
|
||||
public function __construct(SettingsForm $form)
|
||||
{
|
||||
$this->form = $form;
|
||||
}
|
||||
|
||||
public function indexAction(Request $request, Response $response): ResponseInterface
|
||||
{
|
||||
return $this->renderSettingsForm($request, $response, 'admin/branding/index');
|
||||
if (false !== $this->form->process($request)) {
|
||||
$request->getSession()->flash(__('Changes saved.'), 'green');
|
||||
return $response->withRedirect($request->getUri()->getPath());
|
||||
}
|
||||
|
||||
return $request->getView()->renderToResponse($response, 'admin/branding/index', [
|
||||
'form' => $this->form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,51 +3,35 @@ namespace App\Controller\Admin;
|
|||
|
||||
use App\Entity;
|
||||
use App\Form\Form;
|
||||
use App\Form\SettingsForm;
|
||||
use App\Http\Request;
|
||||
use App\Http\Response;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class SettingsController
|
||||
{
|
||||
/** @var Entity\Repository\SettingsRepository */
|
||||
protected $settings_repo;
|
||||
|
||||
/** @var array */
|
||||
protected $form_config;
|
||||
/** @var SettingsForm */
|
||||
protected $form;
|
||||
|
||||
/**
|
||||
* @param Entity\Repository\SettingsRepository $settings_repo
|
||||
* @param array $form_config
|
||||
* @param SettingsForm $form
|
||||
*
|
||||
* @see \App\Provider\AdminProvider
|
||||
*/
|
||||
public function __construct(Entity\Repository\SettingsRepository $settings_repo, array $form_config)
|
||||
public function __construct(SettingsForm $form)
|
||||
{
|
||||
$this->settings_repo = $settings_repo;
|
||||
$this->form_config = $form_config;
|
||||
$this->form = $form;
|
||||
}
|
||||
|
||||
public function indexAction(Request $request, Response $response): ResponseInterface
|
||||
{
|
||||
return $this->renderSettingsForm($request, $response, 'system/form_page');
|
||||
}
|
||||
|
||||
protected function renderSettingsForm(Request $request, Response $response, $form_template): ResponseInterface
|
||||
{
|
||||
$existing_settings = $this->settings_repo->fetchArray(false);
|
||||
$form = new Form($this->form_config, $existing_settings);
|
||||
|
||||
if ($request->isPost() && $form->isValid($_POST)) {
|
||||
$data = $form->getValues();
|
||||
|
||||
$this->settings_repo->setSettings($data);
|
||||
|
||||
if (false !== $this->form->process($request)) {
|
||||
$request->getSession()->flash(__('Changes saved.'), 'green');
|
||||
|
||||
return $response->withRedirect($request->getUri()->getPath());
|
||||
}
|
||||
|
||||
return $request->getView()->renderToResponse($response, $form_template, [
|
||||
'form' => $form,
|
||||
return $request->getView()->renderToResponse($response, 'system/form_page', [
|
||||
'form' => $this->form,
|
||||
'render_mode' => 'edit',
|
||||
'title' => __('System Settings'),
|
||||
]);
|
||||
|
|
|
@ -35,6 +35,12 @@ class Settings
|
|||
public const CUSTOM_JS_PUBLIC = 'custom_js_public';
|
||||
public const CUSTOM_CSS_INTERNAL = 'custom_css_internal';
|
||||
|
||||
// Backup settings
|
||||
public const BACKUP_ENABLED = 'backup_enabled';
|
||||
public const BACKUP_TIME = 'backup_time';
|
||||
public const BACKUP_EXCLUDE_MEDIA = 'backup_exclude_media';
|
||||
public const BACKUP_KEEP_COPIES = 'backup_keep_copies';
|
||||
|
||||
// Internal settings
|
||||
public const SETUP_COMPLETE = 'setup_complete';
|
||||
|
||||
|
@ -52,6 +58,10 @@ class Settings
|
|||
public const UPDATE_RESULTS = 'central_update_results';
|
||||
public const UPDATE_LAST_RUN = 'central_update_last_run';
|
||||
|
||||
public const BACKUP_LAST_RUN = 'backup_last_run';
|
||||
public const BACKUP_LAST_RESULT = 'backup_last_result';
|
||||
public const BACKUP_LAST_OUTPUT = 'backup_last_output';
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="setting_key", type="string", length=64)
|
||||
* @ORM\Id
|
||||
|
|
|
@ -656,25 +656,7 @@ class Station
|
|||
*/
|
||||
public function setRadioBaseDir($new_dir): void
|
||||
{
|
||||
$new_dir = $this->_truncateString(trim($new_dir));
|
||||
|
||||
if (strcmp($this->radio_base_dir, $new_dir) !== 0) {
|
||||
$this->radio_base_dir = $new_dir;
|
||||
|
||||
$radio_dirs = [
|
||||
$this->radio_base_dir,
|
||||
$this->getRadioMediaDir(),
|
||||
$this->getRadioAlbumArtDir(),
|
||||
$this->getRadioPlaylistsDir(),
|
||||
$this->getRadioConfigDir(),
|
||||
$this->getRadioTempDir(),
|
||||
];
|
||||
foreach ($radio_dirs as $radio_dir) {
|
||||
if (!file_exists($radio_dir) && !mkdir($radio_dir, 0777) && !is_dir($radio_dir)) {
|
||||
throw new \RuntimeException(sprintf('Directory "%s" was not created', $radio_dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->radio_base_dir = $this->_truncateString(trim($new_dir));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity;
|
||||
use App\Http\Request;
|
||||
use Azura\Doctrine\Repository;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
class SettingsForm extends Form
|
||||
{
|
||||
/** @var EntityManager */
|
||||
protected $em;
|
||||
|
||||
/** @var Entity\Repository\SettingsRepository */
|
||||
protected $settings_repo;
|
||||
|
||||
/**
|
||||
* @param EntityManager $em
|
||||
* @param array $form_config
|
||||
*/
|
||||
public function __construct(
|
||||
EntityManager $em,
|
||||
array $form_config)
|
||||
{
|
||||
parent::__construct($form_config);
|
||||
|
||||
$this->em = $em;
|
||||
$this->settings_repo = $em->getRepository(Entity\Settings::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EntityManager
|
||||
*/
|
||||
public function getEntityManager(): EntityManager
|
||||
{
|
||||
return $this->em;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Entity\Repository\SettingsRepository
|
||||
*/
|
||||
public function getEntityRepository(): Entity\Repository\SettingsRepository
|
||||
{
|
||||
return $this->settings_repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @return bool
|
||||
*/
|
||||
public function process(Request $request): bool
|
||||
{
|
||||
// Populate the form with existing values (if they exist).
|
||||
$this->populate($this->settings_repo->fetchArray(false));
|
||||
|
||||
// Handle submission.
|
||||
if ($request->isPost() && $this->isValid($request->getParsedBody())) {
|
||||
$data = $this->getValues();
|
||||
$this->settings_repo->setSettings($data);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
namespace App\Message;
|
||||
|
||||
class AddNewMedia extends AbstractMessage
|
||||
class AddNewMediaMessage extends AbstractMessage
|
||||
{
|
||||
/** @var int The numeric identifier for the station. */
|
||||
public $station_id;
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
namespace App\Message;
|
||||
|
||||
class BackupMessage extends AbstractMessage
|
||||
{
|
||||
/** @var string|null The absolute or relative path of the backup file. */
|
||||
public $path;
|
||||
|
||||
/** @var bool Whether to exclude media, producing a much more compact backup. */
|
||||
public $exclude_media = false;
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
namespace App\Message;
|
||||
|
||||
class ReprocessMedia extends AbstractMessage
|
||||
class ReprocessMediaMessage extends AbstractMessage
|
||||
{
|
||||
/** @var int The numeric identifier for the StationMedia record being processed. */
|
||||
public $media_id;
|
|
@ -25,13 +25,34 @@ class AdminProvider implements ServiceProviderInterface
|
|||
);
|
||||
};
|
||||
|
||||
$di[Admin\BrandingController::class] = function($di) {
|
||||
$di[Admin\BackupsController::class] = function($di) {
|
||||
/** @var Azura\Config $config */
|
||||
$config = $di[Azura\Config::class];
|
||||
|
||||
$settings_form = new App\Form\SettingsForm(
|
||||
$di[EntityManager::class],
|
||||
$config->get('forms/backup')
|
||||
);
|
||||
|
||||
$backup_run_form = new App\Form\Form(
|
||||
$config->get('forms/backup_run')
|
||||
);
|
||||
|
||||
return new Admin\BackupsController(
|
||||
$settings_form,
|
||||
$backup_run_form,
|
||||
$di[App\Sync\Task\Backup::class]
|
||||
);
|
||||
};
|
||||
|
||||
$di[Admin\BrandingController::class] = function($di) {
|
||||
/** @var \Azura\Config $config */
|
||||
$config = $di[\Azura\Config::class];
|
||||
|
||||
$form_config = $config->get('forms/branding', ['settings' => $di['settings']]);
|
||||
|
||||
return new Admin\BrandingController(
|
||||
$di[Entity\Repository\SettingsRepository::class],
|
||||
$config->get('forms/branding', ['settings' => $di['settings']])
|
||||
new App\Form\SettingsForm($di[EntityManager::class], $form_config)
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -72,8 +93,7 @@ class AdminProvider implements ServiceProviderInterface
|
|||
$config = $di[\Azura\Config::class];
|
||||
|
||||
return new Admin\SettingsController(
|
||||
$di[Entity\Repository\SettingsRepository::class],
|
||||
$config->get('forms/settings')
|
||||
new App\Form\SettingsForm($di[EntityManager::class], $config->get('forms/settings'))
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ class SyncProvider implements ServiceProviderInterface
|
|||
new \Pimple\ServiceIterator($di, [
|
||||
// Every minute tasks
|
||||
Task\RadioRequests::class,
|
||||
Task\Backup::class,
|
||||
]),
|
||||
new \Pimple\ServiceIterator($di, [
|
||||
// Every 5 minutes tasks
|
||||
|
@ -46,6 +47,15 @@ class SyncProvider implements ServiceProviderInterface
|
|||
);
|
||||
};
|
||||
|
||||
$di[Task\Backup::class] = function($di) {
|
||||
return new Task\Backup(
|
||||
$di[\Doctrine\ORM\EntityManager::class],
|
||||
$di[\Monolog\Logger::class],
|
||||
$di[\App\MessageQueue::class],
|
||||
$di[\Azura\Console\Application::class]
|
||||
);
|
||||
};
|
||||
|
||||
$di[Task\CheckForUpdates::class] = function($di) {
|
||||
return new Task\CheckForUpdates(
|
||||
$di[\Doctrine\ORM\EntityManager::class],
|
||||
|
|
|
@ -35,10 +35,6 @@ class Configuration
|
|||
* @param Station $station
|
||||
* @param bool $regen_auth_key
|
||||
* @param bool $force_restart Always restart this station's supervisor instances, even if nothing changed.
|
||||
*
|
||||
* @throws \App\Exception\NotFound
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
*/
|
||||
public function writeConfiguration(Station $station, $regen_auth_key = false, $force_restart = false): void
|
||||
{
|
||||
|
@ -76,6 +72,21 @@ class Configuration
|
|||
return;
|
||||
}
|
||||
|
||||
// Ensure all directories exist.
|
||||
$radio_dirs = [
|
||||
$station->getRadioBaseDir(),
|
||||
$station->getRadioMediaDir(),
|
||||
$station->getRadioAlbumArtDir(),
|
||||
$station->getRadioPlaylistsDir(),
|
||||
$station->getRadioConfigDir(),
|
||||
$station->getRadioTempDir(),
|
||||
];
|
||||
foreach ($radio_dirs as $radio_dir) {
|
||||
if (!file_exists($radio_dir) && !mkdir($radio_dir, 0777) && !is_dir($radio_dir)) {
|
||||
throw new \RuntimeException(sprintf('Directory "%s" was not created', $radio_dir));
|
||||
}
|
||||
}
|
||||
|
||||
// Write config files for both backend and frontend.
|
||||
$frontend->write($station);
|
||||
$backend->write($station);
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
namespace App\Sync\Task;
|
||||
|
||||
use App\MessageQueue;
|
||||
use App\Message;
|
||||
use Azura\Console\Application;
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use App\Entity;
|
||||
use Monolog\Logger;
|
||||
|
||||
class Backup extends AbstractTask
|
||||
{
|
||||
public const BASE_DIR = '/var/azuracast/backups';
|
||||
|
||||
/** @var Entity\Repository\SettingsRepository */
|
||||
protected $settings_repo;
|
||||
|
||||
/** @var MessageQueue */
|
||||
protected $message_queue;
|
||||
|
||||
/** @var Application */
|
||||
protected $console;
|
||||
|
||||
/**
|
||||
* @param EntityManager $em
|
||||
* @param Logger $logger
|
||||
* @param MessageQueue $message_queue
|
||||
* @param Application $console
|
||||
*
|
||||
* @see \App\Provider\SyncProvider
|
||||
*/
|
||||
public function __construct(
|
||||
EntityManager $em,
|
||||
Logger $logger,
|
||||
MessageQueue $message_queue,
|
||||
Application $console
|
||||
) {
|
||||
parent::__construct($em, $logger);
|
||||
|
||||
$this->settings_repo = $em->getRepository(Entity\Settings::class);
|
||||
|
||||
$this->message_queue = $message_queue;
|
||||
$this->console = $console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle event dispatch.
|
||||
*
|
||||
* @param Message\AbstractMessage $message
|
||||
*/
|
||||
public function __invoke(Message\AbstractMessage $message)
|
||||
{
|
||||
if ($message instanceof Message\BackupMessage) {
|
||||
|
||||
[$result_code, $result_output] = $this->runBackup(
|
||||
$message->path,
|
||||
$message->exclude_media
|
||||
);
|
||||
|
||||
$this->settings_repo->setSettings([
|
||||
Entity\Settings::BACKUP_LAST_RUN => time(),
|
||||
Entity\Settings::BACKUP_LAST_RESULT => $result_code,
|
||||
Entity\Settings::BACKUP_LAST_OUTPUT => $result_output,
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $path
|
||||
* @param bool $exclude_media
|
||||
* @return array [$result_code, $result_output]
|
||||
*/
|
||||
public function runBackup($path = null, $exclude_media = false): array
|
||||
{
|
||||
$input_params = [];
|
||||
if (null !== $path) {
|
||||
$input_params['path'] = $path;
|
||||
}
|
||||
if ($exclude_media) {
|
||||
$input_params['--exclude-media'] = true;
|
||||
}
|
||||
|
||||
return $this->console->runCommand('azuracast:backup', $input_params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function run($force = false): void
|
||||
{
|
||||
$logging_enabled = (bool)$this->settings_repo->getSetting(Entity\Settings::BACKUP_ENABLED, 0);
|
||||
if (!$logging_enabled) {
|
||||
$this->logger->debug('Automated backups disabled; skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
$now_utc = Chronos::now('UTC');
|
||||
|
||||
$threshold = $now_utc->subDay()->getTimestamp();
|
||||
$last_run = $this->settings_repo->getSetting(Entity\Settings::BACKUP_LAST_RUN, 0);
|
||||
|
||||
if ($last_run <= $threshold) {
|
||||
// Check if the backup time matches (if it's set).
|
||||
$backup_timecode = (int)$this->settings_repo->getSetting(Entity\Settings::BACKUP_TIME);
|
||||
if (0 !== $backup_timecode) {
|
||||
$current_timecode = $now_utc->format('Hi');
|
||||
|
||||
if ($backup_timecode !== $current_timecode) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger a new backup.
|
||||
$message = new Message\BackupMessage;
|
||||
$message->path = 'automatic_backup.tar.gz';
|
||||
$message->exclude_media = (bool)$this->settings_repo->getSetting(Entity\Settings::BACKUP_EXCLUDE_MEDIA, 0);
|
||||
$this->message_queue->produce($message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -52,7 +52,7 @@ class Media extends AbstractTask
|
|||
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
|
||||
|
||||
try {
|
||||
if ($message instanceof Message\ReprocessMedia) {
|
||||
if ($message instanceof Message\ReprocessMediaMessage) {
|
||||
$media_row = $media_repo->find($message->media_id);
|
||||
|
||||
if ($media_row instanceof Entity\StationMedia) {
|
||||
|
@ -60,7 +60,7 @@ class Media extends AbstractTask
|
|||
|
||||
$this->em->flush($media_row);
|
||||
}
|
||||
} else if ($message instanceof Message\AddNewMedia) {
|
||||
} else if ($message instanceof Message\AddNewMediaMessage) {
|
||||
$station = $this->em->find(Entity\Station::class, $message->station_id);
|
||||
|
||||
if ($station instanceof Entity\Station) {
|
||||
|
@ -151,7 +151,7 @@ class Media extends AbstractTask
|
|||
|
||||
$file_info = $music_files[$path_hash];
|
||||
if ($force_reprocess || $media_row->needsReprocessing($file_info['timestamp'])) {
|
||||
$message = new Message\ReprocessMedia;
|
||||
$message = new Message\ReprocessMediaMessage;
|
||||
$message->media_id = $media_row->getId();
|
||||
$message->force = $force_reprocess;
|
||||
|
||||
|
@ -182,7 +182,7 @@ class Media extends AbstractTask
|
|||
|
||||
// Create files that do not currently exist.
|
||||
foreach ($music_files as $path_hash => $new_music_file) {
|
||||
$message = new Message\AddNewMedia;
|
||||
$message = new Message\AddNewMediaMessage;
|
||||
$message->station_id = $station->getId();
|
||||
$message->path = $new_music_file['path'];
|
||||
|
||||
|
|
|
@ -65,6 +65,18 @@ class RotateLogs extends AbstractTask
|
|||
$rotate->keep(5);
|
||||
$rotate->size('5MB');
|
||||
$rotate->run();
|
||||
|
||||
// Rotate the automated backups.
|
||||
/** @var Entity\Repository\SettingsRepository $settings_repo */
|
||||
$settings_repo = $this->em->getRepository(Entity\Settings::class);
|
||||
|
||||
$backups_to_keep = (int)$settings_repo->getSetting(Entity\Settings::BACKUP_KEEP_COPIES, 0);
|
||||
|
||||
if ($backups_to_keep > 0) {
|
||||
$rotate = new Rotate\Rotate(Backup::BASE_DIR . '/automatic_backup.tar.gz');
|
||||
$rotate->keep($backups_to_keep);
|
||||
$rotate->run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
$(function() {
|
||||
|
||||
moment.relativeTimeThreshold('ss', 1);
|
||||
moment.relativeTimeRounding(function (value) {
|
||||
return Math.round(value * 10) / 10;
|
||||
});
|
||||
|
||||
$('time[data-content]').each(function () {
|
||||
let tz_display = $(this).data('content');
|
||||
$(this).text(moment.unix(tz_display).format('lll'));
|
||||
});
|
||||
|
||||
$('time[data-duration]').each(function () {
|
||||
$(this).text(moment.duration($(this).data('duration'), "seconds").humanize(true));
|
||||
});
|
||||
|
||||
$('span[data-file-size]').each(function() {
|
||||
let original_size = $(this).data('file-size');
|
||||
$(this).text(formatFileSize(original_size));
|
||||
});
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
var s = ['bytes', 'KB','MB','GB','TB','PB','EB'];
|
||||
for(var pos = 0;bytes >= 1000; pos++,bytes /= 1000);
|
||||
var d = Math.round(bytes*10);
|
||||
return pos ? [parseInt(d/10),".",d%10," ",s[pos]].join('') : bytes + ' bytes';
|
||||
}
|
||||
|
||||
var log_modal = $('#modal-log-view');
|
||||
|
||||
if (log_modal.length > 0) {
|
||||
log_modal.modal({
|
||||
focus: false,
|
||||
show: false
|
||||
});
|
||||
|
||||
$('#btn-view-log').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
log_modal.modal('show');
|
||||
return false;
|
||||
});
|
||||
|
||||
}
|
||||
});
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
/**
|
||||
* @var array $backups
|
||||
* @var \Azura\Assets $assets
|
||||
*/
|
||||
|
||||
$this->layout('main', [
|
||||
'title' => __('Backups'),
|
||||
'manual' => true
|
||||
]);
|
||||
|
||||
$assets
|
||||
->load('moment')
|
||||
->addInlineJs($this->fetch('admin/backups/index.js'), 99);
|
||||
?>
|
||||
|
||||
<div class="card-deck">
|
||||
<div class="card mb-3">
|
||||
<?php if ($is_enabled): ?>
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h3 class="card-title">
|
||||
<?=__('Automatic Backups') ?>
|
||||
<small class="badge badge-pill badge-success"><?=__('Enabled') ?></small>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">
|
||||
<?php if ($last_run > 0): ?>
|
||||
<?=__('Last run: %s', '<time data-duration="'.(0-(time()-$last_run)).'"></time>') ?>
|
||||
<?php else: ?>
|
||||
<?=__('Never run') ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn btn-outline-primary" href="<?=$router->fromHere('admin:backups:configure') ?>">
|
||||
<i class="material-icons" aria-hidden="true">settings</i>
|
||||
<?=__('Configure') ?>
|
||||
</a>
|
||||
<?php if (!empty($last_output)): ?>
|
||||
<a class="btn btn-outline-secondary" id="btn-view-log" href="#">
|
||||
<i class="material-icons" aria-hidden="true">assignment</i>
|
||||
<?=__('Most Recent Backup Log') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h3 class="card-title">
|
||||
<?=__('Automatic Backups') ?>
|
||||
<small class="badge badge-pill badge-danger"><?=__('Disabled') ?></small>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn btn-outline-primary" href="<?=$router->fromHere('admin:backups:configure') ?>">
|
||||
<i class="material-icons" aria-hidden="true">settings</i>
|
||||
<?=__('Configure') ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h3 class="card-title"><?=__('Restoring Backups') ?></h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text"><?=__('To restore a backup from your host computer, run:') ?></p>
|
||||
|
||||
<?php if (APP_INSIDE_DOCKER): ?>
|
||||
<pre><code>./docker.sh restore path_to_backup.tar.gz</code></pre>
|
||||
<?php else: ?>
|
||||
<pre><code>/var/azuracast/www/bin/azuracast azuracast:restore path_to_backup.tar.gz</code></pre>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="card-text text-warning"><?=__('Note that restoring a backup will clear your existing database. Never restore backup files from untrusted users.') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h3 class="card-title"><?=__('Backups') ?></h3>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn btn-outline-primary" role="button" href="<?=$router->named('admin:backups:run') ?>">
|
||||
<i class="material-icons" aria-hidden="true">send</i>
|
||||
<?=__('Run Manual Backup') ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<colgroup>
|
||||
<col width="20%">
|
||||
<col width="35%">
|
||||
<col width="25%">
|
||||
<col width="20%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?=__('Actions') ?></th>
|
||||
<th><?=__('Backup') ?></th>
|
||||
<th><?=__('Last Modified') ?></th>
|
||||
<th><?=__('Size') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($backups as $row): ?>
|
||||
<tr class="align-middle">
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a class="btn btn-sm btn-primary" href="<?=$router->fromHere('admin:backups:download', ['path' => base64_encode($row['path'])]) ?>"><?=__('Download') ?></a>
|
||||
<a class="btn btn-sm btn-danger" href="<?=$router->fromHere('admin:backups:delete', ['path' => base64_encode($row['path']), 'csrf' => $csrf]) ?>" data-confirm-title="<?=$this->e(__('Delete backup "%s"?', $row['filename'])) ?>"><?=__('Delete') ?></a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<big><?=$this->e($row['basename']) ?></big>
|
||||
</td>
|
||||
<td><time data-content="<?=$row['timestamp'] ?>"><?=gmdate('Y-m-d', $row['timestamp']) ?></time></td>
|
||||
<td><span data-file-size="<?=$row['size'] ?>"><?=$row['size'] ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($last_output)): ?>
|
||||
<div class="modal fade" id="modal-log-view" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-log-view-label"><?=__('Most Recent Backup Log') ?></h4>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="modal-log-view-contents"><?=$last_output ?></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
/**
|
||||
* @var array $backups
|
||||
* @var \Azura\Assets $assets
|
||||
*/
|
||||
|
||||
$this->layout('main', [
|
||||
'title' => __('Run Manual Backup'),
|
||||
'manual' => true
|
||||
]);
|
||||
?>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h3 class="card-title"><?=__('Run Manual Backup') ?></h3>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn btn-outline-primary" href="<?=$router->fromHere('admin:backups:index') ?>">
|
||||
<i class="material-icons" aria-hidden="true">arrow_back</i>
|
||||
<?=__('Backups Home') ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if ($is_successful): ?>
|
||||
<p class="card-text text-success"><?=__('Backup was run successfully.') ?></p>
|
||||
<?php else: ?>
|
||||
<p class="card-text text-danger"><?=__('Backup encountered errors when running. Check the log below for details.') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<pre id="modal-log-view-contents">
|
||||
<?=$this->e($output) ?>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +1,9 @@
|
|||
<?php
|
||||
/** @var \Azura\Assets $assets */
|
||||
/**
|
||||
* @var \App\Form\Form $form
|
||||
* @var \Azura\Assets $assets
|
||||
*/
|
||||
|
||||
$assets
|
||||
->load('codemirror_css')
|
||||
->addInlineJs($this->fetch('admin/branding/index.js'), 99);
|
||||
|
@ -7,5 +11,4 @@ $assets
|
|||
echo $this->fetch('system/form_page', [
|
||||
'title' => __('Custom Branding'),
|
||||
'form' => $form,
|
||||
'render_mode' => 'edit'
|
||||
]);
|
||||
|
|
|
@ -8,7 +8,7 @@ $assets
|
|||
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-log-view-label">Log View</h4>
|
||||
<h4 class="modal-title" id="modal-log-view-label"><?=__('Log View') ?></h4>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
|
|
@ -417,7 +417,7 @@ $(function() {
|
|||
|
||||
function formatFileSize(bytes) {
|
||||
var s = ['bytes', 'KB','MB','GB','TB','PB','EB'];
|
||||
for(var pos = 0;bytes >= 1000; pos++,bytes /= 1024);
|
||||
for(var pos = 0;bytes >= 1000; pos++,bytes /= 1000);
|
||||
var d = Math.round(bytes*10);
|
||||
return pos ? [parseInt(d/10),".",d%10," ",s[pos]].join('') : bytes + ' bytes';
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
- "{{ tmp_base }}/proxies"
|
||||
- "{{ app_base }}/stations"
|
||||
- "{{ app_base }}/geoip"
|
||||
- "{{ app_base }}/backups"
|
||||
- "{{ app_base }}/servers"
|
||||
- "{{ app_base }}/servers/shoutcast2"
|
||||
- "{{ app_base }}/servers/icecast2"
|
||||
|
|
Loading…
Reference in New Issue