Die gebrauchte Vertragsversion steht an einer Stelle und wird gemeldet

main
nexxo 2026-08-04 17:21:54 +02:00
parent daeea1db0e
commit 512fad11fb
5 changed files with 137 additions and 4 deletions

View File

@ -325,6 +325,20 @@ final class UpdateChannel
'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null,
'checked_at' => $checkedAt, '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, 'agent_seen' => $agentAlive,
// Seit wann der Agent nur noch überspringt, und wer die Sperre // Seit wann der Agent nur noch überspringt, und wer die Sperre
// hält. Beides null, solange er arbeitet. // hält. Beides null, solange er arbeitet.

View File

@ -216,3 +216,17 @@ json_escape() {
# raw; dropping them beats emitting a broken document. # raw; dropping them beats emitting a broken document.
printf '%s' "$s" | tr -d '\000-\037' 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; }

View File

@ -176,7 +176,7 @@ release_stuck_lock() {
have="$("$step" contract 2>/dev/null || true)" have="$("$step" contract 2>/dev/null || true)"
[[ "$have" =~ ^[0-9]+$ ]] || have=0 [[ "$have" =~ ^[0-9]+$ ]] || have=0
if (( have < 3 )); then if (( have < $(release_host_step_needs) )); then
write_unblock failed unblock_helper_old write_unblock failed unblock_helper_old
return 0 return 0
fi fi
@ -393,6 +393,22 @@ releases_json() {
printf '%s' "${out%,}" 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() { write_status() {
local state="$1" error="${2-}" local state="$1" error="${2-}"
cat > "$STATUS.tmp" <<EOF cat > "$STATUS.tmp" <<EOF
@ -408,6 +424,8 @@ write_status() {
"remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")", "remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")",
"target_release": "$(json_escape "${TARGET_RELEASE:-}")", "target_release": "$(json_escape "${TARGET_RELEASE:-}")",
"behind": ${BEHIND:-null}, "behind": ${BEHIND:-null},
"host_step_contract": ${HOST_STEP_HAVE:-0},
"host_step_needs": ${HOST_STEP_NEEDS:-0},
"ceiling": "$(json_escape "${CEILING:-}")", "ceiling": "$(json_escape "${CEILING:-}")",
"ceiling_error": "$(json_escape "${CEILING_ERROR:-}")", "ceiling_error": "$(json_escape "${CEILING_ERROR:-}")",
"releases": [$(releases_json)], "releases": [$(releases_json)],

View File

@ -70,9 +70,11 @@ STATE_FILE="storage/app/deployed-commit"
# fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS # fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS
# here is what makes an update tell the operator to run the installer again. # here is what makes an update tell the operator to run the installer again.
HOST_STEP=/usr/local/sbin/clupilot-host-step HOST_STEP=/usr/local/sbin/clupilot-host-step
# 3 seit `release-update-lock`: ohne den Schritt bleibt der Knopf „Sperre lösen" # Seit `release-update-lock`: ohne den Schritt bleibt der Knopf „Sperre lösen"
# in der Konsole ein Knopf, der nichts tun kann. # in der Konsole ein Knopf, der nichts tun kann. Die Zahl selbst steht nur noch
HOST_STEP_NEEDS=3 # in deploy/lib/release.sh (release_host_step_needs) — hier gäbe eine zweite
# Kopie die Möglichkeit, dass sie mit der im Agenten auseinanderläuft.
HOST_STEP_NEEDS="$(release_host_step_needs)"
# Which step is running, for the console to show. Written as a KEY, not as the # Which step is running, for the console to show. Written as a KEY, not as the
# sentence below it: the console is translated and this script is not, so an # sentence below it: the console is translated and this script is not, so an
# English line here would surface untranslated in the interface. A run that dies # English line here would surface untranslated in the interface. A run that dies

View File

@ -0,0 +1,85 @@
<?php
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Der Wirt-Helfer ist root-eigen und wird von `install-agent.sh` gesetzt.
* Hebt ein Release seine Vertragsversion an, braucht JEDER Wirt einmal
* Handarbeit als root bei zehn Servern zehnmal.
*
* Automatisieren lässt sich das nicht: `sudoers` gewährt dem Dienstbenutzer
* genau drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem
* (vom Dienstbenutzer beschreibbaren) Checkout ausführt, gäbe jedem, der je
* an diesen Benutzer kommt, Root auf dem Wirt.
*
* Also wird gemeldet statt automatisiert aber VOLLSTÄNDIG und BEVOR jemand
* einen Knopf drückt, der daran scheitert.
*/
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('keeps the needed contract version in exactly one place', function () {
// Sie stand zweimal im Repo: `HOST_STEP_NEEDS=3` in update.sh und eine
// hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen,
// laufen irgendwann auseinander.
$lib = File::get(base_path('deploy/lib/release.sh'));
$update = File::get(base_path('deploy/update.sh'));
$agent = File::get(base_path('deploy/update-agent.sh'));
expect($lib)->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();
});