Merge branch 'claude/nice-moser-521659'

# Conflicts:
#	VERSION
#	deploy/install-agent.sh
#	deploy/update.sh
feat/versandtakt
nexxo 2026-08-04 10:31:59 +02:00
commit 37e1914246
18 changed files with 1185 additions and 8 deletions

View File

@ -1 +1 @@
1.6.6
1.7.1

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Deployment\UpdateChannel;
use LivewireUI\Modal\ModalComponent;
/**
* Rückfrage, bevor ein Prozess auf dem Wirt beendet wird (R23).
*
* Es steht hier, was gleich stirbt PID, Laufzeit und Kommandozeile aus dem
* Lebenszeichen des Agenten , und zwar VORHER. Das ist der ganze Sinn der
* Rückfrage: eine Meldung hinterher wäre keine.
*
* Das Modal mutiert nichts. Es wirft ein Ereignis, das
* App\Livewire\Admin\Settings auffängt; die Berechtigungsprüfung bleibt dort,
* wo sie schon steht, statt hier verdoppelt zu werden.
*
* Der Halter wird HIER neu gelesen und nicht als Eigenschaft hereingereicht:
* ein Modal ist ohne die Route-Middleware der Seite erreichbar (R20), und ein
* vom Browser hydrierter Text wäre eine Angabe, die der Server nicht geprüft
* hat ausgerechnet in der Zeile, die den Betreiber entscheiden lässt.
*/
class ConfirmReleaseUpdateLock extends ModalComponent
{
/** Was der Agent zuletzt als Sperrenhalter gesehen hat. */
public string $heldBy = '';
public function mount(): void
{
$this->authorize('deployment.unblock');
$this->heldBy = (string) (app(UpdateChannel::class)->state()['blocked_by'] ?? '');
}
public function confirm(): void
{
$this->authorize('deployment.unblock');
$this->dispatch('update-lock-release-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-release-update-lock');
}
}

View File

