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
aa417a705a
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._
|
_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
|
## [0.9.8] - 2026-06-19
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
|
|
||||||
namespace App\Livewire\Versions;
|
namespace App\Livewire\Versions;
|
||||||
|
|
||||||
|
use App\Models\AuditEvent;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use App\Services\DeploymentService;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -30,6 +34,9 @@ class Index extends Component
|
||||||
/** 'current' | 'update' | null — drives the banner tone. */
|
/** 'current' | 'update' | null — drives the banner tone. */
|
||||||
public ?string $updateState = null;
|
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
|
* Honest update check: compare the installed version against the newest release
|
||||||
* tag visible in the channel. No external server, no stars, no CVE feed.
|
* 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. */
|
/** Resolve the configured channel, clamped to the user-facing set. */
|
||||||
private function channel(): string
|
private function channel(): string
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ class DeploymentService
|
||||||
*/
|
*/
|
||||||
private const RESTART_FILE = 'app/restart-signal/restart.request';
|
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
|
* 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
|
* (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). */
|
/** True when the panel domain is a dashboard override (a panel_domain row exists). */
|
||||||
public function domainIsOverridden(): bool
|
public function domainIsOverridden(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -312,7 +359,7 @@ class DeploymentService
|
||||||
* Whether the domain's DNS (A/AAAA) currently resolves to THIS server.
|
* Whether the domain's DNS (A/AAAA) currently resolves to THIS server.
|
||||||
*
|
*
|
||||||
* @return array{ok: ?bool, resolved: string[], serverIp: ?string}
|
* @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
|
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.
|
* 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}
|
* @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
|
public function requestCertificate(string $domain): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
|
// 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).
|
// 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.
|
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
|
||||||
'channel' => 'stable',
|
'channel' => 'stable',
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,15 @@ restart. The file content is a non-sensitive ISO timestamp; the trigger is the f
|
||||||
|
|
||||||
| File | Role |
|
| 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. |
|
| `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 sentinel, triggers the service. |
|
| `clusev-restart.path` | systemd `.path` unit — `PathExists=` the restart sentinel, triggers the restart service. |
|
||||||
| `clusev-restart.service` | systemd oneshot — runs `watch.sh once`. |
|
| `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)
|
## 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_DIR="${CLUSEV_DIR:-$DEFAULT_DIR}"
|
||||||
CLUSEV_SIGNAL="${CLUSEV_SIGNAL:-${CLUSEV_DIR}/run/restart.request}"
|
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}"
|
CLUSEV_POLL="${CLUSEV_POLL:-5}"
|
||||||
COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
|
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
|
# 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.
|
# changed image/config is also applied and any down service is brought back.
|
||||||
|
|
@ -59,6 +61,38 @@ run_once() {
|
||||||
fi
|
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.
|
# Fallback loop for hosts without systemd: prefer inotifywait, else poll.
|
||||||
run_loop() {
|
run_loop() {
|
||||||
log "watching $CLUSEV_SIGNAL (loop mode)"
|
log "watching $CLUSEV_SIGNAL (loop mode)"
|
||||||
|
|
@ -81,6 +115,7 @@ run_loop() {
|
||||||
|
|
||||||
case "${1:-once}" in
|
case "${1:-once}" in
|
||||||
once) run_once ;;
|
once) run_once ;;
|
||||||
|
update) run_once_update ;;
|
||||||
loop) run_loop ;;
|
loop) run_loop ;;
|
||||||
*) log "usage: $0 [once|loop]"; exit 64 ;;
|
*) log "usage: $0 [once|update|loop]"; exit 64 ;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
41
install.sh
41
install.sh
|
|
@ -229,38 +229,45 @@ phase 5/9 "Stack starten"
|
||||||
$COMPOSE up -d
|
$COMPOSE up -d
|
||||||
info "Container gestartet"
|
info "Container gestartet"
|
||||||
|
|
||||||
# ── restart sentinel (host watcher) — idempotent, best-effort ────────
|
# ── host watchers (restart + update sentinels) — idempotent, best-effort ────
|
||||||
# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" button
|
# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and
|
||||||
# works (it writes ./run/restart.request; the host unit restarts the stack). The
|
# "Jetzt aktualisieren" buttons work (they write ./run/{restart,update}.request; the
|
||||||
# container never gets the Docker socket. Skipped (with a hint) when systemd is
|
# host units act on them). The container never gets the Docker socket. Skipped (with a
|
||||||
# unavailable — see docker/restart-sentinel/README.md for the manual path. The
|
# hint) when systemd is unavailable — see docker/restart-sentinel/README.md. The restart
|
||||||
# unit runs as the dedicated `clusev` user (a member of the docker group).
|
# unit runs as the `clusev` user (docker group); the update unit runs as ROOT, because
|
||||||
install_restart_sentinel() {
|
# update.sh -> install.sh needs root for docker/systemd/apt.
|
||||||
|
install_host_watchers() {
|
||||||
local src="docker/restart-sentinel" dst="/etc/systemd/system" proj
|
local src="docker/restart-sentinel" dst="/etc/systemd/system" proj
|
||||||
proj="$(pwd)"
|
proj="$(pwd)"
|
||||||
if ! command -v systemctl >/dev/null 2>&1; then
|
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
|
fi
|
||||||
# The container's ./run bind mount is owned by clusev; let clusev own it host-side too.
|
# The container's ./run bind mount is owned by clusev; let clusev own it host-side too.
|
||||||
mkdir -p ./run
|
mkdir -p ./run
|
||||||
chown -R clusev:clusev ./run
|
chown -R clusev:clusev ./run
|
||||||
# Render the units with THIS project's path + the clusev user (the units in the
|
# Render the units with THIS project's path (the repo units default to /home/nexxo/clusev).
|
||||||
# repo default to /home/nexxo/clusev; rewrite to the real deploy location).
|
local tmp_path tmp_svc tmp_upath tmp_usvc
|
||||||
local tmp_path tmp_svc
|
tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"; tmp_upath="$(mktemp)"; tmp_usvc="$(mktemp)"
|
||||||
tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"
|
# Restart units run as the clusev user.
|
||||||
sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-restart.path" > "$tmp_path"
|
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"
|
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" \
|
if install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \
|
||||||
&& install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \
|
&& 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 daemon-reload \
|
||||||
&& systemctl enable --now clusev-restart.path; then
|
&& systemctl enable --now clusev-restart.path \
|
||||||
info "Neustart-Watcher aktiv (clusev-restart.path, User=clusev)"
|
&& systemctl enable --now clusev-update.path; then
|
||||||
|
info "Host-Watcher aktiv (clusev-restart.path User=clusev, clusev-update.path User=root)"
|
||||||
else
|
else
|
||||||
warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md."
|
warn "Host-Watcher konnten nicht installiert werden — siehe ${src}/README.md."
|
||||||
fi
|
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 ──────────────────────────────────────
|
# ── [6/9] wait for the database ──────────────────────────────────────
|
||||||
phase 6/9 "Datenbank bereit"
|
phase 6/9 "Datenbank bereit"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,13 @@ return [
|
||||||
'update_title' => 'Aktualisierung',
|
'update_title' => 'Aktualisierung',
|
||||||
'update_hint' => 'Updates kommen über getaggte Releases im Deploy — neues Image ziehen und Migrationen anwenden:',
|
'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 panel
|
||||||
'project_title' => 'Projekt',
|
'project_title' => 'Projekt',
|
||||||
'open_core' => 'Open Core · :license',
|
'open_core' => 'Open Core · :license',
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,13 @@ return [
|
||||||
'update_title' => 'Update',
|
'update_title' => 'Update',
|
||||||
'update_hint' => 'Updates arrive via tagged releases in the deploy — pull the new image and apply migrations:',
|
'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 panel
|
||||||
'project_title' => 'Project',
|
'project_title' => 'Project',
|
||||||
'open_core' => 'Open Core · :license',
|
'open_core' => 'Open Core · :license',
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,20 @@
|
||||||
@endif
|
@endif
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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)
|
@if ($lastChecked)
|
||||||
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>
|
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Livewire\Versions\Index;
|
use App\Livewire\Versions\Index;
|
||||||
|
use App\Models\AuditEvent;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\DeploymentService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -26,9 +29,16 @@ class VersionUpdateCheckTest extends TestCase
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
Cache::flush(); // never inherit a cached latest-release between tests
|
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]));
|
$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
|
public function test_reports_update_available_from_the_remote_tag_api(): void
|
||||||
{
|
{
|
||||||
config()->set('clusev.version', '0.9.4');
|
config()->set('clusev.version', '0.9.4');
|
||||||
|
|
@ -84,4 +94,55 @@ class VersionUpdateCheckTest extends TestCase
|
||||||
->assertSet('updateState', 'current')
|
->assertSet('updateState', 'current')
|
||||||
->assertSet('lastChecked', now()->format('H:i'));
|
->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