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'); +});