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); } /** * 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', <<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(); });