Tunnel-Rettung aus der Konsole, Waechter und Wirt-Helfer sichtbar
Neue Anfrageart rescue-tunnel im bestehenden Update-Postkasten (kein zweiter Weg): UpdateChannel::requestRescueTunnel(), der Agent fuehrt deploy/rescue-tunnel.sh mit Frist aus, die Konsole zeigt Ergebnis, Waechter-Stand und Wirt-Helfer-Vertrag in der Update-Karte, Bestaetigung im Modal (R23) nach dem Muster von ConfirmReleaseUpdateLock. Zusatzfix in deploy/watchdog.sh: der wg0-Block meldete geheilt=true schon, bevor geprueft war, ob wg-quick up wg0 wirklich gewirkt hat -- ein fehlgeschlagener Tunnelaufbau haette der Konsole "healed" vorgemacht. geheilt wird jetzt erst nach der zweiten wg-show-Probe gesetzt, mit Regressionstest in WatchdogVisibilityTest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
parent
a51220f2de
commit
9d1811beea
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Rückfrage, bevor die Tunnel-Rettung auf dem Wirt läuft (R23).
|
||||
*
|
||||
* Das Modal mutiert nichts. Es wirft ein Ereignis, das
|
||||
* App\Livewire\Admin\Settings auffängt; die Berechtigungsprüfung bleibt dort,
|
||||
* wo sie schon steht, statt hier verdoppelt zu werden.
|
||||
*/
|
||||
class ConfirmRescueTunnel extends ModalComponent
|
||||
{
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
$this->dispatch('rescue-tunnel-confirmed');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-rescue-tunnel');
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ use App\Models\VpnPeer;
|
|||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use App\Services\Deployment\UpdateWindow;
|
||||
use App\Services\Deployment\WatchdogLog;
|
||||
use App\Services\Terminal\ServerIdentity;
|
||||
use App\Services\Terminal\ServerTerminalSetup;
|
||||
use App\Services\Terminal\ServerTicket;
|
||||
|
|
@ -814,6 +815,28 @@ class Settings extends Component
|
|||
: 'admin_settings.release_lock_already_requested'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Tunnel wieder hinbekommen.
|
||||
*
|
||||
* Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht
|
||||
* hier, wie bei jeder anderen Handlung dieser Seite.
|
||||
*/
|
||||
#[On('rescue-tunnel-confirmed')]
|
||||
public function rescueTunnel(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$accepted = app(UpdateChannel::class)->requestRescueTunnel($operator->email);
|
||||
|
||||
$this->dispatch('notify', message: __($accepted
|
||||
? 'admin_settings.rescue_requested'
|
||||
: 'admin_settings.update_already_requested'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$operator = $this->currentOperator();
|
||||
|
|
@ -838,6 +861,9 @@ class Settings extends Component
|
|||
return view('livewire.admin.settings', [
|
||||
'update' => app(UpdateChannel::class)->state(),
|
||||
'updateLog' => app(UpdateChannel::class)->lastLog(),
|
||||
// Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht —
|
||||
// er redet ins Journal, und das liegt auf dem Wirt.
|
||||
'watchdog' => app(WatchdogLog::class)->lastRun(),
|
||||
'sitePublic' => AppSettings::bool('site.public', true),
|
||||
'canManageSite' => $operator?->can('site.manage') ?? false,
|
||||
// Shown so nobody has to guess why they still see the real site.
|
||||
|
|
|
|||
|
|
@ -142,6 +142,18 @@ final class UpdateChannel
|
|||
*/
|
||||
private const ARCHIVE_KEY = 'deploy/archive-key.json';
|
||||
|
||||
/**
|
||||
* Den Tunnel wieder hinbekommen.
|
||||
*
|
||||
* `deploy/rescue-tunnel.sh` stand in zwei Runbooks als Handarbeit. Es
|
||||
* läuft als Dienstbenutzer und tut ausdrücklich nichts Zerstörerisches —
|
||||
* kein Neubau, kein Neustart des Tunnel-Containers, weil genau das die
|
||||
* Ursache wäre und nicht die Lösung.
|
||||
*/
|
||||
private const KIND_RESCUE_TUNNEL = 'rescue-tunnel';
|
||||
|
||||
private const RESCUE_LAST_RUN = 'deploy/rescue-last-run.json';
|
||||
|
||||
/** Written by the agent after every check and every run. */
|
||||
private const STATUS = 'deploy/update-status.json';
|
||||
|
||||
|
|
@ -407,6 +419,11 @@ final class UpdateChannel
|
|||
'last_restart_state' => $restartLastRun['state'] ?? null,
|
||||
'last_restart_finished_at' => $this->timestamp($restartLastRun['finished_at'] ?? null),
|
||||
'last_restart_error' => $this->errorMessage($restartLastRun),
|
||||
|
||||
// `readJson` fängt kaputtes JSON bereits ab und liefert `[]`.
|
||||
'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== []
|
||||
? $last
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -604,6 +621,15 @@ final class UpdateChannel
|
|||
return $this->submit($by, self::KIND_CHECK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Tunnel wieder hinbekommen — kein zweiter Weg, derselbe Postkasten
|
||||
* wie jede andere Anfrageart.
|
||||
*/
|
||||
public function requestRescueTunnel(string $by): bool
|
||||
{
|
||||
return $this->submit($by, self::KIND_RESCUE_TUNNEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Server auf eine Version festnageln — oder die Decke abnehmen.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -681,6 +681,34 @@ if [[ "$REQUEST_KIND" == "proxy-hosts" ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$REQUEST_KIND" == "rescue-tunnel" ]]; then
|
||||
# Läuft als derselbe Dienstbenutzer wie dieser Agent — genau so, wie das
|
||||
# Runbook es von Hand vorsah. Das Skript weigert sich als root zu laufen.
|
||||
#
|
||||
# Frist, weil dieser Aufruf die Sperre hält: die Tunnel-Rettung fasst
|
||||
# `docker compose exec` an, und ein hängender Docker-Daemon hätte den
|
||||
# Agenten sonst stundenlang blockiert — dieselbe Ausfallart, die diese
|
||||
# Anlage schon zweimal hatte.
|
||||
RESCUE_STATE=ok
|
||||
RESCUE_ERROR=''
|
||||
if ! timeout -k 10 180 bash "$ROOT/deploy/rescue-tunnel.sh" > "$STATE_DIR/rescue-last-run.log" 2>&1; then
|
||||
RESCUE_STATE=failed
|
||||
RESCUE_ERROR=rescue_failed
|
||||
fi
|
||||
|
||||
cat > "$STATE_DIR/rescue-last-run.json.tmp" <<EOF
|
||||
{
|
||||
"state": "$(json_escape "$RESCUE_STATE")",
|
||||
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"error": "$(json_escape "$RESCUE_ERROR")"
|
||||
}
|
||||
EOF
|
||||
mv -f "$STATE_DIR/rescue-last-run.json.tmp" "$STATE_DIR/rescue-last-run.json"
|
||||
|
||||
write_status idle
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$REQUEST_KIND" == "restart" ]]; then
|
||||
# queue, queue-provisioning, scheduler, reverb — and ONLY those four.
|
||||
# `app` is deliberately never in this list: it is the container serving
|
||||
|
|
|
|||
|
|
@ -164,10 +164,16 @@ if printf '%s\n' "$ist" | grep -qx vpn-hub; then
|
|||
if darf_eingreifen; then
|
||||
say "wg0 steht nicht — ziehe den Tunnel hoch."
|
||||
lange_frist docker compose exec -T vpn-hub wg-quick up wg0 >/dev/null 2>&1 || true
|
||||
geheilt=true
|
||||
|
||||
# `geheilt` erst HIER, nach dem zweiten Blick: die Konsole
|
||||
# zeigt `outcome` inzwischen einem Menschen (Task 3). Vorher
|
||||
# stand die Zeile vor dieser Pruefung — ein fehlgeschlagenes
|
||||
# `wg-quick up wg0` meldete sich trotzdem als "healed", weil
|
||||
# der Text im ACHTUNG-Zweig darunter das `outcome` nicht mehr
|
||||
# aendern konnte. Der Satz stimmte, das Feld nicht.
|
||||
if frist docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then
|
||||
say "wg0 steht wieder."
|
||||
geheilt=true
|
||||
else
|
||||
say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md."
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -139,6 +139,18 @@ return [
|
|||
'update_agent_blocked' => 'Der Update-Dienst läuft, kommt aber seit :since nicht an die Arbeit — ein anderer Vorgang hält die Sperre. Solange das so ist, stammen die Angaben oben von vorher. Löst es sich nicht von selbst, hilft ein Blick auf den Prozess unten.',
|
||||
'update_log' => 'Protokoll des letzten Laufs',
|
||||
|
||||
// ── Der Wächter ──────────────────────────────────────────────────────
|
||||
// Redet bisher nur ins Journal auf dem Wirt — die Konsole sah ihn bis
|
||||
// August 2026 gar nicht (App\Services\Deployment\WatchdogLog).
|
||||
'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.',
|
||||
'watchdog_healed' => 'Zuletzt eingegriffen :when: :what',
|
||||
'watchdog_stood_down' => ':when zurückgetreten, weil ein Update lief.',
|
||||
'watchdog_stale' => 'Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr.',
|
||||
|
||||
// ── Der Wirt-Helfer ──────────────────────────────────────────────────
|
||||
// Der root-eigene Helfer auf dem Wirt (UpdateChannel::state()['host_step_ok']).
|
||||
'host_step_old' => 'Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:',
|
||||
|
||||
// ── Die Sperre lösen ─────────────────────────────────────────────────
|
||||
// Der Griff, der bisher per SSH auf dem Wirt lag. Er beendet einen
|
||||
// laufenden Prozess, deshalb nennt die Rückfrage ihn vorher beim Namen.
|
||||
|
|
@ -152,6 +164,17 @@ return [
|
|||
'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.',
|
||||
'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.',
|
||||
|
||||
// ── Tunnel-Rettung ───────────────────────────────────────────────────
|
||||
// deploy/rescue-tunnel.sh stand bisher in zwei Runbooks als Handarbeit.
|
||||
// Derselbe Postkasten wie jede andere Anfrageart
|
||||
// (UpdateChannel::KIND_RESCUE_TUNNEL) — kein zweiter Weg.
|
||||
'rescue_action' => 'Tunnel retten',
|
||||
'rescue_requested' => 'Tunnel-Rettung angefordert.',
|
||||
'rescue_title' => 'Den Tunnel jetzt retten?',
|
||||
'rescue_body' => 'Zieht das WireGuard-Interface auf dem Tunnel-Server neu hoch. Tut ausdrücklich nichts Zerstörerisches — kein Neubau, kein Neustart des Tunnel-Containers.',
|
||||
'rescue_cancel' => 'Abbrechen',
|
||||
'rescue_confirm' => 'Tunnel retten',
|
||||
|
||||
// ── Festnageln ────────────────────────────────────────────────────────
|
||||
// Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt
|
||||
// (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird
|
||||
|
|
|
|||
|
|
@ -136,6 +136,18 @@ return [
|
|||
'update_agent_blocked' => 'The update service is running but has been unable to work since :since — another process holds the lock. While that lasts, the figures above are from before. If it does not clear on its own, look at the process named below.',
|
||||
'update_log' => 'Log of the last run',
|
||||
|
||||
// ── The watchdog ─────────────────────────────────────────────────────
|
||||
// It used to talk only to the host's journal — the console could not see
|
||||
// it until August 2026 (App\Services\Deployment\WatchdogLog).
|
||||
'watchdog_idle' => 'Last checked :when — nothing to do.',
|
||||
'watchdog_healed' => 'Last intervened :when: :what',
|
||||
'watchdog_stood_down' => 'Stood down :when because an update was running.',
|
||||
'watchdog_stale' => 'The watchdog has not reported in since :when — it is probably no longer running.',
|
||||
|
||||
// ── The host helper ──────────────────────────────────────────────────
|
||||
// The root-owned helper on the host (UpdateChannel::state()['host_step_ok']).
|
||||
'host_step_old' => 'The host helper meets contract :have, this version needs :needs. Run once on this host:',
|
||||
|
||||
// ── Releasing the lock ───────────────────────────────────────────────
|
||||
// The handle that used to live behind an SSH session. It ends a running
|
||||
// process, so the confirmation names it before it does.
|
||||
|
|
@ -149,6 +161,17 @@ return [
|
|||
'release_lock_requested' => 'Releasing — the service reports back within a minute.',
|
||||
'release_lock_already_requested' => 'Already requested — please wait a moment.',
|
||||
|
||||
// ── Rescuing the tunnel ──────────────────────────────────────────────
|
||||
// deploy/rescue-tunnel.sh used to be manual work written down in two
|
||||
// runbooks. The same mailbox as every other request kind
|
||||
// (UpdateChannel::KIND_RESCUE_TUNNEL) — no second path.
|
||||
'rescue_action' => 'Rescue tunnel',
|
||||
'rescue_requested' => 'Tunnel rescue requested.',
|
||||
'rescue_title' => 'Rescue the tunnel now?',
|
||||
'rescue_body' => 'Raises the WireGuard interface on the tunnel server again. Deliberately does nothing destructive — no rebuild, no restart of the tunnel container.',
|
||||
'rescue_cancel' => 'Cancel',
|
||||
'rescue_confirm' => 'Rescue tunnel',
|
||||
|
||||
// ── Pinning a ceiling ────────────────────────────────────────────────
|
||||
// The ceiling that limits both the update agent AND the maintenance
|
||||
// window (App\Services\Deployment\UpdateChannel::setCeiling()). Only
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-text">
|
||||
<x-ui.icon name="life-buoy" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('admin_settings.rescue_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.rescue_body') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin_settings.rescue_cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="life-buoy" class="size-4" />{{ __('admin_settings.rescue_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -117,6 +117,35 @@
|
|||
<dd class="text-body">{{ $update['checked_at']->diffForHumans() }}</dd></div>
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
{{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet
|
||||
ins Journal, und das liegt auf dem Wirt. --}}
|
||||
@if ($watchdog)
|
||||
<p class="mt-2 text-sm {{ $watchdog['stale'] ? 'text-warning' : 'text-muted' }}">
|
||||
@if ($watchdog['stale'])
|
||||
{{ __('admin_settings.watchdog_stale', ['when' => $watchdog['at']->local()->diffForHumans()]) }}
|
||||
@elseif ($watchdog['outcome'] === 'healed')
|
||||
{{ __('admin_settings.watchdog_healed', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }}
|
||||
@elseif ($watchdog['outcome'] === 'stood_down')
|
||||
{{ __('admin_settings.watchdog_stood_down', ['when' => $watchdog['at']->local()->diffForHumans()]) }}
|
||||
@else
|
||||
{{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }}
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Der Wirt-Helfer. Steht VOR den Knoepfen, nicht als Fehler danach: wer
|
||||
gleich „Sperre loesen" drueckt, soll vorher wissen, dass es scheitern
|
||||
wird. --}}
|
||||
@unless ($update['host_step_ok'])
|
||||
<x-ui.alert variant="warning" class="mt-2">
|
||||
{{ __('admin_settings.host_step_old', [
|
||||
'have' => $update['host_step_have'],
|
||||
'needs' => $update['host_step_needs'],
|
||||
]) }}
|
||||
<code class="mt-1 block select-all text-xs">sudo bash /opt/clupilot/deploy/install-agent.sh</code>
|
||||
</x-ui.alert>
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
{{-- Bound, not @disabled(): the directive compiles to inline PHP
|
||||
|
|
@ -174,6 +203,16 @@
|
|||
{{ __('admin_settings.update_now') }}
|
||||
</x-ui.button>
|
||||
|
||||
{{-- Tunnel-Rettung, in derselben Handlungsleiste wie die übrigen —
|
||||
dieselbe Begründung wie beim Festnageln daneben (flex-wrap,
|
||||
w-full sm:w-auto). Bestätigt im Modal (R23), weil es einen
|
||||
laufenden Zustand auf dem Wirt anfasst. --}}
|
||||
<x-ui.button class="w-full sm:w-auto" variant="secondary"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-rescue-tunnel' })">
|
||||
<x-ui.icon name="life-buoy" class="size-4" />
|
||||
{{ __('admin_settings.rescue_action') }}
|
||||
</x-ui.button>
|
||||
|
||||
{{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren
|
||||
statt einer zweiten Leiste daneben — dieselbe Begründung wie bei
|
||||
den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Settings;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* `deploy/rescue-tunnel.sh` stand in zwei Runbooks als Handarbeit. Es läuft
|
||||
* ohnehin als Dienstbenutzer und tut nichts Zerstörerisches — der Agent kann
|
||||
* es also fahren wie jede andere Anfrage.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::delete(File::glob(storage_path('app/deploy/*')));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
it('puts a rescue request in the same mailbox as every other kind', function () {
|
||||
// Kein zweiter Weg: dieselbe Datei, derselbe Agent, derselbe Lauf.
|
||||
expect(app(UpdateChannel::class)->requestRescueTunnel('chef@example.com'))->toBeTrue();
|
||||
|
||||
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
|
||||
|
||||
expect($request['kind'])->toBe('rescue-tunnel')
|
||||
->and($request['requested_by'])->toBe('chef@example.com');
|
||||
});
|
||||
|
||||
it('takes only one request at a time', function () {
|
||||
$channel = app(UpdateChannel::class);
|
||||
|
||||
expect($channel->requestRescueTunnel('chef@example.com'))->toBeTrue()
|
||||
->and($channel->requestRescueTunnel('chef@example.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rescues the tunnel from the console', function () {
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->call('rescueTunnel');
|
||||
|
||||
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('refuses to rescue without site.manage', function () {
|
||||
// Eine Livewire-Aktion ist ein oeffentlicher Endpunkt.
|
||||
$staff = Operator::factory()->create();
|
||||
|
||||
Livewire::actingAs($staff, 'operator')
|
||||
->test(Settings::class)
|
||||
->call('rescueTunnel')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('reads the outcome without falling over when it is rubbish', function () {
|
||||
File::put(storage_path('app/deploy/rescue-last-run.json'), 'kein json {');
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['rescue_last_run'])->toBeNull();
|
||||
});
|
||||
|
||||
it('agent knows the kind', function () {
|
||||
// Der Agent muss die Art kennen, sonst liegt die Anfrage bis zum Ablauf
|
||||
// im Postkasten und niemand erfaehrt warum.
|
||||
expect(File::get(base_path('deploy/update-agent.sh')))
|
||||
->toContain('rescue-tunnel')
|
||||
->toContain('rescue-tunnel.sh');
|
||||
});
|
||||
|
|
@ -61,6 +61,58 @@ function runWatchdog(string $running = "app\nredis", bool $holdLock = false): ar
|
|||
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);
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
|
@ -91,6 +143,24 @@ it('records that it stood down because the lock was held', function () {
|
|||
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('reads nothing rather than falling over when the file is absent', function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue