diff --git a/VERSION b/VERSION index ec70f75..943f9cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.6 +1.7.1 diff --git a/app/Livewire/Admin/ConfirmReleaseUpdateLock.php b/app/Livewire/Admin/ConfirmReleaseUpdateLock.php new file mode 100644 index 0000000..fa260be --- /dev/null +++ b/app/Livewire/Admin/ConfirmReleaseUpdateLock.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 4cc03d7..8d5558d 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -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. diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index dca5d86..d648981 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -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 $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 === '') { diff --git a/database/migrations/2026_08_04_160000_eine_haengende_sperre_darf_geloest_werden.php b/database/migrations/2026_08_04_160000_eine_haengende_sperre_darf_geloest_werden.php new file mode 100644 index 0000000..1f04ca1 --- /dev/null +++ b/database/migrations/2026_08_04_160000_eine_haengende_sperre_darf_geloest_werden.php @@ -0,0 +1,46 @@ +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(); + } +}; diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index 030bd9e..bf639e6 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -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 </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] diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index a7773a5..931f6a7 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -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 </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 </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 diff --git a/deploy/update.sh b/deploy/update.sh index 1552ffb..c143f93 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -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 diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 5a53e08..6da0b3d 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -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.', diff --git a/lang/de/roles.php b/lang/de/roles.php index 7e9166b..319eae2 100644 --- a/lang/de/roles.php +++ b/lang/de/roles.php @@ -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', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 2d6313d..1de8b8c 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -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.', diff --git a/lang/en/roles.php b/lang/en/roles.php index f8ce752..dc6ac81 100644 --- a/lang/en/roles.php +++ b/lang/en/roles.php @@ -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', diff --git a/resources/views/livewire/admin/confirm-release-update-lock.blade.php b/resources/views/livewire/admin/confirm-release-update-lock.blade.php new file mode 100644 index 0000000..2833aec --- /dev/null +++ b/resources/views/livewire/admin/confirm-release-update-lock.blade.php @@ -0,0 +1,27 @@ +
+
+ + + +
+

{{ __('admin_settings.release_lock_title') }}

+

{{ __('admin_settings.release_lock_body') }}

+ + {{-- 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 !== '') +

{{ $heldBy }}

+ @endif + +

{{ __('admin_settings.release_lock_note') }}

+
+
+
+ {{ __('admin_settings.release_lock_cancel') }} + + {{ __('admin_settings.release_lock_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 5708506..fa112fb 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -186,6 +186,29 @@ @if ($update['blocked_by']) {{ $update['blocked_by'] }} @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) +
+ + + {{ $update['unblock_requested'] + ? __('admin_settings.release_lock_pending') + : __('admin_settings.update_release_lock') }} + +
+ @endif + + @if ($update['unblock_error']) + {{ $update['unblock_error'] }} + @endif @elseif ($update['last_error']) {{ $update['last_error'] }} diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index cb08c65..4b79106 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -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. diff --git a/tests/Feature/Admin/UpdateLockReleaseTest.php b/tests/Feature/Admin/UpdateLockReleaseTest.php new file mode 100644 index 0000000..421447a --- /dev/null +++ b/tests/Feature/Admin/UpdateLockReleaseTest.php @@ -0,0 +1,175 @@ + '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')); +}); diff --git a/tests/Feature/UpdateAgentSkipCountTest.php b/tests/Feature/UpdateAgentSkipCountTest.php new file mode 100644 index 0000000..702534c --- /dev/null +++ b/tests/Feature/UpdateAgentSkipCountTest.php @@ -0,0 +1,142 @@ +|null $seedAlive Lebenszeichen, das vorher liegt + * @return array> 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(<</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); +}); diff --git a/tests/Feature/UpdateLockReleaseOnTheHostTest.php b/tests/Feature/UpdateLockReleaseOnTheHostTest.php new file mode 100644 index 0000000..67d05ea --- /dev/null +++ b/tests/Feature/UpdateLockReleaseOnTheHostTest.php @@ -0,0 +1,227 @@ + \"\$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(<<"$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(<<"$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(<<"$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'); +});