feat(system): auto-restart sentinel — one-click restart via host watcher (no docker socket)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
73eecd03e0
commit
3399a5017a
|
|
@ -40,6 +40,9 @@ class Index extends Component
|
|||
/** True after a save — shows the "restart the stack to apply" notice. */
|
||||
public bool $restartPending = false;
|
||||
|
||||
/** True once the operator clicked "restart now" — the host watcher is applying it. */
|
||||
public bool $restartRequested = false;
|
||||
|
||||
public string $channel = 'stable';
|
||||
|
||||
public string $tlsMode = 'caddy';
|
||||
|
|
@ -60,6 +63,7 @@ class Index extends Component
|
|||
$this->domain = (string) ($deployment->domain() ?? '');
|
||||
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
|
||||
$this->restartPending = $deployment->restartPending();
|
||||
$this->restartRequested = $deployment->restartRequested();
|
||||
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
|
||||
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
|
||||
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
|
||||
|
|
@ -123,6 +127,19 @@ class Index extends Component
|
|||
$this->dispatch('notify', message: __('system.domain_saved_notify'));
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click restart: write the sentinel and let the HOST watcher restart the stack.
|
||||
* The container never touches the Docker socket — it only requests via a marker file
|
||||
* the scoped systemd .path unit reacts to (see docker/restart-sentinel/). Kept explicit
|
||||
* (a button, not auto-fired on save) so the operator controls the timing.
|
||||
*/
|
||||
public function restartNow(DeploymentService $deployment): void
|
||||
{
|
||||
$deployment->requestRestart();
|
||||
$this->restartRequested = true;
|
||||
$this->dispatch('notify', message: __('system.restart_running'));
|
||||
}
|
||||
|
||||
/** Release channel changes are audited (confirmation via the shared modal). */
|
||||
public function confirmChannel(string $channel): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ class DeploymentService
|
|||
/** Snapshot of the active domain, written at container start (relative to storage/). */
|
||||
private const ACTIVE_FILE = 'framework/active-domain';
|
||||
|
||||
/**
|
||||
* Restart sentinel — a marker file the dashboard writes to REQUEST a stack restart
|
||||
* (relative to storage/). It lives in its own bind-mounted directory (storage/app/
|
||||
* restart-signal, mapped to ./run on the host in docker-compose.prod.yml) so a
|
||||
* host-side watcher (a scoped systemd .path unit — see docker/restart-sentinel/)
|
||||
* sees it appear and runs `docker compose restart`, then deletes it. The container
|
||||
* NEVER gets the Docker socket; it only writes a non-sensitive marker file.
|
||||
*/
|
||||
private const RESTART_FILE = 'app/restart-signal/restart.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
|
||||
|
|
@ -142,6 +152,46 @@ class DeploymentService
|
|||
return $this->configuredDomain() !== $this->domain();
|
||||
}
|
||||
|
||||
/** Absolute path of the restart sentinel on the shared (bind-mounted) volume. */
|
||||
public function restartSignalPath(): string
|
||||
{
|
||||
return storage_path(self::RESTART_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a stack restart by writing the sentinel file. A host-side watcher (a scoped
|
||||
* systemd .path unit, see docker/restart-sentinel/) reacts to its appearance and runs
|
||||
* `docker compose restart`, then removes it. The content is a non-sensitive marker
|
||||
* (an ISO timestamp) — the trigger is the file existing, not what it holds. Never throws.
|
||||
*/
|
||||
public function requestRestart(): void
|
||||
{
|
||||
$path = $this->restartSignalPath();
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
@file_put_contents($path, now()->toIso8601String().PHP_EOL);
|
||||
}
|
||||
|
||||
/** True while a restart request is pending (the host watcher clears it once handled). */
|
||||
public function restartRequested(): bool
|
||||
{
|
||||
return is_file($this->restartSignalPath());
|
||||
}
|
||||
|
||||
/** Remove the sentinel (used by tests / a manual reset; the host watcher deletes it normally). */
|
||||
public function clearRestartRequest(): void
|
||||
{
|
||||
$path = $this->restartSignalPath();
|
||||
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the panel domain is a dashboard override (a panel_domain row exists). */
|
||||
public function domainIsOverridden(): bool
|
||||
{
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ services:
|
|||
target: prod
|
||||
expose:
|
||||
- "80"
|
||||
volumes:
|
||||
# Restart sentinel — the dashboard writes ./run/restart.request here (inside the
|
||||
# container: storage/app/restart-signal/) to REQUEST a stack restart. A host-side
|
||||
# watcher (a scoped systemd .path unit — see docker/restart-sentinel/) sees the file
|
||||
# appear, runs `docker compose restart`, then deletes it. The container gets NO Docker
|
||||
# socket; it only writes a non-sensitive marker file on this shared bind mount.
|
||||
- ./run:/var/www/html/storage/app/restart-signal
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
# Clusev restart sentinel (host watcher)
|
||||
|
||||
One-click stack restart from the dashboard — **without giving the container the Docker
|
||||
socket**.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Dashboard (app container) Host
|
||||
───────────────────────── ────
|
||||
System → "Jetzt neu starten"
|
||||
│
|
||||
▼
|
||||
DeploymentService::requestRestart()
|
||||
writes storage/app/restart-signal/restart.request
|
||||
│ (shared bind mount: ./run on the host)
|
||||
▼
|
||||
./run/restart.request appears ──────────► systemd clusev-restart.path
|
||||
│ (PathExists=)
|
||||
▼
|
||||
clusev-restart.service (oneshot)
|
||||
│ runs watch.sh once
|
||||
▼
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
rm ./run/restart.request
|
||||
```
|
||||
|
||||
The app only ever **writes a marker file**. A scoped host-side systemd unit does the
|
||||
restart. The file content is a non-sensitive ISO timestamp; the trigger is the file
|
||||
*existing*, not what it holds.
|
||||
|
||||
## Files
|
||||
|
||||
| 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`. |
|
||||
|
||||
## Install (systemd — preferred)
|
||||
|
||||
From the project root (default path `/home/nexxo/clusev`; adjust the units if yours differs):
|
||||
|
||||
```bash
|
||||
sudo cp docker/restart-sentinel/clusev-restart.path /etc/systemd/system/
|
||||
sudo cp docker/restart-sentinel/clusev-restart.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now clusev-restart.path
|
||||
```
|
||||
|
||||
Check it:
|
||||
|
||||
```bash
|
||||
systemctl status clusev-restart.path
|
||||
# Trigger a test restart by hand:
|
||||
touch ./run/restart.request
|
||||
journalctl -u clusev-restart.service -f
|
||||
```
|
||||
|
||||
Both units hard-code `/home/nexxo/clusev`. If your project lives elsewhere, edit
|
||||
`PathExists=` (in `.path`), and `WorkingDirectory=` / `Environment=CLUSEV_DIR=` /
|
||||
`ExecStart=` (in `.service`) before copying. The `User=` in the service must be able to
|
||||
run `docker compose` (root, or a member of the `docker` group).
|
||||
|
||||
## Install (no systemd — poll/inotify fallback)
|
||||
|
||||
Run the watcher as a long-lived loop (e.g. under your own supervisor, tmux, or `nohup`):
|
||||
|
||||
```bash
|
||||
CLUSEV_DIR=/home/nexxo/clusev docker/restart-sentinel/watch.sh loop
|
||||
```
|
||||
|
||||
It uses `inotifywait` if available (`apt-get install inotify-tools`), otherwise polls
|
||||
every `CLUSEV_POLL` seconds (default 5).
|
||||
|
||||
## Security note
|
||||
|
||||
- The **container gets NO Docker socket** — it cannot run `docker` at all. It only writes
|
||||
`storage/app/restart-signal/restart.request` on a shared bind mount.
|
||||
- The restart is performed by a **scoped host systemd unit** (`clusev-restart.service`),
|
||||
the only component permitted to drive Docker. A panel compromise therefore cannot run
|
||||
arbitrary `docker` commands — at worst it can request a restart of the same stack.
|
||||
- `watch.sh` only deletes the sentinel **after a successful** `docker compose up -d`, so a
|
||||
transient failure is retried on the next trigger rather than silently lost.
|
||||
|
||||
## Config (watch.sh env)
|
||||
|
||||
| Var | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `CLUSEV_DIR` | repo root (resolved from the script path) | dir holding `docker-compose.prod.yml` |
|
||||
| `CLUSEV_SIGNAL` | `$CLUSEV_DIR/run/restart.request` | absolute path of the sentinel file |
|
||||
| `CLUSEV_POLL` | `5` | poll interval (seconds) for the no-inotify loop fallback |
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Clusev restart-sentinel — path unit (HOST-side).
|
||||
#
|
||||
# Watches for the restart sentinel the dashboard writes and triggers
|
||||
# clusev-restart.service the moment it appears. Install both this unit and
|
||||
# clusev-restart.service into /etc/systemd/system/, then:
|
||||
#
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable --now clusev-restart.path
|
||||
#
|
||||
# Adjust PathExists= below if your project lives somewhere other than
|
||||
# /home/nexxo/clusev (it must match the ./run bind mount in docker-compose.prod.yml).
|
||||
[Unit]
|
||||
Description=Clusev restart sentinel — watch for a dashboard restart request
|
||||
After=docker.service
|
||||
Wants=docker.service
|
||||
|
||||
[Path]
|
||||
# Fires the .service whenever this file exists. systemd re-arms after the service
|
||||
# runs; the service deletes the sentinel, so each request triggers exactly once.
|
||||
PathExists=/home/nexxo/clusev/run/restart.request
|
||||
Unit=clusev-restart.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# Clusev restart-sentinel — service unit (HOST-side).
|
||||
#
|
||||
# Triggered by clusev-restart.path. Runs watch.sh once: restarts the prod stack
|
||||
# (docker compose up -d) and deletes the sentinel. A oneshot — it exits when done,
|
||||
# and the .path unit re-arms for the next request.
|
||||
#
|
||||
# SECURITY: this is the ONLY component allowed to drive Docker. The panel container
|
||||
# has NO Docker socket; it merely writes the sentinel file this unit reacts to.
|
||||
#
|
||||
# Edit WorkingDirectory= / ExecStart= / User= below if your project path or the
|
||||
# Docker-capable user differ from the defaults.
|
||||
[Unit]
|
||||
Description=Clusev restart sentinel — restart the prod stack on request
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# The user must be able to run `docker compose` (root, or a member of the docker group).
|
||||
User=nexxo
|
||||
WorkingDirectory=/home/nexxo/clusev
|
||||
Environment=CLUSEV_DIR=/home/nexxo/clusev
|
||||
ExecStart=/home/nexxo/clusev/docker/restart-sentinel/watch.sh once
|
||||
# Don't let a single failed restart wedge the unit; the sentinel survives a failure
|
||||
# (watch.sh only deletes it on success), so the next .path trigger retries.
|
||||
TimeoutStartSec=180
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
# Clusev restart-sentinel watcher — runs on the HOST (never in a container).
|
||||
#
|
||||
# Watches for the restart sentinel file the dashboard writes (via a shared bind
|
||||
# mount, see docker-compose.prod.yml: ./run -> storage/app/restart-signal) and,
|
||||
# when it appears, restarts the prod stack, then deletes the sentinel.
|
||||
#
|
||||
# SECURITY: the panel container gets NO Docker socket. It only writes a marker
|
||||
# file. This scoped host unit is the ONLY thing that talks to Docker — so a panel
|
||||
# compromise cannot drive arbitrary `docker` commands.
|
||||
#
|
||||
# Two ways to run it:
|
||||
# 1. systemd .path unit (preferred) — clusev-restart.path triggers
|
||||
# clusev-restart.service, which runs this script ONCE per sentinel.
|
||||
# Invoke as: watch.sh once
|
||||
# 2. Standalone poll/inotify loop (fallback, no systemd) — runs forever:
|
||||
# Invoke as: watch.sh loop
|
||||
#
|
||||
# Config via env (with sane defaults):
|
||||
# CLUSEV_DIR project dir holding docker-compose.prod.yml (default: script's repo root)
|
||||
# CLUSEV_SIGNAL absolute path of the sentinel file (default: $CLUSEV_DIR/run/restart.request)
|
||||
# CLUSEV_POLL poll interval seconds for the loop fallback (default: 5)
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve the repo root: docker/restart-sentinel/ -> ../../
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
CLUSEV_DIR="${CLUSEV_DIR:-$DEFAULT_DIR}"
|
||||
CLUSEV_SIGNAL="${CLUSEV_SIGNAL:-${CLUSEV_DIR}/run/restart.request}"
|
||||
CLUSEV_POLL="${CLUSEV_POLL:-5}"
|
||||
COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
|
||||
|
||||
log() { printf '%s clusev-restart: %s\n' "$(date -Is)" "$*"; }
|
||||
|
||||
# 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.
|
||||
do_restart() {
|
||||
[ -f "$COMPOSE_FILE" ] || { log "compose file not found: $COMPOSE_FILE — skipping"; return 1; }
|
||||
|
||||
log "restart requested — applying (docker compose up -d) in ${CLUSEV_DIR}"
|
||||
if docker compose -f "$COMPOSE_FILE" up -d; then
|
||||
log "stack restarted"
|
||||
else
|
||||
log "docker compose up -d FAILED — leaving sentinel so it retries"
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -f "$CLUSEV_SIGNAL" && log "sentinel cleared: $CLUSEV_SIGNAL"
|
||||
}
|
||||
|
||||
# One shot: only act if the sentinel is actually present (the .path unit guarantees this,
|
||||
# but a manual invocation might not).
|
||||
run_once() {
|
||||
if [ -f "$CLUSEV_SIGNAL" ]; then
|
||||
do_restart
|
||||
else
|
||||
log "no sentinel at $CLUSEV_SIGNAL — nothing to do"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fallback loop for hosts without systemd: prefer inotifywait, else poll.
|
||||
run_loop() {
|
||||
log "watching $CLUSEV_SIGNAL (loop mode)"
|
||||
if command -v inotifywait >/dev/null 2>&1; then
|
||||
# Act once on startup in case the sentinel already exists, then block on events.
|
||||
[ -f "$CLUSEV_SIGNAL" ] && do_restart || true
|
||||
while true; do
|
||||
# Watch the directory (the file is created/moved into it) for create/move events.
|
||||
inotifywait -q -e create -e moved_to -e close_write "$(dirname "$CLUSEV_SIGNAL")" >/dev/null 2>&1 || true
|
||||
[ -f "$CLUSEV_SIGNAL" ] && do_restart || true
|
||||
done
|
||||
else
|
||||
log "inotifywait not found — polling every ${CLUSEV_POLL}s"
|
||||
while true; do
|
||||
[ -f "$CLUSEV_SIGNAL" ] && do_restart || true
|
||||
sleep "$CLUSEV_POLL"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-once}" in
|
||||
once) run_once ;;
|
||||
loop) run_loop ;;
|
||||
*) log "usage: $0 [once|loop]"; exit 64 ;;
|
||||
esac
|
||||
33
install.sh
33
install.sh
|
|
@ -140,6 +140,39 @@ phase 4/7 "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 or
|
||||
# sudo are unavailable — see docker/restart-sentinel/README.md for the manual path.
|
||||
install_restart_sentinel() {
|
||||
local src="docker/restart-sentinel" dst="/etc/systemd/system" proj sysd
|
||||
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
|
||||
fi
|
||||
sysd="sudo"; [ "$(id -u)" = 0 ] && sysd=""
|
||||
if [ -n "$sysd" ] && ! sudo -n true 2>/dev/null; then
|
||||
warn "Kein passwortloses sudo — Neustart-Watcher nicht installiert. Manuell: ${src}/README.md."; return 0
|
||||
fi
|
||||
# Render the units with THIS project's path + the invoking 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)"
|
||||
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=$(id -un)/" "${src}/clusev-restart.service" > "$tmp_svc"
|
||||
if $sysd install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \
|
||||
&& $sysd install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \
|
||||
&& $sysd systemctl daemon-reload \
|
||||
&& $sysd systemctl enable --now clusev-restart.path; then
|
||||
info "Neustart-Watcher aktiv (clusev-restart.path)"
|
||||
else
|
||||
warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md."
|
||||
fi
|
||||
rm -f "$tmp_path" "$tmp_svc"
|
||||
}
|
||||
install_restart_sentinel
|
||||
|
||||
# ── [5/7] wait for the database ──────────────────────────────────────
|
||||
phase 5/7 "Datenbank bereit"
|
||||
db_ready=0
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ return [
|
|||
|
||||
// Restart-required notice
|
||||
'restart_title' => 'Neustart erforderlich',
|
||||
'restart_body' => 'Die Domain ist gespeichert. Starte den Stack auf dem Host neu, damit sie übernommen wird:',
|
||||
'restart_body' => 'Die Änderung ist gespeichert. Starte den Stack neu, damit sie übernommen wird.',
|
||||
'restart_now' => 'Jetzt neu starten',
|
||||
'restart_running' => 'Neustart wird ausgeführt …',
|
||||
'restart_watcher_hint' => 'Falls nach ~30 s nichts passiert, ist der Host-Watcher evtl. nicht installiert — siehe Doku (docker/restart-sentinel).',
|
||||
'restart_lockout_hint' => 'Nach dem Neustart eventuell neu anmelden. Falls die neue Domain (DNS/Zertifikat) noch nicht erreichbar ist, bleibt das Panel über http://<Server-IP> als Rückfallweg erreichbar.',
|
||||
|
||||
// Release channel panel
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ return [
|
|||
|
||||
// Restart-required notice
|
||||
'restart_title' => 'Restart required',
|
||||
'restart_body' => 'The domain is saved. Restart the stack on the host to apply it:',
|
||||
'restart_body' => 'The change is saved. Restart the stack to apply it.',
|
||||
'restart_now' => 'Restart now',
|
||||
'restart_running' => 'Restart is running …',
|
||||
'restart_watcher_hint' => 'If nothing happens after ~30 s, the host watcher may not be installed — see the docs (docker/restart-sentinel).',
|
||||
'restart_lockout_hint' => 'You may need to sign in again after the restart. If the new domain (DNS/certificate) is not reachable yet, the panel stays available at http://<server-IP> as a fallback.',
|
||||
|
||||
// Release channel panel
|
||||
|
|
|
|||
|
|
@ -47,14 +47,29 @@
|
|||
<p class="font-mono text-[11px] leading-relaxed">{{ __('system.ssh_reset_hint') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Restart-required notice (shown after a successful save) --}}
|
||||
{{-- Restart-required notice (shown after a successful save). One-click: the button
|
||||
writes a sentinel a HOST-side watcher reacts to and restarts the stack — the
|
||||
container never gets the Docker socket. No terminal needed. --}}
|
||||
@if ($restartPending)
|
||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-warning/30 bg-warning/10 p-3">
|
||||
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-ink">{{ __('system.restart_title') }}</p>
|
||||
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.restart_body') }}</p>
|
||||
<code class="mt-2 block overflow-x-auto rounded border border-line bg-void px-2.5 py-1.5 font-mono text-[11px] text-accent-text">docker compose -f docker-compose.prod.yml restart</code>
|
||||
|
||||
@if ($restartRequested)
|
||||
<p class="mt-2 flex items-center gap-2 font-mono text-[11px] text-accent-text">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0" />{{ __('system.restart_running') }}
|
||||
</p>
|
||||
@else
|
||||
<div class="mt-3">
|
||||
<x-btn variant="primary" wire:click="restartNow" wire:loading.attr="disabled" wire:target="restartNow">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('system.restart_now') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_watcher_hint') }}</p>
|
||||
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_lockout_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\System\Index;
|
||||
use App\Models\User;
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The restart sentinel: the dashboard writes a marker file (DeploymentService) that a
|
||||
* HOST-side watcher reacts to and restarts the stack. The container never gets the
|
||||
* Docker socket. These tests exercise the file lifecycle + the one-click Livewire flow,
|
||||
* and always clean the sentinel up afterwards (it lives on a real storage path).
|
||||
*/
|
||||
class RestartSentinelTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function sentinelPath(): string
|
||||
{
|
||||
return app(DeploymentService::class)->restartSignalPath();
|
||||
}
|
||||
|
||||
private function removeSentinel(): void
|
||||
{
|
||||
$path = $this->sentinelPath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->removeSentinel(); // never inherit a stray sentinel from a prior run
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->removeSentinel(); // leave the storage dir as we found it
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_request_restart_creates_the_sentinel_and_request_reports_it(): void
|
||||
{
|
||||
$deployment = app(DeploymentService::class);
|
||||
|
||||
$this->assertFalse($deployment->restartRequested(), 'no sentinel before requesting');
|
||||
|
||||
$deployment->requestRestart();
|
||||
|
||||
$this->assertTrue(is_file($this->sentinelPath()), 'sentinel file exists on disk');
|
||||
$this->assertTrue($deployment->restartRequested(), 'restartRequested() reflects the file');
|
||||
$this->assertNotSame('', trim((string) file_get_contents($this->sentinelPath())), 'sentinel carries a marker');
|
||||
}
|
||||
|
||||
public function test_clear_restart_request_removes_the_sentinel(): void
|
||||
{
|
||||
$deployment = app(DeploymentService::class);
|
||||
|
||||
$deployment->requestRestart();
|
||||
$this->assertTrue($deployment->restartRequested());
|
||||
|
||||
$deployment->clearRestartRequest();
|
||||
$this->assertFalse($deployment->restartRequested(), 'sentinel is gone after clearing');
|
||||
}
|
||||
|
||||
public function test_restart_now_writes_the_sentinel_and_sets_the_flag(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('restartNow', app(DeploymentService::class))
|
||||
->assertSet('restartRequested', true);
|
||||
|
||||
$this->assertTrue(
|
||||
app(DeploymentService::class)->restartRequested(),
|
||||
'the one-click action persisted the sentinel a host watcher will see',
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue