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. */
|
/** True after a save — shows the "restart the stack to apply" notice. */
|
||||||
public bool $restartPending = false;
|
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 $channel = 'stable';
|
||||||
|
|
||||||
public string $tlsMode = 'caddy';
|
public string $tlsMode = 'caddy';
|
||||||
|
|
@ -60,6 +63,7 @@ class Index extends Component
|
||||||
$this->domain = (string) ($deployment->domain() ?? '');
|
$this->domain = (string) ($deployment->domain() ?? '');
|
||||||
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
|
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
|
||||||
$this->restartPending = $deployment->restartPending();
|
$this->restartPending = $deployment->restartPending();
|
||||||
|
$this->restartRequested = $deployment->restartRequested();
|
||||||
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
|
$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.
|
// 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';
|
$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'));
|
$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). */
|
/** Release channel changes are audited (confirmation via the shared modal). */
|
||||||
public function confirmChannel(string $channel): void
|
public function confirmChannel(string $channel): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,16 @@ class DeploymentService
|
||||||
/** Snapshot of the active domain, written at container start (relative to storage/). */
|
/** Snapshot of the active domain, written at container start (relative to storage/). */
|
||||||
private const ACTIVE_FILE = 'framework/active-domain';
|
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
|
* 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
|
||||||
|
|
@ -142,6 +152,46 @@ class DeploymentService
|
||||||
return $this->configuredDomain() !== $this->domain();
|
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). */
|
/** True when the panel domain is a dashboard override (a panel_domain row exists). */
|
||||||
public function domainIsOverridden(): bool
|
public function domainIsOverridden(): bool
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@ services:
|
||||||
target: prod
|
target: prod
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "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:
|
depends_on:
|
||||||
mariadb:
|
mariadb:
|
||||||
condition: service_healthy
|
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
|
$COMPOSE up -d
|
||||||
info "Container gestartet"
|
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 ──────────────────────────────────────
|
# ── [5/7] wait for the database ──────────────────────────────────────
|
||||||
phase 5/7 "Datenbank bereit"
|
phase 5/7 "Datenbank bereit"
|
||||||
db_ready=0
|
db_ready=0
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ return [
|
||||||
|
|
||||||
// Restart-required notice
|
// Restart-required notice
|
||||||
'restart_title' => 'Neustart erforderlich',
|
'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.',
|
'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
|
// Release channel panel
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ return [
|
||||||
|
|
||||||
// Restart-required notice
|
// Restart-required notice
|
||||||
'restart_title' => 'Restart required',
|
'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.',
|
'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
|
// Release channel panel
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,29 @@
|
||||||
<p class="font-mono text-[11px] leading-relaxed">{{ __('system.ssh_reset_hint') }}</p>
|
<p class="font-mono text-[11px] leading-relaxed">{{ __('system.ssh_reset_hint') }}</p>
|
||||||
</div>
|
</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)
|
@if ($restartPending)
|
||||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-warning/30 bg-warning/10 p-3">
|
<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" />
|
<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="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>
|
<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>
|
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_lockout_hint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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