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
parent
fceb0eef77
commit
997b624b0f
13
CHANGELOG.md
13
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
41
install.sh
41
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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue