diff --git a/app/Livewire/Admin/ConfirmPinRelease.php b/app/Livewire/Admin/ConfirmPinRelease.php new file mode 100644 index 0000000..09e7d43 --- /dev/null +++ b/app/Livewire/Admin/ConfirmPinRelease.php @@ -0,0 +1,51 @@ +authorize('site.manage'); + + $this->version = $version; + } + + public function confirm(): void + { + $this->authorize('site.manage'); + + // Die Version geht als Event-Nutzlast mit, nicht nur als das, was + // dieses Modal auf dem Bildschirm zeigt — genau wie + // ConfirmSaveSecret::confirm() den Schlüssel mitschickt. Sonst + // bestätigt der Klick nur, DASS etwas festgenagelt wird, nicht WAS: + // die Seite las bisher ihr eigenes `ceilingChoice` erneut, und eine + // leere Auswahl dort landete unbestätigt in setCeiling(null) — der + // Gegenhandlung, nicht dem Festnageln. + $this->dispatch('pin-release-confirmed', version: $this->version); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-pin-release'); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 8d5558d..77baa34 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -167,6 +167,12 @@ class Settings extends Component $this->autoDays = $window->days(); $this->autoFrom = $window->from(); $this->autoTo = $window->to(); + + // Das Auswahlfeld zeigt, was tatsächlich gilt, statt auf "Neueste + // Version (nicht festgenagelt)" zu stehen, während die Zeile darunter + // "Festgenagelt auf 1.8.0" sagt — zwei Bedienelemente derselben + // Karte, die sich sonst widersprechen. + $this->ceilingChoice = app(UpdateChannel::class)->ceiling() ?? ''; } /** @@ -717,6 +723,70 @@ class Settings extends Component : 'admin_settings.update_already_requested')); } + /** Die im Auswahlfeld stehende Version, ehe sie bestätigt wird. */ + public string $ceilingChoice = ''; + + /** + * Den Server auf eine Version festnageln. + * + * 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. + * + * `site.manage` wie `requestUpdate()`: festnageln entscheidet, welche + * Fassung dieser Server je bekommt, und ist damit dieselbe Entscheidung + * wie sie auszulösen — nur früher. + * + * Die Version kommt als Parameter aus dem Event, nicht mehr aus + * `$this->ceilingChoice`: ConfirmPinRelease schickt mit, was im Modal + * tatsächlich bestätigt wurde (genau wie ConfirmSaveSecret den + * Schlüssel mitschickt). Eine leere Version wird HIER ausdrücklich + * abgewiesen, mit ihrer eigenen Meldung — nicht länger `?: null` in + * setCeiling() gereicht, das eine leere Auswahl als "Decke abnehmen" + * gelesen und dabei die Festnageln-Erfolgsmeldung gezeigt hätte. Das + * Auswahlfeld trägt ``, + * und das Einzige, was einen Klick darauf bisher verhinderte, war ein + * Alpine-`disabled` — kein Ersatz für eine serverseitige Prüfung, siehe + * `requestUpdate()` oben. + */ + #[On('pin-release-confirmed')] + public function pinRelease(string $version): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + if ($version === '') { + $this->dispatch('notify', message: __('admin_settings.release_pin_empty')); + + return; + } + + $accepted = app(UpdateChannel::class)->setCeiling($operator->email, $version); + + $this->dispatch('notify', message: __($accepted + ? 'admin_settings.release_pinned' + : 'admin_settings.release_pin_invalid')); + } + + /** + * Die Decke abnehmen — der Server nimmt wieder, was neu ist. + */ + public function unpinRelease(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + app(UpdateChannel::class)->setCeiling($operator->email, null); + + $this->dispatch('notify', message: __('admin_settings.release_unpinned')); + } + /** * Eine hängende Sperre auf dem Wirt lösen lassen. * diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index d648981..20590ec 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -46,6 +46,16 @@ final class UpdateChannel /** Written by the panel, consumed by the agent. */ private const REQUEST = 'deploy/update-request.json'; + /** + * Wie weit dieser Server gehen darf. + * + * Keine Anfrage an den Agenten, sondern Zustand, den er liest: die Datei + * bleibt liegen und gilt, bis sie jemand ändert. Deshalb steht sie neben + * dem Postkasten und nicht darin — eine Anfrage wird verbraucht, eine + * Decke nicht. + */ + private const CEILING = 'deploy/release-ceiling'; + /** * Die Bitte, eine hängende Sperre zu lösen — in einer EIGENEN Datei. * @@ -275,6 +285,11 @@ final class UpdateChannel // "three updates behind" from last week reads exactly like now. $behind = $agentWorking && isset($status['behind']) ? (int) $status['behind'] : null; + // Aus der Datei, nicht aus $status: siehe ceiling() weiter unten. Nur + // einmal gelesen, weil state() bei jedem Seitenaufbau läuft und die + // Datei sonst zweimal geöffnet würde, um zweimal dasselbe zu sagen. + $ceiling = $this->ceiling(); + return [ 'version' => $release->version, 'commit' => $release->commit, @@ -284,6 +299,22 @@ final class UpdateChannel // Null means "we do not know", which is not the same as "up to // date" and must not be shown as it. 'behind' => $behind, + // Die Decke aus der Datei, ihre Beanstandung vom Agenten: nur er + // sieht die Tags und kann sagen, ob sie ins Leere zeigt. + 'ceiling' => $ceiling, + 'ceiling_error' => ($status['ceiling_error'] ?? '') !== '' + ? (string) $status['ceiling_error'] + : null, + // Die Decke steht UNTER dem, was läuft. Kein Fehler, aber auch + // nicht „aktuell": es steht eine Decke, sie ist nur überholt. + // Hier gerechnet und nicht vom Agenten geholt — die Konsole hat + // beide Zahlen bereits, und eine Frage weniger an den Wirt ist + // eine Frage weniger, die veralten kann. + 'ceiling_passed' => $ceiling !== null + && version_compare(ltrim($ceiling, 'v'), $release->version, '<'), + 'releases' => is_array($status['releases'] ?? null) + ? array_values(array_filter($status['releases'], 'is_string')) + : [], 'available' => $behind !== null && $behind > 0, // The tag an update would install, e.g. "v1.1.0". An update is // always to a released version now — a commit landing on main is @@ -559,6 +590,118 @@ final class UpdateChannel return $this->submit($by, self::KIND_CHECK); } + /** + * Den Server auf eine Version festnageln — oder die Decke abnehmen. + * + * `null` nimmt sie ab. Rückgabe `false` heißt: die Form stimmt nicht, oder + * das Schreiben ist fehlgeschlagen (volle Platte, Rechteproblem auf + * storage/app/deploy) — in beiden Fällen wurde nichts Halbes hinterlassen, + * das der Agent falsch lesen müsste. + * + * Die Decke wirkt NICHT dadurch, dass hier etwas ausgelöst wird. Der Agent + * liest die Datei bei jedem Takt und rechnet `behind` dagegen; Knopf und + * Wartungsfenster folgen daraus von selbst. Die `KIND_CHECK`-Anfrage + * daneben ist reine Höflichkeit gegenüber der Anzeige: ohne sie stünde die + * alte Zahl bis zum nächsten Takt, und ein Betreiber, der gerade + * festgenagelt hat, läse sie als „hat nicht gewirkt". + */ + public function setCeiling(string $by, ?string $tag): bool + { + if ($tag === null) { + File::delete(storage_path('app/'.self::CEILING)); + $this->requestCheck($by); + + return true; + } + + // Dieselbe Form, die der Agent prüft. Beide Seiten prüfen sie, weil + // keine der anderen glauben kann: der Agent nicht der Datei, die + // Konsole nicht der Eingabe. + if (! preg_match('/^v\d+(\.\d+)*$/', $tag)) { + return false; + } + + if (! $this->writeAtomic(self::CEILING, $tag)) { + return false; + } + + $this->requestCheck($by); + + return true; + } + + /** + * Die gesetzte Decke, direkt aus der Datei. + * + * Nicht aus der Statusdatei des Agenten: die ist seine Rückmeldung und + * hinkt bis zu einem Takt hinterher. Was die Konsole selbst geschrieben + * hat, muss sie sofort anzeigen können. + * + * Zwischen `File::exists()` und `File::get()` liegt ein Zeitfenster — + * verschwindet die Datei genau dort (ein zweiter Aufruf nimmt die Decke + * ab, während dieser noch liest), wirft `File::get()` eine + * FileNotFoundException. Dasselbe Muster wie readJson() und lastLog() + * weiter unten: state() darf unter keinen Umständen werfen. + * + * Öffentlich, weil auch App\Livewire\Admin\Settings::mount() sie + * braucht — um `ceilingChoice` mit der tatsächlich gesetzten Decke + * vorzubelegen, statt das Auswahlfeld auf einem festgenagelten Server + * "Neueste Version (nicht festgenagelt)" zeigen zu lassen, während der + * Satz darunter das Gegenteil sagt. + */ + public function ceiling(): ?string + { + try { + $path = storage_path('app/'.self::CEILING); + + if (! File::exists($path)) { + return null; + } + + $tag = trim((string) File::get($path)); + + return $tag === '' ? null : $tag; + } catch (Throwable) { + return null; + } + } + + /** + * Schreiben, das der Agent nie halb sieht. + * + * `write()` daneben benutzt File::put und taugt dafür nicht: der Agent + * liest diese Datei bei jedem Takt, und eine halbe Zeile ist genau die + * Art Decke, die er als kaputt ablehnen müsste. + * + * Prüft beide Rückgabewerte, statt ihnen zu glauben: `File::move()` ruft + * nur `rename()` auf, und ein fehlgeschlagenes `rename()` liefert `false` + * ohne von sich aus eine Ausnahme zu werfen. Schlägt einer der beiden + * Schritte fehl — volle Platte, Rechteproblem — räumt diese Methode die + * `.tmp` weg und meldet `false`, statt einen Erfolg zu behaupten, der + * nicht stattfand. + */ + private function writeAtomic(string $relative, string $contents): bool + { + $path = storage_path('app/'.$relative); + $tmp = $path.'.tmp'; + + try { + File::ensureDirectoryExists(dirname($path)); + + if (File::put($tmp, $contents) === false || ! File::move($tmp, $path)) { + File::delete($tmp); + + return false; + } + + return true; + } catch (Throwable) { + File::delete($tmp); + + return false; + } + } + /** * Den Proxy die Hostnamen aus der Konsole übernehmen lassen. * diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index ef53480..25849bd 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -38,8 +38,17 @@ release_manifest_commit() { # failed in between leaves a newer number on disk than is serving. Deciding # "is there something newer" against the file would then offer nothing, because # the checkout already claims to be the version it never finished installing. +# +# `|| true` at the end, same idiom as release_version()'s `|| echo '0.0.0'`: +# guarantee a zero exit status regardless of what happened upstream. Without +# it, a missing manifest — an ordinary state before the first install — makes +# `sed` exit 2, `pipefail` carries that out of the pipeline, and the caller's +# `DEPLOYED_VERSION="$(release_manifest_version)"` is a bare assignment: under +# `set -e` that ends the agent on every tick, before it ever writes a status. +# Empty output either way, so the caller's existing fallback to +# release_version() still fires exactly as before. release_manifest_version() { - sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1 + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1 || true } # release_version_gt A B — true when A is strictly a higher version than B. @@ -51,7 +60,12 @@ release_version_gt() { [[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]] } -# release_newest_tag — the highest v* tag by version order, or nothing. +# release_newest_tag [CEILING] — the highest v* tag by version order, or nothing. +# +# Mit CEILING: der hoechste Tag, der NICHT darueber liegt. Das ist die ganze +# Mechanik des Festnagelns — der Agent fragt nach dem neuesten Tag, den er +# nehmen DARF, und `behind`, der Knopf und das Wartungsfenster fallen alle aus +# dieser einen Antwort. # # Deliberately not `git tag … | head -1`. head exits after the first line, git # gets SIGPIPE, and under `set -o pipefail` that non-zero status leaves the @@ -59,9 +73,13 @@ release_version_gt() { # and the same trap install-agent.sh already carries a warning about. A read # loop over a process substitution is not a pipeline and cannot do it. release_newest_tag() { - local tag + local ceiling="${1-}" tag + while read -r tag; do [[ -n "$tag" ]] || continue + if [[ -n "$ceiling" ]] && release_version_gt "${tag#v}" "${ceiling#v}"; then + continue + fi printf '%s' "$tag" return 0 done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) @@ -69,31 +87,82 @@ release_newest_tag() { return 0 } -# release_tags_ahead VERSION — how many v* tags are higher than VERSION. +# release_tags_ahead VERSION [CEILING] — how many v* tags are higher than +# VERSION, counting only up to CEILING. # # Lives here rather than inline in the agent because it has to be testable. The # inline version killed the update agent on every tick: the loop body was # `release_version_gt … && echo`, so the LAST iteration — almost always a tag # that is not newer — left the loop with a non-zero status, `pipefail` carried # it out of the pipeline, the command substitution inherited it, and `set -e` -# ended the agent before it could write a status. The console then showed a -# check that never finished and a last-checked time frozen at the deployment. +# ended the agent before it could write a status. # # `if` rather than `&&` is the whole fix: an if whose condition is false still -# returns 0. Anything added here must keep that property. +# returns 0. Anything added here must keep that property — auch die +# Decken-Pruefung unten ist deshalb ein verschachteltes `if` und kein `&&`. release_tags_ahead() { - local current="$1" tag count=0 + local current="$1" ceiling="${2-}" tag count=0 while read -r tag; do [[ -n "$tag" ]] || continue if release_version_gt "${tag#v}" "$current"; then - count=$((count + 1)) + if [[ -z "$ceiling" ]] || ! release_version_gt "${tag#v}" "${ceiling#v}"; then + count=$((count + 1)) + fi fi done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) printf '%s' "$count" } +# release_tag_exists TAG — true when TAG is a real tag in this checkout. +# +# Form und Existenz sind zwei Fragen. Eine Decke, die bloss AUSSIEHT wie eine +# Version, ist keine — ein Tag, den jemand von Hand in die Deckendatei +# geschrieben hat und der nie existiert hat, oder einer, den DIESER Wirt +# schlicht noch nie geholt hat. +# +# Was das NICHT abdeckt: einen Tag, der hier schon lag und auf der +# Gegenstelle geloescht wurde. Der Agent holt mit +# `git fetch --quiet --tags --force origin` (update-agent.sh), und das +# ENTFERNT keine lokalen Tags, die drueben verschwunden sind — dafuer +# braeuchte es `--prune --prune-tags`. Das ist hier absichtlich NICHT +# gesetzt: es waere eine Entscheidung darueber, was jede der zehn +# Server-Konsolen mit ihren eigenen lokalen Tags tut, sobald irgendwo einer +# zurueckgezogen wird — und die steht dem Besitzer zu, nicht diesem Helfer. +# `git rev-parse --verify` beantwortet also auch dann noch mit "ja", wenn +# der Tag auf der Gegenstelle laengst weg ist, solange dieser Wirt ihn +# irgendwann einmal geholt hatte. +release_tag_exists() { + [[ -n "${1-}" ]] || return 1 + git rev-parse -q --verify "refs/tags/${1}^{commit}" >/dev/null 2>&1 +} + +# release_tags_from VERSION [LIMIT] — die Tags, auf die festgenagelt werden +# darf: VERSION selbst und alles darueber, neueste zuerst, einer je Zeile. +# +# VERSION ist EINGESCHLOSSEN, und das ist der haeufigste Fall: „hier +# einfrieren, nichts Neues nehmen" ist das, was neun von zehn Servern wollen, +# waehrend der zehnte die neue Fassung bekommt. +# +# LIMIT, weil die Statusdatei sonst mit der Tag-Historie mitwaechst. +release_tags_from() { + local current="$1" limit="${2:-20}" tag count=0 out='' + + while read -r tag; do + [[ -n "$tag" ]] || continue + if [[ "${tag#v}" == "$current" ]] || release_version_gt "${tag#v}" "$current"; then + out+="${tag}"$'\n' + count=$((count + 1)) + if (( count >= limit )); then + break + fi + fi + done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) + + printf '%s' "$out" +} + release_remember() { # release_remember MODE SOURCE mkdir -p "$(dirname "$MODE_FILE")" printf '%s' "$1" > "$MODE_FILE" diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 0b5cd9d..d8b4273 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -43,6 +43,13 @@ RESTARTLAST="$STATE_DIR/restart-last-run.json" # initial admin password an instance holds until somebody notes it down. ARCHIVE_KEY="$STATE_DIR/archive-key.json" LOCK="$STATE_DIR/.agent.lock" +# Wie weit dieser Server gehen darf. Von der Konsole geschrieben, hier gelesen. +# +# Sie faellt ZU, nicht auf: ist die Datei unlesbar, formwidrig oder zeigt sie +# auf einen Tag, den es nicht (mehr) gibt, wird NICHTS angeboten. Ein Rueckfall +# auf "neueste Version" installierte genau das, wovon weggenagelt wurde — eine +# Sicherung, die im Zweifel oeffnet, ist keine. +CEILING_FILE="$STATE_DIR/release-ceiling" # Ein Lebenszeichen, geschrieben als ERSTES bei jedem Lauf — vor dem Abruf der # Gegenstelle, vor allem, was blockieren kann. # @@ -329,6 +336,13 @@ sync_console_allowlist() { sync_vpn_certificate() { local stamp seen="$STATE_DIR/.vpn-cert-stamp" path + # Ohne diese Wache stirbt der ganze Agent an dieser Stelle, wenn `.env` + # fehlt: `sed` auf einer nicht vorhandenen Datei liefert Status 2, und eine + # einfache (nicht-`local`) Zuweisung reicht diesen Status unter `pipefail` + # an `set -e` weiter — bevor je eine Statusdatei geschrieben wurde. Auf + # einem echten Wirt existiert `.env` immer; das aendert hier nichts. + [[ -f "$ROOT/.env" ]] || return 0 + path="$(sed -n 's/^VPN_CERT_PATH=//p' "$ROOT/.env" 2>/dev/null | tail -1)" [[ -n "$path" ]] || return 0 @@ -368,6 +382,17 @@ json_escape() { # needed once, to record which step a failed run died at. read_phase() { cut -f1 "$PHASE_FILE" 2>/dev/null | tr -d '\r\n' || true; } +# Die wählbaren Versionen als JSON-Liste. Eigene Funktion, weil eine Schleife +# nicht in eine Here-Dokument-Ersetzung passt. +releases_json() { + local tag out='' + while read -r tag; do + [[ -n "$tag" ]] || continue + out+="\"$(json_escape "$tag")\"," + done <<< "${RELEASES:-}" + printf '%s' "${out%,}" +} + write_status() { local state="$1" error="${2-}" cat > "$STATUS.tmp" </dev/null || true)" + + if [[ ! "$CEILING" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then + CEILING_ERROR='ceiling_invalid' + fi +fi + DEPLOYED_VERSION="$(release_manifest_version)" [[ -n "$DEPLOYED_VERSION" ]] || DEPLOYED_VERSION="$(release_version)" @@ -451,25 +493,36 @@ DEPLOYED_VERSION="$(release_manifest_version)" # grosszuegig fuer einen fetch gegen EINE Gegenstelle; laenger ist kein # langsames Netz mehr, sondern eines, das nicht antwortet. if timeout -k 10 120 git fetch --quiet --tags --force origin 2>/dev/null; then - # Newest by version order, not by tag date. Through the helper rather than - # `| head -1`: head exits after one line, git takes SIGPIPE, and pipefail - # ends the agent — see release_newest_tag. - NEWEST_TAG="$(release_newest_tag)" - NEWEST_VERSION="${NEWEST_TAG#v}" + # Erst jetzt sind die Tags da, also erst jetzt laesst sich sagen, ob die + # Decke auf etwas Wirkliches zeigt. + if [[ -n "$CEILING" && -z "$CEILING_ERROR" ]] && ! release_tag_exists "$CEILING"; then + CEILING_ERROR='ceiling_missing' + fi - if [[ -n "$NEWEST_TAG" ]] && release_version_gt "$NEWEST_VERSION" "$DEPLOYED_VERSION"; then - # How many releases ahead, so the console can say "2 Aktualisierungen" - # rather than only that something exists. Both helpers live in - # deploy/lib/release.sh so a test can run them — the version that lived - # here ended the agent on every tick under `set -e`, and no test in a - # PHP suite can reach a bash pipeline. - BEHIND="$(release_tags_ahead "$DEPLOYED_VERSION")" - TARGET_RELEASE="$NEWEST_TAG" - REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')" - else - # Includes "no tags at all yet": nothing has been released, so there is - # nothing to offer, and saying "up to date" is the truth. + # Was ueberhaupt als Decke in Frage kommt — unabhaengig davon, ob gerade + # eine gesetzt ist. Ohne diese Liste hat die Konsole nichts anzubieten. + RELEASES="$(release_tags_from "$DEPLOYED_VERSION" 20)" + + if [[ -n "$CEILING_ERROR" ]]; then + # Eine kaputte Decke bietet nichts an. Bewusst kein `else`-Zweig, der + # auf die neueste Version zurueckfaellt. BEHIND=0 + else + # Newest by version order, not by tag date — und nicht ueber die Decke + # hinaus. Durch den Helfer statt `| head -1`: head exits after one + # line, git takes SIGPIPE, and pipefail ends the agent. + NEWEST_TAG="$(release_newest_tag "$CEILING")" + NEWEST_VERSION="${NEWEST_TAG#v}" + + if [[ -n "$NEWEST_TAG" ]] && release_version_gt "$NEWEST_VERSION" "$DEPLOYED_VERSION"; then + BEHIND="$(release_tags_ahead "$DEPLOYED_VERSION" "$CEILING")" + TARGET_RELEASE="$NEWEST_TAG" + REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')" + else + # Schliesst „gar keine Tags" und „Decke gleich dem Ausgelieferten" + # ein: beides heisst, es gibt nichts anzubieten. + BEHIND=0 + fi fi else # Reported, not swallowed: "cannot reach the repository" and "already up to diff --git a/docs/superpowers/plans/2026-08-04-release-decke.md b/docs/superpowers/plans/2026-08-04-release-decke.md index 5629256..4cd2ddd 100644 --- a/docs/superpowers/plans/2026-08-04-release-decke.md +++ b/docs/superpowers/plans/2026-08-04-release-decke.md @@ -275,29 +275,50 @@ use Illuminate\Support\Facades\Process; */ function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVersion = '1.7.3'): array { - $dir = storage_path('app/deploy'); - File::ensureDirectoryExists($dir); - File::delete(File::glob($dir.'/*')); + // Ein WEGWERF-Checkout mit EIGENEM .git — niemals das Repository dieses + // Arbeitsbaums. + // + // Tags liegen im gemeinsamen .git und sind damit auch fuer den Hauptbaum + // und jede Parallelsitzung sichtbar. Ein hier angelegtes v9.9.9 wuerde + // `git tag -l 'v*' --sort=-v:refname | head -1` falsch beantworten — und + // genau diese Frage entscheidet, wohin ein Server aktualisiert. Stirbt der + // Test vor seinem Aufraeumen, bliebe es liegen. + // + // Der Agent bestimmt seine Wurzel aus dem eigenen Pfad + // (`ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`). Es genuegt + // also, deploy/ zu kopieren und ihn dort zu starten. + $root = sys_get_temp_dir().'/clupilot-ceiling-'.bin2hex(random_bytes(6)); + File::ensureDirectoryExists($root.'/deploy/lib'); + File::ensureDirectoryExists($root.'/storage/app/deploy'); - // Der Agent liest die ausgelieferte Version aus dem Manifest. - File::put($dir.'/deployment.json', json_encode([ + File::copy(base_path('deploy/update-agent.sh'), $root.'/deploy/update-agent.sh'); + File::copy(base_path('deploy/lib/release.sh'), $root.'/deploy/lib/release.sh'); + + // MANIFEST_FILE ist `storage/app/deployment.json` — eine Ebene UEBER + // storage/app/deploy/. Siehe deploy/lib/release.sh:19. + File::put($root.'/storage/app/deployment.json', json_encode([ 'version' => $deployedVersion, 'commit' => 'deadbeef', 'source' => 'refs/tags/v'.$deployedVersion, ])); if ($ceiling !== null) { - File::put($dir.'/release-ceiling', $ceiling); + File::put($root.'/storage/app/deploy/release-ceiling', $ceiling); } $tagCommands = ''; foreach ($tags as $tag) { - $tagCommands .= "git tag {$tag} 2>/dev/null || true\n"; + $tagCommands .= "git tag {$tag}\n"; } - $result = Process::path(base_path())->timeout(90)->run(<<timeout(90)->run(<< "\$stub/git" <<'GIT' #!/bin/sh if [ "\$1" = "fetch" ]; then exit 0; fi @@ -306,20 +327,18 @@ function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVers chmod +x "\$stub/git" printf '#!/bin/sh\nexit 1\n' > "\$stub/docker" chmod +x "\$stub/docker" - {$tagCommands} PATH="\$stub:\$PATH" bash deploy/update-agent.sh >/dev/null 2>&1 || true rm -rf "\$stub" BASH); expect($result->successful())->toBeTrue($result->errorOutput()); - return json_decode(File::get($dir.'/update-status.json'), true); -} + $status = json_decode(File::get($root.'/storage/app/deploy/update-status.json'), true); -afterEach(function () { - File::deleteDirectory(storage_path('app/deploy')); - Process::path(base_path())->run("git tag -d v9.9.8 v9.9.9 2>/dev/null || true"); -}); + File::deleteDirectory($root); + + return $status; +} it('offers only up to the ceiling', function () { // Ausgeliefert 1.7.3, vorhanden bis v9.9.9, Decke auf v9.9.8: genau eine diff --git a/docs/superpowers/specs/2026-08-04-release-decke-design.md b/docs/superpowers/specs/2026-08-04-release-decke-design.md index 7ec9312..1d8bb17 100644 --- a/docs/superpowers/specs/2026-08-04-release-decke-design.md +++ b/docs/superpowers/specs/2026-08-04-release-decke-design.md @@ -154,9 +154,21 @@ weggenagelt hat. Stattdessen: `behind = 0`, nichts wird angeboten, und Eine Sicherung, die im Zweifel öffnet, ist keine. -Dass ein Tag verschwindet, ist hier kein Randfall: der Release-Prozess sieht -ausdrücklich vor, dass ein **falscher Tag gelöscht und die Nummer übersprungen** -wird. +`ceiling_missing` ist **nicht** die Sicherung gegen ein zurückgezogenes +Release. Der Agent holt Tags mit `git fetch --quiet --tags --force origin` +(deploy/update-agent.sh), und das entfernt **keine** lokalen Tags, die auf +der Gegenstelle verschwunden sind — dafür bräuchte es +`--prune --prune-tags`, eine Änderung, die hier bewusst nicht gemacht ist: +sie würde festlegen, was jede der zehn Server-Konsolen mit ihren eigenen +lokalen Tags tut, sobald irgendwo ein Release zurückgezogen wird, und das ist +eine Entscheidung des Besitzers, keine dieser Spezifikation. Ein Tag, den ein +Wirt einmal geholt hat, bleibt für `release_tag_exists` (deploy/lib/release.sh) +also „vorhanden", auch nachdem er drüben gelöscht wurde. + +Sinnvoll bleibt `ceiling_missing` trotzdem — nur gegen einen anderen Fall: +eine Decke, die auf einen Tag zeigt, den DIESER Wirt nie geholt hat (ein +Tippfehler, eine von Hand in die Datei geschriebene Version) oder der nie +existiert hat. Weiter: @@ -175,7 +187,7 @@ Weiter: |---|---| | `ReleaseComparisonTest` (besteht) | `release_newest_tag`/`release_tags_ahead` mit Decke — die echten Bash-Helfer | | neu, Agent | Echtes Skript gegen ein Wegwerf-Repo mit Tags: Decke gesetzt → `behind` zählt nur bis dorthin, `target_release` **ist** die Decke | -| neu, fällt zu | Decke zeigt auf gelöschten Tag → `behind = 0` **und** `ceiling_error`, nicht „neueste" | +| neu, fällt zu | Decke zeigt auf einen Tag, den dieser Wirt nie geholt hat → `behind = 0` **und** `ceiling_error`, nicht „neueste" | | neu, Automatik | `clupilot:auto-update` mit gesetzter Decke tut nichts | | neu, Konsole | Festnageln schreibt die Datei, „Decke abnehmen" löscht sie, ohne `site.manage` geht beides nicht | diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 6da0b3d..541f2d8 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -152,6 +152,30 @@ return [ '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.', + // ── Festnageln ──────────────────────────────────────────────────────── + // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt + // (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird + // nur das Festnageln selbst im Modal (R23); das Auswahlfeld daneben + // braucht keins (R20). + 'release_pin_action' => 'Festnageln', + 'release_pin_none' => 'Neueste Version (nicht festgenagelt)', + 'release_pin_title' => 'Auf diese Version festnageln?', + 'release_pin_body' => 'Dieser Server nimmt dann nichts Neueres als :version — auch nicht im Wartungsfenster. Zurück geht es nicht; die Decke begrenzt nur nach oben.', + 'release_pin_cancel' => 'Abbrechen', + 'release_pin_confirm' => 'Festnageln', + 'release_pinned' => 'Festgenagelt.', + 'release_pin_invalid' => 'Das ist keine gültige Version.', + // Eigene Meldung für eine leere Bestätigung — nicht die Erfolgsmeldung + // von oben. Eine leere Auswahl darf niemals als "Decke abnehmen" gelten, + // nur weil setCeiling(null) genau das bedeutet. + 'release_pin_empty' => 'Keine Version ausgewählt — nichts wurde geändert.', + 'release_pinned_at' => 'Festgenagelt auf :version.', + 'release_pinned_passed' => 'Festgenagelt auf :version — dieser Server ist bereits weiter. Die Decke hält nichts mehr zurück; zurück geht es nicht.', + 'release_unpin_action' => 'Decke abnehmen', + 'release_unpinned' => 'Decke abgenommen.', + 'release_ceiling_ceiling_missing' => 'Die festgenagelte Version gibt es nicht (mehr). Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.', + 'release_ceiling_ceiling_invalid' => 'Die festgenagelte Version ist unlesbar. Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.', + '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.', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 1de8b8c..ada1cd6 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -149,6 +149,30 @@ return [ 'release_lock_requested' => 'Releasing — the service reports back within a minute.', 'release_lock_already_requested' => 'Already requested — please wait a moment.', + // ── Pinning a ceiling ──────────────────────────────────────────────── + // The ceiling that limits both the update agent AND the maintenance + // window (App\Services\Deployment\UpdateChannel::setCeiling()). Only + // pinning itself is confirmed in a modal (R23); the select beside it + // needs none (R20). + 'release_pin_action' => 'Pin', + 'release_pin_none' => 'Newest release (not pinned)', + 'release_pin_title' => 'Pin to this release?', + 'release_pin_body' => 'This server will then take nothing newer than :version — not in the maintenance window either. There is no way back; the ceiling only limits upwards.', + 'release_pin_cancel' => 'Cancel', + 'release_pin_confirm' => 'Pin', + 'release_pinned' => 'Pinned.', + 'release_pin_invalid' => 'That is not a valid release.', + // Its own message for an empty confirmation — not the success message + // above. An empty selection must never count as "remove the ceiling" + // just because setCeiling(null) means exactly that. + 'release_pin_empty' => 'No release selected — nothing was changed.', + 'release_pinned_at' => 'Pinned to :version.', + 'release_pinned_passed' => 'Pinned to :version — this server is already past it. The ceiling no longer holds anything back; there is no way back.', + 'release_unpin_action' => 'Remove ceiling', + 'release_unpinned' => 'Ceiling removed.', + 'release_ceiling_ceiling_missing' => 'The pinned release no longer exists. Nothing will be installed until this is fixed — pin again or remove the ceiling.', + 'release_ceiling_ceiling_invalid' => 'The pinned release is unreadable. Nothing will be installed until this is fixed — pin again or remove the ceiling.', + '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.', diff --git a/resources/views/livewire/admin/confirm-pin-release.blade.php b/resources/views/livewire/admin/confirm-pin-release.blade.php new file mode 100644 index 0000000..549b5b3 --- /dev/null +++ b/resources/views/livewire/admin/confirm-pin-release.blade.php @@ -0,0 +1,19 @@ +{{-- Zweizeilige Rückfrage mit einem Knopf: braucht nach R24 kein + , weil nichts darin scrollen kann. --}} +
+
+ + + +
+

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

+

{{ __('admin_settings.release_pin_body', ['version' => ltrim($version, 'v')]) }}

+
+
+
+ {{ __('admin_settings.release_pin_cancel') }} + + {{ __('admin_settings.release_pin_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index d4fbd00..a24c59f 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -173,10 +173,68 @@ {{ __('admin_settings.update_now') }} + + {{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren + statt einer zweiten Leiste daneben — dieselbe Begründung wie bei + den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein + Modal für das Auswahlfeld selbst (R20 gilt fürs Bearbeiten + bestehender Datensätze in Tabellenzeilen, nicht für ein + Auswahlfeld, das Teil der Seite ist); bestätigt wird nur das + Festnageln selbst im Modal (R23), weil die Wahl entscheidet, + welche Fassung dieser Server je bekommt. --}} + @if ($update['releases']) + + + + + {{ __('admin_settings.release_pin_action') }} + + @endif @endif + {{-- Der Zustand der Decke selbst — direkt unter der Leiste, die sie + setzt. „Festgenagelt, aber bereits weiter" ist ein eigener Satz und + nicht dieselbe Zeile mit anderer Zahl: wer v1.8.0 festgenagelt hat + und v1.8.1 laufen sieht, muss erfahren, dass die Decke nichts mehr + tut — sonst liest er sie als Zusicherung, die sie nicht ist. Zurück + geht es nicht (update.sh:222). + + Der Knopf steht auch OHNE `$update['ceiling']`, sobald + `ceiling_error` etwas meldet: eine von Hand geleerte + Deckendatei liest die Konsole (UpdateChannel::ceiling()) + als "keine Decke" — leerer Inhalt wird zu null —, während + der Agent dieselbe leere Datei als `ceiling_invalid` + zurückmeldet. Ohne diese Bedingung verschwand der einzige + Ort mit dem "Decke abnehmen"-Knopf genau in dem Zustand, + den nur eine Hand am Wirt erzeugen kann und aus dem die + Konsole ohne Kommandozeile zurückführen muss. --}} + @if ($update['ceiling'] || $update['ceiling_error']) +

+ @if ($update['ceiling']) + {{ $update['ceiling_passed'] + ? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')]) + : __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }} + @endif + +

+ @endif + + @if ($update['ceiling_error']) + + {{ __('admin_settings.release_ceiling_'.$update['ceiling_error']) }} + + @endif + @if (! $update['agent_seen']) {{ __('admin_settings.update_no_agent') }} @elseif ($update['blocked_since']) diff --git a/tests/Feature/ReleaseCeilingAgentTest.php b/tests/Feature/ReleaseCeilingAgentTest.php new file mode 100644 index 0000000..146a9fb --- /dev/null +++ b/tests/Feature/ReleaseCeilingAgentTest.php @@ -0,0 +1,123 @@ + $deployedVersion, + 'commit' => 'deadbeef', + 'source' => 'refs/tags/v'.$deployedVersion, + ])); + + if ($ceiling !== null) { + File::put($root.'/storage/app/deploy/release-ceiling', $ceiling); + } + + $tagCommands = ''; + foreach ($tags as $tag) { + $tagCommands .= "git tag {$tag}\n"; + } + + $result = Process::path($root)->timeout(90)->run(<< "\$stub/git" <<'GIT' + #!/bin/sh + if [ "\$1" = "fetch" ]; then exit 0; fi + exec /usr/bin/git "\$@" + GIT + chmod +x "\$stub/git" + printf '#!/bin/sh\nexit 1\n' > "\$stub/docker" + chmod +x "\$stub/docker" + PATH="\$stub:\$PATH" bash deploy/update-agent.sh >/dev/null 2>&1 || true + rm -rf "\$stub" + BASH); + + expect($result->successful())->toBeTrue($result->errorOutput()); + + $status = json_decode(File::get($root.'/storage/app/deploy/update-status.json'), true); + + File::deleteDirectory($root); + + return $status; +} + +it('offers only up to the ceiling', function () { + // Ausgeliefert 1.7.3, vorhanden bis v9.9.9, Decke auf v9.9.8: genau eine + // Aktualisierung, und das Ziel ist die Decke. + $status = runAgentWithCeiling('v9.9.8', ['v9.9.8', 'v9.9.9']); + + expect($status['target_release'])->toBe('v9.9.8') + ->and($status['behind'])->toBe(1) + ->and($status['ceiling'])->toBe('v9.9.8') + ->and($status['ceiling_error'])->toBe(''); +}); + +it('offers nothing when the ceiling points at a tag that does not exist', function () { + // Die Decke faellt ZU. Ein Rueckfall auf "neueste" installierte genau das, + // wovon der Besitzer weggenagelt hat. + $status = runAgentWithCeiling('v9.9.7', ['v9.9.8', 'v9.9.9']); + + expect($status['behind'])->toBe(0) + ->and($status['ceiling_error'])->toBe('ceiling_missing') + ->and($status['target_release'])->toBe(''); +}); + +it('offers nothing when the ceiling is malformed', function () { + $status = runAgentWithCeiling('neueste bitte', ['v9.9.8', 'v9.9.9']); + + expect($status['behind'])->toBe(0) + ->and($status['ceiling_error'])->toBe('ceiling_invalid'); +}); + +it('behaves exactly as before without a ceiling', function () { + $status = runAgentWithCeiling(null, ['v9.9.8', 'v9.9.9']); + + expect($status['target_release'])->toBe('v9.9.9') + ->and($status['ceiling'])->toBe('') + ->and($status['ceiling_error'])->toBe(''); +}); + +it('reports the versions that may be pinned to', function () { + $status = runAgentWithCeiling(null, ['v9.9.8', 'v9.9.9']); + + expect($status['releases'])->toContain('v9.9.9') + ->and($status['releases'])->toContain('v9.9.8'); +}); diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php new file mode 100644 index 0000000..b5a4b5f --- /dev/null +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -0,0 +1,339 @@ +setCeiling('chef@example.com', 'v1.8.0'))->toBeTrue() + ->and(trim(File::get($path)))->toBe('v1.8.0'); + + expect($channel->setCeiling('chef@example.com', null))->toBeTrue() + ->and(File::exists($path))->toBeFalse(); +}); + +it('refuses a ceiling that is not a version', function () { + // Die Form wird auf BEIDEN Seiten geprueft. Der Agent kann sich auf nichts + // verlassen, was aus einer Datei kommt — und die Konsole soll gar nicht + // erst etwas hinlegen, das er ablehnen muss. + $channel = app(UpdateChannel::class); + + expect($channel->setCeiling('chef@example.com', 'neueste'))->toBeFalse() + ->and(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse(); +}); + +it('leaves no temporary file behind', function () { + // Atomar heisst: der Agent sieht die Datei ganz oder gar nicht. Bliebe die + // .tmp liegen, waere das der Beleg fuer ein File::put statt eines rename. + app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); + + expect(File::exists(storage_path('app/deploy/release-ceiling.tmp')))->toBeFalse(); +}); + +it('reports the ceiling from the file, not from the agent', function () { + // Die Decke ist der Konsole eigener Zustand. Sie muss sofort stehen, statt + // zu warten, bis der Agent sie zurueckmeldet. + app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); + + expect(app(UpdateChannel::class)->state()['ceiling'])->toBe('v1.8.0'); +}); + +it('passes the ceiling complaint from the agent through', function () { + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'ceiling_error' => 'ceiling_missing', + 'releases' => ['v1.8.1', 'v1.8.0'], + ])); + + $state = app(UpdateChannel::class)->state(); + + expect($state['ceiling_error'])->toBe('ceiling_missing') + ->and($state['releases'])->toBe(['v1.8.1', 'v1.8.0']); +}); + +it('marks a ceiling that sits below what is deployed', function () { + // Über das Auswahlfeld nicht erreichbar, über die Kommandozeile schon: + // Decke auf v1.8.0, jemand fährt `RELEASE=v1.8.1 update.sh`. Die Lage + // muss lesbar sein statt als „aktuell" durchzugehen — es steht ja eine + // Decke, sie ist nur überholt. + File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0'); + + $state = app(UpdateChannel::class)->state(); + + // `version` kommt aus Release::current(); in der Testumgebung ist das die + // VERSION-Datei des Checkouts, die über 1.8.0 liegt. + expect($state['ceiling_passed'])->toBeTrue(); +}); + +it('does not mark a ceiling that is still ahead', function () { + File::put(storage_path('app/deploy/release-ceiling'), 'v99.0.0'); + + expect(app(UpdateChannel::class)->state()['ceiling_passed'])->toBeFalse(); +}); + +it('does not throw when the ceiling file exists but cannot be read as a file', function () { + // Kein echtes Wettrennen im Test moeglich, aber derselbe Codepfad wie ein + // Verschwinden zwischen File::exists() und File::get(): ein Verzeichnis + // existiert (File::exists() prueft file_exists(), das ist fuer + // Verzeichnisse wahr), ist aber keine Datei (File::get() prueft + // isFile() und wirft eine FileNotFoundException). Ohne Absicherung + // wuerfe state() genau hier — die Vorgabe, die es unter keinen + // Umstaenden darf. + // + // Bewusst kein expect(fn () => ...)->not->toThrow(Throwable::class): + // Throwable ist ein Interface, class_exists() verneint es, und die + // Pruefung faellt intern auf einen Substring-Vergleich zurueck, der + // nichts mehr ueber ein tatsaechliches Werfen aussagt. Ein direkter + // Aufruf laesst eine durchschlagende Exception den Test regulaer als + // fehlgeschlagen melden — das ist der verlaessliche Nachweis. + File::ensureDirectoryExists(storage_path('app/deploy/release-ceiling')); + + $state = app(UpdateChannel::class)->state(); + + expect($state['ceiling'])->toBeNull(); +}); + +it('does not claim success when writing the ceiling fails', function () { + // Kein File-Facade-Mock: eine echte Rechteverweigerung auf einem + // Verzeichnis, das dieser (nicht-root) Testbenutzer selbst besitzt. + // File::put() auf die .tmp darin schlaegt wirklich fehl, nicht nur dem + // Namen nach. + $deployDir = storage_path('app/deploy'); + chmod($deployDir, 0500); + + try { + $result = app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); + } finally { + chmod($deployDir, 0755); + } + + expect($result)->toBeFalse() + ->and(File::exists($deployDir.'/release-ceiling.tmp'))->toBeFalse() + ->and(File::exists($deployDir.'/release-ceiling'))->toBeFalse(); +}); + +it('makes the automatic window do nothing while the ceiling is reached', function () { + // Der wichtigste Test des ganzen Entwurfs. Er belegt, dass Knopf und + // Wartungsfenster denselben Zustand lesen — die Behauptung, auf der alles + // ruht. Ohne ihn ist „es gibt keinen zweiten Weg in eine Auslieferung" + // eine Absichtserklaerung im Kommentar von AutoUpdate.php, kein Beleg. + // + // Damit ALLEIN die Decke den Ausschlag gibt, muessen alle anderen + // Vorbedingungen aus AutoUpdate::handle() erfuellt sein: das Fenster ist + // eingeschaltet (--force uebergeht nur isOpen(), nicht enabled() — ein + // ausgeschaltetes Fenster wuerde den Test aus dem falschen Grund gruen + // machen), der Agent gilt als lebendig, und es laeuft nichts. Der Agent + // hat gemeldet: nichts verfuegbar, weil die Decke erreicht ist. + AppSettings::set(UpdateWindow::KEY_ENABLED, true); + + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'behind' => 0, + 'ceiling_error' => '', + 'checked_at' => now()->toIso8601String(), + ])); + File::put(storage_path('app/deploy/agent-alive.json'), json_encode([ + 'at' => now()->toIso8601String(), + 'state' => 'running', + 'skips' => 0, + ])); + + // Der Zustand, den Knopf und Wartungsfenster BEIDE lesen: schon hier + // steht `available` auf false, bevor der Befehl ueberhaupt laeuft. + expect(app(UpdateChannel::class)->state()['available'])->toBeFalse(); + + $this->artisan('clupilot:auto-update', ['--force' => true]) + ->assertSuccessful(); + + // Nichts abgelegt: keine Anfrage, kein Wartungsfenster, kein Lauf. + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('takes the same state without a ceiling, and does request an update', function () { + // Der Gegenbeweis zum Test oben: exakt derselbe Aufbau — Fenster an, Agent + // lebendig, nichts laeuft — nur `behind` steht auf 1 statt 0, so wie der + // Agent es meldet, wenn keine Decke ihn bremst. Erst beide Tests + // zusammen belegen, dass die Decke — und keine der anderen + // Vorbedingungen — den Ausschlag gibt: ohne sie legt dieselbe Automatik + // sehr wohl eine Anfrage ab. + AppSettings::set(UpdateWindow::KEY_ENABLED, true); + + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'behind' => 1, + 'ceiling_error' => '', + 'checked_at' => now()->toIso8601String(), + ])); + File::put(storage_path('app/deploy/agent-alive.json'), json_encode([ + 'at' => now()->toIso8601String(), + 'state' => 'running', + 'skips' => 0, + ])); + + expect(app(UpdateChannel::class)->state()['available'])->toBeTrue(); + + $this->artisan('clupilot:auto-update', ['--force' => true]) + ->assertSuccessful(); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue(); +}); + +it('pins from the console', function () { + // Rolle, nicht Einzelberechtigung: die siebzehn Konsolen-Berechtigungen + // hängen am `operator`-Guard über Rollen (R21). `->role('Owner')` ist das + // Muster der übrigen Tests, z. B. SwitchOperatingModeTest. + $owner = Operator::factory()->role('Owner')->create(); + + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'releases' => ['v1.8.1', 'v1.8.0'], + ])); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->set('ceilingChoice', 'v1.8.0') + ->call('pinRelease', 'v1.8.0'); + + expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0'); +}); + +it('takes the ceiling off again', function () { + $owner = Operator::factory()->role('Owner')->create(); + File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0'); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->call('unpinRelease'); + + expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse(); +}); + +it('refuses to pin without site.manage', function () { + // Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. Die eine Stelle, an + // der eine Regel NICHT allein stehen darf, ist das disabled-Attribut eines + // Knopfes. + // + // Angemeldet, aber ohne die Rolle, die `site.manage` traegt: `site.manage` + // liegt am operator-Guard bei Owner und Admin (siehe die Migration + // 2026_07_25_220001 und die Begruendung in 2026_08_04_160000). Geprueft + // wird also die Berechtigung, nicht die Anmeldung. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(Settings::class) + ->set('ceilingChoice', 'v1.8.0') + ->call('pinRelease', 'v1.8.0') + ->assertForbidden(); +}); + +it('does not let an empty confirmation take the ceiling off while reporting success', function () { + // BEFUND 1: `setCeiling($operator->email, $this->ceilingChoice ?: null)` + // liess eine leere Auswahl als "Decke abnehmen" durchgehen — und meldete + // dabei `release_pinned`, die Erfolgsmeldung der GEGENHANDLUNG. Erreichbar + // war das, weil das Auswahlfeld `