Der Agent klemmt Ziel und Zaehler an der Decke

feat/versandtakt
nexxo 2026-08-04 14:53:14 +02:00
parent cfae6e990a
commit 63328d44d4
2 changed files with 193 additions and 17 deletions

View File

@ -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" <<EOF
@ -383,6 +408,9 @@ write_status() {
"remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")",
"target_release": "$(json_escape "${TARGET_RELEASE:-}")",
"behind": ${BEHIND:-null},
"ceiling": "$(json_escape "${CEILING:-}")",
"ceiling_error": "$(json_escape "${CEILING_ERROR:-}")",
"releases": [$(releases_json)],
"branch": "$(json_escape "$BRANCH")",
"error": "$(json_escape "$error")"
}
@ -442,6 +470,20 @@ FETCH_ERROR=''
# Compared by VERSION, not by ancestry or by tag date: a tag can be cut from
# anywhere, and what an operator is being offered is a version number. `sort -V`
# does the comparing, so v1.10.0 correctly beats v1.9.0.
# Die Decke, in zwei Schritten geprueft: Form hier, Existenz nach dem Abruf —
# vorher sind die Tags noch nicht da.
CEILING=''
CEILING_ERROR=''
RELEASES=''
if [[ -f "$CEILING_FILE" ]]; then
CEILING="$(tr -d ' \t\r\n' < "$CEILING_FILE" 2>/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

View File

@ -0,0 +1,123 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Die Decke, gefahren vom echten Agenten.
*
* Nicht nachgerechnet: die Klemmung sitzt zwischen `git fetch` und dem
* Schreiben der Statusdatei, und genau dieses Zusammenspiel ist die Frage.
* `git` liegt als Attrappe im PATH, deren `fetch` sofort gelingt, ohne eine
* Gegenstelle zu brauchen alle uebrigen git-Aufrufe gehen ans echte git im
* Wegwerf-Repo.
*/
function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVersion = '1.7.3'): array
{
// 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');
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($root.'/storage/app/deploy/release-ceiling', $ceiling);
}
$tagCommands = '';
foreach ($tags as $tag) {
$tagCommands .= "git tag {$tag}\n";
}
$result = Process::path($root)->timeout(90)->run(<<<BASH
set -e
git init -q .
git -c user.email=t@example.com -c user.name=t commit -q --allow-empty -m init
{$tagCommands}
stub="\$(mktemp -d)"
# Nur `fetch` wird abgefangen — es gibt keine Gegenstelle. Alles andere
# geht ans echte git und wirkt im Wegwerf-Checkout.
cat > "\$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');
});