Drei Wirt-Vorgaenge kommen in die Konsole
Der Waechter, die Vertragsversion des Wirt-Helfers und die Tunnel-Rettung brauchten bisher die Kommandozeile auf dem Wirt. Bei zehn Servern zehnmal. WAECHTER: Er redete nur ins systemd-Journal — und das liegt auf dem Wirt, waehrend die Konsole in einem Container laeuft. Sie sah ihn also gar nicht. Genau das hat am 4. August die Fehlersuche gekostet: er hielt die Sperre, der Agent kam nicht an die Arbeit, und die einzige Stelle, an der das gestanden haette, war unerreichbar. Er hinterlaesst jetzt seinen Ausgang, einschliesslich des Ruecktritts, und die Konsole sagt auch, wenn er seit Minuten schweigt. WIRT-HELFER: Hebt ein Release seine Vertragsversion an, braucht jeder Wirt einmal "sudo bash deploy/install-agent.sh". Das wird gemeldet statt automatisiert, und zwar bevor jemand einen Knopf drueckt, der daran scheitert. Die sudoers-Grenze bleibt: sie gewaehrt dem Dienstbenutzer genau drei benannte Befehle, und etwas Root-Eigenes, das ungeprueft aus dem beschreibbaren Checkout ausfuehrt, gaebe jedem, der je an diesen Benutzer kommt, Root auf dem Wirt. Nebenbei aufgeloest: die gebrauchte Vertragsversion stand zweimal im Repo, und kein Test hielt die beiden Enden mehr zusammen. TUNNEL-RETTUNG: Ein Knopf, ueber den bestehenden Postkasten. Kein zweiter Weg. Der rote Faden war, dass die Konsole in JEDEM Zustand die Wahrheit sagt, und er ist dabei dreimal gerissen: ein gescheiterter Tunnelaufbau meldete erst "geheilt", nach dem Fix "nichts zu tun", und die Rettung meldete "erfolgreich", wo das Skript bei seinen wichtigsten Teilfehlschlaegen 0 zurueckgab. Alle drei behoben und im gerenderten HTML nachgewiesen — samt Gegenprobe, dass ein gesunder Lauf NICHT warnt. Ein Warnkasten, der immer steht, wird nach zwei Tagen nicht mehr gelesen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
commit
099a29fa5c
|
|
@ -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,14 @@ class Settings extends Component
|
|||
return view('livewire.admin.settings', [
|
||||
'update' => app(UpdateChannel::class)->state(),
|
||||
'updateLog' => app(UpdateChannel::class)->lastLog(),
|
||||
// Review-Runde 2 (C1 Teil 2): derselbe <details>-Auszug wie
|
||||
// updateLog, nur fürs Protokoll der letzten Tunnel-Rettung — der
|
||||
// Betreiber soll den Satz mit dem Handgriff lesen können, ohne
|
||||
// auf den Wirt zu gehen.
|
||||
'rescueLog' => app(UpdateChannel::class)->rescueLog(),
|
||||
// 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,30 @@ 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';
|
||||
|
||||
/**
|
||||
* Der Protokollauszug der letzten Tunnel-Rettung.
|
||||
*
|
||||
* Dasselbe Muster wie LOG/lastLog() weiter unten, nur für
|
||||
* deploy/rescue-tunnel.sh statt deploy/update.sh — deploy/update-agent.sh
|
||||
* schreibt die volle Ausgabe des Skripts hierhin (Zeile ~694), Blade
|
||||
* zeigt sie als <details>, wie das Protokoll der Aktualisierung auch.
|
||||
* Review-Runde 2 (C1 Teil 2): der Betreiber soll den Satz mit dem
|
||||
* Handgriff lesen können, ohne auf den Wirt zu gehen.
|
||||
*/
|
||||
private const RESCUE_LOG = 'deploy/rescue-last-run.log';
|
||||
|
||||
/** Written by the agent after every check and every run. */
|
||||
private const STATUS = 'deploy/update-status.json';
|
||||
|
||||
|
|
@ -325,6 +349,20 @@ final class UpdateChannel
|
|||
'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null,
|
||||
'checked_at' => $checkedAt,
|
||||
|
||||
// Der root-eigene Helfer auf dem Wirt. Fehlt die Meldung ganz
|
||||
// (alte Agentenfassung, allererster Lauf), gilt er als in
|
||||
// Ordnung: „ich weiß es nicht" ist nicht „zu alt", und ein
|
||||
// Warnkasten, der auf jedem frisch aufgesetzten Wirt steht, wird
|
||||
// nach zwei Tagen nicht mehr gelesen.
|
||||
'host_step_have' => isset($status['host_step_contract'])
|
||||
? (int) $status['host_step_contract']
|
||||
: null,
|
||||
'host_step_needs' => isset($status['host_step_needs'])
|
||||
? (int) $status['host_step_needs']
|
||||
: null,
|
||||
'host_step_ok' => ! isset($status['host_step_contract'], $status['host_step_needs'])
|
||||
|| (int) $status['host_step_contract'] >= (int) $status['host_step_needs'],
|
||||
|
||||
'agent_seen' => $agentAlive,
|
||||
// Seit wann der Agent nur noch überspringt, und wer die Sperre
|
||||
// hält. Beides null, solange er arbeitet.
|
||||
|
|
@ -393,6 +431,23 @@ 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 `[]`.
|
||||
//
|
||||
// Fix-Runde 1: dieses Feld existierte, aber kein Blade las es —
|
||||
// ein gescheiterter Rettungsversuch war der Konsole unsichtbar,
|
||||
// genau der Zustand, aus dem dieser Knopf befreien sollte.
|
||||
// `finished_at` geht wie überall hier durch `timestamp()`, damit
|
||||
// die View nicht selbst parsen muss; `error` durch dieselbe
|
||||
// `errorMessage()`, die auch STATUS und LAST_RUN übersetzt —
|
||||
// `rescue_failed` steht dafür jetzt in `update_error`.
|
||||
'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== []
|
||||
? [
|
||||
'state' => $last['state'] ?? null,
|
||||
'finished_at' => $this->timestamp($last['finished_at'] ?? null),
|
||||
'error' => $this->errorMessage($last),
|
||||
]
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -590,6 +645,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.
|
||||
*
|
||||
|
|
@ -905,6 +969,29 @@ final class UpdateChannel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tail of the last tunnel-rescue attempt — the same shape as
|
||||
* lastLog() above, for RESCUE_LOG instead of LOG. Read separately from
|
||||
* state(), same as `updateLog` is in App\Livewire\Admin\Settings::render():
|
||||
* this is something to show, not something to decide on.
|
||||
*/
|
||||
public function rescueLog(int $lines = 40): ?string
|
||||
{
|
||||
$path = storage_path('app/'.self::RESCUE_LOG);
|
||||
|
||||
try {
|
||||
if (! File::exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$all = preg_split('/\R/', trim((string) File::get($path))) ?: [];
|
||||
|
||||
return implode("\n", array_slice($all, -$lines));
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function readJson(string $relative): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Deployment;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Was der Wächter zuletzt getan hat.
|
||||
*
|
||||
* Eigene Klasse und nicht ein weiteres Feld in `UpdateChannel`: der Wächter
|
||||
* ist eine andere Sache als der Update-Kanal — er richtet einen liegen
|
||||
* gebliebenen Stapel, während jener eine Auslieferung vermittelt. Und
|
||||
* `UpdateChannel` ist bereits über neunhundert Zeilen lang.
|
||||
*/
|
||||
class WatchdogLog
|
||||
{
|
||||
private const FILE = 'deploy/watchdog-last-run.json';
|
||||
|
||||
/**
|
||||
* Älter als das, und der Wächter läuft nicht mehr.
|
||||
*
|
||||
* Sein Zeitgeber steht auf einer Minute. Fünf lässt vier ausgefallenen
|
||||
* Takten Luft — ein Wirt unter Last oder ein Lauf, der gerade heilt und
|
||||
* dabei `sleep 15` macht, ist noch kein toter Wächter.
|
||||
*/
|
||||
private const STALE_AFTER_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* @return array{at: Carbon, outcome: string, actions: array<int, string>, stale: bool}|null
|
||||
*/
|
||||
public function lastRun(): ?array
|
||||
{
|
||||
try {
|
||||
$path = storage_path('app/'.self::FILE);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode((string) File::get($path), true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$at = Carbon::parse((string) $data['at']);
|
||||
|
||||
return [
|
||||
'at' => $at,
|
||||
'outcome' => (string) ($data['outcome'] ?? 'idle'),
|
||||
'actions' => array_values(array_filter(
|
||||
is_array($data['actions'] ?? null) ? $data['actions'] : [],
|
||||
'is_string'
|
||||
)),
|
||||
'stale' => $at->lt(Carbon::now()->subMinutes(self::STALE_AFTER_MINUTES)),
|
||||
];
|
||||
} catch (Throwable) {
|
||||
// Dieselbe Haltung wie `UpdateChannel::readJson()`: die Konsole
|
||||
// liest das bei jedem Seitenaufbau, und „ich weiß es nicht" ist
|
||||
// ein brauchbarer Zustand — eine geworfene Ausnahme nicht.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -216,3 +216,17 @@ json_escape() {
|
|||
# raw; dropping them beats emitting a broken document.
|
||||
printf '%s' "$s" | tr -d '\000-\037'
|
||||
}
|
||||
|
||||
# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers
|
||||
# diese Fassung braucht.
|
||||
#
|
||||
# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als
|
||||
# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen
|
||||
# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem
|
||||
# Wirt, dessen Helfer zu alt ist.
|
||||
#
|
||||
# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt,
|
||||
# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal
|
||||
# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter
|
||||
# der root sitzt.
|
||||
release_host_step_needs() { printf '%s' 3; }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,21 @@ ok() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; }
|
|||
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
|
||||
bad() { printf '\033[1;31m ✗\033[0m %s\n' "$*"; }
|
||||
|
||||
# Wie viele Stellen etwas gefunden haben, das nicht ausgerichtet ist, wie es
|
||||
# sein sollte — und zwar am ENDE des Laufs, nicht dem, was ein einzelner Griff
|
||||
# gerade behoben zu haben glaubt. Der Aufrufer (deploy/update-agent.sh) macht
|
||||
# bisher NUR den Exit-Code zur Wahrheit; ohne diesen Zähler endete das Skript
|
||||
# unter `set -uo pipefail` (kein `-e`) nach jedem `bad`/`warn` auf ein
|
||||
# schlichtes `echo` weiter unten — Exit 0, auch wenn mittendrin etwas offen
|
||||
# blieb. Nicht jede `bad`/`warn`-Zeile zählt: eine, die einen unmittelbar
|
||||
# folgenden `exit 1` hat (Container startet nicht, wg0 laesst sich nicht
|
||||
# hochziehen), braucht den Zähler nicht — der Exit-Code steht da schon fest.
|
||||
# Und eine rein informative Meldung (dieser Server hat noch keinen eigenen
|
||||
# Tunnel-Container) sagt nichts darüber, ob DIESER Lauf etwas nicht
|
||||
# hinbekommen hat — sie stünde bei jedem Lauf auf einem älteren Wirt, auch
|
||||
# wenn der Tunnel tadellos steht.
|
||||
probleme=0
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
echo "Bitte als Dienstbenutzer starten, nicht als root:" >&2
|
||||
echo " sudo -u clupilot bash $0" >&2
|
||||
|
|
@ -82,7 +97,11 @@ peers="$(hub wg show wg0 peers | grep -c . || true)"
|
|||
if [[ "${peers:-0}" -gt 0 ]]; then
|
||||
ok "$peers Zugänge geladen"
|
||||
else
|
||||
# Nicht bloss eine Randnotiz: ohne einen einzigen Zugang tut der Tunnel
|
||||
# nichts, egal wie gesund wg0 sonst aussieht. Vorher endete der Lauf hier
|
||||
# trotzdem auf Exit 0.
|
||||
warn "Kein einziger Zugang geladen — /etc/wireguard/wg0.conf ansehen."
|
||||
probleme=$((probleme + 1))
|
||||
fi
|
||||
|
||||
# ── 3. Kommt auch etwas an? ──────────────────────────────────────────────────
|
||||
|
|
@ -108,12 +127,18 @@ if [[ "${stumm:-0}" -gt 0 ]]; then
|
|||
if sudo -n conntrack -D -p udp --dport "$WG_PORT" >/dev/null 2>&1; then
|
||||
ok "erledigt — die Zugänge bauen sich in den nächsten 30 Sekunden neu auf"
|
||||
else
|
||||
# DAS Krankheitsbild, das nie von allein heilt (siehe Kopf dieser
|
||||
# Datei): stumme Zugänge bleiben stumm, wenn dieser Griff scheitert.
|
||||
# Der conntrack-Sudoers-Eintrag ist Handarbeit (siehe unten) und auf
|
||||
# einem frischen Wirt der Normalfall, nicht die Ausnahme — genau
|
||||
# deshalb darf ein gescheiterter Versuch hier nicht als Erfolg enden.
|
||||
bad "Dafür fehlt mir Root. Bitte einmal von Hand ausführen:"
|
||||
echo
|
||||
echo " sudo conntrack -D -p udp --dport $WG_PORT"
|
||||
echo
|
||||
warn "Damit ich das künftig selbst darf, siehe docs/runbooks/tunnel-recovery.md,"
|
||||
warn "Abschnitt 'Einmal einrichten'."
|
||||
probleme=$((probleme + 1))
|
||||
fi
|
||||
else
|
||||
ok "Alle Zugänge haben schon Daten geschickt"
|
||||
|
|
@ -136,3 +161,9 @@ echo " wieder auf — und muss danach zurückgenommen werden:"
|
|||
echo
|
||||
echo " /usr/local/sbin/clupilot-emergency-open-firewall.sh"
|
||||
echo
|
||||
|
||||
# Der Aufrufer (deploy/update-agent.sh) macht den Exit-Code zur einzigen
|
||||
# Wahrheitsquelle — siehe `probleme` oben. Kein Problem gezählt heisst hier
|
||||
# tatsächlich Exit 0; eins oder mehr heisst, der Lauf hat nicht ausgerichtet,
|
||||
# was nötig war, auch wenn er bis hierher durchgelaufen ist.
|
||||
exit "$probleme"
|
||||
|
|
@ -176,7 +176,7 @@ release_stuck_lock() {
|
|||
have="$("$step" contract 2>/dev/null || true)"
|
||||
[[ "$have" =~ ^[0-9]+$ ]] || have=0
|
||||
|
||||
if (( have < 3 )); then
|
||||
if (( have < $(release_host_step_needs) )); then
|
||||
write_unblock failed unblock_helper_old
|
||||
return 0
|
||||
fi
|
||||
|
|
@ -425,6 +425,22 @@ releases_json() {
|
|||
printf '%s' "${out%,}"
|
||||
}
|
||||
|
||||
# Welchen Vertrag der Wirt-Helfer erfüllt — und welchen diese Fassung braucht.
|
||||
#
|
||||
# Gemeldet statt automatisiert: `sudoers` gewährt dem Dienstbenutzer genau
|
||||
# drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem
|
||||
# beschreibbaren Checkout ausführt, gäbe jedem, der je an diesen Benutzer
|
||||
# kommt, Root auf dem Wirt. Die Grenze bleibt; die Konsole soll nur aufhören,
|
||||
# den Betreiber raten zu lassen.
|
||||
#
|
||||
# `|| true` und der Zahlentest: ein fehlender Helfer, ein Helfer ohne diesen
|
||||
# Schritt und ein Helfer, der etwas Unerwartetes druckt, sind alle „0" — und
|
||||
# keiner davon darf den Agenten unter `set -e` beenden, bevor er eine
|
||||
# Statusdatei schreibt.
|
||||
HOST_STEP_HAVE="$( { /usr/local/sbin/clupilot-host-step contract 2>/dev/null || true; } | head -1 )"
|
||||
[[ "$HOST_STEP_HAVE" =~ ^[0-9]+$ ]] || HOST_STEP_HAVE=0
|
||||
HOST_STEP_NEEDS="$(release_host_step_needs)"
|
||||
|
||||
write_status() {
|
||||
local state="$1" error="${2-}"
|
||||
cat > "$STATUS.tmp" <<EOF
|
||||
|
|
@ -440,6 +456,8 @@ write_status() {
|
|||
"remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")",
|
||||
"target_release": "$(json_escape "${TARGET_RELEASE:-}")",
|
||||
"behind": ${BEHIND:-null},
|
||||
"host_step_contract": ${HOST_STEP_HAVE:-0},
|
||||
"host_step_needs": ${HOST_STEP_NEEDS:-0},
|
||||
"ceiling": "$(json_escape "${CEILING:-}")",
|
||||
"ceiling_error": "$(json_escape "${CEILING_ERROR:-}")",
|
||||
"releases": [$(releases_json)],
|
||||
|
|
@ -695,6 +713,41 @@ 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"
|
||||
|
||||
# Fix-Runde 1: `write_status idle` (ohne zweites Argument) verschluckte
|
||||
# RESCUE_ERROR — ein Fehlschlag stand zwar in rescue-last-run.json, aber
|
||||
# `update-status.json`s `error`-Feld blieb leer. Wie proxy-hosts daneben
|
||||
# (Zeile ~674) wird der Fehler hier durchgereicht, auch wenn er wie bei
|
||||
# jedem anderen Zweig hier nur bis zum naechsten Takt sichtbar bleibt —
|
||||
# die dauerhafte Ablage ist rescue-last-run.json selbst, das die Konsole
|
||||
# jetzt separat liest (UpdateChannel::state()['rescue_last_run']).
|
||||
write_status idle "$RESCUE_ERROR"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -70,9 +70,11 @@ STATE_FILE="storage/app/deployed-commit"
|
|||
# fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS
|
||||
# here is what makes an update tell the operator to run the installer again.
|
||||
HOST_STEP=/usr/local/sbin/clupilot-host-step
|
||||
# 3 seit `release-update-lock`: ohne den Schritt bleibt der Knopf „Sperre lösen"
|
||||
# in der Konsole ein Knopf, der nichts tun kann.
|
||||
HOST_STEP_NEEDS=3
|
||||
# Seit `release-update-lock`: ohne den Schritt bleibt der Knopf „Sperre lösen"
|
||||
# in der Konsole ein Knopf, der nichts tun kann. Die Zahl selbst steht nur noch
|
||||
# in deploy/lib/release.sh (release_host_step_needs) — hier gäbe eine zweite
|
||||
# Kopie die Möglichkeit, dass sie mit der im Agenten auseinanderläuft.
|
||||
HOST_STEP_NEEDS="$(release_host_step_needs)"
|
||||
# Which step is running, for the console to show. Written as a KEY, not as the
|
||||
# sentence below it: the console is translated and this script is not, so an
|
||||
# English line here would surface untranslated in the interface. A run that dies
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ say() {
|
|||
logger -t "$LOG_TAG" -- "$*"
|
||||
fi
|
||||
printf '%s\n' "$*"
|
||||
# Jede Meldung ist zugleich ein Eintrag fuer die Konsole. Eine zweite
|
||||
# Stelle, an der man daran denken muesste, waere eine Stelle, an der es
|
||||
# irgendwann vergessen wird.
|
||||
AKTIONEN+=("$*")
|
||||
}
|
||||
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||
|
|
@ -101,6 +105,20 @@ darf_eingreifen() {
|
|||
|
||||
geheilt=false
|
||||
|
||||
# Ein Misserfolg muss den Ausgang gewinnen — auch wenn im selben Lauf ein
|
||||
# ANDERER Griff gewirkt hat. Ohne dieses eigene Flag setzte ein erfolgreicher
|
||||
# Zweig `geheilt=true`, und die Konsole zeigte "eingegriffen" in ruhiger
|
||||
# Farbe, waehrend ein zweiter Zweig (z. B. wg0) im selben Lauf nicht gewirkt
|
||||
# hat — der Satz, der genau fuer diesen Fall geschrieben wurde
|
||||
# (watchdog_tried), wurde nie gedruckt, weil seine Bedingung bisher an
|
||||
# `outcome === 'idle'` hing und `outcome` durch den erfolgreichen Zweig
|
||||
# bereits `healed` war.
|
||||
fehlgeschlagen=false
|
||||
|
||||
# Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus
|
||||
# einen Satz; das Journal hat weiterhin die Langfassung.
|
||||
AKTIONEN=()
|
||||
|
||||
# ── 1. Fehlt ein Dienst? ─────────────────────────────────────────────────────
|
||||
#
|
||||
# `config --services` liest die Profile aus der .env mit, vpn-dns und
|
||||
|
|
@ -113,9 +131,21 @@ if [[ -n "$soll" ]]; then
|
|||
if [[ -n "$fehlt" ]] && darf_eingreifen; then
|
||||
say "Es fehlen Dienste: $fehlt — starte sie."
|
||||
lange_frist docker compose up -d >/dev/null 2>&1 || true
|
||||
geheilt=true
|
||||
sleep 10
|
||||
ist="$(frist docker compose ps --services --status running 2>/dev/null | sort || true)"
|
||||
|
||||
# Zweiter Blick, wie beim wg0-Zweig weiter unten: `up -d` kann
|
||||
# scheitern (Docker-Daemon nicht erreichbar, ein Abbild fehlt) und
|
||||
# gab bisher trotzdem `geheilt=true` — die Konsole zeigte dann
|
||||
# "eingegriffen" in ruhiger Farbe, waehrend der Stapel weiter unten
|
||||
# lag.
|
||||
fehlt_nach="$(comm -23 <(printf '%s\n' "$soll") <(printf '%s\n' "$ist") | tr '\n' ' ' | sed 's/ *$//')"
|
||||
if [[ -z "$fehlt_nach" ]]; then
|
||||
geheilt=true
|
||||
else
|
||||
say "ACHTUNG: es fehlen weiterhin Dienste: $fehlt_nach."
|
||||
fehlgeschlagen=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -139,8 +169,19 @@ if printf '%s\n' "$ist" | grep -qx app && printf '%s\n' "$ist" | grep -qx redis;
|
|||
if darf_eingreifen && ! frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then
|
||||
say "Die Container finden einander nicht mehr (redis nicht auflösbar) — erzeuge sie neu."
|
||||
lange_frist docker compose up -d --force-recreate >/dev/null 2>&1 || true
|
||||
geheilt=true
|
||||
sleep 15
|
||||
|
||||
# Dritter Blick: hat `--force-recreate` selbst gewirkt? Bisher
|
||||
# stand `geheilt=true` schon VOR dieser Frage — derselbe Fehler
|
||||
# wie beim wg0-Zweig, nur an einer teureren Stelle (dieser Griff
|
||||
# reisst jede offene Verbindung ab, und tat es hier auch dann
|
||||
# unwidersprochen, wenn Docker den Neubau selbst nicht schaffte).
|
||||
if frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then
|
||||
geheilt=true
|
||||
else
|
||||
say "ACHTUNG: redis ist weiterhin nicht auflösbar."
|
||||
fehlgeschlagen=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
|
@ -156,12 +197,26 @@ 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."
|
||||
# Review-Runde 2: `outcome` blieb bis hierher `idle`,
|
||||
# wenn kein ANDERER Zweig im selben Lauf gegriffen hatte
|
||||
# — dann las die Konsole "actions" und zeigte
|
||||
# watchdog_tried. Griff aber ein anderer Zweig ERFOLGREICH
|
||||
# (z. B. fehlende Dienste), stand `outcome` schon auf
|
||||
# `healed`, und dieser Fehlschlag verschwand darin. Das
|
||||
# eigene Flag gewinnt jetzt unabhaengig davon.
|
||||
fehlgeschlagen=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
|
@ -196,13 +251,70 @@ if [[ ! -f "$HOLD" ]] && frist docker compose exec -T -u www-data app test -f st
|
|||
&& frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then
|
||||
say "Der Wartungsmodus haengt seit ueber einer halben Stunde ohne laufendes Update — beende ihn."
|
||||
lange_frist docker compose exec -T -u www-data app php artisan up >/dev/null 2>&1 || true
|
||||
geheilt=true
|
||||
|
||||
# Zweiter Blick, dasselbe Muster wie in den drei Zweigen oben: `up`
|
||||
# kann scheitern (Docker-Daemon nicht erreichbar), und ohne diese
|
||||
# Pruefung stand `geheilt=true` bereits fest, waehrend die Seite
|
||||
# unten blieb.
|
||||
if ! frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then
|
||||
geheilt=true
|
||||
else
|
||||
say "ACHTUNG: der Wartungsmodus liess sich nicht beenden."
|
||||
fehlgeschlagen=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Nur reden, wenn es etwas zu sagen gab. Ein Waechter, der jede Minute meldet,
|
||||
# dass alles in Ordnung ist, wird nach zwei Tagen nicht mehr gelesen — und dann
|
||||
# auch nicht mehr an dem Tag, an dem er etwas Wichtiges sagt.
|
||||
if [[ "$geheilt" == true ]]; then
|
||||
if [[ "$geheilt" == true || "$fehlgeschlagen" == true ]]; then
|
||||
say "Nachgesehen und eingegriffen."
|
||||
fi
|
||||
|
||||
# ── Was die Konsole davon erfaehrt ───────────────────────────────────────────
|
||||
#
|
||||
# Der Waechter redete bisher NUR ins Journal — und das liegt auf dem Wirt,
|
||||
# waehrend die Konsole in einem Container laeuft. Sie sah 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 von der Konsole aus unerreichbar.
|
||||
#
|
||||
# Vier Ausgaenge, weil sie vier verschiedene Dinge bedeuten:
|
||||
# idle — nachgesehen, nichts zu tun. Der Normalfall.
|
||||
# healed — eingegriffen, und JEDER Griff in diesem Lauf hat gewirkt.
|
||||
# tried — eingegriffen, aber mindestens EIN Griff hat NICHT gewirkt —
|
||||
# auch wenn ein anderer im selben Lauf erfolgreich war. Ein
|
||||
# Misserfolg gewinnt den Ausgang; siehe `fehlgeschlagen` oben.
|
||||
# stood_down — nicht drangekommen, weil ein Update die Sperre hielt.
|
||||
# Betrieb, kein Fehler — aber es muss unterscheidbar sein.
|
||||
#
|
||||
# Atomar geschrieben: die Konsole liest diese Datei bei jedem Seitenaufbau,
|
||||
# und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand
|
||||
# nachsieht.
|
||||
ausgang=idle
|
||||
if [[ "$fehlgeschlagen" == true ]]; then
|
||||
ausgang=tried
|
||||
elif [[ "$geheilt" == true ]]; then
|
||||
ausgang=healed
|
||||
elif [[ "$SPERRE" == verwehrt ]]; then
|
||||
ausgang=stood_down
|
||||
fi
|
||||
|
||||
# Die Liste als JSON-Array. Anfuehrungszeichen, Backslashes und Umbrueche raus
|
||||
# — der einzige freie Text sind die eigenen Meldungen oben, aber verlassen
|
||||
# wird sich darauf nicht.
|
||||
eintraege=''
|
||||
for a in ${AKTIONEN+"${AKTIONEN[@]}"}; do
|
||||
a="$(printf '%s' "$a" | tr -d '"\\' | tr '\n\r\t' ' ')"
|
||||
eintraege+="\"$a\","
|
||||
done
|
||||
|
||||
cat > "$STATE_DIR/watchdog-last-run.json.tmp" 2>/dev/null <<EOF && \
|
||||
mv -f "$STATE_DIR/watchdog-last-run.json.tmp" "$STATE_DIR/watchdog-last-run.json" 2>/dev/null || true
|
||||
{
|
||||
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"outcome": "$ausgang",
|
||||
"actions": [${eintraege%,}]
|
||||
}
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -85,7 +85,18 @@ kommen, der einen zur Konsole bringt.
|
|||
|
||||
---
|
||||
|
||||
## Zuerst: ein Befehl, der das meiste allein macht
|
||||
## Zuerst: der Knopf in der Konsole
|
||||
|
||||
Einstellungen → „Tunnel retten". Derselbe Rettungsversuch wie unten, nur ohne
|
||||
SSH: die Konsole legt eine Bitte ab, der Update-Agent auf dem Wirt fährt
|
||||
`deploy/rescue-tunnel.sh` (dasselbe Skript, kein zweiter Weg) und meldet
|
||||
zurück, was er getan hat — samt Protokollauszug, direkt unter dem Knopf. Der
|
||||
Griff geht damit vom Wirt in die Konsole, wo dieser ganze Vorgang eigentlich
|
||||
hingehört.
|
||||
|
||||
Rückfall, wenn die Konsole selbst nicht erreichbar ist (dann zuerst zurück zu
|
||||
„Die vier Wege hinein" oben) oder wenn man von Hand nachvollziehen will, was
|
||||
das Skript tut:
|
||||
|
||||
```bash
|
||||
cd /opt/clupilot && sudo -u clupilot bash deploy/rescue-tunnel.sh
|
||||
|
|
|
|||
|
|
@ -0,0 +1,825 @@
|
|||
# Wirt-Vorgänge in die Konsole — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Drei Vorgänge, für die heute die Kommandozeile auf dem Wirt nötig ist, werden aus der Konsole heraus sichtbar bzw. bedienbar — der Wächter, der Zustand des Wirt-Helfers, und die Tunnel-Rettung.
|
||||
|
||||
**Architecture:** Alle drei folgen dem Muster, das Agent und Update-Kanal bereits benutzen: der Wirt schreibt Zustand als JSON nach `storage/app/deploy/` (Bind-Mount), die Konsole liest ihn. Neue Handlungen gehen durch den bestehenden Postkasten (`update-request.json` + `KIND_*`), nie über einen zweiten Weg.
|
||||
|
||||
**Tech Stack:** Bash (`deploy/*.sh`), Laravel 13.8, Livewire 3, Pest, Tailwind v4.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Jede Datei, die der Wirt schreibt und die Konsole liest, wird ATOMAR geschrieben** (`.tmp` + `mv`/`rename`), und **beim Lesen fällt nichts um**, wenn sie fehlt, leer ist oder Unsinn enthält. In dieser Codebasis waren beide Punkte schon je ein Important-Befund. Vorbilder: `write_alive()` in `deploy/update-agent.sh`, `readJson()` in `UpdateChannel`.
|
||||
- **Kein zweiter Weg in eine Auslieferung oder einen Wirt-Vorgang.** Neue Handlungen gehen durch `UpdateChannel::submit()` mit eigener `KIND_*`, wie `KIND_CHECK`/`KIND_RESTART`/`KIND_ARCHIVE_KEY`.
|
||||
- **`deploy/update-agent.sh` und `deploy/watchdog.sh` laufen unter `set -e`-Regimen.** Eine Zuweisung aus einer Kommandoersetzung reicht deren Status weiter und beendet das Skript, BEVOR es Zustand schreibt — genau diese Ausfallart hat die Konsole schon zweimal eine nie endende Prüfung zeigen lassen. Jeder neue Ausdruck muss das überleben. `watchdog.sh` läuft unter `set -uo pipefail` (**ohne** `-e`), `update-agent.sh` unter `set -Eeuo pipefail`.
|
||||
- **Kein Aufruf nach draußen ohne Frist.** `timeout -k` wie im übrigen Wächter.
|
||||
- **Die Sicherheitsgrenze des Wirt-Helfers bleibt.** `sudoers` gewährt genau drei benannte Befehle. Es wird **nichts** gebaut, das root ungeprüft aus dem beschreibbaren Checkout ausführen lässt. Aufgabe ist zu **melden**, nicht zu automatisieren.
|
||||
- **R21** Operator-Guard, **R23** Bestätigung im Modal (kein `wire:confirm`, kein `confirm(`), **R18/R24** wie in CLAUDE.md.
|
||||
- **Sprachdateien:** jede Zeichenkette in `lang/de/` **und** `lang/en/`.
|
||||
- **Testlauf:** `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=<X>` — der `docker compose`-Aufruf **muss** aus `/home/nexxo/clupilot` kommen, nie aus dem Worktree (sonst „service app is not running"). `cd` hält zwischen Aufrufen nicht.
|
||||
- **Nie `git add -A`.** Eigene Pfade einzeln nennen, `git status` vorher prüfen — es laufen mehrere Sitzungen in diesem Arbeitsbaum.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Datei | Verantwortung |
|
||||
|---|---|
|
||||
| `deploy/watchdog.sh` | Schreibt am Ende jedes Laufs seinen Ausgang — auch den Rücktritt. |
|
||||
| `app/Services/Deployment/WatchdogLog.php` | **Neu.** Liest den Ausgang, beurteilt Frische. Eigene Klasse, weil `UpdateChannel` bereits über 900 Zeilen hat und der Wächter eine andere Sache ist als der Update-Kanal. |
|
||||
| `deploy/lib/release.sh` | Hält die gebrauchte Vertragsversion des Wirt-Helfers an **einer** Stelle. |
|
||||
| `deploy/update-agent.sh` | Meldet Vertragsversion (vorhanden/gebraucht); führt die Tunnel-Rettung aus. |
|
||||
| `app/Services/Deployment/UpdateChannel.php` | Neue Anfrageart Tunnel-Rettung, Helfer-Vertrag in `state()`. |
|
||||
| `app/Livewire/Admin/Settings.php` + Blade | Anzeige Wächter, Anzeige Helfer-Vertrag, Knopf Tunnel-Rettung. |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Der Wächter hinterlässt, was er getan hat
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/watchdog.sh`
|
||||
- Create: `app/Services/Deployment/WatchdogLog.php`
|
||||
- Test: `tests/Feature/WatchdogVisibilityTest.php` (neu)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Datei `storage/app/deploy/watchdog-last-run.json` mit
|
||||
`{"at":"<ISO8601 UTC>","outcome":"idle"|"healed"|"stood_down","actions":["…"]}`
|
||||
- Produces: `WatchdogLog::lastRun(): ?array` mit den Schlüsseln `at` (Carbon), `outcome` (string), `actions` (string[]), `stale` (bool — älter als 5 Minuten, also mehr als vier ausgefallene Takte)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Neue Datei `tests/Feature/WatchdogVisibilityTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Services\Deployment\WatchdogLog;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
File::put($stub.'/docker', <<<SH
|
||||
#!/bin/sh
|
||||
case "\$*" in
|
||||
*config*) printf '%s\\n' app redis ;;
|
||||
*"ps "*) printf '%s\\n' $running ;;
|
||||
*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);
|
||||
}
|
||||
|
||||
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('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();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=WatchdogVisibility`
|
||||
Expected: FAIL — weder die Datei noch die Klasse gibt es.
|
||||
|
||||
- [ ] **Step 3: Implement — der Wächter schreibt seinen Ausgang**
|
||||
|
||||
In `deploy/watchdog.sh`: eine Liste mitführen und am Ende schreiben.
|
||||
|
||||
Direkt hinter `geheilt=false` einfügen:
|
||||
|
||||
```bash
|
||||
# Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus
|
||||
# einen Satz; das Journal hat weiterhin die Langfassung.
|
||||
AKTIONEN=()
|
||||
```
|
||||
|
||||
Die Funktion `say()` erweitern, damit jede Meldung zugleich in die Liste geht —
|
||||
**nicht** eine zweite Stelle, an der man daran denken muss:
|
||||
|
||||
```bash
|
||||
say() {
|
||||
if command -v logger >/dev/null 2>&1; then
|
||||
logger -t "$LOG_TAG" -- "$*"
|
||||
fi
|
||||
printf '%s\n' "$*"
|
||||
# Jede Meldung ist zugleich ein Eintrag fuer die Konsole. Eine zweite
|
||||
# Stelle, an der man daran denken muesste, waere eine Stelle, an der es
|
||||
# irgendwann vergessen wird.
|
||||
AKTIONEN+=("$*")
|
||||
}
|
||||
```
|
||||
|
||||
Am **Ende** der Datei, nach dem bestehenden `if [[ "$geheilt" == true ]]`-Block:
|
||||
|
||||
```bash
|
||||
# ── Was die Konsole davon erfaehrt ───────────────────────────────────────────
|
||||
#
|
||||
# Der Waechter redete bisher NUR ins Journal — und das liegt auf dem Wirt,
|
||||
# waehrend die Konsole in einem Container laeuft. Sie sah 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 von der Konsole aus unerreichbar.
|
||||
#
|
||||
# Drei Ausgaenge, weil sie drei verschiedene Dinge bedeuten:
|
||||
# idle — nachgesehen, nichts zu tun. Der Normalfall.
|
||||
# healed — eingegriffen. Was, steht in `actions`.
|
||||
# stood_down — nicht drangekommen, weil ein Update die Sperre hielt.
|
||||
# Betrieb, kein Fehler — aber es muss unterscheidbar sein.
|
||||
#
|
||||
# Atomar geschrieben: die Konsole liest diese Datei bei jedem Seitenaufbau,
|
||||
# und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand
|
||||
# nachsieht.
|
||||
ausgang=idle
|
||||
if [[ "$geheilt" == true ]]; then
|
||||
ausgang=healed
|
||||
elif [[ "$SPERRE" == verwehrt ]]; then
|
||||
ausgang=stood_down
|
||||
fi
|
||||
|
||||
# Die Liste als JSON-Array. Anfuehrungszeichen, Backslashes und Umbrueche raus
|
||||
# — der einzige freie Text sind die eigenen Meldungen oben, aber verlassen
|
||||
# wird sich darauf nicht.
|
||||
eintraege=''
|
||||
for a in ${AKTIONEN+"${AKTIONEN[@]}"}; do
|
||||
a="$(printf '%s' "$a" | tr -d '"\\' | tr '\n\r\t' ' ')"
|
||||
eintraege+="\"$a\","
|
||||
done
|
||||
|
||||
cat > "$STATE_DIR/watchdog-last-run.json.tmp" 2>/dev/null <<EOF && \
|
||||
mv -f "$STATE_DIR/watchdog-last-run.json.tmp" "$STATE_DIR/watchdog-last-run.json" 2>/dev/null || true
|
||||
{
|
||||
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"outcome": "$ausgang",
|
||||
"actions": [${eintraege%,}]
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Achtung `set -u`:** `${AKTIONEN+"${AKTIONEN[@]}"}` statt `"${AKTIONEN[@]}"` — ein leeres Array gilt unter `set -u` in älteren bash als ungesetzt und bricht den Lauf.
|
||||
|
||||
- [ ] **Step 4: Implement — die Konsole liest ihn**
|
||||
|
||||
Neue Datei `app/Services/Deployment/WatchdogLog.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Deployment;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Was der Wächter zuletzt getan hat.
|
||||
*
|
||||
* Eigene Klasse und nicht ein weiteres Feld in `UpdateChannel`: der Wächter
|
||||
* ist eine andere Sache als der Update-Kanal — er richtet einen liegen
|
||||
* gebliebenen Stapel, während jener eine Auslieferung vermittelt. Und
|
||||
* `UpdateChannel` ist bereits über neunhundert Zeilen lang.
|
||||
*/
|
||||
class WatchdogLog
|
||||
{
|
||||
private const FILE = 'deploy/watchdog-last-run.json';
|
||||
|
||||
/**
|
||||
* Älter als das, und der Wächter läuft nicht mehr.
|
||||
*
|
||||
* Sein Zeitgeber steht auf einer Minute. Fünf lässt vier ausgefallenen
|
||||
* Takten Luft — ein Wirt unter Last oder ein Lauf, der gerade heilt und
|
||||
* dabei `sleep 15` macht, ist noch kein toter Wächter.
|
||||
*/
|
||||
private const STALE_AFTER_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* @return array{at: Carbon, outcome: string, actions: array<int, string>, stale: bool}|null
|
||||
*/
|
||||
public function lastRun(): ?array
|
||||
{
|
||||
try {
|
||||
$path = storage_path('app/'.self::FILE);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode((string) File::get($path), true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$at = Carbon::parse((string) $data['at']);
|
||||
|
||||
return [
|
||||
'at' => $at,
|
||||
'outcome' => (string) ($data['outcome'] ?? 'idle'),
|
||||
'actions' => array_values(array_filter(
|
||||
is_array($data['actions'] ?? null) ? $data['actions'] : [],
|
||||
'is_string'
|
||||
)),
|
||||
'stale' => $at->lt(Carbon::now()->subMinutes(self::STALE_AFTER_MINUTES)),
|
||||
];
|
||||
} catch (Throwable) {
|
||||
// Dieselbe Haltung wie `UpdateChannel::readJson()`: die Konsole
|
||||
// liest das bei jedem Seitenaufbau, und „ich weiß es nicht" ist
|
||||
// ein brauchbarer Zustand — eine geworfene Ausnahme nicht.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter="WatchdogVisibility|WatchdogLockContention"`
|
||||
Expected: PASS — beide, denn `WatchdogLockContention` fährt denselben Wächter.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/watchdog.sh app/Services/Deployment/WatchdogLog.php tests/Feature/WatchdogVisibilityTest.php
|
||||
git commit -m "Der Waechter hinterlaesst, was er getan hat"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Die gebrauchte Vertragsversion an einer Stelle, und ehrlich gemeldet
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/lib/release.sh` (neue Konstante/Funktion)
|
||||
- Modify: `deploy/update.sh:75` (die doppelte Stelle auflösen)
|
||||
- Modify: `deploy/update-agent.sh` (Vertrag melden; hartkodierte `3` auflösen)
|
||||
- Modify: `app/Services/Deployment/UpdateChannel.php` (`state()` durchreichen)
|
||||
- Test: `tests/Feature/HostStepContractTest.php` (neu)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `release_host_step_needs` in `deploy/lib/release.sh` → gibt die gebrauchte Vertragsversion aus (heute `3`)
|
||||
- Produces: `update-status.json` bekommt `host_step_contract` (int, `0` wenn der Helfer fehlt oder nicht antwortet) und `host_step_needs` (int)
|
||||
- Produces: `state()` bekommt `host_step_ok` (bool) und `host_step_have`/`host_step_needs` (int)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Neue Datei `tests/Feature/HostStepContractTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Der Wirt-Helfer ist root-eigen und wird von `install-agent.sh` gesetzt.
|
||||
* Hebt ein Release seine Vertragsversion an, braucht JEDER Wirt einmal
|
||||
* Handarbeit als root — bei zehn Servern zehnmal.
|
||||
*
|
||||
* Automatisieren lässt sich das nicht: `sudoers` gewährt dem Dienstbenutzer
|
||||
* genau drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem
|
||||
* (vom Dienstbenutzer beschreibbaren) Checkout ausführt, gäbe jedem, der je
|
||||
* an diesen Benutzer kommt, Root auf dem Wirt.
|
||||
*
|
||||
* Also wird gemeldet statt automatisiert — aber VOLLSTÄNDIG und BEVOR jemand
|
||||
* einen Knopf drückt, der daran scheitert.
|
||||
*/
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
it('keeps the needed contract version in exactly one place', function () {
|
||||
// Sie stand zweimal im Repo: `HOST_STEP_NEEDS=3` in update.sh und eine
|
||||
// hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen,
|
||||
// laufen irgendwann auseinander.
|
||||
$lib = File::get(base_path('deploy/lib/release.sh'));
|
||||
$update = File::get(base_path('deploy/update.sh'));
|
||||
$agent = File::get(base_path('deploy/update-agent.sh'));
|
||||
|
||||
expect($lib)->toContain('release_host_step_needs')
|
||||
->and($update)->toContain('release_host_step_needs')
|
||||
->and($agent)->toContain('release_host_step_needs');
|
||||
|
||||
// Und keine nackte Zahl mehr an den beiden alten Stellen.
|
||||
expect($update)->not->toContain('HOST_STEP_NEEDS=3')
|
||||
->and($agent)->not->toContain('(( have < 3 ))');
|
||||
});
|
||||
|
||||
it('answers the needed contract version from the shell', function () {
|
||||
$result = Process::path(base_path())->timeout(30)->run(
|
||||
'bash -c '.escapeshellarg('set -Eeuo pipefail; . deploy/lib/release.sh; release_host_step_needs')
|
||||
);
|
||||
|
||||
expect($result->exitCode())->toBe(0)
|
||||
->and(trim($result->output()))->toMatch('/^[0-9]+$/');
|
||||
});
|
||||
|
||||
it('reports the helper as not ok when the host has an older one', function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
||||
'state' => 'idle',
|
||||
'host_step_contract' => 2,
|
||||
'host_step_needs' => 3,
|
||||
]));
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['host_step_ok'])->toBeFalse()
|
||||
->and($state['host_step_have'])->toBe(2)
|
||||
->and($state['host_step_needs'])->toBe(3);
|
||||
});
|
||||
|
||||
it('reports the helper as ok when it is current', function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
||||
'state' => 'idle',
|
||||
'host_step_contract' => 3,
|
||||
'host_step_needs' => 3,
|
||||
]));
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not cry wolf when the agent has not reported yet', function () {
|
||||
// Ein Wirt, dessen Agent die Zahlen noch nie gemeldet hat (alte Fassung,
|
||||
// erster Lauf), darf nicht als kaputt dastehen. „Ich weiß es nicht" ist
|
||||
// nicht dasselbe wie „zu alt".
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode(['state' => 'idle']));
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=HostStepContract`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement — eine Stelle für die Zahl**
|
||||
|
||||
Ans Ende von `deploy/lib/release.sh`:
|
||||
|
||||
```bash
|
||||
# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers
|
||||
# diese Fassung braucht.
|
||||
#
|
||||
# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als
|
||||
# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen
|
||||
# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem
|
||||
# Wirt, dessen Helfer zu alt ist.
|
||||
#
|
||||
# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt,
|
||||
# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal
|
||||
# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter
|
||||
# der root sitzt.
|
||||
release_host_step_needs() { printf '%s' 3; }
|
||||
```
|
||||
|
||||
In `deploy/update.sh` die Zeile `HOST_STEP_NEEDS=3` ersetzen durch:
|
||||
|
||||
```bash
|
||||
HOST_STEP_NEEDS="$(release_host_step_needs)"
|
||||
```
|
||||
|
||||
(`deploy/lib/release.sh` wird dort bereits geladen — nachprüfen und, falls nicht, den Aufruf hinter das vorhandene `.`-Einbinden setzen.)
|
||||
|
||||
In `deploy/update-agent.sh`, in `release_stuck_lock()`, `if (( have < 3 ))` ersetzen durch:
|
||||
|
||||
```bash
|
||||
if (( have < $(release_host_step_needs) )); then
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement — der Agent meldet den Vertrag**
|
||||
|
||||
In `deploy/update-agent.sh`, vor `write_status()`, die Werte ermitteln. **Wichtig unter `set -e`:** ein fehlender oder fehlschlagender Helfer darf den Agenten nicht beenden.
|
||||
|
||||
```bash
|
||||
# Welchen Vertrag der Wirt-Helfer erfüllt — und welchen diese Fassung braucht.
|
||||
#
|
||||
# Gemeldet statt automatisiert: `sudoers` gewährt dem Dienstbenutzer genau
|
||||
# drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem
|
||||
# beschreibbaren Checkout ausführt, gäbe jedem, der je an diesen Benutzer
|
||||
# kommt, Root auf dem Wirt. Die Grenze bleibt; die Konsole soll nur aufhören,
|
||||
# den Betreiber raten zu lassen.
|
||||
#
|
||||
# `|| true` und der Zahlentest: ein fehlender Helfer, ein Helfer ohne diesen
|
||||
# Schritt und ein Helfer, der etwas Unerwartetes druckt, sind alle „0" — und
|
||||
# keiner davon darf den Agenten unter `set -e` beenden, bevor er eine
|
||||
# Statusdatei schreibt.
|
||||
HOST_STEP_HAVE="$( { /usr/local/sbin/clupilot-host-step contract 2>/dev/null || true; } | head -1 )"
|
||||
[[ "$HOST_STEP_HAVE" =~ ^[0-9]+$ ]] || HOST_STEP_HAVE=0
|
||||
HOST_STEP_NEEDS="$(release_host_step_needs)"
|
||||
```
|
||||
|
||||
In `write_status()` hinter der `"behind"`-Zeile ergänzen:
|
||||
|
||||
```bash
|
||||
"host_step_contract": ${HOST_STEP_HAVE:-0},
|
||||
"host_step_needs": ${HOST_STEP_NEEDS:-0},
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Implement — die Konsole liest ihn**
|
||||
|
||||
In `UpdateChannel::state()`, im `return`-Array:
|
||||
|
||||
```php
|
||||
// Der root-eigene Helfer auf dem Wirt. Fehlt die Meldung ganz
|
||||
// (alte Agentenfassung, allererster Lauf), gilt er als in
|
||||
// Ordnung: „ich weiß es nicht" ist nicht „zu alt", und ein
|
||||
// Warnkasten, der auf jedem frisch aufgesetzten Wirt steht, wird
|
||||
// nach zwei Tagen nicht mehr gelesen.
|
||||
'host_step_have' => isset($status['host_step_contract'])
|
||||
? (int) $status['host_step_contract']
|
||||
: null,
|
||||
'host_step_needs' => isset($status['host_step_needs'])
|
||||
? (int) $status['host_step_needs']
|
||||
: null,
|
||||
'host_step_ok' => ! isset($status['host_step_contract'], $status['host_step_needs'])
|
||||
|| (int) $status['host_step_contract'] >= (int) $status['host_step_needs'],
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests, then commit**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter="HostStepContract|ReleaseComparison|ReleaseCeiling|UpdateLockRelease"`
|
||||
Expected: PASS
|
||||
|
||||
```bash
|
||||
git add deploy/lib/release.sh deploy/update.sh deploy/update-agent.sh app/Services/Deployment/UpdateChannel.php tests/Feature/HostStepContractTest.php
|
||||
git commit -m "Die gebrauchte Vertragsversion steht an einer Stelle und wird gemeldet"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Tunnel-Rettung aus der Konsole, und die drei Anzeigen
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Services/Deployment/UpdateChannel.php` (neue Anfrageart)
|
||||
- Modify: `deploy/update-agent.sh` (Anfrageart ausführen)
|
||||
- Modify: `app/Livewire/Admin/Settings.php`
|
||||
- Create: `app/Livewire/Admin/ConfirmRescueTunnel.php` + Blade
|
||||
- Modify: `resources/views/livewire/admin/settings.blade.php`
|
||||
- Modify: `lang/de/admin_settings.php`, `lang/en/admin_settings.php`
|
||||
- Test: `tests/Feature/RescueTunnelTest.php` (neu)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `WatchdogLog::lastRun()` (Task 1), `state()['host_step_ok'|'host_step_have'|'host_step_needs']` (Task 2)
|
||||
- Produces: `UpdateChannel::KIND_RESCUE_TUNNEL = 'rescue-tunnel'`, `UpdateChannel::requestRescueTunnel(string $by): bool`
|
||||
- Produces: `storage/app/deploy/rescue-last-run.json` `{"state":"ok"|"failed","finished_at":"…","error":"…"}` und `storage/app/deploy/rescue-last-run.log`
|
||||
- Produces: `state()['rescue_last_run']`, `Settings::rescueTunnel()`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Neue Datei `tests/Feature/RescueTunnelTest.php`:
|
||||
|
||||
```php
|
||||
<?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');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=RescueTunnel`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement — Anfrageart im Kanal**
|
||||
|
||||
In `UpdateChannel`, bei den übrigen `KIND_*`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 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';
|
||||
```
|
||||
|
||||
Und die Methode, neben `requestCheck()`:
|
||||
|
||||
```php
|
||||
public function requestRescueTunnel(string $by): bool
|
||||
{
|
||||
return $this->submit($by, self::KIND_RESCUE_TUNNEL);
|
||||
}
|
||||
```
|
||||
|
||||
In `state()` im `return`-Array:
|
||||
|
||||
```php
|
||||
// `readJson` fängt kaputtes JSON bereits ab und liefert `[]`.
|
||||
'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== []
|
||||
? $last
|
||||
: null,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement — der Agent führt sie aus**
|
||||
|
||||
In `deploy/update-agent.sh`, dort wo die übrigen Anfragearten unterschieden werden (bei `KIND`/`kind`), einen Zweig ergänzen — **nach** dem Muster der bestehenden Arten, mit Frist und ohne den Lauf sterben zu lassen:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
**Vor dem Schreiben nachsehen**, wie die bestehenden Arten (`check`, `restart`, `archive-key`, `proxy-hosts`) im Agenten unterschieden werden, und sich genau dort einfügen — Variablenname und Stelle aus dem vorhandenen Code übernehmen, nicht raten.
|
||||
|
||||
- [ ] **Step 5: Implement — die drei Anzeigen in der Konsole**
|
||||
|
||||
`Settings.php`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 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'));
|
||||
}
|
||||
```
|
||||
|
||||
In `render()` den Wächter-Zustand mitgeben:
|
||||
|
||||
```php
|
||||
$watchdog = app(WatchdogLog::class)->lastRun();
|
||||
```
|
||||
|
||||
und in die View-Daten aufnehmen (`'watchdog' => $watchdog`).
|
||||
|
||||
`ConfirmRescueTunnel.php` und sein Blade nach dem Muster von `ConfirmReleaseUpdateLock` — **den bestehenden lesen und die Bauform übernehmen**, nicht erfinden.
|
||||
|
||||
Im Blade, im selben Abschnitt wie die übrigen Update-Anzeigen:
|
||||
|
||||
```blade
|
||||
{{-- 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
|
||||
```
|
||||
|
||||
**R19 beachten:** `->local()` vor jeder Zeitausgabe. `diffForHumans()` ist davon ausgenommen (relativ), aber `->local()` schadet nicht und hält die Regel sichtbar.
|
||||
|
||||
Der Knopf für die Tunnel-Rettung kommt in die **bestehende** Knopfleiste (siehe wie das Festnageln dort eingefügt wurde), mit `$dispatch('openModal', { component: 'admin.confirm-rescue-tunnel' })`.
|
||||
|
||||
Sprachschlüssel (de **und** en), sinngemäß:
|
||||
`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." ·
|
||||
`host_step_old` „Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:" ·
|
||||
`rescue_requested` „Tunnel-Rettung angefordert." ·
|
||||
plus Titel/Text/Knöpfe für das Bestätigungs-Modal.
|
||||
|
||||
- [ ] **Step 6: Ganze Suite, dann committen**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test`
|
||||
Expected: PASS, vollständig. Achte auf `ConfirmInModalTest`, `ModalHeightTest`, `IconLayoutTest`, `DisplayTimezoneTest`, `TranslationParityTest`.
|
||||
|
||||
```bash
|
||||
git add app/Services/Deployment/UpdateChannel.php deploy/update-agent.sh \
|
||||
app/Livewire/Admin/Settings.php app/Livewire/Admin/ConfirmRescueTunnel.php \
|
||||
resources/views/livewire/admin/confirm-rescue-tunnel.blade.php \
|
||||
resources/views/livewire/admin/settings.blade.php \
|
||||
lang/de/admin_settings.php lang/en/admin_settings.php \
|
||||
tests/Feature/RescueTunnelTest.php
|
||||
git commit -m "Tunnel-Rettung aus der Konsole, Waechter und Wirt-Helfer sichtbar"
|
||||
```
|
||||
|
|
@ -139,6 +139,28 @@ 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',
|
||||
// M3: die Sperre halten inzwischen mehr Vorgaenge als ein Update —
|
||||
// proxy-hosts, restart, archive-key, rescue-tunnel. „weil ein Update
|
||||
// lief" war damit schon falsch, sobald einer der anderen die Sperre hielt.
|
||||
'watchdog_stood_down' => ':when zurückgetreten — ein anderer Vorgang hielt die Sperre.',
|
||||
// Fix-Runde 1 / Review-Runde 2: gegriffen, aber mindestens ein Griff
|
||||
// ohne Erfolg — auch wenn ein anderer im selben Lauf gewirkt hat (z. B.
|
||||
// fehlende Dienste kamen hoch, wg0 blieb trotz wg-quick up unten). Ohne
|
||||
// diesen Satz verschwand der Fall entweder in „nichts zu tun"
|
||||
// (watchdog_idle) oder, seit der ersten Fix-Runde, in „eingegriffen"
|
||||
// (watchdog_healed) — outcome traegt jetzt ein eigenes `tried`.
|
||||
'watchdog_tried' => 'Zuletzt eingegriffen :when, ohne Erfolg: :what',
|
||||
'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 +174,28 @@ 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',
|
||||
// Fix-Runde 1: das Ergebnis war bisher unsichtbar — UpdateChannel::state()
|
||||
// lieferte `rescue_last_run`, aber kein Blade las es.
|
||||
'rescue_last_run' => 'Letzte Tunnel-Rettung: :state, :when.',
|
||||
// Review-Runde 2 (C1 Teil 3): eigenes Wort statt update_state.succeeded
|
||||
// ("erfolgreich") — ein durchgelaufenes Skript hat nur geprüft, dass
|
||||
// jeder Griff ausgerichtet hat, was nötig war (deploy/rescue-tunnel.sh),
|
||||
// nicht dass der Tunnel jetzt wieder zweifelsfrei Daten empfängt.
|
||||
'rescue_state_ok' => 'durchgelaufen',
|
||||
// Review-Runde 2 (C1 Teil 2): dasselbe Muster wie 'update_log', nur fürs
|
||||
// Protokoll der letzten Tunnel-Rettung (UpdateChannel::rescueLog()).
|
||||
'rescue_log' => 'Protokoll der letzten Tunnel-Rettung',
|
||||
|
||||
// ── Festnageln ────────────────────────────────────────────────────────
|
||||
// Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt
|
||||
// (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird
|
||||
|
|
@ -181,6 +225,7 @@ return [
|
|||
'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.',
|
||||
'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.',
|
||||
'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue scheduler reverb.',
|
||||
'rescue_failed' => 'Die Tunnel-Rettung ist fehlgeschlagen. Protokoll auf dem Wirt: storage/app/deploy/rescue-last-run.log.',
|
||||
// Der Wirt kennt den Schritt noch nicht. Ein Knopf, der nichts tut und
|
||||
// nichts sagt, schickt den Betreiber genau dorthin zurück, wo er ohne
|
||||
// die Konsole schon war.
|
||||
|
|
|
|||
|
|
@ -136,6 +136,28 @@ 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',
|
||||
// M3: more than an update now holds the lock — proxy-hosts, restart,
|
||||
// archive-key, rescue-tunnel. "because an update was running" was already
|
||||
// wrong the moment any of the others held it instead.
|
||||
'watchdog_stood_down' => 'Stood down :when — another process held the lock.',
|
||||
// Fix round 1 / review round 2: intervened, but at least one attempt
|
||||
// failed — even if another one in the same run worked (e.g. missing
|
||||
// services came back up, wg0 stayed down despite wg-quick up). Without
|
||||
// this sentence the case disappeared either into "nothing to do"
|
||||
// (watchdog_idle) or, since the first fix round, into "intervened"
|
||||
// (watchdog_healed) — outcome now carries its own `tried`.
|
||||
'watchdog_tried' => 'Last intervened :when, without success: :what',
|
||||
'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 +171,28 @@ 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',
|
||||
// Fix round 1: the outcome used to be invisible — UpdateChannel::state()
|
||||
// returned `rescue_last_run`, but no blade read it.
|
||||
'rescue_last_run' => 'Last tunnel rescue: :state, :when.',
|
||||
// Review round 2 (C1 part 3): its own word instead of update_state.succeeded
|
||||
// ("succeeded") — a run that completed only confirmed that every step
|
||||
// lined up what was needed (deploy/rescue-tunnel.sh), not that the tunnel
|
||||
// is now definitely receiving data again.
|
||||
'rescue_state_ok' => 'completed',
|
||||
// Review round 2 (C1 part 2): same pattern as 'update_log', for the last
|
||||
// tunnel-rescue attempt's log (UpdateChannel::rescueLog()).
|
||||
'rescue_log' => 'Log of the last tunnel rescue',
|
||||
|
||||
// ── Pinning a ceiling ────────────────────────────────────────────────
|
||||
// The ceiling that limits both the update agent AND the maintenance
|
||||
// window (App\Services\Deployment\UpdateChannel::setCeiling()). Only
|
||||
|
|
@ -178,6 +222,7 @@ return [
|
|||
'detached_no_release' => 'The checkout is on no branch and pinned to no release.',
|
||||
'update_failed' => 'The update failed (code :code). See the log.',
|
||||
'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue scheduler reverb.',
|
||||
'rescue_failed' => 'The tunnel rescue failed. Log on the host: storage/app/deploy/rescue-last-run.log.',
|
||||
// The host does not know the step yet. A button that does nothing and
|
||||
// says nothing sends the operator right back where they were without
|
||||
// the console.
|
||||
|
|
|
|||
|
|
@ -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,46 @@
|
|||
<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)
|
||||
{{-- Review-Runde 2 (I1): `outcome` traegt jetzt selbst "tried" —
|
||||
gesetzt in watchdog.sh, sobald mindestens EIN Griff im Lauf
|
||||
nicht gewirkt hat, auch wenn ein ANDERER im selben Lauf
|
||||
erfolgreich war (z. B. fehlende Dienste kamen hoch, wg0 aber
|
||||
nicht). Die Bedingung haengt deshalb nicht mehr an `idle`: vorher
|
||||
stand "gegriffen, aber wg0 blieb unten" nur dann als
|
||||
watchdog_tried, wenn KEIN anderer Zweig im selben Lauf gegriffen
|
||||
hatte — griff einer, war `outcome` schon `healed`, und der
|
||||
Fehlschlag verschwand darin, ruhig eingefaerbt. --}}
|
||||
<p class="mt-2 text-sm {{ $watchdog['stale'] || $watchdog['outcome'] === 'tried' ? '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()]) }}
|
||||
@elseif ($watchdog['outcome'] === 'tried')
|
||||
{{ __('admin_settings.watchdog_tried', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }}
|
||||
@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
|
||||
|
|
@ -185,6 +225,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
|
||||
|
|
@ -394,6 +444,45 @@
|
|||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Fix-Runde 1 (Task 3): das Ergebnis der Tunnel-Rettung. Der Agent
|
||||
schrieb `rescue-last-run.json` von Anfang an — nur las kein Blade
|
||||
es. Ein Betreiber, der „Tunnel retten" drueckt und dessen Versuch
|
||||
auf dem Wirt scheitert (oder in die Frist laeuft), sah danach
|
||||
nichts: kein Fehler, kein Zeitstempel, kein Hinweis aufs
|
||||
Protokoll — er musste auf den Wirt, genau dorthin, wovon dieser
|
||||
Knopf ihn befreien sollte. --}}
|
||||
@if ($update['rescue_last_run'])
|
||||
{{-- Review-Runde 2 (C1 Teil 3): „erfolgreich" (update_state.succeeded)
|
||||
behauptet mehr, als ein durchgelaufenes Skript weiss — es prüft
|
||||
am Ende nicht nach, ob der Tunnel jetzt wirklich wieder Daten
|
||||
empfängt, nur ob jeder einzelne Griff ausgerichtet hat, was nötig
|
||||
war (siehe deploy/rescue-tunnel.sh). Eigene, bewusst zurückhaltende
|
||||
Formulierung statt der Beschriftung, die für eine echte
|
||||
Aktualisierung (git pull, Container neu, Migration) gilt. --}}
|
||||
<p class="mt-3 text-xs {{ $update['rescue_last_run']['state'] === 'failed' ? 'text-danger' : 'text-muted' }}">
|
||||
{{ __('admin_settings.rescue_last_run', [
|
||||
'state' => $update['rescue_last_run']['state'] === 'ok'
|
||||
? __('admin_settings.rescue_state_ok')
|
||||
: __('admin_settings.update_state.failed'),
|
||||
'when' => $update['rescue_last_run']['finished_at']?->diffForHumans() ?? '—',
|
||||
]) }}
|
||||
@if ($update['rescue_last_run']['error'])
|
||||
· {{ $update['rescue_last_run']['error'] }}
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Review-Runde 2 (C1 Teil 2): dasselbe <details>-Muster wie das
|
||||
Protokoll der Aktualisierung darunter, nur für die Tunnel-Rettung —
|
||||
der Betreiber soll den Satz mit dem Handgriff lesen können, ohne auf
|
||||
den Wirt zu gehen. --}}
|
||||
@if ($rescueLog)
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.rescue_log') }}</summary>
|
||||
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $rescueLog }}</pre>
|
||||
</details>
|
||||
@endif
|
||||
|
||||
{{-- Open while it runs: "läuft gerade" on its own tells an operator
|
||||
nothing about whether it is progressing or wedged. --}}
|
||||
@if ($updateLog)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ConfirmRescueTunnel;
|
||||
use App\Livewire\Admin\Settings as AdminSettings;
|
||||
use App\Models\Operator;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* M7 (Review-Runde 2): alle fünf Geschwister-Modals (ConfirmSaveSecret,
|
||||
* ConfirmForgetSecret, ConfirmReissueVpnPeer, ConfirmDisableTwoFactor,
|
||||
* ConfirmReleaseUpdateLock) haben einen Test — dieses hatte keinen. `mount()`s
|
||||
* `authorize` und die Ereignis-Verdrahtung Modal → Seite waren ungeprüft.
|
||||
*
|
||||
* Nach dem Vorbild von tests/Feature/Admin/UpdateLockReleaseTest.php.
|
||||
*/
|
||||
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('lets an operator with site.manage open the confirmation', function () {
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(ConfirmRescueTunnel::class)
|
||||
->assertSee(__('admin_settings.rescue_title'));
|
||||
});
|
||||
|
||||
it('refuses to even mount the confirmation for an operator without site.manage', function () {
|
||||
// Eine Livewire-Komponente ist ein oeffentlicher Endpunkt — auch das
|
||||
// Modal selbst, nicht nur die Aktion, die es am Ende auslöst.
|
||||
$staff = Operator::factory()->create();
|
||||
|
||||
Livewire::actingAs($staff, 'operator')
|
||||
->test(ConfirmRescueTunnel::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('leaves the deed to the page component, and only dispatches', function () {
|
||||
// R23: das Modal mutiert nichts. Die Berechtigungsprüfung UND das
|
||||
// eigentliche Anfordern bleiben an der einen Stelle, an der sie schon
|
||||
// stehen (App\Livewire\Admin\Settings::rescueTunnel()).
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(ConfirmRescueTunnel::class)
|
||||
->call('confirm')
|
||||
->assertDispatched('rescue-tunnel-confirmed');
|
||||
|
||||
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('leaves a rescue request for the agent once the confirmation comes back', function () {
|
||||
// Die Ereignis-Verdrahtung Modal → Seite: Settings faengt
|
||||
// 'rescue-tunnel-confirmed' per #[On(...)] auf, genau wie beim Sperre-
|
||||
// Loesen daneben.
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->dispatch('rescue-tunnel-confirmed');
|
||||
|
||||
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
|
||||
|
||||
expect($request['kind'])->toBe('rescue-tunnel')
|
||||
->and($request['requested_by'])->toBe($owner->email);
|
||||
});
|
||||
|
||||
it('refuses to rescue the tunnel for an operator without the capability, even via the event', function () {
|
||||
$staff = Operator::factory()->create();
|
||||
|
||||
Livewire::actingAs($staff, 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->dispatch('rescue-tunnel-confirmed')
|
||||
->assertForbidden();
|
||||
|
||||
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Der Wirt-Helfer ist root-eigen und wird von `install-agent.sh` gesetzt.
|
||||
* Hebt ein Release seine Vertragsversion an, braucht JEDER Wirt einmal
|
||||
* Handarbeit als root — bei zehn Servern zehnmal.
|
||||
*
|
||||
* Automatisieren lässt sich das nicht: `sudoers` gewährt dem Dienstbenutzer
|
||||
* genau drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem
|
||||
* (vom Dienstbenutzer beschreibbaren) Checkout ausführt, gäbe jedem, der je
|
||||
* an diesen Benutzer kommt, Root auf dem Wirt.
|
||||
*
|
||||
* Also wird gemeldet statt automatisiert — aber VOLLSTÄNDIG und BEVOR jemand
|
||||
* einen Knopf drückt, der daran scheitert.
|
||||
*/
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
it('keeps the needed contract version in exactly one place', function () {
|
||||
// Sie stand zweimal im Repo: `HOST_STEP_NEEDS=3` in update.sh und eine
|
||||
// hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen,
|
||||
// laufen irgendwann auseinander.
|
||||
$lib = File::get(base_path('deploy/lib/release.sh'));
|
||||
$update = File::get(base_path('deploy/update.sh'));
|
||||
$agent = File::get(base_path('deploy/update-agent.sh'));
|
||||
|
||||
expect($lib)->toContain('release_host_step_needs')
|
||||
->and($update)->toContain('release_host_step_needs')
|
||||
->and($agent)->toContain('release_host_step_needs');
|
||||
|
||||
// Und keine nackte Zahl mehr an den beiden alten Stellen.
|
||||
expect($update)->not->toContain('HOST_STEP_NEEDS=3')
|
||||
->and($agent)->not->toContain('(( have < 3 ))');
|
||||
});
|
||||
|
||||
it('answers the needed contract version from the shell', function () {
|
||||
$result = Process::path(base_path())->timeout(30)->run(
|
||||
'bash -c '.escapeshellarg('set -Eeuo pipefail; . deploy/lib/release.sh; release_host_step_needs')
|
||||
);
|
||||
|
||||
expect($result->exitCode())->toBe(0)
|
||||
->and(trim($result->output()))->toMatch('/^[0-9]+$/');
|
||||
});
|
||||
|
||||
it('reports the helper as not ok when the host has an older one', function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
||||
'state' => 'idle',
|
||||
'host_step_contract' => 2,
|
||||
'host_step_needs' => 3,
|
||||
]));
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['host_step_ok'])->toBeFalse()
|
||||
->and($state['host_step_have'])->toBe(2)
|
||||
->and($state['host_step_needs'])->toBe(3);
|
||||
});
|
||||
|
||||
it('reports the helper as ok when it is current', function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
||||
'state' => 'idle',
|
||||
'host_step_contract' => 3,
|
||||
'host_step_needs' => 3,
|
||||
]));
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not cry wolf when the agent has not reported yet', function () {
|
||||
// Ein Wirt, dessen Agent die Zahlen noch nie gemeldet hat (alte Fassung,
|
||||
// erster Lauf), darf nicht als kaputt dastehen. „Ich weiß es nicht" ist
|
||||
// nicht dasselbe wie „zu alt".
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode(['state' => 'idle']));
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
|
||||
});
|
||||
|
|
@ -130,9 +130,31 @@ it('reports the contract version the updater compares against', function () {
|
|||
// failure mode this number exists for; if update.sh asked for a version the
|
||||
// current installer never writes, every up-to-date server would be told to
|
||||
// run the installer again forever.
|
||||
$needs = Str::between(file_get_contents(base_path('deploy/update.sh')), 'HOST_STEP_NEEDS=', "\n");
|
||||
//
|
||||
// Compared at RUNTIME against deploy/lib/release.sh's
|
||||
// release_host_step_needs(), not by pulling the literal `HOST_STEP_NEEDS=`
|
||||
// line out of update.sh: since Task 2 update.sh no longer carries the bare
|
||||
// number itself, it asks that shell function for it —
|
||||
// `HOST_STEP_NEEDS="$(release_host_step_needs)"`. Extracting the text
|
||||
// between `HOST_STEP_NEEDS=` and the newline would hand back the command
|
||||
// substitution string, not a number, and this test would silently compare
|
||||
// "3" against "0" forever without ever catching the two halves drifting
|
||||
// apart. The left side (CONTRACT) and the right side (release_host_step_needs)
|
||||
// come from two different files — install-agent.sh and deploy/lib/release.sh
|
||||
// — so raising one without the other still turns this test red.
|
||||
$needsProcess = Process::fromShellCommandline(
|
||||
'bash -c '.escapeshellarg(
|
||||
'set -Eeuo pipefail; . '.escapeshellarg(base_path('deploy/lib/release.sh')).'; release_host_step_needs'
|
||||
)
|
||||
);
|
||||
$needsProcess->run();
|
||||
|
||||
expect((int) trim($process->getOutput()))->toBe((int) trim($needs));
|
||||
expect($needsProcess->getExitCode())->toBe(0);
|
||||
|
||||
$needs = trim($needsProcess->getOutput());
|
||||
|
||||
expect($needs)->toMatch('/^\d+$/')
|
||||
->and((int) trim($process->getOutput()))->toBe((int) $needs);
|
||||
});
|
||||
|
||||
it('grants one command line, not a script the service account can rewrite', function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Settings;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
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'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Baut eine `docker`- und `sudo`-Attrappe für deploy/rescue-tunnel.sh und
|
||||
* gibt ihr Verzeichnis zurück. Hier läuft das ECHTE Skript, nicht eine
|
||||
* Nachbildung seiner Logik — genau daran ist der letzte Fix schon einmal
|
||||
* gescheitert (siehe WatchdogVisibilityTest).
|
||||
*
|
||||
* @param array<int, string> $peers Zeilen für "wg show wg0 peers" (leer = kein Zugang)
|
||||
* @param array<int, string> $transfer Zeilen für "wg show wg0 transfer" ("pubkey empfangen gesendet")
|
||||
*/
|
||||
function rescueTunnelStub(array $peers = ['PEER1'], array $transfer = ['PEER1 0 999'], bool $sudoOk = true, bool $ownContainer = true): string
|
||||
{
|
||||
$dir = storage_path('app/deploy');
|
||||
File::ensureDirectoryExists($dir);
|
||||
|
||||
$stub = $dir.'/stub-rescue';
|
||||
File::ensureDirectoryExists($stub);
|
||||
|
||||
$peersOut = implode('\n', $peers);
|
||||
$transferOut = implode('\n', $transfer);
|
||||
$cmdlineOut = $ownContainer ? 'vpn-hub' : 'some-other-process';
|
||||
|
||||
File::put($stub.'/docker', <<<SH
|
||||
#!/bin/sh
|
||||
case "\$*" in
|
||||
*"ps --status running vpn-hub"*) printf '%s\\n' vpn-hub ;;
|
||||
*"vpn-hub sh -c"*) printf '%s\\n' '$cmdlineOut' ;;
|
||||
*"vpn-hub wg-quick up wg0"*) exit 0 ;;
|
||||
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
|
||||
*"vpn-hub wg show wg0 peers"*) printf '%s\\n' '$peersOut' ;;
|
||||
*"vpn-hub wg show wg0 transfer"*) printf '%s\\n' '$transferOut' ;;
|
||||
*"vpn-hub wg show wg0"*) exit 0 ;;
|
||||
*"vpn-hub ls -l /etc/wireguard/"*) exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
SH);
|
||||
|
||||
File::put($stub.'/sudo', $sudoOk
|
||||
? "#!/bin/sh\nexit 0\n"
|
||||
: "#!/bin/sh\nexit 1\n");
|
||||
|
||||
Process::run("chmod +x {$stub}/docker {$stub}/sudo");
|
||||
|
||||
return $stub;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('agent passes the rescue error through instead of swallowing it', function () {
|
||||
// Fix-Runde 1: `write_status idle` (ohne zweites Argument) verschluckte
|
||||
// RESCUE_ERROR. Der Zweig muss den Fehler weiterreichen, wie proxy-hosts
|
||||
// es daneben schon tut.
|
||||
expect(File::get(base_path('deploy/update-agent.sh')))
|
||||
->toContain('write_status idle "$RESCUE_ERROR"');
|
||||
});
|
||||
|
||||
it('shapes the last rescue outcome for the view, with a local timestamp and a translated error', function () {
|
||||
// Fix-Runde 1: das Feld existierte, aber nichts las es. Hier der
|
||||
// Nachweis auf Datenebene, dass die Form stimmt, die das Blade braucht —
|
||||
// `finished_at` als Carbon (R19: die View parst nicht selbst) und
|
||||
// `error` schon uebersetzt (dieselbe errorMessage(), die auch STATUS und
|
||||
// LAST_RUN uebersetzt).
|
||||
File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
|
||||
'state' => 'failed',
|
||||
'finished_at' => now()->utc()->toIso8601String(),
|
||||
'error' => 'rescue_failed',
|
||||
]));
|
||||
|
||||
$run = app(UpdateChannel::class)->state()['rescue_last_run'];
|
||||
|
||||
expect($run['state'])->toBe('failed')
|
||||
->and($run['finished_at'])->toBeInstanceOf(\Illuminate\Support\Carbon::class)
|
||||
->and($run['error'])->toBe(__('admin_settings.update_error.rescue_failed'));
|
||||
});
|
||||
|
||||
it('shows a failed rescue attempt in the console, instead of silence', function () {
|
||||
// Das eigentliche Befund-Szenario: Betreiber drueckt "Tunnel retten",
|
||||
// bekommt "angefordert", der Versuch scheitert auf dem Wirt. Vorher
|
||||
// zeigte die Konsole danach exakt nichts.
|
||||
File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
|
||||
'state' => 'failed',
|
||||
'finished_at' => now()->utc()->toIso8601String(),
|
||||
'error' => 'rescue_failed',
|
||||
]));
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSee(__('admin_settings.update_state.failed'))
|
||||
->assertSee(__('admin_settings.update_error.rescue_failed'), false)
|
||||
->assertSeeHtml('text-danger');
|
||||
});
|
||||
|
||||
it('shows a successful rescue attempt too', function () {
|
||||
File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
|
||||
'state' => 'ok',
|
||||
'finished_at' => now()->utc()->toIso8601String(),
|
||||
'error' => '',
|
||||
]));
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSee(__('admin_settings.rescue_state_ok'));
|
||||
});
|
||||
|
||||
it('does not call a rescue attempt "succeeded" — a run that completed is not proof the tunnel works', function () {
|
||||
// Review-Runde 2 (C1 Teil 3): update_state.succeeded ("erfolgreich")
|
||||
// behauptet mehr, als ein durchgelaufenes rescue-tunnel.sh weiss. Dieser
|
||||
// Test faellt auf den Vorzustand rot: dort las settings.blade.php das
|
||||
// 'ok'-Ergebnis noch als admin_settings.update_state.succeeded.
|
||||
File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
|
||||
'state' => 'ok',
|
||||
'finished_at' => now()->utc()->toIso8601String(),
|
||||
'error' => '',
|
||||
]));
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertDontSee(__('admin_settings.update_state.succeeded'));
|
||||
});
|
||||
|
||||
it('shows the rescue log tail in the console, the same way the update log already does', function () {
|
||||
// Review-Runde 2 (C1 Teil 2): der Log-Auszug gehoert in die Konsole,
|
||||
// uebernimmt das Muster von UpdateChannel::lastLog() / 'update_log'
|
||||
// statt eines neu erfundenen.
|
||||
File::put(storage_path('app/deploy/rescue-last-run.log'), "Zeile eins\nACHTUNG: wg0 liess sich nicht hochziehen.\n");
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSee(__('admin_settings.rescue_log'))
|
||||
->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false);
|
||||
});
|
||||
|
||||
it('shows nothing for the rescue log when no rescue has ever run', function () {
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertDontSee(__('admin_settings.rescue_log'));
|
||||
});
|
||||
|
||||
// ── C1 Teil 1: das Skript selbst muss Misserfolg als Misserfolg melden ──────
|
||||
|
||||
it('exits with a problem when not a single peer is loaded', function () {
|
||||
// Der zweite der beiden im Review genannten Stellen: "Kein einziger
|
||||
// Zugang geladen" endete bisher trotzdem auf Exit 0.
|
||||
$stub = rescueTunnelStub(peers: [], transfer: []);
|
||||
|
||||
$result = Process::path(base_path())->timeout(60)->env([
|
||||
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
||||
])->run('bash deploy/rescue-tunnel.sh');
|
||||
|
||||
expect($result->exitCode())->not->toBe(0);
|
||||
});
|
||||
|
||||
it('exits with a problem when stale conntrack entries cannot be cleared for lack of root', function () {
|
||||
// DAS Fehlerszenario aus dem Review: 1 von 1 Zugaengen stumm, `sudo -n
|
||||
// conntrack -D` scheitert (kein sudoers-Eintrag — laut Runbook auf einem
|
||||
// frischen Wirt der Normalfall). Bisher: nur `bad(...)`, Exit 0.
|
||||
$stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false);
|
||||
|
||||
$result = Process::path(base_path())->timeout(60)->env([
|
||||
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
||||
])->run('bash deploy/rescue-tunnel.sh');
|
||||
|
||||
expect($result->exitCode())->not->toBe(0)
|
||||
->and($result->output())->toContain('Dafür fehlt mir Root');
|
||||
});
|
||||
|
||||
it('still exits clean when everything actually lines up, including on a host without its own tunnel container', function () {
|
||||
// Gegenprobe zur Einteilung aus C1 Teil 1: die Meldung ueber einen noch
|
||||
// fehlenden eigenen Tunnel-Container (Zeile 58/59) ist eine reine
|
||||
// Versionsauskunft, kein Fehlschlag DIESES Laufs — sie darf den
|
||||
// Rueckgabewert nicht anfassen, sonst meldete jeder Lauf auf einem
|
||||
// aelteren Wirt "fehlgeschlagen", obwohl der Tunnel tadellos steht.
|
||||
$stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 100 999'], sudoOk: true, ownContainer: false);
|
||||
|
||||
$result = Process::path(base_path())->timeout(60)->env([
|
||||
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
||||
])->run('bash deploy/rescue-tunnel.sh');
|
||||
|
||||
expect($result->exitCode())->toBe(0)
|
||||
->and($result->output())->toContain('eigenen Tunnel-Container noch nicht');
|
||||
});
|
||||
|
||||
it('marks a rescue attempt failed end-to-end when conntrack cannot be cleared, and the console does not call it "succeeded"', function () {
|
||||
// C1, der ganze Weg durch: das ECHTE deploy/update-agent.sh ruft das
|
||||
// ECHTE deploy/rescue-tunnel.sh, genau wie auf dem Wirt. Der Nachweis
|
||||
// wird — wie gefordert — im gerenderten HTML gefuehrt, nicht nur auf
|
||||
// Datenebene: genau dort ist der vorige Fix schon einmal durchgerutscht.
|
||||
$dir = storage_path('app/deploy');
|
||||
$stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false);
|
||||
|
||||
// `git` scheitert sofort — dieser Tick soll die bereits im Postkasten
|
||||
// liegende Bitte abarbeiten, kein echtes `git fetch` versuchen.
|
||||
File::put($stub.'/git', "#!/bin/sh\nexit 1\n");
|
||||
Process::run("chmod +x {$stub}/git");
|
||||
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
app(UpdateChannel::class)->requestRescueTunnel($owner->email);
|
||||
|
||||
$result = Process::path(base_path())->timeout(90)->env([
|
||||
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
||||
])->run('bash deploy/update-agent.sh >/dev/null 2>&1 || true');
|
||||
|
||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||
|
||||
$run = json_decode(File::get($dir.'/rescue-last-run.json'), true);
|
||||
expect($run['state'])->toBe('failed');
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSee(__('admin_settings.update_state.failed'))
|
||||
->assertDontSee(__('admin_settings.update_state.succeeded'))
|
||||
->assertSee(__('admin_settings.rescue_log'));
|
||||
});
|
||||
|
|
@ -223,5 +223,9 @@ it('grants the new step in sudoers and raises the contract for it', function ()
|
|||
expect($installer)->toContain('release-update-lock)')
|
||||
->and($installer)->toContain('$HOST_STEP release-update-lock')
|
||||
->and($installer)->toContain('CONTRACT=3')
|
||||
->and($updater)->toContain('HOST_STEP_NEEDS=3');
|
||||
// Die gebrauchte Vertragsversion steht seit deploy/lib/release.sh nur
|
||||
// noch an einer Stelle (release_host_step_needs); update.sh fragt sie
|
||||
// ab, statt sie ein zweites Mal als Zahl mitzuführen. Siehe
|
||||
// tests/Feature/HostStepContractTest.php.
|
||||
->and($updater)->toContain('release_host_step_needs');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
<?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();
|
||||
});
|
||||
Loading…
Reference in New Issue