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 1080a45..26cf883 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -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. diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 5601d36..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'; @@ -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 === '') { 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 da15f98..bc9795d 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -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 </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] diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index b044b6e..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 @@ -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 </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 diff --git a/deploy/update.sh b/deploy/update.sh index ef5179f..c143f93 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -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 diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 912b120..a0965f8 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -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.', diff --git a/lang/de/roles.php b/lang/de/roles.php index e9d1147..ce2991e 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', 'instances.adminlogin' => 'Sich in die Cloud eines Kunden einloggen', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index d7b9375..620faf8 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -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.', diff --git a/lang/en/roles.php b/lang/en/roles.php index e68ae2c..300a3b7 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', 'instances.adminlogin' => 'Sign in to a customer cloud', 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 1472bbf..7781e87 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -180,6 +180,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/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php index def9f66..d78ba87 100644 --- a/tests/Feature/Admin/RbacMoveTest.php +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -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) 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/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'); +});