Web updater initial WIP.

This commit is contained in:
Buster Neece 2023-01-05 12:54:03 -06:00
parent c8e18a7324
commit d76ff450c8
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
11 changed files with 505 additions and 125 deletions

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

@ -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:
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,180 @@
<template>
<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>
<div class="card-actions buttons">
<a
class="btn btn-outline-info"
href="#"
@click.prevent="checkForUpdates()"
>
<icon icon="sync"></icon>
{{ $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-body">
{{ $gettext('Your installation is currently on this release channel:') }}
</p>
<p class="card-body 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-channel"
>
<icon icon="info"></icon>
{{ $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">
</p>
</div>
<div class="card-actions buttons">
<a
class="btn btn-outline-success"
:data-confirm-title="$gettext('Update AzuraCast? Your installation will restart.')"
href="#"
@click.prevent="doUpdate()"
>
<icon icon="update"></icon>
{{ $gettext('Update AzuraCast 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"
>
<icon icon="info"></icon>
{{ $gettext('Update Instructions') }}
</a>
</div>
</section>
</div>
</div>
</template>
<script setup>
import {computed, ref} from "vue";
import Icon from "~/components/InlinePlayer.vue";
import {useTranslate} from "~/vendor/gettext";
import {useNotify} from "~/vendor/bootstrapVue";
import {useAxios} from "~/vendor/axios";
const props = defineProps({
releaseChannel: {
type: String,
required: true
},
initialUpdateInfo: {
type: Object,
default: () => {
return {};
}
},
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 {wrapWithLoading, notifySuccess} = useNotify();
const {axios} = useAxios();
const checkForUpdates = () => {
wrapWithLoading(
axios.get(props.restartStatusUrl)
).then((resp) => {
});
};
const doUpdate = () => {
wrapWithLoading(
axios.put(props.restartStatusUrl)
).then(() => {
notifySuccess(
$gettext('Update started. Your installation will restart shortly.')
);
});
}
</script>

View File

@ -0,0 +1,42 @@
<?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(),
'updatesApiUrl' => $router->fromHere('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

@ -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/v1/update',
[
'headers' => [
'Authorization' => 'Bearer ' . self::WATCHTOWER_TOKEN,
],
]
);
}
}