Merge commit '5617a9f12f94934d86172b601cd11a30a9441811'

This commit is contained in:
Buster Neece 2023-01-05 14:05:52 -06:00
commit e5ebbd014d
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
18 changed files with 606 additions and 129 deletions

View File

@ -5,6 +5,12 @@ release channel, you can take advantage of these new features and fixes.
## New Features/Changes
- **Web Updater**: We're rolling out an initial test of our web updater component for Docker installations. Based on
[Watchtower](https://containrrr.dev/watchtower/), our updater sits outside our normal Docker image and can handle
pulling the latest image for your installation, spinning down the current version and updating to the newer one, all
from entirely within your web browser. Note that if you want to make any core configuration changes (i.e. change your
release channel or other environment configuration) you must use the regular update script.
- **Per-Station Branding**: You can now provide custom album art, public page backgrounds, CSS and JavaScript on a
per-station basis, using a new Station Branding page that is very similar to the system-wide Branding page.

View File

@ -106,7 +106,8 @@ ENV LANG="en_US.UTF-8" \
PROFILING_EXTENSION_ENABLED=0 \
PROFILING_EXTENSION_ALWAYS_ON=0 \
PROFILING_EXTENSION_HTTP_KEY=dev \
PROFILING_EXTENSION_HTTP_IP_WHITELIST=*
PROFILING_EXTENSION_HTTP_IP_WHITELIST=* \
ENABLE_AUTO_UPDATER="false"
# Entrypoint and default command
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]

View File

@ -36,16 +36,21 @@ return static function (App\Event\BuildAdminMenu $e) {
'url' => $router->named('admin:storage_locations:index'),
'permission' => GlobalPermissions::StorageLocations,
],
'backups' => [
'backups' => [
'label' => __('Backups'),
'url' => $router->named('admin:backups:index'),
'permission' => GlobalPermissions::Backups,
],
'debug' => [
'debug' => [
'label' => __('System Debugger'),
'url' => $router->named('admin:debug:index'),
'permission' => GlobalPermissions::All,
],
'updates' => [
'label' => __('Update AzuraCast'),
'url' => $router->named('admin:updates:index'),
'permission' => GlobalPermissions::All,
],
],
],
'users' => [

View File

@ -115,6 +115,10 @@ return static function (RouteCollectorProxy $app) {
->setName('admin:storage_locations:index')
->add(new Middleware\Permissions(GlobalPermissions::StorageLocations));
$group->get('/updates', Controller\Admin\UpdatesAction::class)
->setName('admin:updates:index')
->add(new Middleware\Permissions(GlobalPermissions::All));
$group->get('/users', Controller\Admin\UsersAction::class)
->setName('admin:users:index')
->add(new Middleware\Permissions(GlobalPermissions::All));

View File

@ -211,6 +211,16 @@ return static function (RouteCollectorProxy $group) {
->setName('api:admin:log');
}
)->add(new Middleware\Permissions(GlobalPermissions::Logs));
$group->group(
'/updates',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Admin\Updates\GetUpdatesAction::class)
->setName('api:admin:updates');
$group->put('', Controller\Api\Admin\Updates\PutUpdatesAction::class);
}
)->add(new Middleware\Permissions(GlobalPermissions::All));
}
);
};

View File

@ -14,6 +14,8 @@ services:
web:
container_name: azuracast
image: "azuracast.docker.scarf.sh/azuracast/azuracast:${AZURACAST_VERSION:-latest}"
labels:
- "com.centurylinklabs.watchtower.scope=azuracast"
# Want to customize the HTTP/S ports? Follow the instructions here:
# https://docs.azuracast.com/en/administration/docker#using-non-standard-ports
ports:
@ -202,6 +204,16 @@ services:
max-size: "1m"
max-file: "5"
updater:
container_name: azuracast_updater
image: ghcr.io/azuracast/updater:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
logging:
options:
max-size: "1m"
max-file: "5"
volumes:
db_data: { }
acme: { }

View File

