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:
Sebastian Almberg 2026-05-03 23:00:31 +02:00 committed by GitHub
parent 91bf8371ad
commit d346708150
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1577 additions and 55 deletions

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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.',
};

View file

@ -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,
];
}

View file

@ -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

View 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,
];
}
}