timeout(90)->env([ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), 'STUB_UP_CALLED' => $dir.'/.stub-up-called', ])->run(<</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', <<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); } 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. 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' => [ '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('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(); });