From 997b624b0f2af76c7562544281c97120da5587f1 Mon Sep 17 00:00:00 2001
From: boban
Date: Fri, 19 Jun 2026 18:13:34 +0200
Subject: [PATCH] feat(versions): one-click 'update now' from the dashboard
Add a host-watcher-backed update button on Version & Releases. The container
(no Docker socket) writes an update sentinel; a new root-run systemd unit
(clusev-update.path/.service) runs watch.sh update -> update.sh (git pull +
idempotent install). The sentinel is consumed before running so a persistent
failure can't loop the path unit.
The button only appears once a check finds a newer release, is per-user
throttled (3/10min, auto-expiring) and audited (deploy.update_request); the UI
warns the dashboard is briefly down during the rebuild. install.sh now installs
both the restart and update host units (update unit runs as root for
docker/systemd/apt).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 13 ++++
app/Livewire/Versions/Index.php | 55 +++++++++++++++++
app/Services/DeploymentService.php | 51 +++++++++++++++-
config/clusev.php | 2 +-
docker/restart-sentinel/README.md | 12 +++-
docker/restart-sentinel/clusev-update.path | 24 ++++++++
docker/restart-sentinel/clusev-update.service | 28 +++++++++
docker/restart-sentinel/watch.sh | 39 +++++++++++-
install.sh | 41 +++++++------
lang/de/versions.php | 7 +++
lang/en/versions.php | 7 +++
.../views/livewire/versions/index.blade.php | 14 +++++
tests/Feature/VersionUpdateCheckTest.php | 61 +++++++++++++++++++
13 files changed, 329 insertions(+), 25 deletions(-)
create mode 100644 docker/restart-sentinel/clusev-update.path
create mode 100644 docker/restart-sentinel/clusev-update.service
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bef1dee..4b40799 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,19 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
+## [0.9.9] - 2026-06-19
+
+### Hinzugefügt
+- **„Jetzt aktualisieren" im Dashboard (System → Version & Releases).** Ist ein neueres Release im
+ Kanal verfügbar, lässt sich das Update jetzt per Knopf auslösen — ganz ohne SSH. Gleiches sichere
+ Muster wie der Neustart-Knopf: der Container schreibt nur eine Sentinel-Datei (kein Docker-Socket),
+ ein **als root laufender** Host-Watcher (`clusev-update.path`/`.service`) führt `update.sh` aus
+ (`git pull` + idempotenter Re-Install) und verbraucht die Sentinel **vor** dem Lauf, damit ein
+ dauerhafter Fehler nicht endlos wiederholt. Der Knopf erscheint nur, wenn eine neuere Version
+ gefunden wurde, ist per-Nutzer gedrosselt (3/10 Min, auto-ablaufend) und auditiert
+ (`deploy.update_request`). Hinweis im UI: das Dashboard ist während des Rebuilds ein bis zwei
+ Minuten nicht erreichbar; danach neu laden. `install.sh` installiert die neuen Host-Units mit.
+
## [0.9.8] - 2026-06-19
### Hinzugefügt
diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php
index bbad0aa..5e30d7d 100644
--- a/app/Livewire/Versions/Index.php
+++ b/app/Livewire/Versions/Index.php
@@ -2,9 +2,13 @@
namespace App\Livewire\Versions;
+use App\Models\AuditEvent;
use App\Models\Setting;
+use App\Services\DeploymentService;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Component;
@@ -30,6 +34,9 @@ class Index extends Component
/** 'current' | 'update' | null — drives the banner tone. */
public ?string $updateState = null;
+ /** Set once an update has been requested this session — the stack is rebuilding. */
+ public bool $updateRequested = false;
+
/**
* Honest update check: compare the installed version against the newest release
* tag visible in the channel. No external server, no stars, no CVE feed.
@@ -55,6 +62,54 @@ class Index extends Component
}
}
+ /**
+ * Operator-triggered update: write the update sentinel a host watcher reacts to by running
+ * update.sh (git pull + idempotent re-install). Re-checks that a newer release than the
+ * installed one is actually published in the channel (never fires blindly), is per-user
+ * throttled (auto-expiring — never a control-plane lockout) and audited. The container never
+ * runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
+ * say so and do not await a result.
+ */
+ public function requestUpdate(DeploymentService $deployment): void
+ {
+ $channel = $this->channel();
+ $installed = (string) config('clusev.version');
+ $latest = $this->resolveLatestTag($channel, forceRemote: true);
+
+ // Reflect the freshly-checked state, then refuse if there is nothing newer to apply.
+ $this->lastChecked = now()->format('H:i');
+ if ($latest === null || ! $this->isNewer($latest, $installed)) {
+ $this->updateState = 'current';
+ $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
+ $this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
+
+ return;
+ }
+ $this->updateState = 'update';
+ $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
+
+ $key = 'update-request:'.Auth::id();
+ if (RateLimiter::tooManyAttempts($key, 3)) {
+ $this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
+
+ return;
+ }
+ RateLimiter::hit($key, 600);
+
+ $deployment->requestUpdate();
+
+ AuditEvent::create([
+ 'user_id' => Auth::id(),
+ 'actor' => Auth::user()?->name ?? 'system',
+ 'action' => 'deploy.update_request',
+ 'target' => $installed.' → '.$latest.' ('.$channel.')',
+ 'ip' => request()->ip(),
+ ]);
+
+ $this->updateRequested = true;
+ $this->dispatch('notify', message: __('versions.update_started'));
+ }
+
/** Resolve the configured channel, clamped to the user-facing set. */
private function channel(): string
{
diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php
index e8050cc..2227a6d 100644
--- a/app/Services/DeploymentService.php
+++ b/app/Services/DeploymentService.php
@@ -45,6 +45,13 @@ class DeploymentService
*/
private const RESTART_FILE = 'app/restart-signal/restart.request';
+ /**
+ * Update sentinel — same mechanism and bind-mounted directory as RESTART_FILE, but the
+ * host watcher reacts by running update.sh (git pull + idempotent re-install) instead of a
+ * plain restart. Separate filename so the two requests trigger distinct host units.
+ */
+ private const UPDATE_FILE = 'app/restart-signal/update.request';
+
/**
* The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists
* (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is
@@ -194,6 +201,46 @@ class DeploymentService
}
}
+ /** Absolute path of the update sentinel on the shared (bind-mounted) volume. */
+ public function updateSignalPath(): string
+ {
+ return storage_path(self::UPDATE_FILE);
+ }
+
+ /**
+ * Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped
+ * clusev-update.path unit) reacts by running update.sh — git pull + idempotent re-install
+ * (rebuild, migrate, restart) — then removes the sentinel. The container never runs git or
+ * Docker itself; it only writes this non-sensitive marker. Never throws.
+ */
+ public function requestUpdate(): void
+ {
+ $path = $this->updateSignalPath();
+ $dir = dirname($path);
+
+ if (! is_dir($dir)) {
+ @mkdir($dir, 0775, true);
+ }
+
+ @file_put_contents($path, now()->toIso8601String().PHP_EOL);
+ }
+
+ /** True while an update request is pending (the host watcher clears it once handled). */
+ public function updateRequested(): bool
+ {
+ return is_file($this->updateSignalPath());
+ }
+
+ /** Remove the update sentinel (tests / manual reset; the host watcher deletes it normally). */
+ public function clearUpdateRequest(): void
+ {
+ $path = $this->updateSignalPath();
+
+ if (is_file($path)) {
+ @unlink($path);
+ }
+ }
+
/** True when the panel domain is a dashboard override (a panel_domain row exists). */
public function domainIsOverridden(): bool
{
@@ -312,7 +359,7 @@ class DeploymentService
* Whether the domain's DNS (A/AAAA) currently resolves to THIS server.
*
* @return array{ok: ?bool, resolved: string[], serverIp: ?string}
- * ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable)
+ * ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable)
*/
public function domainResolvesHere(string $domain): array
{
@@ -375,7 +422,7 @@ class DeploymentService
* dashboard can show a status without a live (ACME-triggering) probe on every page load.
*
* @return array{status: string, checkedAt: string, resolved?: string[], serverIp?: ?string}
- * status: issued | dns_mismatch | failed | not_applicable
+ * status: issued | dns_mismatch | failed | not_applicable
*/
public function requestCertificate(string $domain): array
{
diff --git a/config/clusev.php b/config/clusev.php
index b84438e..0c829c9 100644
--- a/config/clusev.php
+++ b/config/clusev.php
@@ -3,7 +3,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
- 'version' => '0.9.8',
+ 'version' => '0.9.9',
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable',
diff --git a/docker/restart-sentinel/README.md b/docker/restart-sentinel/README.md
index 04bc245..298449c 100644
--- a/docker/restart-sentinel/README.md
+++ b/docker/restart-sentinel/README.md
@@ -33,9 +33,15 @@ restart. The file content is a non-sensitive ISO timestamp; the trigger is the f
| File | Role |
|---|---|
-| `watch.sh` | The actual logic: `once` (systemd) restarts the stack + deletes the sentinel; `loop` is a standalone inotify/poll fallback for hosts without systemd. |
-| `clusev-restart.path` | systemd `.path` unit — `PathExists=` the sentinel, triggers the service. |
-| `clusev-restart.service` | systemd oneshot — runs `watch.sh once`. |
+| `watch.sh` | The actual logic: `once` (systemd) restarts the stack + deletes the sentinel; `update` runs `update.sh` (git pull + re-install); `loop` is a standalone inotify/poll fallback for hosts without systemd (restart only). |
+| `clusev-restart.path` | systemd `.path` unit — `PathExists=` the restart sentinel, triggers the restart service. |
+| `clusev-restart.service` | systemd oneshot — runs `watch.sh once` as the `clusev` user. |
+| `clusev-update.path` | systemd `.path` unit — `PathExists=` the update sentinel (`run/update.request`), triggers the update service. |
+| `clusev-update.service` | systemd oneshot — runs `watch.sh update` as **root** (update.sh → install.sh needs root). |
+
+The update sentinel (dashboard → Version & Releases → "Jetzt aktualisieren") works exactly like the
+restart one, but the watcher runs `update.sh` instead of a plain restart, and consumes the sentinel
+*before* running so a persistent pull failure can't loop. `install.sh` installs both pairs of units.
## Install (systemd — preferred)
diff --git a/docker/restart-sentinel/clusev-update.path b/docker/restart-sentinel/clusev-update.path
new file mode 100644
index 0000000..6c1f449
--- /dev/null
+++ b/docker/restart-sentinel/clusev-update.path
@@ -0,0 +1,24 @@
+# Clusev update-sentinel — path unit (HOST-side).
+#
+# Watches for the update sentinel the dashboard writes (Versions → "Jetzt aktualisieren")
+# and triggers clusev-update.service the moment it appears. Install this unit and
+# clusev-update.service into /etc/systemd/system/, then:
+#
+# systemctl daemon-reload
+# systemctl enable --now clusev-update.path
+#
+# Adjust PathExists= if your project lives somewhere other than /home/nexxo/clusev (it must
+# match the ./run bind mount in docker-compose.prod.yml). install.sh rewrites this for you.
+[Unit]
+Description=Clusev update sentinel — watch for a dashboard update request
+After=docker.service
+Wants=docker.service
+
+[Path]
+# Fires the .service whenever this file exists. The service consumes the sentinel before
+# running update.sh, so a persistent update failure cannot loop here.
+PathExists=/home/nexxo/clusev/run/update.request
+Unit=clusev-update.service
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docker/restart-sentinel/clusev-update.service b/docker/restart-sentinel/clusev-update.service
new file mode 100644
index 0000000..c65592a
--- /dev/null
+++ b/docker/restart-sentinel/clusev-update.service
@@ -0,0 +1,28 @@
+# Clusev update-sentinel — service unit (HOST-side).
+#
+# Triggered by clusev-update.path. Runs watch.sh update: consumes the sentinel, then runs
+# update.sh (git pull --ff-only + the idempotent install.sh). A oneshot — it exits when done
+# and the .path unit re-arms for the next request.
+#
+# SECURITY: like the restart unit, this is host-side; the panel container has NO Docker socket
+# and only writes the sentinel file. This service runs as ROOT (not the `clusev` user the
+# restart unit uses) because update.sh -> install.sh needs root for docker/systemd/apt. It only
+# ever runs the project's own committed update.sh, pulling from the configured (trusted) remote.
+#
+# install.sh rewrites WorkingDirectory= / ExecStart= / Environment=CLUSEV_DIR to the real path.
+[Unit]
+Description=Clusev update sentinel — pull + re-install the stack on request
+After=docker.service
+Requires=docker.service
+
+[Service]
+Type=oneshot
+User=root
+# HOME is needed so update.sh's `git config --global --add safe.directory` lands in root's
+# config and the subsequent root-run `git pull` is allowed on the clusev-owned tree.
+Environment=HOME=/root
+Environment=CLUSEV_DIR=/home/nexxo/clusev
+WorkingDirectory=/home/nexxo/clusev
+ExecStart=/home/nexxo/clusev/docker/restart-sentinel/watch.sh update
+# A full update (pull + image rebuild + migrate) takes longer than a restart.
+TimeoutStartSec=900
diff --git a/docker/restart-sentinel/watch.sh b/docker/restart-sentinel/watch.sh
index bb9de94..a032fc4 100755
--- a/docker/restart-sentinel/watch.sh
+++ b/docker/restart-sentinel/watch.sh
@@ -28,10 +28,12 @@ DEFAULT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
CLUSEV_DIR="${CLUSEV_DIR:-$DEFAULT_DIR}"
CLUSEV_SIGNAL="${CLUSEV_SIGNAL:-${CLUSEV_DIR}/run/restart.request}"
+CLUSEV_UPDATE_SIGNAL="${CLUSEV_UPDATE_SIGNAL:-${CLUSEV_DIR}/run/update.request}"
CLUSEV_POLL="${CLUSEV_POLL:-5}"
COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
-log() { printf '%s clusev-restart: %s\n' "$(date -Is)" "$*"; }
+TAG="clusev-restart"
+log() { printf '%s %s: %s\n' "$(date -Is)" "$TAG" "$*"; }
# Restart the stack and consume the sentinel. `up -d` (not bare `restart`) so a
# changed image/config is also applied and any down service is brought back.
@@ -59,6 +61,38 @@ run_once() {
fi
}
+# Run update.sh (git pull + idempotent re-install). Unlike restart, the sentinel is consumed
+# BEFORE running: a persistent failure (e.g. a non-fast-forwardable pull) must NOT loop the
+# .path unit forever — the failure is in the journal and the operator re-triggers from the
+# dashboard. Runs as root (the service unit sets User=root) because update.sh -> install.sh
+# needs root for docker/systemd/apt.
+do_update() {
+ TAG="clusev-update"
+ [ -f "$COMPOSE_FILE" ] || { log "compose file not found: $COMPOSE_FILE — skipping"; return 1; }
+ local updater="${CLUSEV_DIR}/update.sh"
+ if [ ! -f "$updater" ]; then
+ log "update.sh not found at $updater — clearing sentinel, nothing to do"
+ rm -f "$CLUSEV_UPDATE_SIGNAL"
+ return 1
+ fi
+ rm -f "$CLUSEV_UPDATE_SIGNAL" && log "update sentinel consumed: $CLUSEV_UPDATE_SIGNAL"
+ log "update requested — running update.sh in ${CLUSEV_DIR}"
+ if bash "$updater"; then
+ log "update applied"
+ else
+ log "update.sh FAILED — see output above; re-trigger from the dashboard to retry"
+ return 1
+ fi
+}
+
+run_once_update() {
+ if [ -f "$CLUSEV_UPDATE_SIGNAL" ]; then
+ do_update
+ else
+ TAG="clusev-update"; log "no sentinel at $CLUSEV_UPDATE_SIGNAL — nothing to do"
+ fi
+}
+
# Fallback loop for hosts without systemd: prefer inotifywait, else poll.
run_loop() {
log "watching $CLUSEV_SIGNAL (loop mode)"
@@ -81,6 +115,7 @@ run_loop() {
case "${1:-once}" in
once) run_once ;;
+ update) run_once_update ;;
loop) run_loop ;;
- *) log "usage: $0 [once|loop]"; exit 64 ;;
+ *) log "usage: $0 [once|update|loop]"; exit 64 ;;
esac
diff --git a/install.sh b/install.sh
index d49860f..24430df 100755
--- a/install.sh
+++ b/install.sh
@@ -229,38 +229,45 @@ phase 5/9 "Stack starten"
$COMPOSE up -d
info "Container gestartet"
-# ── restart sentinel (host watcher) — idempotent, best-effort ────────
-# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" button
-# works (it writes ./run/restart.request; the host unit restarts the stack). The
-# container never gets the Docker socket. Skipped (with a hint) when systemd is
-# unavailable — see docker/restart-sentinel/README.md for the manual path. The
-# unit runs as the dedicated `clusev` user (a member of the docker group).
-install_restart_sentinel() {
+# ── host watchers (restart + update sentinels) — idempotent, best-effort ────
+# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and
+# "Jetzt aktualisieren" buttons work (they write ./run/{restart,update}.request; the
+# host units act on them). The container never gets the Docker socket. Skipped (with a
+# hint) when systemd is unavailable — see docker/restart-sentinel/README.md. The restart
+# unit runs as the `clusev` user (docker group); the update unit runs as ROOT, because
+# update.sh -> install.sh needs root for docker/systemd/apt.
+install_host_watchers() {
local src="docker/restart-sentinel" dst="/etc/systemd/system" proj
proj="$(pwd)"
if ! command -v systemctl >/dev/null 2>&1; then
- warn "systemd nicht gefunden — Neustart-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0
+ warn "systemd nicht gefunden — Host-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0
fi
# The container's ./run bind mount is owned by clusev; let clusev own it host-side too.
mkdir -p ./run
chown -R clusev:clusev ./run
- # Render the units with THIS project's path + the clusev user (the units in the
- # repo default to /home/nexxo/clusev; rewrite to the real deploy location).
- local tmp_path tmp_svc
- tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"
+ # Render the units with THIS project's path (the repo units default to /home/nexxo/clusev).
+ local tmp_path tmp_svc tmp_upath tmp_usvc
+ tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"; tmp_upath="$(mktemp)"; tmp_usvc="$(mktemp)"
+ # Restart units run as the clusev user.
sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-restart.path" > "$tmp_path"
sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=clusev/" "${src}/clusev-restart.service" > "$tmp_svc"
+ # Update units keep User=root (only the path is rewritten).
+ sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.path" > "$tmp_upath"
+ sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.service" > "$tmp_usvc"
if install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \
&& install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \
+ && install -m 0644 "$tmp_upath" "${dst}/clusev-update.path" \
+ && install -m 0644 "$tmp_usvc" "${dst}/clusev-update.service" \
&& systemctl daemon-reload \
- && systemctl enable --now clusev-restart.path; then
- info "Neustart-Watcher aktiv (clusev-restart.path, User=clusev)"
+ && systemctl enable --now clusev-restart.path \
+ && systemctl enable --now clusev-update.path; then
+ info "Host-Watcher aktiv (clusev-restart.path User=clusev, clusev-update.path User=root)"
else
- warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md."
+ warn "Host-Watcher konnten nicht installiert werden — siehe ${src}/README.md."
fi
- rm -f "$tmp_path" "$tmp_svc"
+ rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc"
}
-install_restart_sentinel
+install_host_watchers
# ── [6/9] wait for the database ──────────────────────────────────────
phase 6/9 "Datenbank bereit"
diff --git a/lang/de/versions.php b/lang/de/versions.php
index f1e8d66..078f8e0 100644
--- a/lang/de/versions.php
+++ b/lang/de/versions.php
@@ -46,6 +46,13 @@ return [
'update_title' => 'Aktualisierung',
'update_hint' => 'Updates kommen über getaggte Releases im Deploy — neues Image ziehen und Migrationen anwenden:',
+ // Dashboard-triggered update (one-click; a host watcher runs update.sh)
+ 'update_now' => 'Jetzt aktualisieren',
+ 'update_started' => 'Update gestartet — die Oberfläche ist gleich für ein bis zwei Minuten nicht erreichbar, während der Stack neu gebaut wird. Danach Seite neu laden.',
+ 'update_running' => 'Update läuft — Stack wird neu gebaut. Bitte gleich neu laden.',
+ 'update_not_available' => 'Kein Update verfügbar — die installierte Version ist bereits aktuell.',
+ 'update_throttled' => 'Zu viele Update-Anfragen — bitte in :seconds Sekunden erneut versuchen.',
+
// Project panel
'project_title' => 'Projekt',
'open_core' => 'Open Core · :license',
diff --git a/lang/en/versions.php b/lang/en/versions.php
index e646395..b274eed 100644
--- a/lang/en/versions.php
+++ b/lang/en/versions.php
@@ -46,6 +46,13 @@ return [
'update_title' => 'Update',
'update_hint' => 'Updates arrive via tagged releases in the deploy — pull the new image and apply migrations:',
+ // Dashboard-triggered update (one-click; a host watcher runs update.sh)
+ 'update_now' => 'Update now',
+ 'update_started' => 'Update started — the interface will be unreachable for a minute or two while the stack rebuilds. Reload the page afterwards.',
+ 'update_running' => 'Update in progress — the stack is rebuilding. Reload shortly.',
+ 'update_not_available' => 'No update available — the installed version is already current.',
+ 'update_throttled' => 'Too many update requests — try again in :seconds seconds.',
+
// Project panel
'project_title' => 'Project',
'open_core' => 'Open Core · :license',
diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php
index 4bd1029..2e25920 100644
--- a/resources/views/livewire/versions/index.blade.php
+++ b/resources/views/livewire/versions/index.blade.php
@@ -65,6 +65,20 @@
@endif
+ {{-- One-click update: writes the update sentinel; a host watcher runs update.sh. Mirrors
+ the System restart button (direct click → "running" notice via a flag — not
+ data-destructive, so no modal). Only offered once a check found a newer release. --}}
+ @if ($updateRequested)
+
+ {{ __('versions.update_running') }}
+
+ @elseif ($isUpdate)
+
+
+
+ {{ __('versions.update_now') }}
+
+ @endif
@if ($lastChecked)
{{ __('versions.checked_at', ['time' => $lastChecked]) }}
@endif
diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php
index b5ade1c..1795577 100644
--- a/tests/Feature/VersionUpdateCheckTest.php
+++ b/tests/Feature/VersionUpdateCheckTest.php
@@ -3,11 +3,14 @@
namespace Tests\Feature;
use App\Livewire\Versions\Index;
+use App\Models\AuditEvent;
use App\Models\Setting;
use App\Models\User;
+use App\Services\DeploymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
use Tests\TestCase;
@@ -26,9 +29,16 @@ class VersionUpdateCheckTest extends TestCase
{
parent::setUp();
Cache::flush(); // never inherit a cached latest-release between tests
+ app(DeploymentService::class)->clearUpdateRequest(); // never inherit a stray sentinel
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
+ protected function tearDown(): void
+ {
+ app(DeploymentService::class)->clearUpdateRequest(); // leave the storage dir clean
+ parent::tearDown();
+ }
+
public function test_reports_update_available_from_the_remote_tag_api(): void
{
config()->set('clusev.version', '0.9.4');
@@ -84,4 +94,55 @@ class VersionUpdateCheckTest extends TestCase
->assertSet('updateState', 'current')
->assertSet('lastChecked', now()->format('H:i'));
}
+
+ public function test_request_update_writes_the_sentinel_and_audits_when_available(): void
+ {
+ config()->set('clusev.version', '0.9.4');
+ Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
+
+ Livewire::test(Index::class)
+ ->call('requestUpdate')
+ ->assertSet('updateRequested', true);
+
+ $this->assertTrue(
+ app(DeploymentService::class)->updateRequested(),
+ 'the update sentinel a host watcher reacts to must exist',
+ );
+ $this->assertTrue(AuditEvent::where('action', 'deploy.update_request')->exists());
+ }
+
+ public function test_request_update_refuses_when_already_current(): void
+ {
+ config()->set('clusev.version', '0.9.9');
+ Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
+
+ Livewire::test(Index::class)
+ ->call('requestUpdate')
+ ->assertSet('updateRequested', false)
+ ->assertSet('updateState', 'current');
+
+ $this->assertFalse(
+ app(DeploymentService::class)->updateRequested(),
+ 'no sentinel when there is nothing newer to apply',
+ );
+ }
+
+ public function test_request_update_is_throttled_per_user(): void
+ {
+ config()->set('clusev.version', '0.9.4');
+ Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
+ $key = 'update-request:'.auth()->id();
+ for ($i = 0; $i < 3; $i++) {
+ RateLimiter::hit($key, 600);
+ }
+
+ Livewire::test(Index::class)
+ ->call('requestUpdate')
+ ->assertSet('updateRequested', false);
+
+ $this->assertFalse(
+ app(DeploymentService::class)->updateRequested(),
+ 'the per-user throttle must block the sentinel write',
+ );
+ }
}