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) <noreply@anthropic.com>
feat/v1-foundation v0.9.9
boban 2026-06-19 18:13:34 +02:00
parent fceb0eef77
commit 997b624b0f
13 changed files with 329 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,6 +65,20 @@
@endif
</p>
</div>
{{-- 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)
<p class="flex shrink-0 items-center gap-1.5 font-mono text-[11px] text-warning">
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />{{ __('versions.update_running') }}
</p>
@elseif ($isUpdate)
<x-btn variant="primary" wire:click="requestUpdate" wire:target="requestUpdate" wire:loading.attr="disabled" class="shrink-0">
<svg wire:loading wire:target="requestUpdate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestUpdate" />
{{ __('versions.update_now') }}
</x-btn>
@endif
@if ($lastChecked)
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>
@endif

View File

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