@ -717,6 +717,33 @@ class Settings extends Component
: 'admin_settings.update_already_requested'));
}
/**
* Eine hängende Sperre auf dem Wirt lösen lassen.
*
* Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht
* hier, an derselben Stelle wie bei jeder anderen Handlung dieser Seite,
* statt im Modal verdoppelt zu werden.
*
* Eigene Berechtigung: das hier beendet einen laufenden Prozess auf der
* Maschine, notfalls mit SIGKILL. Wer aktualisieren darf, darf damit nicht
* automatisch auch in einen laufenden Vorgang hineingreifen.
*/
#[On('update-lock-release-confirmed')]
public function releaseUpdateLock(): void
{
$this->authorize('deployment.unblock');
if (! $operator = $this->currentOperator()) {
return;
}
$accepted = app(UpdateChannel::class)->requestUnblock($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.release_lock_requested'
: 'admin_settings.release_lock_already_requested'));
}
public function render()
{
$operator = $this->currentOperator();
@ -778,6 +805,9 @@ class Settings extends Component
'staff' => $staff,
'roles' => Operator::operatorRoles(),
'canManageStaff' => $operator?->can('staff.manage') ?? false,
// Die Warnung über eine hängende Sperre sieht jeder, der die Seite
// sieht. Den Griff dagegen nicht.
'canReleaseLock' => $operator?->can('deployment.unblock') ?? false,
'isOwner' => $operator?->hasRole('Owner') ?? false,
// Damit der Hinweis unter dem Einladen-Formular dieselbe Frist
// nennt wie die Mail und der Broker — eine Quelle, drei Leser.

View File

@ -46,6 +46,32 @@ final class UpdateChannel
/** Written by the panel, consumed by the agent. */
private const REQUEST = 'deploy/update-request.json';
/**
* Die Bitte, eine hängende Sperre zu lösen in einer EIGENEN Datei.
*
* Nicht im Postkasten oben, und das ist keine Geschmacksfrage: der Agent
* nimmt die Sperre in den ersten Zeilen seines Laufs, LANGE bevor er
* `update-request.json` überhaupt ansieht. Ein blockierter Lauf steigt
* vorher aus. Eine Entsperr-Bitte im Postkasten käme also genau dann nie
* an, wenn man sie braucht.
*
* Sie wird deshalb im blockierten Zweig gelesen und liegt getrennt, weil
* `submit()` eine Bitte zur Zeit annimmt und ein blockierter Agent keine
* abholt: eine dreißig Minuten lang wartende Update-Anfrage sperrte sonst
* ausgerechnet das Entsperren aus.
*/
private const UNBLOCK_REQUEST = 'deploy/unblock-request.json';
/**
* Was beim letzten Entsperren herauskam.
*
* Ohne diese Rückmeldung wäre der Knopf auf einem Wirt, dessen
* root-eigener Helfer den neuen Schritt noch nicht kennt, ein Knopf, der
* nichts tut und nichts sagt und schickte den Betreiber genau dorthin
* zurück, wo er ohne die Konsole schon war.
*/
private const UNBLOCK_LAST_RUN = 'deploy/unblock-last-run.json';
/** A request the agent only checks against, and never acts on. */
private const KIND_CHECK = 'check';
@ -181,6 +207,28 @@ final class UpdateChannel
*/
private const AGENT_STALE_AFTER_MINUTES = 20;
/**
* Wie viele Läufe hintereinander übersprungen sein müssen, ehe das eine
* Blockade heißt.
*
* Ein einzelner übersprungener Lauf ist Betrieb: der Zeitgeber läuft
* minütlich, der Wächter (deploy/watchdog.sh) kommt dazu, einer nimmt die
* Sperre und der andere geht weg. Gemessen wurde ein Überholen von NEUN
* SEKUNDEN und die Konsole meldete dafür bereits „kommt seit 15:21 nicht
* an die Arbeit". Der Besitzer hat daraufhin eine Stunde lang eine gesunde
* Anlage auseinandergenommen.
*
* Zwei, nicht zehn: lieber früh gewarnt werden. Weg soll nur die Warnung
* beim allerersten übersprungenen Lauf.
*
* Gezählt wird in LÄUFEN, nicht in Minuten. `check_interval_minutes` ist
* eine Behauptung des Agentenskripts; wie oft der Zeitgeber wirklich
* auslöst, steht in der systemd-Unit auf dem Wirt, die diese Anwendung
* nicht sehen kann. Zwei übersprungene Läufe sind zwei übersprungene
* Läufe, wie der Takt auch stehen mag.
*/
private const BLOCKED_AFTER_SKIPS = 2;
/**
* What the panel needs to show, in one read.
*
@ -202,7 +250,17 @@ final class UpdateChannel
// Der Agent läuft, kommt aber nicht an die Sperre. Ein eigener
// Zustand, weil er weder „tot" ist noch „in Ordnung": seine Zahlen
// stammen von vor der Blockade und altern still weiter.
$blockedSince = ($alive['state'] ?? null) === 'blocked' && $agentAlive
//
// Erst ab BLOCKED_AFTER_SKIPS übersprungenen Läufen — und zwar für den
// GANZEN Zustand, nicht nur für den Satz weiter unten in der Ansicht.
// Nur die Meldung zu unterdrücken hätte den Fehlalarm gegen einen
// stilleren getauscht: `blocked_since` blendet über $agentWorking auch
// „N Aktualisierungen zurück" und die Zielversion aus, und die wären
// dann beim ersten übersprungenen Lauf verschwunden, ohne dass
// irgendwo stünde warum. Eine Schwelle, eine Stelle.
$blockedSince = ($alive['state'] ?? null) === 'blocked'
&& $agentAlive
&& $this->skippedRuns($alive) >= self::BLOCKED_AFTER_SKIPS
? $this->timestamp($alive['since'] ?? null)
: null;
@ -241,6 +299,11 @@ final class UpdateChannel
// hält. Beides null, solange er arbeitet.
'blocked_since' => $blockedSince,
'blocked_by' => $blockedSince !== null ? trim((string) ($alive['held_by'] ?? '')) : null,
// Eine abgelegte Entsperr-Bitte, die der Agent noch nicht geholt
// hat. Der Knopf bleibt solange stehen, aber gesperrt: ein zweiter
// Klick wäre Ungeduld, nicht Absicht.
'unblock_requested' => $this->pendingUnblockRequest() !== null,
'unblock_error' => $this->unblockError(),
// A pending CHECK or RESTART must never read as "running": nothing
// is being deployed, the site never enters maintenance mode, and
// the full-screen overlay (layouts/admin.blade.php) exists only
@ -417,6 +480,27 @@ final class UpdateChannel
&& $checkedAt->gt(Carbon::now()->subMinutes(self::AGENT_STALE_AFTER_MINUTES));
}
/**
* Wie viele Läufe der Agent ununterbrochen übersprungen hat.
*
* Vom Agenten mitgezählt (deploy/update-agent.sh), weil nur er weiß, wie
* oft er angetreten ist. Aus `since` eine Zahl auszurechnen hieße, das
* Taktintervall zu raten und das steht auf dem Wirt, nicht hier.
*
* Fehlt das Feld, gilt EINS: das ist das Lebenszeichen eines Agenten von
* vor dieser Zählung, das nach einem Update noch eine Minute liegen
* bleibt. Darauf eine Blockade zu melden hieße raten; der nächste Takt
* schreibt die Zahl und korrigiert es von selbst.
*
* @param array<string, mixed> $alive
*/
private function skippedRuns(array $alive): int
{
return isset($alive['skips']) && is_numeric($alive['skips'])
? (int) $alive['skips']
: 1;
}
/** Is the agent in the middle of a run right now? */
public function isRunning(): bool
{
@ -511,6 +595,66 @@ final class UpdateChannel
return $this->submit($by, self::KIND_RESTART);
}
/**
* Den Agenten bitten, eine hängende Sperre auf dem Wirt zu lösen.
*
* Nicht über submit(): die Bitte liegt in ihrer eigenen Datei, weil der
* Postkasten sie im entscheidenden Fall nie erreichen würde siehe
* UNBLOCK_REQUEST.
*
* Das Ergebnis des vorigen Versuchs wird dabei weggeräumt. Bliebe es
* liegen, läse der Betreiber nach einem zweiten Klick weiter die
* Fehlermeldung des ersten und hielte sie für die neue.
*/
public function requestUnblock(string $by): bool
{
if ($this->pendingUnblockRequest() !== null) {
return false;
}
$this->forget(self::UNBLOCK_LAST_RUN);
$this->write(self::UNBLOCK_REQUEST, json_encode([
'requested_at' => Carbon::now()->toIso8601String(),
'requested_by' => $by,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return true;
}
/** Liegt eine Entsperr-Bitte, die der Agent noch holen kann? */
public function pendingUnblockRequest(): ?array
{
$request = $this->readJson(self::UNBLOCK_REQUEST);
if ($request === []) {
return null;
}
$at = $this->timestamp($request['requested_at'] ?? null);
if ($at === null || $at->lt(Carbon::now()->subMinutes(self::REQUEST_EXPIRES_MINUTES))) {
return null;
}
return $request;
}
/**
* Warum das letzte Entsperren nicht geklappt hat, in der Sprache des
* Betreibers oder null, wenn es geklappt hat oder keins versucht wurde.
*/
private function unblockError(): ?string
{
$last = $this->readJson(self::UNBLOCK_LAST_RUN);
if (($last['state'] ?? null) !== 'failed') {
return null;
}
return $this->errorMessage($last);
}
/**
* Ask the host to mint a collection key for one archive directory.
*
@ -643,6 +787,11 @@ final class UpdateChannel
File::put($path, $contents);
}
private function forget(string $relative): void
{
File::delete(storage_path('app/'.$relative));
}
private function timestamp(mixed $value): ?Carbon
{
if (! is_string($value) || $value === '') {

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* `deployment.unblock` einen hängenden Sperrenhalter auf dem Wirt beenden.
*
* Eigene Berechtigung, nicht `site.manage`: das hier beendet einen laufenden
* Prozess auf der Maschine, notfalls mit SIGKILL. Wer die Anlage aktualisieren
* darf, darf damit nicht automatisch auch in einen laufenden Vorgang
* hineingreifen.
*
* Owner und Admin, wie `site.manage` daneben: es ist der Griff, den der
* Betreiber braucht, wenn die Konsole seit Minuten nichts mehr tut, und er
* darf nicht ausgerechnet dann an einer Rollenfrage hängen.
*
* Guard `operator`, nicht `web`. Die RBAC ist am 29. Juli 2026 auf den
* operator-Guard umgezogen; alles, was danach unter `web` angelegt wird,
* landet in einem Guard, gegen den sich niemand anmeldet und die Konsole
* antwortet 403 für eine Rolle, die die Berechtigung sichtbar hat.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$permission = Permission::findOrCreate('deployment.unblock', 'operator');
foreach (['Owner', 'Admin'] as $role) {
Role::findOrCreate($role, 'operator')->givePermissionTo($permission);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function down(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
Permission::where('name', 'deployment.unblock')->where('guard_name', 'operator')->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
};

View File

@ -241,6 +241,103 @@ case "${1:-}" in
rm -f "$TMP"
;;
release-update-lock)
# Eine hängende Sperre lösen — der Griff, der bisher per SSH auf dem
# Wirt lag.
#
# Der Dienstbenutzer sagt „gib die Sperre frei", nicht „töte 1234".
# WELCHER Prozess das ist, sucht dieser Helfer selbst — dieselbe
# Trennung wie bei apply-proxy-hosts. Dürfte der Anrufer die Nummer
# liefern, wäre die Freigabe das Recht, jeden beliebigen Prozess als
# root zu beenden, und damit nichts mehr wert.
LOCK=/opt/clupilot/storage/app/deploy/.agent.lock
[[ -e "$LOCK" ]] || { echo "nothing to release: no lock file at $LOCK" >&2; exit 0; }
target="$(readlink -f "$LOCK")"
# Zuerst die eigene Hand von der Tuerklinke nehmen.
#
# sudo raeumt Deskriptoren ab dem dritten ab, dieser Helfer haelt die
# Sperrdatei im Betrieb also nicht offen. Wird er anders aufgerufen,
# schon — und dann taucht bei JEDEM Nachsehen etwas anderes in der
# Halterliste auf, weil jede Kommandosubstitution ein eigener Prozess
# ist, der den Deskriptor erbt. Die Runde kaeme nie zum Ende und
# meldete am Schluss „konnte nicht beendet werden" ueber einer Sperre,
# die niemand haelt.
for fd in /proc/self/fd/*; do
n="${fd##*/}"
(( n > 2 )) || continue
[[ "$(readlink -f "$fd" 2>/dev/null)" == "$target" ]] || continue
eval "exec $n>&-" 2>/dev/null || true
done
# Wer die Datei OFFEN hält — nicht, wer das flock genommen hat.
#
# Ein flock hängt an der offenen Dateibeschreibung, und die wird an
# Kindprozesse vererbt. Stirbt also der Agent, sein hängendes
# `docker compose exec` aber nicht, bleibt die Sperre gehalten. Genau
# das ist am 4. August 2026 passiert — nur den Sperrennehmer zu
# beenden hätte an dem Tag nichts gelöst.
#
# Über /proc statt über fuser: hier läuft etwas als root und beendet
# Prozesse, und dafür ist ein Paket, das auf einem schlanken Wirt
# fehlen kann, eine schlechte Voraussetzung.
#
# Verschont bleiben PID 1 und der Anrufer samt seinen Vorfahren. Im
# Betrieb ist das der blockierte Agent: sein `exec 9>` ist geglückt,
# nur das `flock` nicht — er hält die Sperrdatei also selbst offen und
# ist zugleich der, der hier anruft. Ihn mitzubeenden hieße, mitten im
# Anruf aufzulegen, und die Konsole erführe nie, was geschehen ist.
spare=" 1 "
walk=$$
while [[ "$walk" =~ ^[0-9]+$ ]] && (( walk > 0 )); do
spare+="$walk "
walk="$(sed -n 's/^PPid:[[:space:]]*//p' "/proc/$walk/status" 2>/dev/null || true)"
done
holders() {
local fd pid
for fd in /proc/[0-9]*/fd/*; do
[[ "$(readlink -f "$fd" 2>/dev/null)" == "$target" ]] || continue
pid="${fd#/proc/}"
pid="${pid%%/*}"
[[ "$spare" == *" $pid "* ]] && continue
echo "$pid"
done | sort -un
}
held="$(holders || true)"
if [[ -z "$held" ]]; then
echo "nothing holds $LOCK" >&2
exit 0
fi
# Erst bitten. Was noch aufräumen kann, soll das dürfen.
kill -TERM $held 2>/dev/null || true
# Fünf Sekunden, dann das, was niemand ignorieren kann. Eine Frist ohne
# Nachdruck ist keine Frist, sondern eine Bitte — dieselbe Lehre wie
# `timeout -k` in update-agent.sh, und derselbe Vorfall.
for _ in 1 2 3 4 5 6 7 8 9 10; do
sleep 0.5
held="$(holders || true)"
[[ -n "$held" ]] || break
done
if [[ -n "$held" ]]; then
kill -KILL $held 2>/dev/null || true
sleep 0.5
held="$(holders || true)"
fi
if [[ -n "$held" ]]; then
echo "could not end: $(echo "$held" | tr '\n' ' ')" >&2
exit 7
fi
echo "released $LOCK"
;;
*)
# Refused by name. sudoers already permits only the exact command lines
# above, so this is the second of two locks, not the only one.
@ -259,6 +356,7 @@ chmod 0755 "$HOST_STEP"
cat > /etc/sudoers.d/clupilot-host-step <<EOF
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP ensure-rsync
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP apply-proxy-hosts
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP release-update-lock
EOF
chmod 0440 /etc/sudoers.d/clupilot-host-step
visudo -cf /etc/sudoers.d/clupilot-host-step >/dev/null || {
@ -350,6 +448,12 @@ Description=Wake the CluPilot update agent when the console asks for something
[Path]
PathChanged=$ROOT/storage/app/deploy/update-request.json
# Und die Bitte, eine hängende Sperre zu lösen. Sie liegt in einer eigenen
# Datei (siehe update-agent.sh) und braucht deshalb ihre eigene Zeile hier.
# Ohne sie käme der Griff erst beim nächsten Takt an — auf einem Wirt, der
# diese Datei noch nicht kennt, ist genau das der Rückfall, und eine Minute
# ist ein annehmbarer Preis gegenüber der Stunde, die es vorher kostete.
PathChanged=$ROOT/storage/app/deploy/unblock-request.json
Unit=clupilot-update-agent.service
[Install]

View File

@ -58,6 +58,17 @@ LOCK="$STATE_DIR/.agent.lock"
# arbeitet; "blocked" heisst, er ist ausgestiegen, weil jemand anderes sie
# haelt — mit `since` (seit wann ununterbrochen) und `held_by` (wer).
ALIVE="$STATE_DIR/agent-alive.json"
# Die Bitte, eine haengende Sperre zu loesen — und was dabei herauskam.
#
# EIGENE Datei, nicht der Postkasten oben, und zwar zwangslaeufig: die Sperre
# wird ein paar Zeilen weiter unten genommen, LANGE bevor dieses Skript
# `update-request.json` ueberhaupt ansieht. Ein blockierter Lauf steigt vorher
# aus. Eine Entsperr-Bitte im Postkasten erreichte den Agenten also genau dann
# nie, wenn sie gebraucht wird — und zusaetzlich nimmt der Postkasten eine
# Bitte zur Zeit an, sodass eine dreissig Minuten lang wartende Update-Anfrage
# ausgerechnet das Entsperren aussperrte.
UNBLOCK="$STATE_DIR/unblock-request.json"
UNBLOCK_LAST="$STATE_DIR/unblock-last-run.json"
# The reverse proxy's console allowlist, generated from the one the owner keeps
# in the console. Without this the proxy has its own hard-coded list that runs
# FIRST, so everything added in the console has no effect at all — and when the
@ -96,7 +107,7 @@ mkdir -p "$STATE_DIR"
# Text ist die Ausgabe von ps, aus der Anfuehrungszeichen, Backslashes und
# Zeilenumbrueche entfernt werden.
write_alive() {
local state="$1" since="${2-}" held_by="${3-}"
local state="$1" since="${2-}" held_by="${3-}" skips="${4-0}"
held_by="$(printf '%s' "$held_by" | tr -d '"\\' | tr '\n\r\t' ' ')"
cat > "$ALIVE.tmp" 2>/dev/null <<EOF || return 0
@ -104,12 +115,60 @@ write_alive() {
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"state": "$state",
"since": "$since",
"held_by": "$held_by"
"held_by": "$held_by",
"skips": ${skips}
}
EOF
mv -f "$ALIVE.tmp" "$ALIVE" 2>/dev/null || true
}
# Was beim letzten Entsperren herauskam. Ohne diese Zeile waere der Knopf auf
# einem Wirt, dessen root-eigener Helfer den Schritt noch nicht kennt, ein
# Knopf, der nichts tut und nichts sagt.
write_unblock() {
cat > "$UNBLOCK_LAST.tmp" 2>/dev/null <<EOF || return 0
{
"state": "$1",
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"error": "${2-}"
}
EOF
mv -f "$UNBLOCK_LAST.tmp" "$UNBLOCK_LAST" 2>/dev/null || true
}
# Den root-eigenen Helfer die Sperre loesen lassen.
#
# Aus diesem Skript heraus geht es nicht: der Sperrenhalter ist ein Prozess auf
# dem Wirt, und dieser Agent laeuft unprivilegiert als Dienstbenutzer. Was er
# darf, steht woertlich in sudoers und ist genau diese eine Befehlszeile.
release_stuck_lock() {
local step=/usr/local/sbin/clupilot-host-step have
if [[ ! -x "$step" ]]; then
write_unblock failed unblock_helper_missing
return 0
fi
# Kennt der Helfer den Schritt schon? Ein Wirt, auf dem install-agent.sh
# seither nicht mehr lief, hat die aeltere Fassung — und die lehnt
# `release-update-lock` als unbekannt ab. Das ist kein Fehler des
# Betreibers, aber er muss es erfahren, statt einen Knopf zu druecken, der
# still nichts tut.
have="$("$step" contract 2>/dev/null || true)"
[[ "$have" =~ ^[0-9]+$ ]] || have=0
if (( have < 3 )); then
write_unblock failed unblock_helper_old
return 0
fi
if sudo -n "$step" release-update-lock >/dev/null 2>&1; then
write_unblock ok
else
write_unblock failed unblock_failed
fi
}
# One agent at a time. Two overlapping runs of update.sh fight over the
# checkout, and the loser leaves it half-updated.
exec 9>"$LOCK"
@ -125,6 +184,18 @@ if ! flock -n 9; then
# laeuft — sonst stuende dort immer "seit einer Minute", und ein Zustand,
# der seit einer Stunde klemmt, laese sich von einem gesunden Ueberholen
# zweier Laeufe nicht unterscheiden.
#
# Und mit `skips` wird die Serie GEZAEHLT, nicht nur datiert. Bis hierher
# meldete die Konsole schon beim ERSTEN uebersprungenen Lauf eine Blockade;
# gemessen wurde das bei einem Ueberholen von neun Sekunden — Zeitgeber und
# Waechter laufen beide minuetlich, einer nimmt die Sperre, der andere geht
# weg. Das ist Betrieb. Der Besitzer hat daraufhin eine Stunde lang eine
# gesunde Anlage auseinandergenommen.
#
# Gezaehlt wird HIER und nicht in der Konsole: aus `since` eine Zahl von
# Laeufen zu machen hiesse, das Taktintervall zu raten, und das steht in
# der systemd-Unit auf dem Wirt. Ab wann die Zahl eine Meldung wert ist,
# entscheidet die Konsole (UpdateChannel::BLOCKED_AFTER_SKIPS).
HOLDER="$( { fuser "$LOCK" 2>/dev/null || true; } | tr -s ' ' | sed 's/^ *//;s/ *$//' )"
HOLDER_CMD=''
if [[ -n "$HOLDER" ]]; then
@ -133,16 +204,50 @@ if ! flock -n 9; then
PREVIOUS_SINCE="$(sed -n 's/.*"since"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ALIVE" 2>/dev/null | head -1 || true)"
PREVIOUS_STATE="$(sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ALIVE" 2>/dev/null | head -1 || true)"
PREVIOUS_SKIPS="$(sed -n 's/.*"skips"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' "$ALIVE" 2>/dev/null | head -1 || true)"
if [[ "$PREVIOUS_STATE" != "blocked" || -z "$PREVIOUS_SINCE" ]]; then
# Kein Anschluss an eine laufende Serie: dieser Lauf ist der erste.
PREVIOUS_SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
SKIPS=1
else
# Ein Lebenszeichen von vor dieser Zaehlung hat kein `skips`. Es bleibt
# nach einem Update eine Minute lang liegen; solange zaehlt die Serie
# ab eins weiter, statt eine Zahl zu erfinden.
[[ "$PREVIOUS_SKIPS" =~ ^[0-9]+$ ]] || PREVIOUS_SKIPS=0
SKIPS=$(( PREVIOUS_SKIPS + 1 ))
fi
write_alive blocked "$PREVIOUS_SINCE" "${HOLDER_CMD:-$HOLDER}" "$SKIPS"
# Und hier — und NUR hier — wird eine Bitte gelesen, die haengende Sperre
# zu loesen. Dies ist der einzige Zweig, den ein blockierter Lauf erreicht,
# und die Blockade ist der einzige Zustand, in dem die Bitte einen Sinn
# hat.
if [[ -f "$UNBLOCK" ]]; then
UNBLOCK_AGE_MINUTES="$(( ( $(date +%s) - $(stat -c %Y "$UNBLOCK" 2>/dev/null || date +%s) ) / 60 ))"
# Verbraucht, bevor irgendetwas passiert. Bliebe sie liegen, zuendete
# sie bei der naechsten Blockade ungefragt — und das Beenden fremder
# Prozesse ist nichts, was zweimal aus einem Klick folgen darf.
rm -f "$UNBLOCK"
# Dieselbe Frist wie im Postkasten: was abgelegt wurde, als niemand es
# abholen konnte, ist keine Anweisung fuer irgendwann.
if (( UNBLOCK_AGE_MINUTES <= REQUEST_EXPIRES_MINUTES )); then
release_stuck_lock
fi
fi
write_alive blocked "$PREVIOUS_SINCE" "${HOLDER_CMD:-$HOLDER}"
exit 0
fi
write_alive running
# Die Sperre ist frei — eine noch liegende Entsperr-Bitte hat sich damit
# erledigt. Sie liegen zu lassen hiesse, sie bei der naechsten Blockade
# auszufuehren, die mit ihr nichts zu tun hat.
rm -f "$UNBLOCK"
sync_console_allowlist() {
[[ -w "$(dirname "$ALLOWFILE")" || -w "$ALLOWFILE" ]] || return 0

View File

@ -70,6 +70,8 @@ STATE_FILE="storage/app/deployed-commit"
# fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS
# here is what makes an update tell the operator to run the installer again.
HOST_STEP=/usr/local/sbin/clupilot-host-step
# 3 seit `release-update-lock`: ohne den Schritt bleibt der Knopf „Sperre lösen"
# in der Konsole ein Knopf, der nichts tun kann.
HOST_STEP_NEEDS=3
# Which step is running, for the console to show. Written as a KEY, not as the
# sentence below it: the console is translated and this script is not, so an

View File

@ -139,11 +139,30 @@ return [
'update_agent_blocked' => 'Der Update-Dienst läuft, kommt aber seit :since nicht an die Arbeit — ein anderer Vorgang hält die Sperre. Solange das so ist, stammen die Angaben oben von vorher. Löst es sich nicht von selbst, hilft ein Blick auf den Prozess unten.',
'update_log' => 'Protokoll des letzten Laufs',
// ── Die Sperre lösen ─────────────────────────────────────────────────
// Der Griff, der bisher per SSH auf dem Wirt lag. Er beendet einen
// laufenden Prozess, deshalb nennt die Rückfrage ihn vorher beim Namen.
'update_release_lock' => 'Sperre lösen',
'release_lock_pending' => 'Wird gelöst …',
'release_lock_title' => 'Den Vorgang beenden, der die Sperre hält?',
'release_lock_body' => 'Der Update-Dienst kommt nicht an die Arbeit, weil dieser Vorgang auf dem Server die Sperre hält:',
'release_lock_note' => 'Er bekommt zuerst die Aufforderung, sich zu beenden; erst wenn er darauf nicht reagiert, wird er abgebrochen. Läuft gerade eine echte Aktualisierung, wird sie damit mittendrin abgebrochen.',
'release_lock_cancel' => 'Abbrechen',
'release_lock_confirm' => 'Vorgang beenden',
'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.',
'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.',
'update_error' => [
'repo_unreachable' => 'Das Repository ist vom Server aus nicht erreichbar.',
'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.',
'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.',
'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue scheduler reverb.',
// Der Wirt kennt den Schritt noch nicht. Ein Knopf, der nichts tut und
// nichts sagt, schickt den Betreiber genau dorthin zurück, wo er ohne
// die Konsole schon war.
'unblock_helper_missing' => 'Der Server-Helfer fehlt. Einmalig auf dem Server einrichten: sudo bash /opt/clupilot/deploy/install-agent.sh',
'unblock_helper_old' => 'Der Server-Helfer kennt diesen Schritt noch nicht. Einmalig auf dem Server nachziehen: sudo bash /opt/clupilot/deploy/install-agent.sh',
'unblock_failed' => 'Der Vorgang ließ sich nicht beenden. Auf dem Server nachsehen: fuser -v /opt/clupilot/storage/app/deploy/.agent.lock',
],
'update_last_run' => 'Letzter Lauf: :state, :when.',

View File

@ -25,6 +25,7 @@ return [
'customers.impersonate' => 'Sich als Kunde anmelden',
'customers.manage' => 'Kundendaten sehen und ändern',
'datacenters.manage' => 'Rechenzentren verwalten',
'deployment.unblock' => 'Einen hängenden Vorgang auf dem Server beenden',
'dpa.manage' => 'Auftragsverarbeitungsverträge',
'hosts.manage' => 'Hosts anlegen, übernehmen, entfernen',
'server.terminal' => 'Root-Sitzung auf dem CluPilot-Server selbst — Vollzugriff auf die gesamte Anlage',

View File

@ -136,11 +136,30 @@ return [
'update_agent_blocked' => 'The update service is running but has been unable to work since :since — another process holds the lock. While that lasts, the figures above are from before. If it does not clear on its own, look at the process named below.',
'update_log' => 'Log of the last run',
// ── Releasing the lock ───────────────────────────────────────────────
// The handle that used to live behind an SSH session. It ends a running
// process, so the confirmation names it before it does.
'update_release_lock' => 'Release the lock',
'release_lock_pending' => 'Releasing …',
'release_lock_title' => 'End the process holding the lock?',
'release_lock_body' => 'The update service cannot work because this process on the server holds the lock:',
'release_lock_note' => 'It is asked to stop first; only if it does not respond is it terminated. If a real update is running, this aborts it halfway.',
'release_lock_cancel' => 'Cancel',
'release_lock_confirm' => 'End the process',
'release_lock_requested' => 'Releasing — the service reports back within a minute.',
'release_lock_already_requested' => 'Already requested — please wait a moment.',
'update_error' => [
'repo_unreachable' => 'The repository cannot be reached from the server.',
'detached_no_release' => 'The checkout is on no branch and pinned to no release.',
'update_failed' => 'The update failed (code :code). See the log.',
'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue scheduler reverb.',
// The host does not know the step yet. A button that does nothing and
// says nothing sends the operator right back where they were without
// the console.
'unblock_helper_missing' => 'The server helper is missing. Set it up once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh',
'unblock_helper_old' => 'The server helper does not know this step yet. Bring it up to date once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh',
'unblock_failed' => 'The process could not be ended. Look on the server: fuser -v /opt/clupilot/storage/app/deploy/.agent.lock',
],
'update_last_run' => 'Last run: :state, :when.',

View File

@ -25,6 +25,7 @@ return [
'customers.impersonate' => 'Sign in as a customer',
'customers.manage' => 'See and change customer data',
'datacenters.manage' => 'Manage datacenters',
'deployment.unblock' => 'End a stuck process on the server',
'dpa.manage' => 'Data processing agreements',
'hosts.manage' => 'Create, onboard and remove hosts',
'server.terminal' => 'Root session on the CluPilot server itself — full access to the whole installation',

View File

@ -0,0 +1,27 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('admin_settings.release_lock_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.release_lock_body') }}</p>
{{-- Wer gleich stirbt, im Wortlaut. Umbrechend gesetzt: eine
Kommandozeile ist lang, und ein Kasten, der sie seitlich
abschneidet, verschweigt genau den Teil, an dem man sie
erkennt. --}}
@if ($heldBy !== '')
<p class="mt-3 break-all rounded-md border border-line bg-surface-2 px-3 py-2 font-mono text-xs text-ink">{{ $heldBy }}</p>
@endif
<p class="mt-3 text-sm text-muted">{{ __('admin_settings.release_lock_note') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin_settings.release_lock_cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="confirm" wire:loading.attr="disabled">
<x-ui.icon name="alert-triangle" class="size-4" />{{ __('admin_settings.release_lock_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -186,6 +186,29 @@
@if ($update['blocked_by'])
<span class="mt-1 block font-mono text-xs opacity-80">{{ $update['blocked_by'] }}</span>
@endif
{{-- Der Griff, der bisher per SSH auf dem Wirt lag.
Die Konsole kann ihn nicht selbst ausführen
sie ist www-data in einem Behälter, der Halter
ein Prozess auf dem Wirt , also bittet sie den
Agenten darum, und der ruft den root-eigenen
Helfer. Bestätigt wird im Modal (R23), das
vorher nennt, was es beendet. --}}
@if ($canReleaseLock)
<div class="mt-3">
<x-ui.button variant="secondary" :disabled="$update['unblock_requested']"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-release-update-lock' })">
<x-ui.icon name="alert-triangle" class="size-4" />
{{ $update['unblock_requested']
? __('admin_settings.release_lock_pending')
: __('admin_settings.update_release_lock') }}
</x-ui.button>
</div>
@endif
@if ($update['unblock_error'])
<span class="mt-3 block text-xs">{{ $update['unblock_error'] }}</span>
@endif
</x-ui.alert>
@elseif ($update['last_error'])
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>

View File

@ -1026,7 +1026,7 @@ function writeAlive(array $alive): void
*/
it('calls the agent alive on its heartbeat, even when its last check is old', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 3]);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch', 'skips' => 40]);
$state = app(UpdateChannel::class)->state();
@ -1039,7 +1039,7 @@ it('does not pass off an hour-old figure as current while the agent is blocked',
// Die Zahlen stammen von VOR der Blockade und altern still weiter. „Drei
// Updates zurueck" von vor einer Stunde liest sich genau wie von jetzt.
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 3, 'target_release' => 'v9.9.9']);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '']);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '', 'skips' => 40]);
$state = app(UpdateChannel::class)->state();
@ -1050,7 +1050,7 @@ it('does not pass off an hour-old figure as current while the agent is blocked',
it('says the service is blocked, not that it is not running', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 1]);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch', 'skips' => 40]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
@ -1058,6 +1058,65 @@ it('says the service is blocked, not that it is not running', function () {
->assertSee('4242 40:12 git fetch');
});
/**
* ── Ein einzelner uebersprungener Lauf ist Betrieb, kein Fehler ───────────
*
* Der Zeitgeber laeuft minuetlich, der Waechter kommt dazu, einer nimmt die
* Sperre und der andere ueberspringt. Gemessen am 4. August 2026: neun
* Sekunden Ueberholen und die Konsole meldete bereits eine Blockade. Der
* Besitzer hat daraufhin eine Stunde lang eine gesunde Anlage auseinander-
* genommen.
*
* Gezaehlt wird in LAEUFEN, nicht in Minuten: CHECK_INTERVAL_MINUTES ist eine
* Behauptung des Skripts, das tatsaechliche Intervall steht in der
* systemd-Unit auf dem Wirt.
*/
it('does not cry blockade over a single skipped run', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->subMinute()->toIso8601String(), 'behind' => 3, 'target_release' => 'v9.9.9']);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subSeconds(9)->toIso8601String(), 'held_by' => '4242 00:09 watchdog', 'skips' => 1]);
$state = app(UpdateChannel::class)->state();
// Und die Zahlen bleiben stehen. Nur die Meldung zu unterdruecken haette
// den Fehlalarm gegen einen stilleren getauscht: „drei Aktualisierungen
// zurueck" waere beim ersten uebersprungenen Lauf verschwunden, ohne dass
// irgendwo stuende warum.
expect($state['blocked_since'])->toBeNull()
->and($state['blocked_by'])->toBeNull()
->and($state['behind'])->toBe(3)
->and($state['target_release'])->toBe('v9.9.9');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertDontSee(__('admin_settings.update_agent_blocked', ['since' => '00:00']));
});
it('reports the blockade from the second skipped run on', function () {
// Bewusst zwei und nicht zehn: lieber frueh gewarnt werden, nur nicht
// beim allerersten Lauf.
writeStatus(['state' => 'idle', 'checked_at' => now()->subMinutes(2)->toIso8601String(), 'behind' => 3]);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(2)->toIso8601String(), 'held_by' => '4242 02:03 docker compose exec', 'skips' => 2]);
$state = app(UpdateChannel::class)->state();
expect($state['blocked_since'])->not->toBeNull()
->and($state['blocked_by'])->toBe('4242 02:03 docker compose exec')
->and($state['behind'])->toBeNull();
});
it('says nothing about a blockade on a heartbeat from before the counter existed', function () {
// Ein Lebenszeichen, das der vorige Stand geschrieben hat, bleibt nach
// einem Update eine Minute lang liegen. Es hat kein `skips`, und daraus
// eine Blockade zu machen waere geraten.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 2]);
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);
$state = app(UpdateChannel::class)->state();
expect($state['blocked_since'])->toBeNull()
->and($state['behind'])->toBe(2);
});
it('keeps the old rule for an agent that writes no heartbeat at all', function () {
// Ein Agent von vor dieser Aenderung kennt die Datei nicht. Ihn dafuer fuer
// tot zu erklaeren waere derselbe Fehler mit umgekehrtem Vorzeichen.

View File

@ -0,0 +1,175 @@
<?php
use App\Livewire\Admin\ConfirmReleaseUpdateLock;
use App\Livewire\Admin\Settings as AdminSettings;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Eine hängende Sperre aus der Konsole lösen.
*
* Am 4. August 2026 hielt ein einziger Aufruf `timeout 45 docker compose
* exec` ohne `--kill-after`, der nach vier Minuten noch stand die Sperre
* stundenlang, und jeder folgende Lauf wurde übersprungen. Der Griff dagegen
* lag per SSH auf dem Wirt. Er liegt jetzt in der Konsole.
*
* Die Konsole kann ihn nicht selbst ausführen: sie ist www-data in einem
* Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Sie legt deshalb
* eine Bitte ab, wie für alles andere hier auch nur in einer EIGENEN Datei.
* Der Postkasten `update-request.json` taugt dafür nicht: er nimmt eine Bitte
* zur Zeit an, und genau die dreißig Minuten, in denen dort noch eine wartende
* Update-Anfrage liegt, sind die, in denen jemand entsperren will.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
/** Ein Lebenszeichen, das eine echte Blockade meldet (zwei übersprungene Läufe). */
function blockedHeartbeat(string $heldBy = '4242 02:03 docker compose exec -T app'): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'checked_at' => now()->subMinutes(5)->toIso8601String(),
'behind' => 0,
]));
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
'at' => now()->toIso8601String(),
'state' => 'blocked',
'since' => now()->subMinutes(5)->toIso8601String(),
'held_by' => $heldBy,
'skips' => 5,
]));
}
it('offers to release the lock to an operator who may', function () {
blockedHeartbeat();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_release_lock'))
->assertSee("component: 'admin.confirm-release-update-lock'", escape: false);
});
it('offers nothing of the sort to an operator who may not', function () {
// Die Warnung sieht jeder, der die Seite sieht. Den Griff nicht.
blockedHeartbeat();
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->assertDontSee(__('admin_settings.update_release_lock'));
});
it('does not offer to release a lock nobody is holding', function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'behind' => 0,
]));
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertDontSee(__('admin_settings.update_release_lock'));
});
it('names the process it is about to end, before it ends it', function () {
// Der Sinn der ganzen Rückfrage. PID und Kommandozeile stehen im
// Lebenszeichen; sie erst NACH dem Beenden zu zeigen wäre eine Meldung,
// keine Rückfrage.
blockedHeartbeat('4242 02:03 docker compose exec -T app');
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmReleaseUpdateLock::class)
->assertSee('4242 02:03 docker compose exec -T app');
});
it('leaves the deed to the page component, and only dispatches', function () {
// R23: das Modal mutiert nichts. Die Berechtigungsprüfung bleibt an der
// einen Stelle, an der sie schon steht.
blockedHeartbeat();
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmReleaseUpdateLock::class)
->call('confirm')
->assertDispatched('update-lock-release-confirmed');
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
});
it('leaves the request for the agent once the confirmation comes back', function () {
blockedHeartbeat();
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(AdminSettings::class)
->dispatch('update-lock-release-confirmed');
$request = json_decode(File::get(storage_path('app/deploy/unblock-request.json')), true);
expect($request['requested_by'])->toBe($owner->email)
->and($request['requested_at'])->not->toBeEmpty();
});
it('refuses to release the lock for an operator without the capability', function () {
blockedHeartbeat();
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->dispatch('update-lock-release-confirmed')
->assertForbidden();
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
});
it('is not held up by an update request already waiting in the mailbox', function () {
// Der Grund für die eigene Datei. Der Postkasten nimmt eine Bitte zur
// Zeit an — und ein blockierter Agent holt sie nie ab, also läge dort
// dreißig Minuten lang eine, die das Entsperren aussperrt.
blockedHeartbeat();
File::put(storage_path('app/deploy/update-request.json'), json_encode([
'requested_at' => now()->toIso8601String(),
'requested_by' => 'someone@example.com',
'kind' => 'run',
]));
expect(app(UpdateChannel::class)->requestUnblock('owner@example.com'))->toBeTrue()
->and(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeTrue();
});
it('does not queue a second release while the first is still waiting', function () {
blockedHeartbeat();
$channel = app(UpdateChannel::class);
expect($channel->requestUnblock('owner@example.com'))->toBeTrue()
->and($channel->requestUnblock('owner@example.com'))->toBeFalse();
});
it('says the release is on its way while the agent has not collected it', function () {
blockedHeartbeat();
app(UpdateChannel::class)->requestUnblock('owner@example.com');
expect(app(UpdateChannel::class)->state()['unblock_requested'])->toBeTrue();
});
it('reports back when the host helper was too old to do it', function () {
// Der stille Fehlschlag, den dieses Repo nicht duldet: ein Wirt, auf dem
// install-agent.sh seit dem neuen Schritt nicht mehr lief, kann nicht
// entsperren — und ein Knopf, der nichts tut und nichts sagt, schickt den
// Betreiber genau dorthin zurück, wo er ohne die Konsole schon war.
blockedHeartbeat();
File::put(storage_path('app/deploy/unblock-last-run.json'), json_encode([
'state' => 'failed',
'finished_at' => now()->toIso8601String(),
'error' => 'unblock_helper_old',
]));
$state = app(UpdateChannel::class)->state();
expect($state['unblock_error'])->toBe(__('admin_settings.update_error.unblock_helper_old'));
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_error.unblock_helper_old'));
});

View File

@ -0,0 +1,142 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Der Agent zählt, wie oft er hintereinander nicht an die Arbeit kam.
*
* Die Konsole macht daraus erst ab zwei Läufen eine Meldung (siehe
* UpdateChannel::BLOCKED_AFTER_SKIPS). Damit sie das kann, muss der Agent die
* Zahl liefern ausrechnen kann die Konsole sie nicht: dafür müsste sie das
* Taktintervall kennen, und das steht in der systemd-Unit auf dem Wirt.
*
* Hier läuft das ECHTE Skript, nicht eine Nachbildung seiner Logik. Das geht,
* weil der Zweig, um den es hier geht, vor allem Teuren liegt: der Agent nimmt
* die Sperre in der ersten Handvoll Zeilen, und wer sie nicht bekommt, steigt
* aus, bevor irgendein `git fetch` oder `docker compose` in die Nähe kommt.
* Ein Test, der die Zählung nachrechnet statt sie auszuführen, prüft nichts
* das steht schon in R19 im Repo.
*/
/**
* Lässt den Agenten $times mal gegen eine gehaltene Sperre laufen.
*
* @param array<string, mixed>|null $seedAlive Lebenszeichen, das vorher liegt
* @return array<int, array<string, mixed>> je ein Lebenszeichen pro Lauf
*/
function runBlockedAgent(int $times, ?array $seedAlive = null): array
{
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::delete(File::glob($dir.'/.alive-*'));
File::delete($dir.'/agent-alive.json');
File::delete($dir.'/.held');
if ($seedAlive !== null) {
File::put($dir.'/agent-alive.json', json_encode($seedAlive));
}
$runs = '';
for ($i = 1; $i <= $times; $i++) {
$runs .= "bash deploy/update-agent.sh >/dev/null 2>&1\n";
$runs .= "cp storage/app/deploy/agent-alive.json storage/app/deploy/.alive-{$i}\n";
}
// Die Sperre wird nachweislich gehalten, bevor der Agent startet: der
// Halter legt erst die Marke an, dann wird auf sie gewartet. Ohne diesen
// Nachweis liefe der Agent bei einem Fehlschlag von flock in seinen
// NORMALEN Weg — mit Abruf der Gegenstelle und allem, was daran hängt —
// und der Test hätte still etwas ganz anderes gemessen.
$result = Process::path(base_path())
->timeout(60)
->run(<<<BASH
set -e
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
halter=\$!
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
{$runs}
kill \$halter 2>/dev/null || true
BASH);
expect($result->successful())->toBeTrue($result->errorOutput());
return array_map(
fn (int $i) => json_decode(File::get($dir."/.alive-{$i}"), true),
range(1, $times)
);
}
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('counts a single skipped run as one, not as a blockade', function () {
// Der gemessene Fall: neun Sekunden Überholen zwischen Zeitgeber und
// Wächter. Betrieb, kein Fehler.
[$first] = runBlockedAgent(1);
expect($first['state'])->toBe('blocked')
->and($first['skips'])->toBe(1);
});
it('keeps counting while the same blockade holds', function () {
[$first, $second] = runBlockedAgent(2);
expect($first['skips'])->toBe(1)
->and($second['skips'])->toBe(2)
// Und `since` bleibt stehen — sonst stünde dort immer „seit einer
// Minute" und eine Stunde Stillstand sähe aus wie ein Überholen.
->and($second['since'])->toBe($first['since']);
});
it('starts the count over after a run that got the lock', function () {
// Ein Lebenszeichen aus einem Lauf, der gearbeitet hat, beendet die Serie.
// Ohne diesen Schnitt liefe der Zähler über eine gesunde Zwischenzeit
// hinweg weiter und meldete eine Blockade, die längst vorbei war.
[$first] = runBlockedAgent(1, [
'at' => '2026-08-04T05:00:00Z',
'state' => 'running',
'since' => '',
'held_by' => '',
'skips' => 0,
]);
expect($first['state'])->toBe('blocked')
->and($first['skips'])->toBe(1);
});
it('says zero skipped runs while it is working', function () {
// Der Zustand, in dem der Agent die Sperre HAT. Ohne die ausdrückliche
// Null bliebe die Zahl des letzten blockierten Laufs im Lebenszeichen
// stehen, und die Konsole läse sie als fortdauernde Blockade.
//
// Hier läuft der Agent auf der freien Sperre — also durch seinen normalen
// Weg. `git` und `docker` liegen dafür als Attrappen im PATH, die sofort
// scheitern: dieser Test prüft, was der Agent ins Lebenszeichen schreibt,
// und hat keinen Grund, dafür die Gegenstelle abzurufen oder den
// Docker-Daemon anzufassen. Beide Fehlschläge sind Wege, die der Agent
// ohnehin abfängt.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::delete($dir.'/agent-alive.json');
// Der Ausgang des Laufs wird NICHT geprüft: mit einem `git`, das immer
// scheitert, kann der Agent nicht bis zum Ende kommen, und ans Ende zu
// kommen ist hier auch nicht die Frage. Das Lebenszeichen steht als
// ERSTES, noch vor allem, was scheitern kann — genau deshalb gibt es die
// Datei überhaupt.
Process::path(base_path())->timeout(60)->run(<<<'BASH'
stub="$(mktemp -d)"
printf '#!/bin/sh\nexit 1\n' > "$stub/git"
printf '#!/bin/sh\nexit 1\n' > "$stub/docker"
chmod +x "$stub/git" "$stub/docker"
PATH="$stub:$PATH" bash deploy/update-agent.sh >/dev/null 2>&1
rm -rf "$stub"
BASH);
$alive = json_decode(File::get($dir.'/agent-alive.json'), true);
expect($alive['state'])->toBe('running')
->and($alive['skips'])->toBe(0);
});

View File

@ -0,0 +1,227 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Die Wirtsseite des Entsperrens.
*
* Zwei Stücke, beide auf dem Wirt:
*
* 1. Der Agent liest die Bitte im BLOCKIERTEN Zweig das ist der einzige
* Zweig, den ein blockierter Lauf erreicht.
* 2. Der root-eigene Helfer beendet, was die Sperre offen hält.
*
* Das zweite Stück ist die Grenze, an der ein Fehler teuer wird: hier läuft
* etwas als root und beendet Prozesse. Es wird deshalb ausgeführt, nicht
* begutachtet mit genau EINER Änderung am echten Skript, dem Pfad der
* Sperrdatei. Der Test prüft vorher, dass die Zeile, die er ersetzt, auch
* wirklich so dasteht; wird der Helfer umgebaut, fällt das hier auf und nicht
* erst auf einem Server.
*
* Im Betrieb ist derselbe Pfad nicht umzulenken: die Datei gehört root, der
* Dienstbenutzer darf sie nicht schreiben, und sudo räumt die Umgebung ab.
*/
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
/**
* Der Helfer als eigenständiges Skript, mit umgelenkter Sperrdatei.
*/
function hostStepWithLockAt(string $lock): string
{
$installer = File::get(base_path('deploy/install-agent.sh'));
$start = strpos($installer, "cat > \"\$HOST_STEP\" <<'EOF'\n");
expect($start)->not->toBeFalse('install-agent.sh schreibt den Helfer nicht mehr aus einem Here-Dokument.');
$start += strlen("cat > \"\$HOST_STEP\" <<'EOF'\n");
$script = substr($installer, $start, strpos($installer, "\nEOF\n", $start) - $start);
$hardcoded = 'LOCK=/opt/clupilot/storage/app/deploy/.agent.lock';
expect($script)->toContain($hardcoded);
$path = tempnam(sys_get_temp_dir(), 'host-step-').'.sh';
File::put($path, str_replace($hardcoded, "LOCK=$lock", $script));
chmod($path, 0755);
return $path;
}
/**
* ── Der Agent nimmt die Bitte im blockierten Zweig an ─────────────────────
*/
it('answers the release request even though it never got the lock', function () {
// Der ganze Grund für die eigene Datei: ein blockierter Lauf steigt aus,
// bevor er den Postkasten überhaupt ansieht. Läse er die Bitte dort,
// erreichte sie ihn genau dann nie, wenn sie gebraucht wird.
//
// Auf dieser Maschine gibt es keinen root-eigenen Helfer — und genau das
// muss die Konsole erfahren, statt dass der Knopf still nichts tut.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::put($dir.'/unblock-request.json', json_encode([
'requested_at' => now()->toIso8601String(),
'requested_by' => 'owner@example.com',
]));
$result = Process::path(base_path())->timeout(60)->run(<<<'BASH'
set -e
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
halter=$!
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
bash deploy/update-agent.sh >/dev/null 2>&1
kill $halter 2>/dev/null || true
BASH);
expect($result->successful())->toBeTrue($result->errorOutput());
$last = json_decode(File::get($dir.'/unblock-last-run.json'), true);
expect($last['state'])->toBe('failed')
->and($last['error'])->toBe('unblock_helper_missing')
// Verbraucht, nicht liegengelassen: eine Bitte, die bleibt, zündet bei
// der nächsten Blockade ungefragt.
->and(File::exists($dir.'/unblock-request.json'))->toBeFalse();
});
it('throws away a release request that has been lying around too long', function () {
// Dieselbe Frist wie im Postkasten. Eine Bitte, die abgelegt wurde, als
// niemand sie abholen konnte, ist keine Anweisung für irgendwann.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::put($dir.'/unblock-request.json', json_encode([
'requested_at' => now()->subHours(3)->toIso8601String(),
'requested_by' => 'owner@example.com',
]));
touch($dir.'/unblock-request.json', now()->subHours(3)->timestamp);
Process::path(base_path())->timeout(60)->run(<<<'BASH'
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
halter=$!
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
bash deploy/update-agent.sh >/dev/null 2>&1
kill $halter 2>/dev/null || true
BASH);
expect(File::exists($dir.'/unblock-request.json'))->toBeFalse()
->and(File::exists($dir.'/unblock-last-run.json'))->toBeFalse();
});
it('quietly drops a release request once the lock has freed itself', function () {
// Der Lauf, der die Sperre BEKOMMT, findet die Bitte noch liegen. Sie
// einfach liegen zu lassen hiesse, sie bei der nächsten Blockade
// ungefragt auszuführen.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::put($dir.'/unblock-request.json', json_encode([
'requested_at' => now()->toIso8601String(),
'requested_by' => 'owner@example.com',
]));
Process::path(base_path())->timeout(60)->run(<<<'BASH'
stub="$(mktemp -d)"
printf '#!/bin/sh\nexit 1\n' > "$stub/git"
printf '#!/bin/sh\nexit 1\n' > "$stub/docker"
chmod +x "$stub/git" "$stub/docker"
PATH="$stub:$PATH" bash deploy/update-agent.sh >/dev/null 2>&1
rm -rf "$stub"
BASH);
expect(File::exists($dir.'/unblock-request.json'))->toBeFalse();
});
/**
* ── Der root-eigene Helfer ────────────────────────────────────────────────
*/
it('ends the process that is holding the lock open', function () {
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(60)->run(<<<BASH
bash -c 'exec 9>"$tmp/.agent.lock"; sleep 60' &
pid=\$!
sleep 0.3
bash {$helper} release-update-lock
sleep 0.3
kill -0 "\$pid" 2>/dev/null && echo "LEBT" || echo "BEENDET"
BASH);
expect($result->output())->toContain('BEENDET');
});
it('does not end the process that asked it to', function () {
// Im Betrieb hat der blockierte Agent die Sperrdatei SELBST offen — sein
// `exec 9>` ist geglückt, nur das `flock` nicht — und er ist der Vorfahr
// des Helfers. Wer alle Offenhalter beendet, beendet den Anrufer mitten
// im Anruf, und die Konsole erführe nie, was passiert ist.
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(60)->run(<<<BASH
exec 9>"$tmp/.agent.lock"
bash {$helper} release-update-lock
echo "ANRUFER LEBT NOCH"
BASH);
// Und es bleibt bei „da ist nichts". Der Helfer bekommt den Deskriptor
// des Anrufers vererbt (unter sudo nicht, im Test schon) und darf seine
// EIGENEN Kindprozesse — jede Kommandosubstitution ist eine — nicht für
// Halter halten: die sind bei jedem Nachsehen andere, die Runde käme nie
// zum Ende, und am Schluss stünde „konnte nicht beendet werden" über
// einer Sperre, die niemand hält.
expect($result->successful())->toBeTrue($result->errorOutput())
->and($result->output())->toContain('ANRUFER LEBT NOCH')
->and($result->errorOutput())->toContain('nothing holds');
});
it('insists when the holder ignores the polite request', function () {
// `timeout 45 docker compose exec` ohne `--kill-after` war genau das:
// etwas, das ein SIGTERM aussitzt. Eine Frist ohne Nachdruck ist keine
// Frist, sondern eine Bitte.
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(120)->run(<<<BASH
bash -c 'trap "" TERM; exec 9>"$tmp/.agent.lock"; sleep 120' &
pid=\$!
sleep 0.3
bash {$helper} release-update-lock
sleep 0.3
kill -0 "\$pid" 2>/dev/null && echo "LEBT" || echo "BEENDET"
BASH);
expect($result->output())->toContain('BEENDET');
});
it('says so instead of pretending when nothing holds the lock', function () {
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
touch($tmp.'/.agent.lock');
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(60)->run("bash {$helper} release-update-lock");
expect($result->successful())->toBeTrue()
->and($result->output().$result->errorOutput())->toContain('nothing');
});
/**
* ── Der Vertrag zwischen Skript, sudoers und Updater ──────────────────────
*/
it('grants the new step in sudoers and raises the contract for it', function () {
// Ein Schritt, den der Helfer kann und sudoers nicht erlaubt, scheitert
// im Betrieb — und ein Wirt mit altem Helfer täte still gar nichts. Beide
// Enden gehören zusammengehalten, sonst merkt es niemand.
$installer = File::get(base_path('deploy/install-agent.sh'));
$updater = File::get(base_path('deploy/update.sh'));
expect($installer)->toContain('release-update-lock)')
->and($installer)->toContain('$HOST_STEP release-update-lock')
->and($installer)->toContain('CONTRACT=3')
->and($updater)->toContain('HOST_STEP_NEEDS=3');
});