335 lines
14 KiB
PHP
335 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\Settings;
|
|
use App\Models\Operator;
|
|
use App\Services\Deployment\WatchdogLog;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Der Waechter redet bisher nur ins Journal — und das Journal liegt auf dem
|
|
* WIRT, waehrend die Konsole in einem Container laeuft. Sie sieht ihn also
|
|
* gar nicht. Am 4. August 2026 hat genau das die Fehlersuche gekostet: der
|
|
* Waechter hielt die Sperre, der Agent kam nicht an die Arbeit, und die
|
|
* einzige Stelle, an der das gestanden haette, war unerreichbar.
|
|
*
|
|
* Hier laeuft das ECHTE Skript gegen eine docker-Attrappe.
|
|
*/
|
|
function runWatchdog(string $running = "app\nredis", bool $holdLock = false): array
|
|
{
|
|
$dir = storage_path('app/deploy');
|
|
File::ensureDirectoryExists($dir);
|
|
File::delete(File::glob($dir.'/*'));
|
|
|
|
$stub = $dir.'/stub';
|
|
File::ensureDirectoryExists($stub);
|
|
// '$running' bewusst in Anfuehrungszeichen: der Vorgabewert "app\nredis"
|
|
// traegt einen echten Zeilenumbruch, und ohne Quoting zerlegt genau der
|
|
// die generierte Zeile in zwei Shell-Befehle — `printf … app` und ein
|
|
// eigenstaendiges `redis`, das als unbekannter Befehl scheitert. `ps`
|
|
// meldete dann nur "app" und der Waechter sah faelschlich "redis" als
|
|
// fehlenden Dienst. In Anfuehrungszeichen bleibt der Zeilenumbruch Teil
|
|
// des einen printf-Arguments, so wie es die `config`-Zeile ohnehin schon
|
|
// (mit zwei getrennten Woertern statt einem Wert) richtig macht.
|
|
File::put($stub.'/docker', <<<SH
|
|
#!/bin/sh
|
|
case "\$*" in
|
|
*config*) printf '%s\\n' app redis ;;
|
|
*"ps "*)
|
|
# Review-Runde 2 (I1): watchdog.sh prueft nach `up -d` jetzt
|
|
# NOCH EINMAL nach, ob es gewirkt hat. Ohne diese Reaktion auf
|
|
# den eigenen Aufruf blieb "app" fuer den zweiten Blick
|
|
# ebenso fehlend wie fuer den ersten, und ein wirklich
|
|
# erfolgreicher Heilversuch waere in diesem Test nicht mehr
|
|
# von einem gescheiterten zu unterscheiden gewesen.
|
|
if [ -f "\$STUB_UP_CALLED" ]; then
|
|
printf '%s\\n' app redis
|
|
else
|
|
printf '%s\\n' '$running'
|
|
fi
|
|
;;
|
|
*getent*) exit 0 ;;
|
|
*storage/framework/down*) exit 1 ;;
|
|
*" up "*) touch "\$STUB_UP_CALLED" ;;
|
|
esac
|
|
exit 0
|
|
SH);
|
|
Process::run("chmod +x {$stub}/docker");
|
|
|
|
$hold = $holdLock
|
|
? "flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 20' &\n"
|
|
."until [ -f storage/app/deploy/.held ]; do sleep 0.05; done\n"
|
|
: '';
|
|
|
|
$result = Process::path(base_path())->timeout(90)->env([
|
|
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
|
'STUB_UP_CALLED' => $dir.'/.stub-up-called',
|
|
])->run(<<<BASH
|
|
{$hold}
|
|
bash deploy/watchdog.sh >/dev/null 2>&1 || true
|
|
pkill -f 'sleep 20' 2>/dev/null || true
|
|
BASH);
|
|
|
|
expect($result->successful())->toBeTrue($result->errorOutput());
|
|
|
|
return json_decode(File::get($dir.'/watchdog-last-run.json'), true);
|
|
}
|
|
|
|
/**
|
|
* Der wg0-Block, isoliert: vpn-hub laeuft, seine Konfiguration existiert,
|
|
* "wg show wg0" scheitert — der Waechter darf eingreifen und ruft
|
|
* "wg-quick up wg0". Ob der Griff wirklich gewirkt hat, entscheidet
|
|
* $recovers: danach meldet "wg show wg0" entweder wieder oben (true) oder
|
|
* weiterhin unten (false) — genau der Fall aus dem Task-1-Review, in dem
|
|
* `geheilt=true` VOR dieser zweiten Pruefung stand.
|
|
*/
|
|
function runWatchdogTunnelRescue(bool $recovers): array
|
|
{
|
|
$dir = storage_path('app/deploy');
|
|
File::ensureDirectoryExists($dir);
|
|
File::delete(File::glob($dir.'/*'));
|
|
|
|
$stub = $dir.'/stub';
|
|
File::ensureDirectoryExists($stub);
|
|
|
|
$wgShowAfterUp = $recovers ? 'exit 0' : 'exit 1';
|
|
|
|
File::put($stub.'/docker', <<<SH
|
|
#!/bin/sh
|
|
case "\$*" in
|
|
*config*) printf '%s\\n' app redis ;;
|
|
*"ps "*) printf '%s\\n' app redis vpn-hub ;;
|
|
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
|
|
*"vpn-hub wg-quick up wg0"*) touch "\$STUB_WG_UP_CALLED" ;;
|
|
*"vpn-hub wg show wg0"*)
|
|
if [ -f "\$STUB_WG_UP_CALLED" ]; then
|
|
{$wgShowAfterUp}
|
|
else
|
|
exit 1
|
|
fi
|
|
;;
|
|
*getent*) exit 0 ;;
|
|
*storage/framework/down*) exit 1 ;;
|
|
*" up "*) touch "\$STUB_UP_CALLED" ;;
|
|
esac
|
|
exit 0
|
|
SH);
|
|
Process::run("chmod +x {$stub}/docker");
|
|
|
|
$result = Process::path(base_path())->timeout(90)->env([
|
|
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
|
'STUB_UP_CALLED' => $dir.'/.stub-up-called',
|
|
'STUB_WG_UP_CALLED' => $dir.'/.stub-wg-up-called',
|
|
])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true');
|
|
|
|
expect($result->successful())->toBeTrue($result->errorOutput());
|
|
|
|
return json_decode(File::get($dir.'/watchdog-last-run.json'), true);
|
|
}
|
|
|
|
/**
|
|
* Zwei Zweige im selben Lauf: fehlende Dienste kommen mit `up -d` wieder
|
|
* hoch (Zweig 1 heilt erfolgreich), UND wg0 laesst sich trotz `wg-quick up`
|
|
* nicht hochziehen (Zweig 3 scheitert). Genau das Szenario aus I1 — ein
|
|
* Erfolg und ein Misserfolg im selben Lauf.
|
|
*/
|
|
function runWatchdogPartialFailure(): array
|
|
{
|
|
$dir = storage_path('app/deploy');
|
|
File::ensureDirectoryExists($dir);
|
|
File::delete(File::glob($dir.'/*'));
|
|
|
|
$stub = $dir.'/stub';
|
|
File::ensureDirectoryExists($stub);
|
|
|
|
File::put($stub.'/docker', <<<SH
|
|
#!/bin/sh
|
|
case "\$*" in
|
|
*config*) printf '%s\\n' app redis ;;
|
|
*"ps "*)
|
|
if [ -f "\$STUB_APP_STARTED" ]; then
|
|
printf '%s\\n' app redis vpn-hub
|
|
else
|
|
printf '%s\\n' redis vpn-hub
|
|
fi
|
|
;;
|
|
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
|
|
*"vpn-hub wg-quick up wg0"*) exit 0 ;;
|
|
*"vpn-hub wg show wg0"*) exit 1 ;;
|
|
*getent*) exit 0 ;;
|
|
*storage/framework/down*) exit 1 ;;
|
|
*" up "*) touch "\$STUB_APP_STARTED" ;;
|
|
esac
|
|
exit 0
|
|
SH);
|
|
Process::run("chmod +x {$stub}/docker");
|
|
|
|
$result = Process::path(base_path())->timeout(90)->env([
|
|
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
|
'STUB_APP_STARTED' => $dir.'/.stub-app-started',
|
|
])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true');
|
|
|
|
expect($result->successful())->toBeTrue($result->errorOutput());
|
|
|
|
return json_decode(File::get($dir.'/watchdog-last-run.json'), true);
|
|
}
|
|
|
|
afterEach(function () {
|
|
File::deleteDirectory(storage_path('app/deploy'));
|
|
});
|
|
|
|
it('records a run where there was nothing to do', function () {
|
|
$run = runWatchdog();
|
|
|
|
expect($run['outcome'])->toBe('idle')
|
|
->and($run['actions'])->toBe([])
|
|
->and($run['at'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('records what it healed', function () {
|
|
// `app` fehlt in der Liste der laufenden Dienste — der Waechter startet
|
|
// die Dienste und muss das hinterlassen.
|
|
$run = runWatchdog(running: 'redis');
|
|
|
|
expect($run['outcome'])->toBe('healed')
|
|
->and($run['actions'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('records that it stood down because the lock was held', function () {
|
|
// DER Zustand, der bisher unsichtbar war. Ohne ihn sieht ein Waechter,
|
|
// der seit einer Stunde nicht eingreifen kann, genauso aus wie einer,
|
|
// der nichts zu tun hat.
|
|
$run = runWatchdog(running: 'redis', holdLock: true);
|
|
|
|
expect($run['outcome'])->toBe('stood_down');
|
|
});
|
|
|
|
it('reports healed when raising wg0 actually brought the tunnel back', function () {
|
|
$run = runWatchdogTunnelRescue(recovers: true);
|
|
|
|
expect($run['outcome'])->toBe('healed')
|
|
->and(implode(' ', $run['actions']))->toContain('wg0 steht wieder');
|
|
});
|
|
|
|
it('does not claim healed when wg-quick ran but the tunnel stayed down', function () {
|
|
// Der Befund aus dem Task-1-Review: `geheilt` wurde wahr, bevor geprueft
|
|
// war, ob `wg-quick up wg0` ueberhaupt gewirkt hat. Die Konsole zeigt
|
|
// `outcome` jetzt einem Menschen — eine Luege hier waere ein Fehler, kein
|
|
// Journal-Detail mehr.
|
|
$run = runWatchdogTunnelRescue(recovers: false);
|
|
|
|
expect($run['outcome'])->not->toBe('healed')
|
|
->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen');
|
|
});
|
|
|
|
it('shows the operator that an intervention failed, instead of "nothing to do"', function () {
|
|
// Fix-Runde 1 (Codex/Reviewer): auf Datenebene war der Fall (outcome
|
|
// idle, actions nicht leer) schon belegt — hier der Nachweis, dass er
|
|
// auch beim Betreiber ankommt. Vorher fiel er auf watchdog_idle durch:
|
|
// eine ruhige graue Zeile "nichts zu tun", waehrend "ACHTUNG" und "wg0"
|
|
// im HTML gar nicht mehr vorkamen.
|
|
//
|
|
// Review-Runde 2 (I1): `outcome` traegt seither ein eigenes `tried` statt
|
|
// `idle` mit Aktionen — watchdog.sh liefert diese Form fuer genau dieses
|
|
// Szenario jetzt selbst (siehe runWatchdogPartialFailure() weiter unten),
|
|
// und die Blade-Bedingung haengt nicht mehr an `idle`.
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
|
|
'at' => now()->utc()->format('Y-m-d\TH:i:s\Z'),
|
|
'outcome' => 'tried',
|
|
'actions' => [
|
|
'wg0 steht nicht — ziehe den Tunnel hoch.',
|
|
'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.',
|
|
],
|
|
]));
|
|
$owner = Operator::factory()->role('Owner')->create();
|
|
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(Settings::class)
|
|
->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false)
|
|
->assertSeeHtml('text-warning')
|
|
// Das war der Befund: watchdog_idle endet auf genau diesem Satzstueck,
|
|
// und es war der einzige Zweig, der auf einen outcome von "idle" ohne
|
|
// weitere Pruefung durchfiel.
|
|
->assertDontSee('nichts zu tun');
|
|
});
|
|
|
|
it('wins the outcome with "tried" even when another branch in the same run healed something', function () {
|
|
// I1, auf Datenebene: das ECHTE watchdog.sh, nicht eine Nachbildung
|
|
// seiner Logik. Zweig 1 (fehlende Dienste) heilt erfolgreich, Zweig 3
|
|
// (wg0) scheitert im selben Lauf — vorher stand `outcome` dann auf
|
|
// `healed`, weil `geheilt=true` schon durch Zweig 1 galt und nichts den
|
|
// Fehlschlag aus Zweig 3 mehr zurücknehmen konnte.
|
|
$run = runWatchdogPartialFailure();
|
|
|
|
expect($run['outcome'])->toBe('tried')
|
|
->and(implode(' ', $run['actions']))->toContain('Es fehlen Dienste: app — starte sie.')
|
|
->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen');
|
|
});
|
|
|
|
it('does not read as calmly "intervened" when one branch failed, even though another one in the same run healed something', function () {
|
|
// I1, im gerenderten HTML: derselbe Zustand, den runWatchdogPartialFailure()
|
|
// oben tatsächlich erzeugt. Vor dem Fix stand `outcome` hier auf `healed`
|
|
// (Zweig 1 hatte gewirkt) — die Konsole zeigte watchdog_healed in ruhiger
|
|
// Farbe, und der ACHTUNG-Satz stand nur noch als Beiwerk im `:what` drin,
|
|
// ohne die Kennzeichnung "ohne Erfolg" und ohne text-warning.
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
|
|
'at' => now()->utc()->format('Y-m-d\TH:i:s\Z'),
|
|
'outcome' => 'tried',
|
|
'actions' => [
|
|
'Es fehlen Dienste: app — starte sie.',
|
|
'wg0 steht nicht — ziehe den Tunnel hoch.',
|
|
'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.',
|
|
],
|
|
]));
|
|
$owner = Operator::factory()->role('Owner')->create();
|
|
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(Settings::class)
|
|
->assertSee('ohne Erfolg', false)
|
|
->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false)
|
|
->assertSeeHtml('text-warning')
|
|
->assertDontSee('nichts zu tun');
|
|
});
|
|
|
|
it('reads nothing rather than falling over when the file is absent', function () {
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
|
|
expect(app(WatchdogLog::class)->lastRun())->toBeNull();
|
|
});
|
|
|
|
it('reads nothing rather than falling over when the file is rubbish', function () {
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), 'kein json {');
|
|
|
|
expect(app(WatchdogLog::class)->lastRun())->toBeNull();
|
|
});
|
|
|
|
it('calls a run from long ago stale', function () {
|
|
// Ein toter Waechter muss als solcher lesbar sein. Bisher wuerde niemand
|
|
// es je erfahren.
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
|
|
'at' => now()->subMinutes(30)->utc()->format('Y-m-d\TH:i:s\Z'),
|
|
'outcome' => 'idle',
|
|
'actions' => [],
|
|
]));
|
|
|
|
$run = app(WatchdogLog::class)->lastRun();
|
|
|
|
expect($run['stale'])->toBeTrue();
|
|
});
|
|
|
|
it('does not call a fresh run stale', function () {
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
|
|
'at' => now()->utc()->format('Y-m-d\TH:i:s\Z'),
|
|
'outcome' => 'idle',
|
|
'actions' => [],
|
|
]));
|
|
|
|
expect(app(WatchdogLog::class)->lastRun()['stale'])->toBeFalse();
|
|
});
|