Eine hängende Sperre ist jetzt ein Knopf, kein SSH-Zugang

Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose
exec` ohne `--kill-after` — die Sperre stundenlang, und jeder folgende
Lauf wurde übersprungen. Behoben ist die Ursache in v1.6.5; was fehlte,
war der Griff für das nächste Mal. Er lag per SSH auf dem Wirt.

Die Konsole kann ihn nicht selbst führen: sie ist www-data in einem
Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Also bittet
sie den Agenten, und der ruft den root-eigenen Helfer — dasselbe Muster
wie `apply-proxy-hosts`, mit eigener sudoers-Zeile.

Die Bitte liegt in einer EIGENEN Datei, und das ist keine
Geschmacksfrage: der Agent nimmt die Sperre in seinen ersten Zeilen,
lange bevor er den Postkasten ansieht. Ein blockierter Lauf steigt
vorher aus — eine Entsperr-Bitte im Postkasten erreichte ihn also genau
dann nie, wenn sie gebraucht wird. Dazu nimmt der Postkasten eine Bitte
zur Zeit an, und die dreißig Minuten, in denen dort eine wartende
Update-Anfrage liegt, sind die, in denen jemand entsperren will.

Der Dienstbenutzer sagt „gib die Sperre frei", nicht „töte 1234": welcher
Prozess das ist, sucht der Helfer selbst. Dürfte der Anrufer die Nummer
liefern, wäre die Freigabe das Recht, jeden beliebigen Prozess als root
zu beenden.

Beendet werden alle, die die Sperrdatei OFFEN halten — nicht nur der,
der das flock genommen hat. Ein flock hängt an der offenen
Dateibeschreibung, und die wird vererbt: stirbt der Agent, sein
hängendes `docker compose exec` aber nicht, bleibt die Sperre gehalten.
Genau das war der Vorfall. Verschont bleiben PID 1 und der Anrufer samt
Vorfahren — im Betrieb hält der blockierte Agent die Datei selbst offen
und ist zugleich der, der anruft.

Erst SIGTERM, fünf Sekunden, dann SIGKILL. Eine Frist ohne Nachdruck ist
keine Frist.

Bestätigt wird im Modal (R23), das VORHER nennt, was es beendet — PID,
Laufzeit und Kommandozeile aus dem Lebenszeichen. Eigene Berechtigung
`deployment.unblock`, nicht `site.manage`: wer aktualisieren darf, darf
damit nicht automatisch in einen laufenden Vorgang hineingreifen.

Der Helfer-Vertrag steigt auf 3, und update.sh verlangt ihn. Ohne das
wäre der Knopf auf einem Wirt, der install-agent.sh seither nicht mehr
gefahren hat, ein Knopf, der still nichts tut — und die Konsole sagt es
jetzt, statt den Betreiber dorthin zurückzuschicken, wo er ohne sie
schon war.

Geprüft wird ausgeführt, nicht begutachtet: der Helfer läuft im Test
gegen eine echte Sperrdatei mit echten Prozessen daran. Das hat gleich
einen Fehler gefunden — der Helfer erbt den Deskriptor und hielt seine
eigenen Kommandosubstitutionen für Halter, sodass die Runde nie zum Ende
gekommen wäre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
claude/nice-moser-521659
nexxo 2026-08-04 10:07:24 +02:00
parent 6a5609cfc7
commit 4debc79883
16 changed files with 905 additions and 4 deletions

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

@ -21,6 +21,7 @@ use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -682,6 +683,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();
@ -734,6 +762,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';
@ -273,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
@ -564,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.
*
@ -696,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

@ -52,7 +52,7 @@ cat > "$HOST_STEP" <<'EOF'
# One argument, from the list below. Nothing takes free-form input.
set -euo pipefail
CONTRACT=2
CONTRACT=3
case "${1:-}" in
contract)
@ -154,6 +154,103 @@ case "${1:-}" in
systemctl reload caddy
;;
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.
@ -172,6 +269,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 || {
@ -255,6 +353,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
@ -111,6 +122,53 @@ 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"
@ -161,11 +219,35 @@ if ! flock -n 9; then
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
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,7 +70,9 @@ 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
HOST_STEP_NEEDS=2
# 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
# English line here would surface untranslated in the interface. A run that dies

View File

@ -123,11 +123,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',
'instances.adminlogin' => 'Sich in die Cloud eines Kunden einloggen',

View File

@ -120,11 +120,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',
'instances.adminlogin' => 'Sign in to a customer cloud',

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

@ -180,6 +180,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

@ -16,7 +16,7 @@ it('moves every permission and role to the operator guard, leaving none behind',
// instances.restart, dpa.manage and instances.manage — the later
// ones created straight onto this guard, because since this
// migration `web` is where a permission goes to match nobody at all.
->and(Permission::where('guard_name', 'operator')->count())->toBe(21)
->and(Permission::where('guard_name', 'operator')->count())->toBe(22)
->and(Role::where('guard_name', 'operator')->count())->toBe(6);
});
@ -159,7 +159,7 @@ it('preflights every customer conflict before mutating anything, listing all of
// server, which is the whole reason this has to be checked up front.
// Every capability this installation has, pushed back to `web` above to
// stage the pre-migration shape — the figure grows with each new one.
expect(Permission::where('guard_name', 'web')->count())->toBe(21)
expect(Permission::where('guard_name', 'web')->count())->toBe(22)
->and(Permission::where('guard_name', 'operator')->count())->toBe(0)
->and(Role::where('guard_name', 'web')->count())->toBe(6)
->and(Role::where('guard_name', 'operator')->count())->toBe(0)

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,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');
});