@ -446,151 +446,159 @@
</b-col>
</b-row>
<cpu-stats-help-modal ref="cpuStatsHelpModal" />
<memory-stats-help-modal ref="memoryStatsHelpModal" />
<cpu-stats-help-modal ref="$cpuStatsHelpModal" />
<memory-stats-help-modal ref="$memoryStatsHelpModal" />
</div>
</template>
<script>
<script setup>
import Icon from '~/components/Common/Icon';
import CpuStatsHelpModal from "./Index/CpuStatsHelpModal";
import MemoryStatsHelpModal from "./Index/MemoryStatsHelpModal";
import {isObject, upperFirst} from 'lodash';
import RunningBadge from "~/components/Common/Badges/RunningBadge.vue";
import {onMounted, ref, shallowRef} from "vue";
import {useAxios} from "~/vendor/axios";
import {useNotify} from "~/vendor/bootstrapVue";
export default {
name: 'AdminIndex',
components: {RunningBadge, CpuStatsHelpModal, MemoryStatsHelpModal, Icon},
props: {
adminPanels: {
type: Object,
required: true
const props = defineProps({
adminPanels: {
type: Object,
required: true
},
statsUrl: {
type: String,
required: true
},
servicesUrl: {
type: String,
required: true
}
});
const stats = shallowRef({
cpu: {
total: {
name: 'Total',
steal: 0,
io_wait: 0,
usage: 0
},
statsUrl: {
type: String,
required: true
cores: [],
load: [
0,
0,
0
]
},
memory: {
bytes: {
total: 0,
used: 0,
cached: 0
},
servicesUrl: {
type: String,
required: true
readable: {
total: '',
used: '',
cached: ''
}
},
data() {
return {
stats: {
cpu: {
total: {
name: 'Total',
steal: 0,
io_wait: 0,
usage: 0
},
cores: [],
load: [
0,
0,
0
]
},
memory: {
bytes: {
total: 0,
used: 0,
cached: 0
},
readable: {
total: '',
used: '',
cached: ''
}
},
disk: {
bytes: {
total: 0,
used: 0
},
readable: {
total: '',
used: ''
}
},
network: []
},
services: []
};
disk: {
bytes: {
total: 0,
used: 0
},
readable: {
total: '',
used: ''
}
},
created() {
this.updateStats();
this.updateServices();
},
methods: {
formatCpuName(cpuName) {
return upperFirst(cpuName);
},
formatPercentageString(value) {
return value + '%';
},
getNetworkInterfaceTableFields(interfaceData) {
let fields = [];
network: []
});
Object.keys(interfaceData).forEach((key) => {
fields.push({
key: key,
sortable: false
});
});
const services = ref([]);
return fields;
},
getNetworkInterfaceTableItems(interfaceData) {
let item = {};
const formatCpuName = (cpuName) => upperFirst(cpuName);
Object.entries(interfaceData).forEach((data) => {
let key = data[0];
let value = data[1];
const formatPercentageString = (value) => value + '%';
if (isObject(value)) {
value = value.readable + '/s';
}
const getNetworkInterfaceTableFields = (interfaceData) => {
let fields = [];
item[key] = value;
});
Object.keys(interfaceData).forEach((key) => {
fields.push({
key: key,
sortable: false
});
});
return [item];
},
updateStats() {
this.axios.get(this.statsUrl).then((response) => {
this.stats = response.data;
return fields;
};
setTimeout(this.updateStats, (!document.hidden) ? 1000 : 5000);
}).catch((error) => {
if (!error.response || error.response.data.code !== 403) {
setTimeout(this.updateStats, (!document.hidden) ? 5000 : 10000);
}
});
},
updateServices() {
this.axios.get(this.servicesUrl).then((response) => {
this.services = response.data;
const getNetworkInterfaceTableItems = (interfaceData) => {
let item = {};
setTimeout(this.updateServices, (!document.hidden) ? 5000 : 15000);
}).catch((error) => {
if (!error.response || error.response.data.code !== 403) {
setTimeout(this.updateServices, (!document.hidden) ? 15000 : 30000);
}
});
},
doRestart(serviceUrl) {
this.axios.post(serviceUrl).then((resp) => {
this.$notifySuccess(resp.data.message);
});
},
showCpuStatsHelpModal() {
this.$refs.cpuStatsHelpModal.create();
},
showMemoryStatsHelpModal() {
this.$refs.memoryStatsHelpModal.create();
},
}
Object.entries(interfaceData).forEach((data) => {
let key = data[0];
let value = data[1];
if (isObject(value)) {
value = value.readable + '/s';
}
item[key] = value;
});
return [item];
};
const {axios} = useAxios();
const updateStats = () => {
axios.get(props.statsUrl).then((response) => {
stats.value = response.data;
setTimeout(updateStats, (!document.hidden) ? 1000 : 5000);
}).catch((error) => {
if (!error.response || error.response.data.code !== 403) {
setTimeout(updateStats, (!document.hidden) ? 5000 : 10000);
}
});
};
onMounted(updateStats);
const updateServices = () => {
axios.get(props.servicesUrl).then((response) => {
services.value = response.data;
setTimeout(updateServices, (!document.hidden) ? 5000 : 15000);
}).catch((error) => {
if (!error.response || error.response.data.code !== 403) {
setTimeout(updateServices, (!document.hidden) ? 15000 : 30000);
}
});
};
onMounted(updateServices);
const {notifySuccess} = useNotify();
const doRestart = (serviceUrl) => {
axios.post(serviceUrl).then((resp) => {
notifySuccess(resp.data.message);
});
};
const $cpuStatsHelpModal = ref(); // Template Ref
const showCpuStatsHelpModal = () => {
$cpuStatsHelpModal.value.create();
};
const $memoryStatsHelpModal = ref(); // Template Ref
const showMemoryStatsHelpModal = () => {
$memoryStatsHelpModal.value.create();
};
</script>

View File

@ -0,0 +1,243 @@
<template>
<h2 class="outside-card-header mb-1">
{{ $gettext('Update AzuraCast') }}
</h2>
<div class="row">
<div class="col col-md-8">
<section
class="card mb-4"
role="region"
>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
{{ $gettext('Update Details') }}
</h3>
</div>
<div class="card-body">
<div
v-if="needsUpdates"
class="text-warning"
>
{{
$gettext('Your installation needs to be updated. Updating is recommended for performance and security improvements.')
}}
</div>
<div
v-else
class="text-success"
>
{{
$gettext('Your installation is up to date! No update is required.')
}}
</div>
</div>
<div class="card-actions buttons">
<a
class="btn btn-outline-info"
href="#"
@click.prevent="checkForUpdates()"
>
<icon icon="sync" />
{{ $gettext('Check for Updates') }}
</a>
</div>
</section>
</div>
<div class="col col-md-4">
<section
class="card mb-4"
role="region"
>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
{{ $gettext('Release Channel') }}
</h3>
</div>
<div class="card-body">
<p class="card-text">
{{ $gettext('Your installation is currently on this release channel:') }}
</p>
<p class="card-text typography-subheading">
{{ langReleaseChannel }}
</p>
</div>
<div class="card-actions buttons">
<a
class="btn btn-outline-info"
href="https://docs.azuracast.com/en/getting-started/updates/release-channels"
target="_blank"
>
<icon icon="info" />
{{ $gettext('About Release Channels') }}
</a>
</div>
</section>
</div>
</div>
<div class="row">
<div class="col col-md-6">
<section
class="card mb-4"
role="region"
>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
{{ $gettext('Update AzuraCast via Web') }}
</h3>
</div>
<template v-if="enableWebUpdates">
<div class="card-body">
<p class="card-text">
{{
$gettext('For simple updates where you want to keep your current configuration, you can update directly via your web browser. You will be disconnected from the web interface and listeners will be disconnected from all stations.')
}}
</p>
<p class="card-text">
{{
$gettext('Backing up your installation is strongly recommended before any update.')
}}
</p>
</div>
<div class="card-actions buttons">
<a
class="btn btn-outline-default"
:href="backupUrl"
target="_blank"
>
<icon icon="backup" />
{{ $gettext('Backup') }}
</a>
<a
class="btn btn-outline-success"
href="#"
@click.prevent="doUpdate()"
>
<icon icon="update" />
{{ $gettext('Update via Web') }}
</a>
</div>
</template>
<template v-else>
<div class="card-body">
<p class="card-text">
{{
$gettext('Web updates are not available for your installation. To update your installation, perform the manual update process instead.')
}}
</p>
</div>
</template>
</section>
</div>
<div class="col col-md-6">
<section
class="card mb-4"
role="region"
>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
{{ $gettext('Manual Updates') }}
</h3>
</div>
<div class="card-body">
<p class="card-text">
{{
$gettext('To customize installation settings, or if automatic updates are disabled, you can follow our standard update instructions to update via your SSH console.')
}}
</p>
</div>
<div class="card-actions buttons">
<a
class="btn btn-outline-info"
href="https://docs.azuracast.com/en/getting-started/updates"
target="_blank"
>
<icon icon="info" />
{{ $gettext('Update Instructions') }}
</a>
</div>
</section>
</div>
</div>
</template>
<script setup>
import {computed, ref} from "vue";
import Icon from "~/components/Common/Icon.vue";
import {useTranslate} from "~/vendor/gettext";
import {useNotify} from "~/vendor/bootstrapVue";
import {useAxios} from "~/vendor/axios";
import {useSweetAlert} from "~/vendor/sweetalert";
const props = defineProps({
releaseChannel: {
type: String,
required: true
},
initialUpdateInfo: {
type: Object,
default: () => {
return {};
}
},
backupUrl: {
type: String,
required: true
},
updatesApiUrl: {
type: String,
required: true
},
enableWebUpdates: {
type: Boolean,
required: true
}
});
const updateInfo = ref(props.initialUpdateInfo);
const {$gettext} = useTranslate();
const langReleaseChannel = computed(() => {
return (props.releaseChannel === 'stable')
? $gettext('Stable')
: $gettext('Rolling Release');
});
const needsUpdates = computed(() => {
if (props.releaseChannel === 'stable') {
return updateInfo.value?.needs_release_update ?? true;
} else {
return updateInfo.value?.needs_rolling_update ?? true;
}
});
const {wrapWithLoading, notifySuccess} = useNotify();
const {axios} = useAxios();
const checkForUpdates = () => {
wrapWithLoading(
axios.get(props.updatesApiUrl)
).then((resp) => {
updateInfo.value = resp.data;
});
};
const {showAlert} = useSweetAlert();
const doUpdate = () => {
showAlert({
title: $gettext('Update AzuraCast? Your installation will restart.')
}).then((result) => {
if (result.value) {
wrapWithLoading(
axios.put(props.updatesApiUrl)
).then(() => {
notifySuccess(
$gettext('Update started. Your installation will restart shortly.')
);
});
}
});
};
</script>

View File

@ -0,0 +1,5 @@
import initBase from '~/base.js';
import AdminUpdates from '~/components/Admin/Updates.vue';
export default initBase(AdminUpdates);

View File

@ -23,6 +23,7 @@ module.exports = {
AdminStereoTool: '~/pages/Admin/StereoTool.js',
AdminStations: '~/pages/Admin/Stations.js',
AdminStorageLocations: '~/pages/Admin/StorageLocations.js',
AdminUpdates: '~/pages/Admin/Updates.js',
AdminUsers: '~/pages/Admin/Users.js',
PublicFullPlayer: '~/pages/Public/FullPlayer.js',
PublicHistory: '~/pages/Public/History.js',

View File

@ -0,0 +1,43 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Repository\SettingsRepository;
use App\Environment;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Version;
use Psr\Http\Message\ResponseInterface;
final class UpdatesAction
{
public function __construct(
private readonly SettingsRepository $settingsRepo,
private readonly Environment $environment,
private readonly Version $version
) {
}
public function __invoke(
ServerRequest $request,
Response $response
): ResponseInterface {
$settings = $this->settingsRepo->readSettings();
$router = $request->getRouter();
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_AdminUpdates',
id: 'admin-updates',
title: __('Update AzuraCast'),
props: [
'releaseChannel' => $this->version->getReleaseChannelEnum()->value,
'initialUpdateInfo' => $settings->getUpdateResults(),
'backupUrl' => $router->named('admin:backups:index'),
'updatesApiUrl' => $router->named('api:admin:updates'),
'enableWebUpdates' => $this->environment->enableWebUpdater(),
],
);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Admin\Updates;
use App\Entity\Repository\SettingsRepository;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\AzuraCastCentral;
use GuzzleHttp\Exception\TransferException;
use Psr\Http\Message\ResponseInterface;
final class GetUpdatesAction
{
public function __construct(
private readonly SettingsRepository $settingsRepo,
private readonly AzuraCastCentral $azuracastCentral
) {
}
public function __invoke(
ServerRequest $request,
Response $response
): ResponseInterface {
$settings = $this->settingsRepo->readSettings();
try {
$updates = $this->azuracastCentral->checkForUpdates();
if (!empty($updates)) {
$settings->setUpdateResults($updates);
$settings->updateUpdateLastRun();
$this->settingsRepo->writeSettings($settings);
return $response->withJson($updates);
}
throw new \RuntimeException('Error parsing update data response from AzuraCast central.');
} catch (TransferException $e) {
throw new \RuntimeException(
sprintf('Error from AzuraCast Central (%d): %s', $e->getCode(), $e->getMessage())
);
}
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Admin\Updates;
use App\Entity\Api\Status;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\WebUpdater;
use Psr\Http\Message\ResponseInterface;
final class PutUpdatesAction
{
public function __construct(
private readonly WebUpdater $webUpdater
) {
}
public function __invoke(
ServerRequest $request,
Response $response
): ResponseInterface {
$this->webUpdater->triggerUpdate();
return $response->withJson(Status::success());
}
}

View File

@ -51,6 +51,8 @@ final class Environment
public const PROFILING_EXTENSION_ALWAYS_ON = 'PROFILING_EXTENSION_ALWAYS_ON';
public const PROFILING_EXTENSION_HTTP_KEY = 'PROFILING_EXTENSION_HTTP_KEY';
public const ENABLE_WEB_UPDATER = 'ENABLE_WEB_UPDATER';
// Database and Cache Configuration Variables
public const DB_HOST = 'MYSQL_HOST';
public const DB_PORT = 'MYSQL_PORT';
@ -84,6 +86,8 @@ final class Environment
self::PROFILING_EXTENSION_ENABLED => 0,
self::PROFILING_EXTENSION_ALWAYS_ON => 0,
self::PROFILING_EXTENSION_HTTP_KEY => 'dev',
self::ENABLE_WEB_UPDATER => false,
];
public function __construct(array $elements = [])
@ -348,6 +352,11 @@ final class Environment
return $this->data[self::PROFILING_EXTENSION_HTTP_KEY] ?? 'dev';
}
public function enableWebUpdater(): bool
{
return $this->isDocker() && self::envToBool($this->data[self::ENABLE_WEB_UPDATER] ?? false);
}
public static function getDefaultsForEnvironment(Environment $existingEnv): self
{
return new self([

View File

@ -245,6 +245,11 @@ final class InstallCommand extends Command
$azuracastEnvConfig['COMPOSER_PLUGIN_MODE']['name'],
$azuracastEnv->getAsBool('COMPOSER_PLUGIN_MODE', false)
);
$azuracastEnv[Environment::ENABLE_WEB_UPDATER] = $io->confirm(
$azuracastEnvConfig[Environment::ENABLE_WEB_UPDATER]['name'],
$azuracastEnv->getAsBool(Environment::ENABLE_WEB_UPDATER, true)
);
}
$io->writeln(
@ -354,6 +359,11 @@ final class InstallCommand extends Command
unset($service);
}
// Remove web updater if disabled
if (!$env->getAsBool(Environment::ENABLE_WEB_UPDATER, true)) {
unset($yaml['services']['updater']);
}
$yamlRaw = Yaml::dump($yaml, PHP_INT_MAX);
file_put_contents($dockerComposePath, $yamlRaw);

View File

@ -227,6 +227,10 @@ final class AzuraCastEnvFile extends AbstractEnvFile
'options' => ['127.0.0.1', '*'],
'default' => '*',
],
Environment::ENABLE_WEB_UPDATER => [
'name' => __('Enable web-based Docker image updates'),
'default' => true,
],
];
foreach ($config as $key => &$keyInfo) {

View File

@ -38,8 +38,10 @@ final class UpdateCheck
return;
}
$actionLabel = __('Update Instructions');
$actionUrl = Version::UPDATE_URL;
$router = $event->getRequest()->getRouter();
$actionLabel = __('Update AzuraCast');
$actionUrl = $router->named('admin:updates:index');
$releaseChannel = $this->version->getReleaseChannelEnum();

View File

@ -0,0 +1,40 @@
<?php
namespace App\Service;
use App\Environment;
final class WebUpdater
{
// Don't worry that this is insecure; it's only ever used for internal communications.
public const WATCHTOWER_TOKEN = 'azur4c457';
public function __construct(
private readonly Environment $environment,
private readonly GuzzleFactory $guzzleFactory
) {
}
public function isSupported(): bool
{
return $this->environment->enableWebUpdater();
}
public function triggerUpdate(): void
{
if (!$this->isSupported()) {
throw new \RuntimeException('Web updates are not supported on this installation.');
}
$client = $this->guzzleFactory->buildClient();
$client->post(
'http://updater:8080/v1/update',
[
'headers' => [
'Authorization' => 'Bearer ' . self::WATCHTOWER_TOKEN,
],
]
);
}
}