CluPilotCloud/tests/Feature/UpdateAgentSkipCountTest.php

143 lines
5.6 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Der Agent zählt, wie oft er hintereinander nicht an die Arbeit kam.
*
* Die Konsole macht daraus erst ab zwei Läufen eine Meldung (siehe
* UpdateChannel::BLOCKED_AFTER_SKIPS). Damit sie das kann, muss der Agent die
* Zahl liefern — ausrechnen kann die Konsole sie nicht: dafür müsste sie das
* Taktintervall kennen, und das steht in der systemd-Unit auf dem Wirt.
*
* Hier läuft das ECHTE Skript, nicht eine Nachbildung seiner Logik. Das geht,
* weil der Zweig, um den es hier geht, vor allem Teuren liegt: der Agent nimmt
* die Sperre in der ersten Handvoll Zeilen, und wer sie nicht bekommt, steigt
* aus, bevor irgendein `git fetch` oder `docker compose` in die Nähe kommt.
* Ein Test, der die Zählung nachrechnet statt sie auszuführen, prüft nichts —
* das steht schon in R19 im Repo.
*/
/**
* Lässt den Agenten $times mal gegen eine gehaltene Sperre laufen.
*
* @param array<string, mixed>|null $seedAlive Lebenszeichen, das vorher liegt
* @return array<int, array<string, mixed>> je ein Lebenszeichen pro Lauf
*/
function runBlockedAgent(int $times, ?array $seedAlive = null): array
{
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::delete(File::glob($dir.'/.alive-*'));
File::delete($dir.'/agent-alive.json');
File::delete($dir.'/.held');
if ($seedAlive !== null) {
File::put($dir.'/agent-alive.json', json_encode($seedAlive));
}
$runs = '';
for ($i = 1; $i <= $times; $i++) {
$runs .= "bash deploy/update-agent.sh >/dev/null 2>&1\n";
$runs .= "cp storage/app/deploy/agent-alive.json storage/app/deploy/.alive-{$i}\n";
}
// Die Sperre wird nachweislich gehalten, bevor der Agent startet: der
// Halter legt erst die Marke an, dann wird auf sie gewartet. Ohne diesen
// Nachweis liefe der Agent bei einem Fehlschlag von flock in seinen
// NORMALEN Weg — mit Abruf der Gegenstelle und allem, was daran hängt —
// und der Test hätte still etwas ganz anderes gemessen.
$result = Process::path(base_path())
->timeout(60)
->run(<<<BASH
set -e
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
halter=\$!
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
{$runs}
kill \$halter 2>/dev/null || true
BASH);
expect($result->successful())->toBeTrue($result->errorOutput());
return array_map(
fn (int $i) => json_decode(File::get($dir."/.alive-{$i}"), true),
range(1, $times)
);
}
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('counts a single skipped run as one, not as a blockade', function () {
// Der gemessene Fall: neun Sekunden Überholen zwischen Zeitgeber und
// Wächter. Betrieb, kein Fehler.
[$first] = runBlockedAgent(1);
expect($first['state'])->toBe('blocked')
->and($first['skips'])->toBe(1);
});
it('keeps counting while the same blockade holds', function () {
[$first, $second] = runBlockedAgent(2);
expect($first['skips'])->toBe(1)
->and($second['skips'])->toBe(2)
// Und `since` bleibt stehen — sonst stünde dort immer „seit einer
// Minute" und eine Stunde Stillstand sähe aus wie ein Überholen.
->and($second['since'])->toBe($first['since']);
});
it('starts the count over after a run that got the lock', function () {
// Ein Lebenszeichen aus einem Lauf, der gearbeitet hat, beendet die Serie.
// Ohne diesen Schnitt liefe der Zähler über eine gesunde Zwischenzeit
// hinweg weiter und meldete eine Blockade, die längst vorbei war.
[$first] = runBlockedAgent(1, [
'at' => '2026-08-04T05:00:00Z',
'state' => 'running',
'since' => '',
'held_by' => '',
'skips' => 0,
]);
expect($first['state'])->toBe('blocked')
->and($first['skips'])->toBe(1);
});
it('says zero skipped runs while it is working', function () {
// Der Zustand, in dem der Agent die Sperre HAT. Ohne die ausdrückliche
// Null bliebe die Zahl des letzten blockierten Laufs im Lebenszeichen
// stehen, und die Konsole läse sie als fortdauernde Blockade.
//
// Hier läuft der Agent auf der freien Sperre — also durch seinen normalen
// Weg. `git` und `docker` liegen dafür als Attrappen im PATH, die sofort
// scheitern: dieser Test prüft, was der Agent ins Lebenszeichen schreibt,
// und hat keinen Grund, dafür die Gegenstelle abzurufen oder den
// Docker-Daemon anzufassen. Beide Fehlschläge sind Wege, die der Agent
// ohnehin abfängt.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::delete($dir.'/agent-alive.json');
// Der Ausgang des Laufs wird NICHT geprüft: mit einem `git`, das immer
// scheitert, kann der Agent nicht bis zum Ende kommen, und ans Ende zu
// kommen ist hier auch nicht die Frage. Das Lebenszeichen steht als
// ERSTES, noch vor allem, was scheitern kann — genau deshalb gibt es die
// Datei überhaupt.
Process::path(base_path())->timeout(60)->run(<<<'BASH'
stub="$(mktemp -d)"
printf '#!/bin/sh\nexit 1\n' > "$stub/git"
printf '#!/bin/sh\nexit 1\n' > "$stub/docker"
chmod +x "$stub/git" "$stub/docker"
PATH="$stub:$PATH" bash deploy/update-agent.sh >/dev/null 2>&1
rm -rf "$stub"
BASH);
$alive = json_decode(File::get($dir.'/agent-alive.json'), true);
expect($alive['state'])->toBe('running')
->and($alive['skips'])->toBe(0);
});