diff --git a/.env b/.env index 447ff5dee..7741bc21e 100644 --- a/.env +++ b/.env @@ -76,6 +76,12 @@ DISABLE_BACKUP_RESTORE=1 # When enabled, users must confirm their password before downloading. DISABLE_BACKUP_DOWNLOAD=1 +# Watchtower integration for Docker-based updates. +# Set these to enable one-click updates via the Update Manager UI. +# See https://containrrr.dev/watchtower/ for Watchtower setup. +WATCHTOWER_API_URL= +WATCHTOWER_API_TOKEN= + ################################################################################### # SAML Single sign on-settings ################################################################################### diff --git a/assets/controllers/docker_update_progress_controller.js b/assets/controllers/docker_update_progress_controller.js new file mode 100644 index 000000000..bc4c6ff32 --- /dev/null +++ b/assets/controllers/docker_update_progress_controller.js @@ -0,0 +1,377 @@ +/* + * 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 . + */ + +import { Controller } from '@hotwired/stimulus'; + +/** + * Stimulus controller for Docker update progress tracking. + * + * Polls the health check endpoint to detect when the container restarts + * after a Watchtower-triggered update. Drives the step timeline UI + * with timestamps, matching the git update progress style. + */ +export default class extends Controller { + static values = { + healthUrl: String, + previousVersion: { type: String, default: 'unknown' }, + pollInterval: { type: Number, default: 5000 }, + maxWaitTime: { type: Number, default: 600000 }, // 10 minutes + // Translated UI strings (passed from Twig template) + textPulling: { type: String, default: 'Waiting for Watchtower to pull the new image...' }, + textPullingDetail: { type: String, default: 'Watchtower is checking for and downloading the latest Docker image...' }, + textRestarting: { type: String, default: 'Container is restarting with the new image...' }, + textRestartingDetail: { type: String, default: 'The container is being recreated with the updated image. This may take a moment...' }, + textSuccess: { type: String, default: 'Update Complete!' }, + textSuccessDetail: { type: String, default: 'Part-DB has been updated successfully via Docker.' }, + textTimeout: { type: String, default: 'Update Taking Longer Than Expected' }, + textTimeoutDetail: { type: String, default: 'The update may still be in progress. Check your Docker logs for details.' }, + textStepPull: { type: String, default: 'Pull Image' }, + textStepRestart: { type: String, default: 'Restart Container' }, + }; + + static targets = [ + // Header + 'headerWhale', 'titleIcon', + 'statusText', 'statusSubtext', + 'progressBar', 'elapsedTime', + // Alerts + 'stepAlert', 'stepName', 'stepMessage', + 'successAlert', 'timeoutAlert', 'errorAlert', 'errorMessage', 'warningAlert', + // Step timeline (multi-target arrays) + 'stepRow', 'stepIcon', 'stepDetail', 'stepTime', + // Version display + 'newVersion', 'previousVersion', + // Actions + 'actions', + ]; + + // Step definitions: name -> { index, progress% } + static STEPS = { + trigger: { index: 0, progress: 15 }, + pull: { index: 1, progress: 30 }, + stop: { index: 2, progress: 50 }, + restart: { index: 3, progress: 65 }, + health: { index: 4, progress: 80 }, + verify: { index: 5, progress: 100 }, + }; + + connect() { + this.serverWentDown = false; + this.serverCameBack = false; + this.startTime = Date.now(); + this.timer = null; + this.currentStep = 'pull'; // trigger is already done + this.stepTimestamps = { trigger: this.formatTime(new Date()) }; + this.consecutiveSuccessCount = 0; + + // Set the trigger step timestamp + this.setStepTimestamp(0, this.stepTimestamps.trigger); + + this.poll(); + } + + disconnect() { + if (this.timer) { + clearTimeout(this.timer); + } + } + + createTimeoutSignal(ms) { + if (typeof AbortSignal.timeout === 'function') { + return AbortSignal.timeout(ms); + } + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; + } + + async poll() { + const elapsed = Date.now() - this.startTime; + this.updateElapsedTime(elapsed); + + if (elapsed > this.maxWaitTimeValue) { + this.showTimeout(); + return; + } + + try { + const response = await fetch(this.healthUrlValue, { + cache: 'no-store', + signal: this.createTimeoutSignal(4000), + }); + + if (response.ok) { + let data; + try { + data = await response.json(); + } catch (parseError) { + this.schedulePoll(); + return; + } + + if (this.serverWentDown) { + // Server came back! Move through health check -> verify + if (!this.serverCameBack) { + this.serverCameBack = true; + this.advanceToStep('health'); + } + + this.consecutiveSuccessCount++; + + // Wait for 2 consecutive successes to confirm stability + if (this.consecutiveSuccessCount >= 2) { + this.showSuccess(data.version); + return; + } + } else { + // Server still up - Watchtower pulling image + this.showPulling(); + } + } else if (response.status === 503) { + // Maintenance mode or shutting down + this.serverWentDown = true; + this.consecutiveSuccessCount = 0; + this.advanceToStep('stop'); + } else { + if (this.serverWentDown) { + this.showRestarting(); + } else { + this.showPulling(); + } + } + } catch (e) { + // Connection refused = container is down + if (!this.serverWentDown) { + this.serverWentDown = true; + this.advanceToStep('stop'); + } + this.consecutiveSuccessCount = 0; + this.showRestarting(); + } + + this.schedulePoll(); + } + + schedulePoll() { + this.timer = setTimeout(() => this.poll(), this.pollIntervalValue); + } + + /** + * Advance the step timeline to a specific step. + * Marks all previous steps as complete with timestamps. + */ + advanceToStep(stepName) { + const steps = this.constructor.STEPS; + const targetIndex = steps[stepName]?.index; + if (targetIndex === undefined) return; + + const stepNames = Object.keys(steps); + const now = this.formatTime(new Date()); + + for (let i = 0; i < stepNames.length; i++) { + const name = stepNames[i]; + + if (i < targetIndex) { + // Completed step + this.markStepComplete(i, this.stepTimestamps[name] || now); + if (!this.stepTimestamps[name]) { + this.stepTimestamps[name] = now; + } + } else if (i === targetIndex) { + // Current active step + this.markStepActive(i); + this.stepTimestamps[name] = now; + this.setStepTimestamp(i, now); + this.currentStep = name; + } + // Steps after targetIndex remain pending (no change needed) + } + + // Update progress bar + this.updateProgressBar(steps[stepName].progress); + } + + showPulling() { + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textPullingValue; + } + if (this.hasStepNameTarget) { + this.stepNameTarget.textContent = this.textStepPullValue; + } + if (this.hasStepMessageTarget) { + this.stepMessageTarget.textContent = this.textPullingDetailValue; + } + this.updateProgressBar(30); + } + + showRestarting() { + // Advance to restart step if we haven't already + if (this.currentStep !== 'restart' && this.currentStep !== 'health' && this.currentStep !== 'verify') { + this.advanceToStep('restart'); + } + + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textRestartingValue; + } + if (this.hasStepNameTarget) { + this.stepNameTarget.textContent = this.textStepRestartValue; + } + if (this.hasStepMessageTarget) { + this.stepMessageTarget.textContent = this.textRestartingDetailValue; + } + } + + showSuccess(newVersion) { + // Advance all steps to complete + const steps = this.constructor.STEPS; + const stepNames = Object.keys(steps); + const now = this.formatTime(new Date()); + + for (let i = 0; i < stepNames.length; i++) { + const name = stepNames[i]; + this.markStepComplete(i, this.stepTimestamps[name] || now); + } + + this.updateProgressBar(100); + + // Update whale animation + if (this.hasHeaderWhaleTarget) { + this.headerWhaleTarget.classList.add('success'); + } + if (this.hasTitleIconTarget) { + this.titleIconTarget.className = 'fas fa-check-circle text-success'; + } + + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textSuccessValue; + } + if (this.hasStatusSubtextTarget) { + this.statusSubtextTarget.textContent = this.textSuccessDetailValue; + } + + // Hide step alert, show success alert + this.toggleTarget('stepAlert', false); + this.toggleTarget('successAlert', true); + this.toggleTarget('warningAlert', false); + this.toggleTarget('actions', true); + + if (this.hasNewVersionTarget) { + this.newVersionTarget.textContent = newVersion || 'latest'; + } + if (this.hasPreviousVersionTarget) { + this.previousVersionTarget.textContent = this.previousVersionValue; + } + } + + showTimeout() { + this.updateProgressBar(0); + + if (this.hasHeaderWhaleTarget) { + this.headerWhaleTarget.classList.add('timeout'); + } + if (this.hasTitleIconTarget) { + this.titleIconTarget.className = 'fas fa-exclamation-triangle text-warning'; + } + + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textTimeoutValue; + } + if (this.hasStatusSubtextTarget) { + this.statusSubtextTarget.textContent = this.textTimeoutDetailValue; + } + + this.toggleTarget('stepAlert', false); + this.toggleTarget('timeoutAlert', true); + this.toggleTarget('warningAlert', false); + this.toggleTarget('actions', true); + } + + // --- Step timeline helpers --- + + markStepComplete(index, timestamp) { + if (this.stepIconTargets[index]) { + this.stepIconTargets[index].className = 'fas fa-check-circle text-success me-3'; + } + if (this.stepRowTargets[index]) { + this.stepRowTargets[index].classList.remove('text-muted'); + } + if (timestamp) { + this.setStepTimestamp(index, timestamp); + } + } + + markStepActive(index) { + if (this.stepIconTargets[index]) { + this.stepIconTargets[index].className = 'fas fa-spinner fa-spin text-primary me-3'; + } + if (this.stepRowTargets[index]) { + this.stepRowTargets[index].classList.remove('text-muted'); + } + } + + setStepTimestamp(index, time) { + if (this.stepTimeTargets[index]) { + this.stepTimeTargets[index].textContent = time; + } + } + + // --- UI helpers --- + + toggleTarget(name, show) { + const hasMethod = 'has' + name.charAt(0).toUpperCase() + name.slice(1) + 'Target'; + if (this[hasMethod]) { + this[name + 'Target'].classList.toggle('d-none', !show); + } + } + + updateProgressBar(percent) { + if (this.hasProgressBarTarget) { + const bar = this.progressBarTarget; + // Remove all width classes + bar.classList.remove('progress-w-0', 'progress-w-15', 'progress-w-30', 'progress-w-50', 'progress-w-65', 'progress-w-80', 'progress-w-100'); + bar.classList.add('progress-w-' + percent); + bar.textContent = percent + '%'; + bar.setAttribute('aria-valuenow', percent); + + bar.classList.remove('bg-success', 'bg-danger', 'progress-bar-striped', 'progress-bar-animated'); + if (percent === 100) { + bar.classList.add('bg-success'); + } else if (percent === 0) { + bar.classList.add('bg-danger'); + } else { + bar.classList.add('progress-bar-striped', 'progress-bar-animated'); + } + } + } + + updateElapsedTime(elapsed) { + if (this.hasElapsedTimeTarget) { + const seconds = Math.floor(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + this.elapsedTimeTarget.textContent = minutes > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${remainingSeconds}s`; + } + } + + formatTime(date) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } +} diff --git a/docs/installation/installation_docker.md b/docs/installation/installation_docker.md index b7048169e..1c804615a 100644 --- a/docs/installation/installation_docker.md +++ b/docs/installation/installation_docker.md @@ -219,6 +219,51 @@ docker-compose up -d docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate ``` +### Automatic updates via Watchtower (Web UI) + +Part-DB supports triggering Docker container updates directly from the web interface using [Watchtower](https://containrrr.dev/watchtower/). +When configured, administrators can check for and apply updates from the **System > Update Manager** page. + +To enable this feature, add a Watchtower service to your `docker-compose.yaml` and configure the connection: + +```yaml +services: + partdb: + container_name: partdb + image: jbtronics/part-db1:latest + labels: + - com.centurylinklabs.watchtower.enable=true + environment: + # ... your existing environment variables ... + + # Watchtower integration for web-based updates + - WATCHTOWER_API_URL=http://watchtower:8080 + - WATCHTOWER_API_TOKEN=your-secret-token + # ... your existing ports/volumes ... + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - WATCHTOWER_HTTP_API_UPDATE=true + - WATCHTOWER_HTTP_API_TOKEN=your-secret-token + - WATCHTOWER_LABEL_ENABLE=true + - WATCHTOWER_CLEANUP=true + ports: + - '8081:8080' +``` + +{: .important } +> Replace `your-secret-token` with a strong, unique token. The same token must be set in both the Part-DB (`WATCHTOWER_API_TOKEN`) and Watchtower (`WATCHTOWER_HTTP_API_TOKEN`) environment variables. + +{: .info } +> `WATCHTOWER_LABEL_ENABLE=true` ensures Watchtower only manages containers with the `com.centurylinklabs.watchtower.enable=true` label, preventing it from updating other containers on the same host. + +Once configured, the Update Manager page will show the Watchtower connection status and provide an **Update via Watchtower** button when a new version is available. Clicking it triggers Watchtower to pull the latest image and recreate the Part-DB container automatically. + ## Direct use of docker image You can use the `jbtronics/part-db1:master` image directly. You have to expose port 80 to a host port and configure diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 70be714d2..51715e4dc 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -28,6 +28,7 @@ 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 @@ public function __construct( 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,91 @@ public function restore(Request $request): Response 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 + { + return $this->json([ + 'status' => 'ok', + 'version' => $this->versionManager->getVersion()->toString(), + ]); + } } diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php index 654ba9f24..0ba5aa99a 100644 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -62,8 +62,8 @@ public function onKernelRequest(RequestEvent $event): void 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; } diff --git a/src/Services/System/InstallationType.php b/src/Services/System/InstallationType.php index 74479bb9f..2631e644e 100644 --- a/src/Services/System/InstallationType.php +++ b/src/Services/System/InstallationType.php @@ -46,7 +46,7 @@ public function supportsAutoUpdate(): bool { 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 @@ public function getUpdateInstructions(): 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.', }; diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php index fdb8d9dd9..366e8d67a 100644 --- a/src/Services/System/UpdateChecker.php +++ b/src/Services/System/UpdateChecker.php @@ -50,7 +50,8 @@ public function __construct(private readonly HttpClientInterface $httpClient, 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 @@ public function getUpdateStatus(): array $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 @@ public function getUpdateStatus(): array 'can_auto_update' => $canAutoUpdate, 'update_blockers' => $updateBlockers, 'check_enabled' => $this->privacySettings->checkForUpdates, + 'watchtower_configured' => $watchtowerConfigured, + 'watchtower_available' => $watchtowerAvailable, ]; } diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 0992663e8..ccc346d51 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -299,6 +299,23 @@ public function validateUpdatePreconditions(): array ); } + // 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 diff --git a/src/Services/System/WatchtowerClient.php b/src/Services/System/WatchtowerClient.php new file mode 100644 index 000000000..87cc06fde --- /dev/null +++ b/src/Services/System/WatchtowerClient.php @@ -0,0 +1,125 @@ +. + */ + +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 + */ + private function getAuthHeaders(): array + { + return [ + 'Authorization' => 'Bearer ' . $this->apiToken, + ]; + } +} diff --git a/templates/admin/update_manager/docker_progress.html.twig b/templates/admin/update_manager/docker_progress.html.twig new file mode 100644 index 000000000..e43b9afa0 --- /dev/null +++ b/templates/admin/update_manager/docker_progress.html.twig @@ -0,0 +1,235 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}update_manager.docker.progress_title{% endtrans %}{% endblock %} + +{% block card_title %} + + {% trans %}update_manager.docker.progress_title{% endtrans %} +{% endblock %} + +{% block card_content %} + +
+ + {# Progress Header #} +
+
+
+
+ + + +
+
~ ~ ~ ~ ~
+
+
+

+ {% trans %}update_manager.docker.updating{% endtrans %} +

+

+ {% trans %}update_manager.docker.updating_via_watchtower{% endtrans %} +

+
+ + {# Progress Bar #} +
+
+ 15% +
+
+ + {# Current Step Info #} +
+ {% trans %}update_manager.docker.step_trigger{% endtrans %}: + {% trans %}update_manager.docker.step_trigger_desc{% endtrans %} +
+ + {# Success Message #} +
+ + {% trans %}update_manager.docker.success_message{% endtrans %} +
+ {% trans %}update_manager.docker.previous_version{% endtrans %}: + {{ previous_version }} + → + {% trans %}update_manager.docker.new_version{% endtrans %}: + ... +
+ + {# Timeout Message #} +
+ + {% trans %}update_manager.docker.timeout_message{% endtrans %} +
+ + {# Error Message #} +
+ + {% trans %}update_manager.progress.error{% endtrans %}: + +
+ + {# Steps Timeline - matches git progress style #} +
+
+ {% trans %}update_manager.docker.steps{% endtrans %} +
+
+
    + {# Step 1: Trigger Watchtower #} +
  • + +
    + {% trans %}update_manager.docker.step_trigger{% endtrans %} +
    {% trans %}update_manager.docker.step_trigger_desc{% endtrans %} +
    + +
  • + + {# Step 2: Pull Image #} +
  • + +
    + {% trans %}update_manager.docker.step_pull{% endtrans %} +
    {% trans %}update_manager.docker.step_pull_desc{% endtrans %} +
    + +
  • + + {# Step 3: Stop Container #} +
  • + +
    + {% trans %}update_manager.docker.step_stop{% endtrans %} +
    {% trans %}update_manager.docker.step_stop_desc{% endtrans %} +
    + +
  • + + {# Step 4: Restart Container #} +
  • + +
    + {% trans %}update_manager.docker.step_restart{% endtrans %} +
    {% trans %}update_manager.docker.step_restart_desc{% endtrans %} +
    + +
  • + + {# Step 5: Health Check #} +
  • + +
    + {% trans %}update_manager.docker.step_health{% endtrans %} +
    {% trans %}update_manager.docker.step_health_desc{% endtrans %} +
    + +
  • + + {# Step 6: Verify Version #} +
  • + +
    + {% trans %}update_manager.docker.step_verify{% endtrans %} +
    {% trans %}update_manager.docker.step_verify_desc{% endtrans %} +
    + +
  • +
+
+
+ + {# Elapsed Time #} +
+ + {% trans %}update_manager.docker.elapsed{% endtrans %}: + 0s +
+ + {# Actions - shown after completion or timeout #} + + + {# Warning Notice #} +
+ + {% trans %}update_manager.docker.warning{% endtrans %}: + {% trans %}update_manager.docker.do_not_close{% endtrans %} +
+
+{% endblock %} diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 2c6db63c8..0d3c88e59 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -99,25 +99,35 @@ {% endif %} - - {% trans %}update_manager.auto_update_supported{% endtrans %} - - {{ helper.boolean_badge(status.can_auto_update) }} - - - - - {% trans %}update_manager.web_updates_allowed{% endtrans %} - {{ helper.boolean_badge(not web_updates_disabled) }} - - - {% trans %}update_manager.backup_restore_allowed{% endtrans %} - {{ helper.boolean_badge(not backup_restore_disabled) }} - - - {% trans %}update_manager.backup_download_allowed{% endtrans %} - {{ helper.boolean_badge(not backup_download_disabled) }} - + {% if is_docker %} + {# Docker: show Watchtower status #} + + {% trans %}update_manager.docker.watchtower_status{% endtrans %} + + {% if status.watchtower_configured|default(false) and status.watchtower_available|default(false) %} + + {% trans %}update_manager.docker.watchtower_connected{% endtrans %} + + {% elseif status.watchtower_configured|default(false) %} + + {% trans %}update_manager.docker.watchtower_unreachable_short{% endtrans %} + + {% else %} + + {% trans %}update_manager.docker.watchtower_not_configured{% endtrans %} + + {% endif %} + + + {% else %} + {# Git/other: show update readiness #} + + {% trans %}update_manager.auto_update_supported{% endtrans %} + + {{ helper.boolean_badge(status.can_auto_update) }} + + + {% endif %} @@ -158,30 +168,63 @@ {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %} -
- - + {% if is_docker %} + {# Docker update via Watchtower #} + + -
- -
+
+ +
-
- - -
-
+
+ + +
+ +
+ + {% trans %}update_manager.docker.no_rollback_warning{% endtrans %} +
+ + {% else %} + {# Git update #} +
+ + + +
+ +
+ +
+ + +
+
+ {% endif %} {% endif %} {% if status.published_at %} @@ -229,12 +272,55 @@ {# Non-auto-update installations info #} {% if not status.can_auto_update %} -
-
- {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} -
-

{{ status.installation.update_instructions }}

-
+ {% if is_docker and not status.watchtower_configured|default(false) %} + {# Docker without Watchtower - show setup instructions #} +
+
+ {% trans %}update_manager.docker.setup_title{% endtrans %} +
+
+

{% trans %}update_manager.docker.setup_description{% endtrans %}

+ +
{% trans %}update_manager.docker.setup_step1{% endtrans %}
+
services:
+  watchtower:
+    image: containrrr/watchtower
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+    environment:
+      - WATCHTOWER_HTTP_API_UPDATE=true
+      - WATCHTOWER_HTTP_API_TOKEN=your-secret-token
+      - WATCHTOWER_LABEL_ENABLE=true
+    ports:
+      - "8080:8080"
+ +
{% trans %}update_manager.docker.setup_step2{% endtrans %}
+
WATCHTOWER_API_URL=http://watchtower:8080
+WATCHTOWER_API_TOKEN=your-secret-token
+ +
+ + {% trans %}update_manager.docker.setup_network_hint{% endtrans %} +
+
+
+ {% elseif is_docker and status.watchtower_configured|default(false) and not status.watchtower_available|default(false) %} + {# Docker with Watchtower configured but not reachable #} +
+
+ {% trans %}update_manager.docker.watchtower_unreachable_title{% endtrans %} +
+

{% trans %}update_manager.docker.watchtower_unreachable_description{% endtrans %}

+
+ {% else %} + {# Other non-auto-update installations (ZIP, unknown) #} +
+
+ {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} +
+

{{ status.installation.update_instructions }}

+
+ {% endif %} {% endif %}
@@ -277,6 +363,9 @@ {% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %} + {% if is_docker %} + {# Docker: version switching not supported, only update to latest via Watchtower #} + {% else %}
+ {% endif %} {% endif %}
diff --git a/templates/bundles/TwigBundle/Exception/error.html.twig b/templates/bundles/TwigBundle/Exception/error.html.twig index efdba462a..936f5ca3a 100644 --- a/templates/bundles/TwigBundle/Exception/error.html.twig +++ b/templates/bundles/TwigBundle/Exception/error.html.twig @@ -17,7 +17,7 @@ Consider yourself lucky. You found some rare error code.
You should maybe inform your administrator about it... {% endblock %} - {% block further_actions %}

You can try to Go Back or Visit the homepage.

{% endblock %} + {% block further_actions %}

You can try to Go Back or Visit the homepage.

{% endblock %} {% block admin_contact %}

If this error persists, please contact your {% if error_page_admin_email is not empty %} administrator. diff --git a/tests/Services/System/WatchtowerClientTest.php b/tests/Services/System/WatchtowerClientTest.php new file mode 100644 index 000000000..1de4bd2c4 --- /dev/null +++ b/tests/Services/System/WatchtowerClientTest.php @@ -0,0 +1,197 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Services\System; + +use App\Services\System\WatchtowerClient; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class WatchtowerClientTest extends TestCase +{ + private function createClient(string $url = 'http://watchtower:8080', string $token = 'test-token', ?HttpClientInterface $httpClient = null): WatchtowerClient + { + return new WatchtowerClient( + $httpClient ?? $this->createMock(HttpClientInterface::class), + new NullLogger(), + $url, + $token, + ); + } + + public function testIsConfiguredReturnsTrueWhenBothSet(): void + { + $client = $this->createClient('http://watchtower:8080', 'my-token'); + $this->assertTrue($client->isConfigured()); + } + + public function testIsConfiguredReturnsFalseWhenUrlEmpty(): void + { + $client = $this->createClient('', 'my-token'); + $this->assertFalse($client->isConfigured()); + } + + public function testIsConfiguredReturnsFalseWhenTokenEmpty(): void + { + $client = $this->createClient('http://watchtower:8080', ''); + $this->assertFalse($client->isConfigured()); + } + + public function testIsConfiguredReturnsFalseWhenBothEmpty(): void + { + $client = $this->createClient('', ''); + $this->assertFalse($client->isConfigured()); + } + + public function testIsAvailableReturnsFalseWhenNotConfigured(): void + { + $client = $this->createClient('', ''); + $this->assertFalse($client->isAvailable()); + } + + public function testIsAvailableReturnsTrueOnSuccessResponse(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('request') + ->with('GET', 'http://watchtower:8080/v1/update', $this->callback(function (array $options) { + return $options['headers']['Authorization'] === 'Bearer test-token' + && $options['timeout'] === 3; + })) + ->willReturn($response); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertTrue($client->isAvailable()); + } + + public function testIsAvailableReturnsTrueOn401(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(401); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertTrue($client->isAvailable()); + } + + public function testIsAvailableReturnsFalseOn500(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(500); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertFalse($client->isAvailable()); + } + + public function testIsAvailableReturnsFalseOnException(): void + { + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willThrowException(new \RuntimeException('Connection refused')); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertFalse($client->isAvailable()); + } + + public function testTriggerUpdateThrowsWhenNotConfigured(): void + { + $client = $this->createClient('', ''); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Watchtower is not configured'); + $client->triggerUpdate(); + } + + public function testTriggerUpdateReturnsTrueOnSuccess(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('request') + ->with('POST', 'http://watchtower:8080/v1/update', $this->callback(function (array $options) { + return $options['headers']['Authorization'] === 'Bearer test-token' + && $options['timeout'] === 10; + })) + ->willReturn($response); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertTrue($client->triggerUpdate()); + } + + public function testTriggerUpdateReturnsTrueOn202(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(202); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertTrue($client->triggerUpdate()); + } + + public function testTriggerUpdateReturnsFalseOnServerError(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(500); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertFalse($client->triggerUpdate()); + } + + public function testTriggerUpdateReturnsFalseOnException(): void + { + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willThrowException(new \RuntimeException('Network error')); + + $client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient); + $this->assertFalse($client->triggerUpdate()); + } + + public function testUrlTrailingSlashIsNormalized(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('request') + ->with('GET', 'http://watchtower:8080/v1/update', $this->anything()) + ->willReturn($response); + + $client = $this->createClient('http://watchtower:8080/', 'test-token', $httpClient); + $client->isAvailable(); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ce92bda69..acd346a75 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12941,6 +12941,312 @@ Buerklin-API Authentication server: Backup download allowed + + + update_manager.docker.setup_title + Enable One-Click Docker Updates with Watchtower + + + + + update_manager.docker.setup_description + Part-DB can update your Docker container automatically using Watchtower, an open-source container updater. Add Watchtower as a companion container and configure the connection below. + + + + + update_manager.docker.setup_step1 + 1. Add Watchtower to your docker-compose.yml: + + + + + update_manager.docker.setup_step2 + 2. Add these environment variables to your Part-DB container: + + + + + update_manager.docker.setup_network_hint + Make sure Part-DB and Watchtower are on the same Docker network. If you use label-based filtering in Watchtower (WATCHTOWER_LABEL_ENABLE=true), add the label "com.centurylinklabs.watchtower.enable=true" to your Part-DB container. + + + + + update_manager.docker.watchtower_unreachable_title + Watchtower Not Reachable + + + + + update_manager.docker.watchtower_unreachable_description + Watchtower is configured but cannot be reached. Please verify that the Watchtower container is running and that the API URL and token are correct. + + + + + update_manager.docker.confirm_update + Are you sure you want to update Part-DB via Watchtower? The container will be restarted with the new image. Unlike Git updates, Docker updates cannot be automatically rolled back. + + + + + update_manager.docker.update_via_watchtower + Update via Watchtower to + + + + + update_manager.docker.no_rollback_warning + Docker updates cannot be automatically rolled back. A database backup will be created before updating so you can restore your data if needed. + + + + + update_manager.docker.progress_title + Docker Update in Progress + + + + + update_manager.docker.waiting_for_watchtower + Waiting for Watchtower to pull the new image... + + + + + update_manager.docker.elapsed + Elapsed + + + + + update_manager.docker.waiting_title + Update Triggered + + + + + update_manager.docker.waiting_description + Watchtower has been notified. It will pull the latest Docker image and restart the Part-DB container. + + + + + update_manager.docker.watchtower_working + Watchtower is processing the update... + + + + + update_manager.docker.watchtower_working_hint + This may take a few minutes depending on your internet speed and image size. + + + + + update_manager.docker.restarting_title + Container Restarting + + + + + update_manager.docker.restarting_description + Watchtower has pulled the new image and is restarting the Part-DB container. + + + + + update_manager.docker.restarting_hint + The page will automatically detect when the server comes back online. This usually takes 10-30 seconds. + + + + + update_manager.docker.success_title + Update Complete! + + + + + update_manager.docker.success_message + Part-DB has been successfully updated via Watchtower. + + + + + update_manager.docker.previous_version + Previous version + + + + + update_manager.docker.new_version + New version + + + + + update_manager.docker.back_to_update_manager + Back to Update Manager + + + + + update_manager.docker.go_to_homepage + Go to Homepage + + + + + update_manager.docker.timeout_title + Update Taking Longer Than Expected + + + + + update_manager.docker.timeout_message + The update is taking longer than expected. Check the Watchtower container logs for details. The update may still be in progress. + + + + + update_manager.docker.retry + Retry + + + + + update_manager.docker.warning + Warning + + + + + update_manager.docker.do_not_close + Do not close this page. It will automatically detect when the update is complete. + + + + + update_manager.docker.updating_via_watchtower + Updating via Watchtower + + + + + update_manager.docker.step_waiting + Pulling Image + + + + + update_manager.docker.steps + Update Steps + + + + + update_manager.docker.step_trigger + Trigger Update + + + + + update_manager.docker.step_trigger_desc + Watchtower has been notified to check for updates + + + + + update_manager.docker.step_pull + Pull New Image + + + + + update_manager.docker.step_pull_desc + Downloading the latest Docker image from the registry + + + + + update_manager.docker.step_restart + Restart Container + + + + + update_manager.docker.step_restart_desc + Stopping old container and starting new one + + + + + update_manager.docker.step_verify + Verify + + + + + update_manager.docker.step_verify_desc + Confirming Part-DB is running on the new version + + + + + update_manager.docker.watchtower_status + Watchtower + + + + + update_manager.docker.watchtower_connected + Connected + + + + + update_manager.docker.watchtower_unreachable_short + Unreachable + + + + + update_manager.docker.watchtower_not_configured + Not configured + + + + + update_manager.docker.step_stop + Stop Container + + + + + update_manager.docker.step_stop_desc + Gracefully stopping the current container before recreation + + + + + update_manager.docker.step_health + Health Check + + + + + update_manager.docker.step_health_desc + Waiting for the new container to pass health checks + + + + + update_manager.docker.updating + Updating Part-DB via Docker... + + part.create_from_info_provider.lot_filled_from_barcode