mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-10 15:12:12 +00:00
Add Docker update support via Watchtower integration (#1330)
* Add Docker update support via Watchtower integration Add web-based Docker container updates using Watchtower HTTP API. When configured with WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN environment variables, administrators can trigger container updates from the Update Manager page. Features: - WatchtowerClient service for Watchtower HTTP API communication - Docker update progress page with animated Docker whale logo - Real-time step tracking: Trigger, Pull, Stop, Restart, Health Check, Verify - CSP-compatible progress bar using CSS classes - Translated UI strings via Stimulus values - Health endpoint polling to detect container restart - Watchtower setup documentation for Docker installations - WatchtowerClient made nullable for non-Docker installations - Unit tests for WatchtowerClient * Fixed translation message IDs * Switch Watchtower docs to maintained nicholas-fedor fork The original containrrr/watchtower is no longer maintained (last release Nov 2023). Point users to the drop-in compatible active fork and add an info note explaining why. No code changes — the HTTP API is identical, so WatchtowerClient works against either image. * Fixed exception when github is not reachable * Only show version string in health endpoint, when user has permissions * Do not expose watchtower API port in example docker-compose file * Show if updates, backup restore and backup download are allowed in update manager page * Report 'not authorized' for version in health endpoint if user lacks permission --------- Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
parent
91bf8371ad
commit
d346708150
14 changed files with 1577 additions and 55 deletions
|
|
@ -28,6 +28,7 @@ use App\Services\System\BackupManager;
|
|||
use App\Services\System\InstallationTypeDetector;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use App\Services\System\WatchtowerClient;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
|
@ -56,6 +57,7 @@ class UpdateManagerController extends AbstractController
|
|||
private readonly BackupManager $backupManager,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly WatchtowerClient $watchtowerClient,
|
||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||
private readonly bool $webUpdatesDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||
|
|
@ -504,4 +506,100 @@ class UpdateManagerController extends AbstractController
|
|||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Docker update via Watchtower.
|
||||
*/
|
||||
#[Route('/start-docker', name: 'admin_update_manager_start_docker', methods: ['POST'])]
|
||||
public function startDockerUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_start_docker', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if Watchtower is configured and available
|
||||
if (!$this->watchtowerClient->isConfigured()) {
|
||||
$this->addFlash('error', 'Watchtower is not configured. Please set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
if (!$this->watchtowerClient->isAvailable()) {
|
||||
$this->addFlash('error', 'Watchtower is not reachable. Please check that the Watchtower container is running and accessible.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Create backup if requested
|
||||
$createBackup = $request->request->getBoolean('backup', true);
|
||||
if ($createBackup) {
|
||||
try {
|
||||
$this->backupManager->createBackup();
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('error', 'Failed to create backup before update: ' . $e->getMessage());
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger Watchtower update
|
||||
$success = $this->watchtowerClient->triggerUpdate();
|
||||
|
||||
if (!$success) {
|
||||
$this->addFlash('error', 'Failed to trigger Watchtower update. Check the logs for details.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$currentVersion = $this->versionManager->getVersion()->toString();
|
||||
|
||||
// Redirect to Docker progress page
|
||||
return $this->redirectToRoute('admin_update_manager_docker_progress', [
|
||||
'previous_version' => $currentVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker update progress page.
|
||||
* This page contains client-side JavaScript that polls until the container restarts.
|
||||
*/
|
||||
#[Route('/progress/docker', name: 'admin_update_manager_docker_progress', methods: ['GET'])]
|
||||
public function dockerProgress(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$previousVersion = $request->query->get('previous_version', 'unknown');
|
||||
|
||||
return $this->render('admin/update_manager/docker_progress.html.twig', [
|
||||
'previous_version' => $previousVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight health check endpoint used by Docker update progress page.
|
||||
* Returns current version so the client-side JS can detect when the container restarts with a new version.
|
||||
*
|
||||
* Intentionally unauthenticated: after a Docker container restart, the user's session may not survive
|
||||
* (depends on session storage backend). The version string is non-sensitive public information.
|
||||
* This endpoint is also whitelisted in MaintenanceModeSubscriber.
|
||||
*/
|
||||
#[Route('/health', name: 'admin_update_manager_health', methods: ['GET'])]
|
||||
public function healthCheck(): JsonResponse
|
||||
{
|
||||
//Only show version if user is logged in and has permission
|
||||
|
||||
$response = [
|
||||
'status' => 'ok',
|
||||
];
|
||||
|
||||
if ($this->isGranted('@system.show_updates')) {
|
||||
$response['version'] = $this->versionManager->getVersion()->toString();
|
||||
} else {
|
||||
$response['version'] = "not authorized";
|
||||
}
|
||||
|
||||
return $this->json($response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
|
|||
return;
|
||||
}
|
||||
|
||||
//Allow to view the progress page
|
||||
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
|
||||
//Allow to view the progress page and health check endpoint
|
||||
if (preg_match('#^/[a-z]{2}(?:_[A-Z]{2})?/system/update-manager/(progress|health)#', $event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ enum InstallationType: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::GIT => true,
|
||||
self::DOCKER => false,
|
||||
self::DOCKER => true,
|
||||
// ZIP_RELEASE auto-update not yet implemented
|
||||
self::ZIP_RELEASE => false,
|
||||
self::UNKNOWN => false,
|
||||
|
|
@ -57,7 +57,7 @@ enum InstallationType: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::GIT => 'Run: php bin/console partdb:update',
|
||||
self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d',
|
||||
self::DOCKER => 'Configure Watchtower for one-click updates, or manually: docker-compose pull && docker-compose up -d',
|
||||
self::ZIP_RELEASE => 'Download the new release ZIP from GitHub, extract it over your installation, and run: php bin/console doctrine:migrations:migrate && php bin/console cache:clear',
|
||||
self::UNKNOWN => 'Unable to determine installation type. Please update manually.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ class UpdateChecker
|
|||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly GitVersionInfoProvider $gitVersionInfoProvider,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode,
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir,
|
||||
private readonly ?WatchtowerClient $watchtowerClient = null)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -284,8 +285,16 @@ class UpdateChecker
|
|||
$updateBlockers[] = 'local_changes';
|
||||
}
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER) {
|
||||
$updateBlockers[] = 'docker_installation';
|
||||
// Docker installations require Watchtower for auto-update
|
||||
$watchtowerConfigured = $this->watchtowerClient !== null && $this->watchtowerClient->isConfigured();
|
||||
$watchtowerAvailable = $watchtowerConfigured && $this->watchtowerClient->isAvailable();
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerConfigured) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'docker_no_watchtower';
|
||||
} elseif ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerAvailable) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'docker_watchtower_unreachable';
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
@ -301,6 +310,8 @@ class UpdateChecker
|
|||
'can_auto_update' => $canAutoUpdate,
|
||||
'update_blockers' => $updateBlockers,
|
||||
'check_enabled' => $this->privacySettings->checkForUpdates,
|
||||
'watchtower_configured' => $watchtowerConfigured,
|
||||
'watchtower_available' => $watchtowerAvailable,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,23 @@ class UpdateExecutor
|
|||
);
|
||||
}
|
||||
|
||||
// Docker installations are updated via Watchtower - skip Git/Composer/Yarn checks
|
||||
if ($installType === InstallationType::DOCKER) {
|
||||
// Only check if already locked
|
||||
if ($this->isLocked()) {
|
||||
$lockInfo = $this->getLockInfo();
|
||||
$errors[] = sprintf(
|
||||
'An update is already in progress (started at %s).',
|
||||
$lockInfo['started_at'] ?? 'unknown time'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($installType === InstallationType::GIT) {
|
||||
// Check if git is available
|
||||
|
|
|
|||
125
src/Services/System/WatchtowerClient.php
Normal file
125
src/Services/System/WatchtowerClient.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* HTTP client for communicating with the Watchtower container updater API.
|
||||
* Used to trigger Docker container updates from the Part-DB UI.
|
||||
*
|
||||
* @see https://containrrr.dev/watchtower/
|
||||
*/
|
||||
readonly class WatchtowerClient
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire(env: 'WATCHTOWER_API_URL')] private string $apiUrl,
|
||||
#[Autowire(env: 'WATCHTOWER_API_TOKEN')] private string $apiToken,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Watchtower integration is configured (URL and token are set).
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->apiUrl !== '' && $this->apiToken !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Watchtower API is reachable.
|
||||
* Makes a lightweight HTTP request with a short timeout.
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $this->getUpdateEndpoint(), [
|
||||
'headers' => $this->getAuthHeaders(),
|
||||
'timeout' => 3,
|
||||
]);
|
||||
|
||||
// Any response means Watchtower is reachable
|
||||
$statusCode = $response->getStatusCode();
|
||||
return $statusCode < 500;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->debug('Watchtower availability check failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a container update via the Watchtower HTTP API.
|
||||
* This is fire-and-forget: Watchtower will pull the new image and restart the container.
|
||||
*
|
||||
* @return bool True if Watchtower accepted the update request
|
||||
*/
|
||||
public function triggerUpdate(): bool
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
throw new \RuntimeException('Watchtower is not configured. Set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', $this->getUpdateEndpoint(), [
|
||||
'headers' => $this->getAuthHeaders(),
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300) {
|
||||
$this->logger->info('Watchtower update triggered successfully.');
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logger->error('Watchtower update request returned HTTP ' . $statusCode);
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to trigger Watchtower update: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getUpdateEndpoint(): string
|
||||
{
|
||||
return rtrim($this->apiUrl, '/') . '/v1/update';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getAuthHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer ' . $this->apiToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue