From d4a9166c7abb6eca3ee86c7c47ba589a835aaddf Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 14:38:59 +0200 Subject: [PATCH 1/9] Plan-Korrektur vor der Umsetzung: Task-2-Test isolieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Defekte im Vorab-Abgleich gefunden, beide im Testaufbau von Task 2: - Der Test haette echte Tags (v9.9.8, v9.9.9) im GETEILTEN Repository angelegt. Tags liegen im gemeinsamen .git und sind damit auch fuer den Hauptbaum und jede Parallelsitzung sichtbar. Stirbt der Test vor seinem Aufraeumen, beantwortet ein liegengebliebenes v9.9.9 die Frage `git tag -l 'v*' --sort=-v:refname | head -1` falsch — und die entscheidet, wohin ein Server aktualisiert. Jetzt laeuft der Agent in einem Wegwerf- Checkout mit eigenem .git; er bestimmt seine Wurzel ohnehin aus dem eigenen Pfad, es genuegt also, deploy/ dorthin zu kopieren. - Falscher Manifest-Pfad: der Test schrieb nach storage/app/deploy/ deployment.json, gelesen wird storage/app/deployment.json (lib/release.sh:19) — eine Ebene darueber. Der Test haette die ausgelieferte Version nie gesetzt und etwas anderes gemessen, als er behauptet. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-04-release-decke.md | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) 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 From cfae6e990a2399e1faebb4235282e7225d126f60 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 14:41:18 +0200 Subject: [PATCH 2/9] Versions-Arithmetik kennt eine Decke --- deploy/lib/release.sh | 64 ++++++++++++++++++++--- tests/Feature/ReleaseComparisonTest.php | 69 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index ef53480..4cd9117 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -51,7 +51,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 +64,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 +78,70 @@ 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: der Release-Prozess loescht einen falschen Tag und +# ueberspringt die Nummer, eine Decke kann also ohne Zutun dessen, der sie +# gesetzt hat, ins Leere zeigen. +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/tests/Feature/ReleaseComparisonTest.php b/tests/Feature/ReleaseComparisonTest.php index 1634ddd..4042b1a 100644 --- a/tests/Feature/ReleaseComparisonTest.php +++ b/tests/Feature/ReleaseComparisonTest.php @@ -143,3 +143,72 @@ it('answers the pairwise comparison the same way, and exits cleanly either way', ->and(trim($process->getOutput()))->toBe($expected, "{$a} > {$b}"); } }); + +it('stops at the ceiling instead of taking the newest tag', function () { + // Der Kern des Festnagelns: v1.8.1 existiert, darf aber nicht genommen + // werden, weil die Decke auf v1.8.0 steht. + $p = runRelease('release_newest_tag v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('v1.8.0'); +}); + +it('takes the newest tag when no ceiling is given', function () { + // Das bisherige Verhalten muss unveraendert bleiben — ohne Decke aendert + // sich nichts an einem Server, der nie festgenagelt wurde. + $p = runRelease('release_newest_tag', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('v1.8.1'); +}); + +it('answers nothing when every tag is above the ceiling', function () { + $p = runRelease('release_newest_tag v1.0.0', ['v1.7.3', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe(''); +}); + +it('counts only the tags up to the ceiling as ahead', function () { + // Ausgeliefert 1.7.3, Decke v1.8.0, vorhanden bis v1.8.2: genau EINE + // Aktualisierung ist verfuegbar, nicht drei. + $p = runRelease('release_tags_ahead 1.7.3 v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1', 'v1.8.2']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('1'); +}); + +it('counts nothing ahead when the ceiling equals what is deployed', function () { + // Der haeufigste Fall: "hier einfrieren". Muss sich wie "aktuell" + // rechnen, sonst bietet die Konsole etwas an, das nie installiert wird. + $p = runRelease('release_tags_ahead 1.8.0 v1.8.0', ['v1.8.0', 'v1.8.1', 'v1.8.2']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('0'); +}); + +it('knows whether a tag really exists', function () { + // Form und Existenz sind zwei Fragen. Der Release-Prozess loescht einen + // falschen Tag und ueberspringt die Nummer — eine Decke kann also ohne + // Zutun ihres Setzers ins Leere zeigen. + $p = runRelease('release_tag_exists v1.8.0 && echo ja; release_tag_exists v9.9.9 || echo nein', ['v1.8.0']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("ja\nnein"); +}); + +it('offers the deployed version itself as a ceiling, newest first', function () { + // Die ausgelieferte Version steht MIT in der Liste: "einfrieren, nichts + // Neues nehmen" ist das, was neun von zehn Servern brauchen. + $p = runRelease('release_tags_from 1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("v1.8.1\nv1.8.0"); +}); + +it('caps the offered list', function () { + $p = runRelease('release_tags_from 1.0.0 2', ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v1.3.0']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("v1.3.0\nv1.2.0"); +}); From 63328d44d43f5301408444868d688c9ba3fdbb8e Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 14:53:14 +0200 Subject: [PATCH 3/9] Der Agent klemmt Ziel und Zaehler an der Decke --- deploy/update-agent.sh | 87 ++++++++++++--- tests/Feature/ReleaseCeilingAgentTest.php | 123 ++++++++++++++++++++++ 2 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 tests/Feature/ReleaseCeilingAgentTest.php 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/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'); +}); From 4b2c2cd315a97363dc949e9db0bac2dbd521b35c Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 15:06:12 +0200 Subject: [PATCH 4/9] release_manifest_version ueberlebt ein fehlendes Manifest --- deploy/lib/release.sh | 11 ++++++++++- tests/Feature/ReleaseComparisonTest.php | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index 4cd9117..3300009 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. diff --git a/tests/Feature/ReleaseComparisonTest.php b/tests/Feature/ReleaseComparisonTest.php index 4042b1a..fccd3ca 100644 --- a/tests/Feature/ReleaseComparisonTest.php +++ b/tests/Feature/ReleaseComparisonTest.php @@ -115,6 +115,21 @@ it('answers with nothing, and cleanly, when there are no releases yet', function ->and(trim($process->getOutput()))->toBe('[0]'); }); +it('survives a missing manifest instead of taking the agent down with it', function () { + // Kein storage/app/deployment.json in diesem Wegwerf-Repo — der Zustand + // eines frischen Checkouts vor der ersten Installation. `sed` auf einer + // fehlenden Datei liefert Status 2; die Zeile, die diese Funktion in + // update-agent.sh tatsaechlich verbraucht — eine reine Zuweisung, + // `DEPLOYED_VERSION="$(release_manifest_version)"` — reicht diesen + // Status unter `pipefail` an `set -e` weiter. Ohne Absicherung stirbt der + // Agent bei JEDEM Tick, bevor er je eine Statusdatei schreibt — dieselbe + // Fehlerklasse wie bei sync_vpn_certificate und einem fehlenden `.env`. + $process = runRelease('DEPLOYED_VERSION="$(release_manifest_version)"; echo "[$?][$DEPLOYED_VERSION]"'); + + expect($process->getExitCode())->toBe(0) + ->and(trim($process->getOutput()))->toBe('[0][]'); +}); + it('orders by version, not alphabetically', function () { // v1.10.0 is newer than v1.9.0 and sorts earlier as a string. Getting this // wrong means a server sitting on 1.9.0 is told it is current. From 367198459cb1515a7f4dba9db41674b5a8d8a5f7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 15:13:43 +0200 Subject: [PATCH 5/9] Der Kanal schreibt die Decke atomar und reicht sie durch --- app/Services/Deployment/UpdateChannel.php | 101 ++++++++++++++++++++ tests/Feature/ReleaseCeilingConsoleTest.php | 83 ++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tests/Feature/ReleaseCeilingConsoleTest.php diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index d648981..97d063e 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,76 @@ 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, es + * wurde nichts geschrieben. + * + * 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; + } + + $this->writeAtomic(self::CEILING, $tag); + $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. + */ + private function ceiling(): ?string + { + $path = storage_path('app/'.self::CEILING); + + if (! File::exists($path)) { + return null; + } + + $tag = trim((string) File::get($path)); + + return $tag === '' ? null : $tag; + } + + /** + * 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. + */ + private function writeAtomic(string $relative, string $contents): void + { + $path = storage_path('app/'.$relative); + File::ensureDirectoryExists(dirname($path)); + File::put($path.'.tmp', $contents); + File::move($path.'.tmp', $path); + } + /** * Den Proxy die Hostnamen aus der Konsole übernehmen lassen. * diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php new file mode 100644 index 0000000..c381314 --- /dev/null +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -0,0 +1,83 @@ +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(); +}); From c93510ffe446d66ddbfc00a219e542b600714336 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 15:30:10 +0200 Subject: [PATCH 6/9] Fix-Runde 1: writeAtomic prueft Rueckgabewerte, ceiling() faengt Throwable Zwei Important-Befunde aus dem Code-Review zu Task 3: - writeAtomic() ignorierte den Rueckgabewert von File::put()/File::move() und meldete setCeiling() als "true", selbst wenn ein I/O-Fehler (volle Platte, Rechteproblem) nichts geschrieben oder eine .tmp liegen gelassen hatte. Beide Rueckgabewerte werden jetzt geprueft, eine liegen gebliebene .tmp wird im Fehlerfall aufgeraeumt, und der Fehlschlag wird bis zu setCeiling() durchgereicht (Rueckgabe false). - ceiling() konnte state() doch werfen lassen: zwischen File::exists() und File::get() liegt ein Zeitfenster, und File::get() wirft eine FileNotFoundException, wenn die Datei dazwischen verschwindet. readJson() und lastLog() kapseln genau dieses Muster schon in try/catch(Throwable); ceiling() zieht jetzt nach. Beide Befunde tragen einen eigenen Test: ein Verzeichnis an der Ceiling- Datei-Stelle (exists() wahr, get() wirft) fuer den zweiten, eine echte Rechteverweigerung (chmod 0500 als nicht-root Testbenutzer) fuer den ersten -- kein Facade-Mock noetig. --- app/Services/Deployment/UpdateChannel.php | 62 ++++++++++++++++----- tests/Feature/ReleaseCeilingConsoleTest.php | 41 ++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 97d063e..80dc2fe 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -593,8 +593,10 @@ final class UpdateChannel /** * Den Server auf eine Version festnageln — oder die Decke abnehmen. * - * `null` nimmt sie ab. Rückgabe `false` heißt: die Form stimmt nicht, es - * wurde nichts geschrieben. + * `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 @@ -619,7 +621,10 @@ final class UpdateChannel return false; } - $this->writeAtomic(self::CEILING, $tag); + if (! $this->writeAtomic(self::CEILING, $tag)) { + return false; + } + $this->requestCheck($by); return true; @@ -631,18 +636,28 @@ final class UpdateChannel * 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. */ private function ceiling(): ?string { - $path = storage_path('app/'.self::CEILING); + try { + $path = storage_path('app/'.self::CEILING); - if (! File::exists($path)) { + if (! File::exists($path)) { + return null; + } + + $tag = trim((string) File::get($path)); + + return $tag === '' ? null : $tag; + } catch (Throwable) { return null; } - - $tag = trim((string) File::get($path)); - - return $tag === '' ? null : $tag; } /** @@ -651,13 +666,34 @@ final class UpdateChannel * `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): void + private function writeAtomic(string $relative, string $contents): bool { $path = storage_path('app/'.$relative); - File::ensureDirectoryExists(dirname($path)); - File::put($path.'.tmp', $contents); - File::move($path.'.tmp', $path); + $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; + } } /** diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php index c381314..81f9c08 100644 --- a/tests/Feature/ReleaseCeilingConsoleTest.php +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -81,3 +81,44 @@ it('does not mark a ceiling that is still ahead', function () { 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(); +}); From d90137c521fbfdaee53817c798bf0421e90b82b7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 15:39:02 +0200 Subject: [PATCH 7/9] Beleg: das Wartungsfenster achtet die Decke ohne eigene Regel --- tests/Feature/ReleaseCeilingConsoleTest.php | 68 +++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php index 81f9c08..31ba07a 100644 --- a/tests/Feature/ReleaseCeilingConsoleTest.php +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -1,6 +1,8 @@ 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(); +}); From 073e147ff840bdf53038eabd744e6eb7198f3bb9 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 15:53:36 +0200 Subject: [PATCH 8/9] Festnageln aus der Konsole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5: Bedienoberfläche zum Festnageln einer Release-Version, in der bestehenden Update-Handlungsleiste statt einer zweiten daneben. Bestätigt im Modal (R23), Auswahlfeld selbst ohne Modal (R20). --- app/Livewire/Admin/ConfirmPinRelease.php | 44 ++++++++++++++++ app/Livewire/Admin/Settings.php | 46 +++++++++++++++++ lang/de/admin_settings.php | 20 ++++++++ lang/en/admin_settings.php | 20 ++++++++ .../admin/confirm-pin-release.blade.php | 19 +++++++ .../views/livewire/admin/settings.blade.php | 46 +++++++++++++++++ tests/Feature/ReleaseCeilingConsoleTest.php | 51 +++++++++++++++++++ 7 files changed, 246 insertions(+) create mode 100644 app/Livewire/Admin/ConfirmPinRelease.php create mode 100644 resources/views/livewire/admin/confirm-pin-release.blade.php diff --git a/app/Livewire/Admin/ConfirmPinRelease.php b/app/Livewire/Admin/ConfirmPinRelease.php new file mode 100644 index 0000000..78ee5b6 --- /dev/null +++ b/app/Livewire/Admin/ConfirmPinRelease.php @@ -0,0 +1,44 @@ +authorize('site.manage'); + + $this->version = $version; + } + + public function confirm(): void + { + $this->authorize('site.manage'); + + $this->dispatch('pin-release-confirmed'); + $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..f31ca03 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -717,6 +717,52 @@ 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. + */ + #[On('pin-release-confirmed')] + public function pinRelease(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + $accepted = app(UpdateChannel::class)->setCeiling($operator->email, $this->ceilingChoice ?: null); + + $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/lang/de/admin_settings.php b/lang/de/admin_settings.php index 6da0b3d..a034307 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -152,6 +152,26 @@ 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.', + '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..e861919 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -149,6 +149,26 @@ 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.', + '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..fe37b71 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -173,10 +173,56 @@ {{ __('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). --}} + @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 + + @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/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php index 31ba07a..478983d 100644 --- a/tests/Feature/ReleaseCeilingConsoleTest.php +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -1,9 +1,12 @@ 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'); + + 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') + ->assertForbidden(); +}); From 24eb7b3d80693613dd323ed98dd01100020e8e75 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 16:25:48 +0200 Subject: [PATCH 9/9] Fix-Welle: Schlussreview-Befunde 1-6 zur Release-Decke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sechs Befunde aus dem Schlussreview, in einer Welle behoben: - BEFUND 1 (Important): eine leere Auswahl im Festnageln-Feld liess pinRelease() ueber `$this->ceilingChoice ?: null` in setCeiling(null) laufen — die Gegenhandlung (Decke abnehmen) — und meldete dabei die Erfolgsmeldung des Festnagelns. ConfirmPinRelease::confirm() schickt die Version jetzt als Event-Nutzlast (wie ConfirmSaveSecret den Schluessel), und pinRelease(string $version) weist eine leere Version ausdruecklich ab, mit einer eigenen Meldung (release_pin_empty). - BEFUND 2 (Minor, durch 1 miterledigt): Modal und Seite lasen bisher zwei getrennte Eigenschaften ($version vs. $ceilingChoice). Der Fix oben beseitigt die Trennung. - BEFUND 3 (Important, Text only): der Kommentar bei release_tag_exists() in deploy/lib/release.sh und der Fehlerbehandlungs-Abschnitt der Spec behaupteten, ceiling_missing schuetze gegen einen vom Release-Prozess geloeschten Tag. Tut es nicht: `git fetch --tags --force` (ohne --prune-tags, bewusst) entfernt keine lokal bereits geholten Tags, die drueben verschwunden sind. Beide Stellen beschreiben jetzt, wogegen die Pruefung tatsaechlich schuetzt (ein nie geholter oder nie existierender Tag) und wogegen nicht. Kein --prune-tags hinzugefuegt. - BEFUND 4 (Minor): ConfirmPinRelease hatte keinen Test. Zwei neue Tests nach dem Vorbild von ConfirmSaveSecret in IntegrationsPageTest. - BEFUND 5 (Minor): ceilingChoice wurde nie aus dem gesetzten Zustand vorbelegt. UpdateChannel::ceiling() ist jetzt public, Settings::mount() belegt das Feld damit vor. - BEFUND 6 (Minor): eine von Hand geleerte Deckendatei liest die Konsole als "keine Decke" (ceiling() -> null), der Agent meldet dafuer aber ceiling_error. Der "Decke abnehmen"-Knopf stand nur hinter @if($update['ceiling']) und verschwand damit genau in dem Zustand, aus dem er zurueckfuehren muesste. Bedingung erweitert auf ($update['ceiling'] || $update['ceiling_error']). Jeder Befund traegt einen eigenen Test in ReleaseCeilingConsoleTest.php. Volle Suite: 2973 passed (10389 assertions). Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/ConfirmPinRelease.php | 9 +- app/Livewire/Admin/Settings.php | 28 ++++- app/Services/Deployment/UpdateChannel.php | 8 +- deploy/lib/release.sh | 18 +++- .../specs/2026-08-04-release-decke-design.md | 20 +++- lang/de/admin_settings.php | 4 + lang/en/admin_settings.php | 4 + .../views/livewire/admin/settings.blade.php | 22 +++- tests/Feature/ReleaseCeilingConsoleTest.php | 100 +++++++++++++++++- 9 files changed, 195 insertions(+), 18 deletions(-) diff --git a/app/Livewire/Admin/ConfirmPinRelease.php b/app/Livewire/Admin/ConfirmPinRelease.php index 78ee5b6..09e7d43 100644 --- a/app/Livewire/Admin/ConfirmPinRelease.php +++ b/app/Livewire/Admin/ConfirmPinRelease.php @@ -33,7 +33,14 @@ class ConfirmPinRelease extends ModalComponent { $this->authorize('site.manage'); - $this->dispatch('pin-release-confirmed'); + // 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(); } diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index f31ca03..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() ?? ''; } /** @@ -730,9 +736,21 @@ class Settings extends Component * `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(): void + public function pinRelease(string $version): void { $this->authorize('site.manage'); @@ -740,7 +758,13 @@ class Settings extends Component return; } - $accepted = app(UpdateChannel::class)->setCeiling($operator->email, $this->ceilingChoice ?: null); + 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' diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 80dc2fe..20590ec 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -642,8 +642,14 @@ final class UpdateChannel * 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. */ - private function ceiling(): ?string + public function ceiling(): ?string { try { $path = storage_path('app/'.self::CEILING); diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index 3300009..25849bd 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -118,9 +118,21 @@ release_tags_ahead() { # 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: der Release-Prozess loescht einen falschen Tag und -# ueberspringt die Nummer, eine Decke kann also ohne Zutun dessen, der sie -# gesetzt hat, ins Leere zeigen. +# 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 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 a034307..541f2d8 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -165,6 +165,10 @@ return [ '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', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index e861919..ada1cd6 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -162,6 +162,10 @@ return [ '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', diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index fe37b71..a24c59f 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -207,12 +207,24 @@ 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). --}} - @if ($update['ceiling']) + 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'])

- {{ $update['ceiling_passed'] - ? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')]) - : __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }} + @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 diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php index 478983d..b5a4b5f 100644 --- a/tests/Feature/ReleaseCeilingConsoleTest.php +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -1,5 +1,6 @@ test(Settings::class) ->set('ceilingChoice', 'v1.8.0') - ->call('pinRelease'); + ->call('pinRelease', 'v1.8.0'); expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0'); }); @@ -238,6 +239,101 @@ it('refuses to pin without site.manage', function () { Livewire::actingAs($staff, 'operator') ->test(Settings::class) ->set('ceilingChoice', 'v1.8.0') - ->call('pinRelease') + ->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 `