From 512fad11fbcd7d8276a48862c2ae8ff179a4bfb8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:21:54 +0200 Subject: [PATCH] Die gebrauchte Vertragsversion steht an einer Stelle und wird gemeldet --- app/Services/Deployment/UpdateChannel.php | 14 ++++ deploy/lib/release.sh | 14 ++++ deploy/update-agent.sh | 20 +++++- deploy/update.sh | 8 ++- tests/Feature/HostStepContractTest.php | 85 +++++++++++++++++++++++ 5 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/HostStepContractTest.php diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 20590ec..58eb505 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -325,6 +325,20 @@ final class UpdateChannel 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, 'checked_at' => $checkedAt, + // Der root-eigene Helfer auf dem Wirt. Fehlt die Meldung ganz + // (alte Agentenfassung, allererster Lauf), gilt er als in + // Ordnung: „ich weiß es nicht" ist nicht „zu alt", und ein + // Warnkasten, der auf jedem frisch aufgesetzten Wirt steht, wird + // nach zwei Tagen nicht mehr gelesen. + 'host_step_have' => isset($status['host_step_contract']) + ? (int) $status['host_step_contract'] + : null, + 'host_step_needs' => isset($status['host_step_needs']) + ? (int) $status['host_step_needs'] + : null, + 'host_step_ok' => ! isset($status['host_step_contract'], $status['host_step_needs']) + || (int) $status['host_step_contract'] >= (int) $status['host_step_needs'], + 'agent_seen' => $agentAlive, // Seit wann der Agent nur noch überspringt, und wer die Sperre // hält. Beides null, solange er arbeitet. diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index 25849bd..814ad63 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -216,3 +216,17 @@ json_escape() { # raw; dropping them beats emitting a broken document. printf '%s' "$s" | tr -d '\000-\037' } + +# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers +# diese Fassung braucht. +# +# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als +# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen +# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem +# Wirt, dessen Helfer zu alt ist. +# +# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt, +# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal +# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter +# der root sitzt. +release_host_step_needs() { printf '%s' 3; } diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index d8b4273..77f4b0e 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -176,7 +176,7 @@ release_stuck_lock() { have="$("$step" contract 2>/dev/null || true)" [[ "$have" =~ ^[0-9]+$ ]] || have=0 - if (( have < 3 )); then + if (( have < $(release_host_step_needs) )); then write_unblock failed unblock_helper_old return 0 fi @@ -393,6 +393,22 @@ releases_json() { printf '%s' "${out%,}" } +# Welchen Vertrag der Wirt-Helfer erfüllt — und welchen diese Fassung braucht. +# +# Gemeldet statt automatisiert: `sudoers` gewährt dem Dienstbenutzer genau +# drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem +# beschreibbaren Checkout ausführt, gäbe jedem, der je an diesen Benutzer +# kommt, Root auf dem Wirt. Die Grenze bleibt; die Konsole soll nur aufhören, +# den Betreiber raten zu lassen. +# +# `|| true` und der Zahlentest: ein fehlender Helfer, ein Helfer ohne diesen +# Schritt und ein Helfer, der etwas Unerwartetes druckt, sind alle „0" — und +# keiner davon darf den Agenten unter `set -e` beenden, bevor er eine +# Statusdatei schreibt. +HOST_STEP_HAVE="$( { /usr/local/sbin/clupilot-host-step contract 2>/dev/null || true; } | head -1 )" +[[ "$HOST_STEP_HAVE" =~ ^[0-9]+$ ]] || HOST_STEP_HAVE=0 +HOST_STEP_NEEDS="$(release_host_step_needs)" + write_status() { local state="$1" error="${2-}" cat > "$STATUS.tmp" <toContain('release_host_step_needs') + ->and($update)->toContain('release_host_step_needs') + ->and($agent)->toContain('release_host_step_needs'); + + // Und keine nackte Zahl mehr an den beiden alten Stellen. + expect($update)->not->toContain('HOST_STEP_NEEDS=3') + ->and($agent)->not->toContain('(( have < 3 ))'); +}); + +it('answers the needed contract version from the shell', function () { + $result = Process::path(base_path())->timeout(30)->run( + 'bash -c '.escapeshellarg('set -Eeuo pipefail; . deploy/lib/release.sh; release_host_step_needs') + ); + + expect($result->exitCode())->toBe(0) + ->and(trim($result->output()))->toMatch('/^[0-9]+$/'); +}); + +it('reports the helper as not ok when the host has an older one', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'host_step_contract' => 2, + 'host_step_needs' => 3, + ])); + + $state = app(UpdateChannel::class)->state(); + + expect($state['host_step_ok'])->toBeFalse() + ->and($state['host_step_have'])->toBe(2) + ->and($state['host_step_needs'])->toBe(3); +}); + +it('reports the helper as ok when it is current', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'host_step_contract' => 3, + 'host_step_needs' => 3, + ])); + + expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue(); +}); + +it('does not cry wolf when the agent has not reported yet', function () { + // Ein Wirt, dessen Agent die Zahlen noch nie gemeldet hat (alte Fassung, + // erster Lauf), darf nicht als kaputt dastehen. „Ich weiß es nicht" ist + // nicht dasselbe wie „zu alt". + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode(['state' => 'idle'])); + + expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue(); +});