143 lines
5.6 KiB
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);
|
|
});
|