From 4debc798839c6d857b337047d868598ad2b862a4 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 10:07:24 +0200 Subject: [PATCH] =?UTF-8?q?Eine=20h=C3=A4ngende=20Sperre=20ist=20jetzt=20e?= =?UTF-8?q?in=20Knopf,=20kein=20SSH-Zugang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose exec` ohne `--kill-after` — die Sperre stundenlang, und jeder folgende Lauf wurde übersprungen. Behoben ist die Ursache in v1.6.5; was fehlte, war der Griff für das nächste Mal. Er lag per SSH auf dem Wirt. Die Konsole kann ihn nicht selbst führen: sie ist www-data in einem Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Also bittet sie den Agenten, und der ruft den root-eigenen Helfer — dasselbe Muster wie `apply-proxy-hosts`, mit eigener sudoers-Zeile. Die Bitte liegt in einer EIGENEN Datei, und das ist keine Geschmacksfrage: der Agent nimmt die Sperre in seinen ersten Zeilen, lange bevor er den Postkasten ansieht. Ein blockierter Lauf steigt vorher aus — eine Entsperr-Bitte im Postkasten erreichte ihn also genau dann nie, wenn sie gebraucht wird. Dazu nimmt der Postkasten eine Bitte zur Zeit an, und die dreißig Minuten, in denen dort eine wartende Update-Anfrage liegt, sind die, in denen jemand entsperren will. Der Dienstbenutzer sagt „gib die Sperre frei", nicht „töte 1234": welcher Prozess das ist, sucht der Helfer selbst. Dürfte der Anrufer die Nummer liefern, wäre die Freigabe das Recht, jeden beliebigen Prozess als root zu beenden. Beendet werden alle, die die Sperrdatei OFFEN halten — nicht nur der, der das flock genommen hat. Ein flock hängt an der offenen Dateibeschreibung, und die wird vererbt: stirbt der Agent, sein hängendes `docker compose exec` aber nicht, bleibt die Sperre gehalten. Genau das war der Vorfall. Verschont bleiben PID 1 und der Anrufer samt Vorfahren — im Betrieb hält der blockierte Agent die Datei selbst offen und ist zugleich der, der anruft. Erst SIGTERM, fünf Sekunden, dann SIGKILL. Eine Frist ohne Nachdruck ist keine Frist. Bestätigt wird im Modal (R23), das VORHER nennt, was es beendet — PID, Laufzeit und Kommandozeile aus dem Lebenszeichen. Eigene Berechtigung `deployment.unblock`, nicht `site.manage`: wer aktualisieren darf, darf damit nicht automatisch in einen laufenden Vorgang hineingreifen. Der Helfer-Vertrag steigt auf 3, und update.sh verlangt ihn. Ohne das wäre der Knopf auf einem Wirt, der install-agent.sh seither nicht mehr gefahren hat, ein Knopf, der still nichts tut — und die Konsole sagt es jetzt, statt den Betreiber dorthin zurückzuschicken, wo er ohne sie schon war. Geprüft wird ausgeführt, nicht begutachtet: der Helfer läuft im Test gegen eine echte Sperrdatei mit echten Prozessen daran. Das hat gleich einen Fehler gefunden — der Helfer erbt den Deskriptor und hielt seine eigenen Kommandosubstitutionen für Halter, sodass die Runde nie zum Ende gekommen wäre. Co-Authored-By: Claude Opus 5 --- .../Admin/ConfirmReleaseUpdateLock.php | 48 ++++ app/Livewire/Admin/Settings.php | 31 +++ app/Services/Deployment/UpdateChannel.php | 96 ++++++++ ...e_haengende_sperre_darf_geloest_werden.php | 46 ++++ deploy/install-agent.sh | 106 +++++++- deploy/update-agent.sh | 82 +++++++ deploy/update.sh | 4 +- lang/de/admin_settings.php | 19 ++ lang/de/roles.php | 1 + lang/en/admin_settings.php | 19 ++ lang/en/roles.php | 1 + .../confirm-release-update-lock.blade.php | 27 +++ .../views/livewire/admin/settings.blade.php | 23 ++ tests/Feature/Admin/RbacMoveTest.php | 4 +- tests/Feature/Admin/UpdateLockReleaseTest.php | 175 ++++++++++++++ .../UpdateLockReleaseOnTheHostTest.php | 227 ++++++++++++++++++ 16 files changed, 905 insertions(+), 4 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmReleaseUpdateLock.php create mode 100644 database/migrations/2026_08_04_160000_eine_haengende_sperre_darf_geloest_werden.php create mode 100644 resources/views/livewire/admin/confirm-release-update-lock.blade.php create mode 100644 tests/Feature/Admin/UpdateLockReleaseTest.php create mode 100644 tests/Feature/UpdateLockReleaseOnTheHostTest.php 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'); +});