Ein Waechter, der aufraeumt — und die vier offenen Punkte aus dem Betrieb

DER WAECHTER. Ein abgebrochenes Update liess den Stapel halb unten liegen, den
Tunnel weg und die Seite eine Stunde lang mit 500 antworten — bis jemand von
Hand nachsah. Das wartet nie wieder auf einen Menschen.

deploy/watchdog.sh laeuft jede Minute als systemd-Dienst AUF DEM WIRT, nicht in
einem Container: ein Waechter im Container braeuchte den Docker-Socket
hineingereicht (Root auf dem Wirt fuer jeden, der je hineinkommt) und waere
genau dann tot, wenn man ihn braucht. Er nimmt dieselbe Sperre wie der
Update-Agent und fasst waehrend eines Updates nichts an.

Er kennt vier Fehlerbilder, alle vier heute wirklich passiert, und fuer jedes
genau einen Griff: fehlende Dienste starten; Container, die einander nicht mehr
finden, NEU ERZEUGEN (ein Neustart hilft da nicht); wg0 hochziehen; einen seit
ueber einer halben Stunde haengenden Wartungsmodus beenden. Was er nicht kennt,
protokolliert er und laesst es in Ruhe. `maintenance-hold` ist die Handbremse.
Beides nachgemessen: Dienst gestoppt -> wieder da; wg0 abgeraeumt -> wieder oben.

DREI LUECKEN, DIE DERSELBE VORFALL AUFGEDECKT HAT:

1. `phase()` machte `mkdir -p` ohne Fangnetz. Gehoerte storage/ nach einem
   frueheren Fehltritt root, starb das Update mit `set -e` an seiner ERSTEN
   Zeile, ohne eine einzige Ausgabe. Von aussen sah das aus wie
   "haengengeblieben"; in Wahrheit war es nach einer Millisekunde vorbei.
2. Ein zweiter Aufruf meldete "Already up to date" und tat NICHTS — der Checkout
   stand ja schon auf dem Ziel. Der Commit ist die falsche Frage; jetzt wird der
   Zustand gefragt: laeuft jeder Dienst, ist der Wartungsmodus aus.
3. Nach einer Netz-Umstellung reicht `up -d` nicht: nur neu gestartete Container
   haengen weiter am alten Netz, alle laufen, und trotzdem loest kein Name mehr
   auf. Jetzt `--force-recreate`.

DIE VIER PUNKTE AUS DEM BETRIEB:

- Provisioning zeigte 15/16 in der Liste und "16 von 16" in der Karte daneben,
  bei Status "Fertig" und 100 %. Die Liste rechnete `current_step + 1` — "dieser
  Schritt laeuft gerade" —, und bei einem fertigen Lauf laeuft keiner mehr.
- Mahngebuehren standen in CENT im Feld. Wer eine Gebuehr eintraegt, denkt in
  Euro und tippt "5"; daraus wurden fuenf Cent, ohne Widerspruch. Jetzt Euro im
  Feld, Cent in der Datenbank, gerundet statt abgeschnitten ((int)(19.99*100)
  ist 1998). Und Fristen und Geld stehen in zwei eigenen Bloecken mit eigener
  Ueberschrift, die Einheit im Feld statt in der Beschriftung.
- Die Bueroadresse stand in der Konsole neben dem Management-Netz mit dem
  Vermerk "nicht entfernbar" — sie kommt aber aus TRUSTED_RANGES in der .env und
  ist sehr wohl aenderbar. Beim naechsten Umzug des Anschlusses waere das ein
  Aussperren gewesen. Strukturell sind nur zwei Eintraege; alles andere steht
  jetzt als das da, was es ist, mit dem Weg heraus.
- Ein Host-Zugang hiess weiter "pve-fns-1", waehrend der Host laengst "fsn-01"
  hiess. Die Zeile zeigt jetzt den Namen des Hosts, nicht den einmal
  gespeicherten — damit traegt sie jede kuenftige Umbenennung von selbst.

Und die Frage "wofuer brauche ich Uptime Kuma": es ist benutzt — RegisterMonitoring
legt fuer jede Kunden-Instanz eine Ueberwachung an, SyncMonitoringStatus holt den
Stand, und ein Ausfall steht auf der Uebersicht. Ohne Kunden-Instanzen gibt es
nichts zu sehen, was wie "unbenutzt" aussieht. Das steht jetzt in den
Einstellungen, statt dort nur "API-Token und wo die Bruecke erreichbar ist".

2522 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 09:43:33 +02:00
parent d91d548d18
commit 5ae2dc41fc
22 changed files with 631 additions and 38 deletions

View File

@ -49,7 +49,15 @@ class PaymentProblems extends Component
$this->days = DunningSchedule::days();
$this->feeFromLevel = DunningSchedule::feeFromLevel();
$this->fees = [2 => DunningSchedule::feeCents(2), 3 => DunningSchedule::feeCents(3)];
// In EURO ins Formular, in Cent in die Datenbank.
//
// Hier stand Cent, und im Feld stand „500". Wer eine Mahngebühr
// einträgt, denkt in Euro — und tippt „5". Aus fünf Euro wurden damit
// fünf Cent, ohne dass irgendetwas widersprochen hätte.
$this->fees = [
2 => self::centsToEuro(DunningSchedule::feeCents(2)),
3 => self::centsToEuro(DunningSchedule::feeCents(3)),
];
}
/**
@ -61,6 +69,20 @@ class PaymentProblems extends Component
* sehen, statt eine andere Reihenfolge gespeichert zu bekommen als die,
* die er gelesen hat.
*/
/** Cent → das, was im Feld stehen soll: „5" statt „5,00", aber „5,50" wenn nötig. */
private static function centsToEuro(int $cents): string
{
return $cents % 100 === 0
? (string) intdiv($cents, 100)
: number_format($cents / 100, 2, ',', '');
}
/** Was im Feld steht → Zahl. Komma und Punkt gelten beide als Dezimaltrenner. */
private static function euroToFloat(string $eingabe): float
{
return (float) str_replace(',', '.', trim($eingabe));
}
public function saveSchedule(): void
{
$this->authorize('billing.manage');
@ -69,7 +91,9 @@ class PaymentProblems extends Component
'days' => ['required', 'array', 'size:5'],
'days.*' => ['required', 'integer', 'min:0', 'max:365'],
'feeFromLevel' => ['required', 'integer', 'min:1', 'max:3'],
'fees.*' => ['required', 'integer', 'min:0', 'max:100000'],
// Euro, mit Komma oder Punkt, höchstens zwei Nachkommastellen.
// 1000,00 € ist die Obergrenze — dieselbe wie vorher, nur lesbar.
'fees.*' => ['required', 'regex:/^\\d{1,4}([.,]\\d{1,2})?$/'],
]);
$tage = array_map('intval', array_values($this->days));
@ -82,7 +106,13 @@ class PaymentProblems extends Component
Settings::set(DunningSchedule::DAYS, $tage);
Settings::set(DunningSchedule::FEE_FROM_LEVEL, (int) $this->feeFromLevel);
Settings::set(DunningSchedule::FEES, array_map('intval', $this->fees));
// Gerundet, nicht abgeschnitten: (int) (19.99 * 100) ist in PHP 1998,
// weil 19.99 als Gleitkommazahl knapp darunter liegt. Ein Cent, der bei
// jedem Speichern verschwindet, fällt erst nach Monaten auf.
Settings::set(DunningSchedule::FEES, array_map(
fn ($euro) => (int) round(self::euroToFloat((string) $euro) * 100),
$this->fees,
));
$this->dispatch('notify', message: __('payment_problems.schedule_saved'));
}

View File

@ -128,7 +128,18 @@ class Provisioning extends Component
'customer' => $this->subjectLabel($r),
'pipeline' => $r->pipeline,
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
'n' => ($r->current_step + 1).'/'.$total,
// Bei einem fertigen Lauf dasselbe wie die Karte daneben.
//
// Hier stand `current_step + 1`, und das liest sich als
// „dieser Schritt läuft gerade" — richtig, solange einer
// läuft. Ist der Lauf durch, läuft keiner mehr, und die
// Zeile behauptete trotzdem einen. In der Liste stand dann
// 15/16, in der Karte daneben „16 von 16 abgeschlossen", bei
// Status „Fertig" und 100 %. Zwei Zahlen für denselben Lauf,
// und keine davon falsch genug, um aufzufallen.
'n' => ($r->status === ProvisioningRun::STATUS_COMPLETED
? $total
: min($r->current_step + 1, $total)).'/'.$total,
'percent' => (int) round($done / $total * 100),
'attempt' => $r->attempt,
'state' => $this->runState($r),

View File

@ -627,7 +627,19 @@ class Settings extends Component
// shown so the consequence of a change is visible before it is made.
'consoleRestricted' => RestrictConsoleNetwork::isRestricted(),
'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
// Getrennt, nicht in einen Topf: nur das Management-Netz und der
// eigene Rechner sind wirklich unveraenderlich. Was sonst in
// TRUSTED_RANGES steht, hat jemand von Hand eingetragen und muss
// auch von Hand wieder heraus — die Oberflaeche sagt das jetzt,
// statt es als „nicht entfernbar" auszugeben.
'consoleVpnRanges' => array_values(array_intersect(
(array) config('admin_access.trusted_ranges', []),
(array) config('admin_access.structural_ranges', []),
)),
'consoleEnvRanges' => array_values(array_diff(
(array) config('admin_access.trusted_ranges', []),
(array) config('admin_access.structural_ranges', []),
)),
'viewerIp' => (string) request()->ip(),
'staff' => $staff,
'roles' => Operator::operatorRoles(),

View File

@ -179,4 +179,19 @@ return [
explode(',', (string) env('TRUSTED_RANGES', '10.66.0.0/24,127.0.0.1')),
))),
/*
| Die zwei Einträge, die STRUKTURELL sind: das Management-Netz und der
| eigene Rechner. Nur die dürfen in der Konsole als „nicht entfernbar"
| dastehen.
|
| Alles andere, was jemand in TRUSTED_RANGES geschrieben hat, ist eine
| Eintragung von Hand und die gehört in die Liste, die der Betreiber in
| der Konsole selbst pflegen kann. Der Unterschied war unsichtbar: eine
| Büroadresse, die einmal in der .env gelandet war, stand in der Oberfläche
| neben dem VPN-Netz mit demselben Vermerk „nicht entfernbar" — und der
| Betreiber hätte sie beim nächsten Umzug nicht mehr ändern können, ohne zu
| wissen, dass sie überhaupt aus einer Datei kommt.
*/
'structural_ranges' => ['10.66.0.0/24', '127.0.0.1'],
];

View File

@ -261,6 +261,54 @@ Unit=clupilot-update-agent.service
WantedBy=paths.target
EOF
# ── Der Waechter ─────────────────────────────────────────────────────────────
#
# Jede Minute nachsehen, ob steht, was stehen soll — und richten, was nicht
# steht. Eigener Dienst, nicht Teil des Update-Agenten: der Agent macht etwas,
# wenn er gefragt wird, der Waechter, wenn niemand hinsieht.
#
# Auf dem WIRT, nicht in einem Container. Ein Waechter im Container braeuchte den
# Docker-Socket hineingereicht — das ist Root auf dem Wirt fuer jeden, der je in
# diesen Container kommt — und waere genau dann tot, wenn man ihn braucht,
# naemlich wenn der Stapel unten liegt.
#
# Er nimmt dieselbe Sperre wie der Agent und geht weg, wenn er sie nicht
# bekommt; waehrend eines Updates fasst er also nichts an.
cat > /etc/systemd/system/clupilot-watchdog.service <<EOF
[Unit]
Description=CluPilot watchdog
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=$APP_USER
WorkingDirectory=$ROOT
ExecStart=$ROOT/deploy/watchdog.sh
EOF
mkdir -p /etc/systemd/system/clupilot-watchdog.service.d
cat > /etc/systemd/system/clupilot-watchdog.service.d/rate-limit.conf <<'EOF'
[Unit]
StartLimitIntervalSec=0
EOF
cat > /etc/systemd/system/clupilot-watchdog.timer <<'EOF'
[Unit]
Description=Nachsehen, ob der CluPilot-Stapel steht — und richten, was nicht steht
[Timer]
# Zwei Minuten nach dem Booten, dann jede Minute. Der Bootabstand ist wichtig:
# direkt nach dem Start sind Container noch am Hochfahren, und ein Waechter, der
# das fuer einen Ausfall haelt, greift in einen normalen Start ein.
OnBootSec=2min
OnUnitActiveSec=1min
AccuracySec=10s
[Install]
WantedBy=timers.target
EOF
# ── The proxy's console allowlist ────────────────────────────────────────────
# The proxy has its own allowlist and it runs BEFORE the application, so
# everything the owner adds in the console has no effect until the proxy is
@ -486,6 +534,7 @@ fi
systemctl daemon-reload
systemctl enable --now clupilot-update-agent.timer >/dev/null
systemctl enable --now clupilot-watchdog.timer >/dev/null
systemctl enable --now clupilot-update-agent.path >/dev/null
# Run it once now, so the panel has a status to show instead of "never reported
@ -493,6 +542,7 @@ systemctl enable --now clupilot-update-agent.path >/dev/null
systemctl start clupilot-update-agent.service || true
echo "Update agent installed and running (clupilot-update-agent.timer)."
echo "Watchdog installed and running (clupilot-watchdog.timer) — checks every minute."
# ── Die von der Konsole verwalteten Hostnamen ────────────────────────────────
# Die Datei MUSS existieren, bevor irgendetwas sie importiert: ein Import ins

View File

@ -84,7 +84,13 @@ warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
# A step, announced to the terminal and recorded for the console.
phase() {
local key="$1"; shift
mkdir -p "$(dirname "$PHASE_FILE")"
# `|| true`, wie beim Schreiben darunter: gehoert storage/ nach einem
# frueheren Fehltritt root, scheitert das mkdir — und mit `set -e` starb das
# ganze Update an seiner ERSTEN Zeile, ohne eine einzige Ausgabe. Von aussen
# sah das aus wie "haengengeblieben"; in Wahrheit war es nach einer
# Millisekunde vorbei. Eine fehlende Fortschrittsanzeige ist kein Grund,
# ein Deployment abzubrechen.
mkdir -p "$(dirname "$PHASE_FILE")" 2>/dev/null || true
# Not atomic on purpose: this is a hint for a progress line, and a torn read
# costs a single poll. Writing a temp file per step would be more moving
# parts than the thing is worth.
@ -398,7 +404,34 @@ if [[ "$before" == "$target" && "$deployed" == "$target" ]]; then
else
log "Already up to date ($(git rev-parse --short HEAD), ${source_ref})"
fi
exit 0
# ABER: derselbe Commit heisst nicht, dass der letzte Lauf FERTIG geworden
# ist. Bricht ein Update mittendrin ab, steht der Checkout schon auf dem
# Ziel — und ein zweiter Aufruf ging bis hierher davon aus, es sei alles
# erledigt, und tat gar nichts. Genau so stand ein Server eine Stunde lang
# halb unten, waehrend `bash deploy/update.sh` freundlich "Already up to
# date" meldete.
#
# Deshalb wird hier nicht der Commit gefragt, sondern der Zustand: laeuft
# jeder Dienst, und ist der Wartungsmodus aus? Wenn nein, laeuft der Rest
# dieses Skripts trotzdem und richtet es.
unhealthy=""
soll_services="$(docker compose config --services 2>/dev/null | sort || true)"
ist_services="$(docker compose ps --services --status running 2>/dev/null | sort || true)"
if [[ -n "$soll_services" ]]; then
fehlende="$(comm -23 <(printf '%s\n' "$soll_services") <(printf '%s\n' "$ist_services") | tr '\n' ' ' | sed 's/ *$//')"
[[ -n "$fehlende" ]] && unhealthy="es fehlen Dienste: $fehlende"
fi
if [[ -z "$unhealthy" ]] && docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then
unhealthy="der Wartungsmodus ist noch an"
fi
if [[ -z "$unhealthy" ]]; then
exit 0
fi
warn "Derselbe Stand, aber $unhealthy — der letzte Lauf ist offenbar abgebrochen."
warn "Ich fahre den Rest trotzdem, statt 'fertig' zu melden."
fi
# What the change checks below compare against. After a failed run the checkout
@ -553,7 +586,14 @@ if [[ -n "$have_subnet" && "$have_subnet" != "$want_subnet" ]]; then
fi
hub_before="$(docker compose ps -q vpn-hub 2>/dev/null || true)"
docker compose up -d
# Nach einer Netz-Umstellung NEU ERZEUGEN, nicht nur starten: ein Container, der
# nur neu gestartet wird, haengt weiter am alten Netz. Alle laufen dann, und
# trotzdem loest kein Name mehr auf — das teuerste Fehlerbild dieses Tages.
if [[ "$net_migrated" == true ]]; then
docker compose up -d --force-recreate
else
docker compose up -d
fi
hub_after="$(docker compose ps -q vpn-hub 2>/dev/null || true)"
# Seit der Tunnel einen eigenen Container mit eigenem, selten wechselndem Abbild
@ -722,6 +762,14 @@ if [[ -z "$agent_hint" ]] && command -v caddy >/dev/null 2>&1 \
agent_hint="the console's access list does not reach the reverse proxy"
fi
# Der Wächter. Ohne ihn bleibt ein Stapel, den ein abgebrochenes Update halb
# unten liegen ließ, genau so liegen — bis jemand nachsieht. Genau das ist
# einmal passiert und hat eine Stunde gekostet.
if [[ -z "$agent_hint" ]] \
&& ! systemctl list-unit-files clupilot-watchdog.timer >/dev/null 2>&1; then
agent_hint="der Wächter läuft nicht — ein abgebrochenes Update heilt dann nicht von allein"
fi
if [[ -n "$agent_hint" ]]; then
printf '\033[1;33m !\033[0m %s\n' "One-time setup missing — $agent_hint."
printf ' %s\n' "Run once: sudo bash $(pwd)/deploy/install-agent.sh"

136
deploy/watchdog.sh Executable file
View File

@ -0,0 +1,136 @@
#!/usr/bin/env bash
#
# CluPilot — der Wächter.
#
# Läuft jede Minute und stellt genau eine Frage: steht, was stehen soll? Wenn
# nein, richtet er es. Er ist die Antwort auf einen Vorfall, bei dem ein
# abgebrochenes Update den Stapel halb unten liegen ließ, der Tunnel weg war und
# die Seite eine Stunde lang 500 antwortete — bis jemand von Hand nachsah.
#
# ER LÄUFT AUF DEM WIRT, nicht in einem Container. Das ist Absicht: ein Wächter
# im Container müsste den Docker-Socket hineingereicht bekommen, und das ist
# Root auf dem Wirt für jeden, der je in diesen Container kommt. Und er wäre
# genau dann tot, wenn man ihn braucht — nämlich wenn der Stapel unten ist.
#
# WAS ER NICHT TUT: raten. Er kennt vier Fehlerbilder, alle vier sind heute
# schon einmal echt passiert, und für jedes gibt es genau einen Griff. Was er
# nicht kennt, protokolliert er und lässt es in Ruhe. Ein Wächter, der bei
# Unbekanntem herumprobiert, ist der nächste Ausfall.
#
# Er fasst NICHTS an, solange ein Update läuft — er nimmt dieselbe Sperre wie
# der Update-Agent und geht weg, wenn er sie nicht bekommt.
set -uo pipefail
cd "$(cd "$(dirname "$0")/.." && pwd)"
STATE_DIR="storage/app/deploy"
LOCK="$STATE_DIR/.agent.lock"
HOLD="$STATE_DIR/maintenance-hold"
PHASE_FILE="$STATE_DIR/update-phase"
LOG_TAG="clupilot-watchdog"
# Ins Journal, nicht in eine eigene Datei: der Wächter läuft als systemd-Dienst,
# und dort gehört seine Stimme hin. `logger` fehlt praktisch nie; wenn doch,
# reicht die Standardausgabe, die systemd ohnehin einsammelt.
say() {
if command -v logger >/dev/null 2>&1; then
logger -t "$LOG_TAG" -- "$*"
fi
printf '%s\n' "$*"
}
# Solange ein Update läuft, ist Stillstand normal und Eingreifen schädlich.
mkdir -p "$STATE_DIR" 2>/dev/null || true
exec 9>"$LOCK" 2>/dev/null || exit 0
flock -n 9 || exit 0
geheilt=false
# ── 1. Fehlt ein Dienst? ─────────────────────────────────────────────────────
#
# `config --services` liest die Profile aus der .env mit, vpn-dns und
# vpn-gateway zählen also nur, wo der Tunnel überhaupt eingerichtet ist.
soll="$(docker compose config --services 2>/dev/null | sort || true)"
ist="$(docker compose ps --services --status running 2>/dev/null | sort || true)"
if [[ -n "$soll" ]]; then
fehlt="$(comm -23 <(printf '%s\n' "$soll") <(printf '%s\n' "$ist") | tr '\n' ' ' | sed 's/ *$//')"
if [[ -n "$fehlt" ]]; then
say "Es fehlen Dienste: $fehlt — starte sie."
docker compose up -d >/dev/null 2>&1 || true
geheilt=true
sleep 10
ist="$(docker compose ps --services --status running 2>/dev/null | sort || true)"
fi
fi
# ── 2. Finden die Container einander noch? ───────────────────────────────────
#
# Das Fehlerbild, das am teuersten war und am harmlosesten aussah: nach einem
# Neuaufbau des Compose-Netzes hingen die nur NEU GESTARTETEN Container noch am
# alten. Alle liefen, `docker compose ps` sah tadellos aus — und jede
# Namensauflösung scheiterte. Die Seite antwortete 500, der Warteschlangen-
# Arbeiter startete im Kreis, und nichts davon nannte die Ursache.
#
# Ein Neustart hilft dabei NICHT; die Container müssen neu erzeugt werden.
if printf '%s\n' "$ist" | grep -qx app && printf '%s\n' "$ist" | grep -qx redis; then
if ! 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."
docker compose up -d --force-recreate >/dev/null 2>&1 || true
geheilt=true
sleep 15
fi
fi
# ── 3. Steht der Tunnel? ─────────────────────────────────────────────────────
#
# Der wichtigste Punkt: ohne wg0 ist kein Host erreichbar, keine Provisionierung
# möglich und kein Terminal. Ohne Konfiguration ist der Tunnel auf diesem Server
# schlicht nicht eingerichtet — dann gibt es nichts zu heilen.
if docker compose ps --services --status running 2>/dev/null | grep -qx vpn-hub; then
if docker compose exec -T vpn-hub test -f /etc/wireguard/wg0.conf >/dev/null 2>&1; then
if ! docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then
say "wg0 steht nicht — ziehe den Tunnel hoch."
docker compose exec -T vpn-hub wg-quick up wg0 >/dev/null 2>&1 || true
geheilt=true
if docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then
say "wg0 steht wieder."
else
say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md."
fi
fi
fi
fi
# ── 4. Hängt der Wartungsmodus? ──────────────────────────────────────────────
#
# Ein Update, das mittendrin abbricht, lässt die Seite unten. Bis hierher blieb
# sie das, bis jemand nachsah — im Ernstfall stundenlang.
#
# Die Sperre oben ist der eigentliche Schutz: läuft ein Update, kommt dieser
# Code gar nicht erst dran. Die halbe Stunde darunter ist der zweite Riegel für
# einen Lauf, der ohne Aufräumen gestorben ist und seine Sperre nie freigab.
#
# `maintenance-hold` ist die Handbremse: wer die Seite bewusst unten haben will,
# legt die Datei an, und der Wächter fasst den Wartungsmodus nicht mehr an.
if [[ ! -f "$HOLD" ]] && docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then
phase_alt=true
if [[ -f "$PHASE_FILE" ]]; then
alter=$(( $(date +%s) - $(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0) ))
[[ "$alter" -lt 1800 ]] && phase_alt=false
fi
if [[ "$phase_alt" == true ]]; then
say "Der Wartungsmodus haengt seit ueber einer halben Stunde ohne laufendes Update — beende ihn."
docker compose exec -T -u www-data app php artisan up >/dev/null 2>&1 || true
geheilt=true
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
say "Nachgesehen und eingegriffen."
fi

View File

@ -63,6 +63,7 @@ return [
'console_unlock' => 'Freigeben',
'console_always' => 'Immer erlaubt',
'console_vpn_note' => 'Management-VPN, nicht entfernbar',
'console_env_note' => 'Steht in TRUSTED_RANGES in der .env — gehört in die Liste darunter, wo du sie ändern kannst.',
'console_extra' => 'Zusätzlich erlaubt',
'console_none' => 'Noch keine weitere Adresse — ohne VPN kommt dann niemand herein.',
'console_add' => 'Hinzufügen',

View File

@ -44,7 +44,7 @@ return [
'traefik_path_hint' => 'Verzeichnis auf dem Traffic-Host, in das Routen geschrieben werden.',
'monitoring_title' => 'Monitoring',
'monitoring_body' => 'API-Token und wo die Kuma-Bridge erreichbar ist.',
'monitoring_body' => 'Damit merkt CluPilot, dass die Cloud eines Kunden steht — bevor der Kunde anruft. Bei jeder neuen Instanz entsteht automatisch eine Überwachung auf deren Statusseite; fällt sie aus, steht die Meldung auf der Übersicht. Ohne Kunden-Instanzen gibt es hier folglich nichts zu sehen. Leer lassen schaltet die Überwachung ab, sonst ändert sich nichts.',
'monitoring_url' => 'Monitoring-Bridge-URL',
'monitoring_url_hint' => 'Zum Beispiel http://kuma-bridge:8080. Leer lässt Monitoring aus.',

View File

@ -25,6 +25,10 @@ return [
'resolve_body' => 'Der Vorgang verschwindet aus dieser Liste. Was Sie hier eintragen, steht später daneben — zusammen mit Ihrer Adresse.',
'note_hint' => 'Zum Beispiel: hat überwiesen, hat neu bestellt, nicht erreichbar.',
'schedule_title' => 'Fristen und Gebühren',
'schedule_days_title' => 'Fristen',
'schedule_fees_title' => 'Gebühren',
'schedule_fees_body' => 'In Euro. Ab welcher Stufe überhaupt eine Gebühr anfällt, steht links — davor kostet eine Mahnung nichts.',
'unit_days' => 'Tage',
'schedule_body' => 'Tage seit der ersten gescheiterten Abbuchung. Stufe 0 ist der Hinweis und kostet nie etwas — eine abgelaufene Karte ist keine Zahlungsverweigerung.',
'day_0' => 'Hinweis',
'day_1' => '1. Mahnung',
@ -32,8 +36,8 @@ return [
'day_3' => '3. Mahnung',
'day_4' => 'Abschaltung',
'fee_from' => 'Gebühr ab Stufe',
'fee_2' => 'Gebühr 2. Mahnung (Cent)',
'fee_3' => 'Gebühr 3. Mahnung (Cent)',
'fee_2' => 'Gebühr 2. Mahnung',
'fee_3' => 'Gebühr 3. Mahnung',
'schedule_saved' => 'Fristen und Gebühren gespeichert. Sie gelten ab dem nächsten Tageslauf.',
'days_out_of_order' => 'Die Fristen müssen aufsteigend sein. So stünde eine spätere Mahnung vor einer früheren.',
'extend' => 'Frist verlängern',

View File

@ -63,6 +63,7 @@ return [
'console_unlock' => 'Open up',
'console_always' => 'Always allowed',
'console_vpn_note' => 'Management VPN, not removable',
'console_env_note' => 'Set in TRUSTED_RANGES in the .env — belongs in the list below, where you can change it.',
'console_extra' => 'Additionally allowed',
'console_none' => 'No further address yet — without the VPN, nobody gets in.',
'console_add' => 'Add',

View File

@ -44,7 +44,7 @@ return [
'traefik_path_hint' => 'Directory on the traffic host that routes get written into.',
'monitoring_title' => 'Monitoring',
'monitoring_body' => 'The API token and where the Kuma bridge is reachable.',
'monitoring_body' => 'This is how CluPilot notices that a customer cloud is down — before the customer calls. Every new instance automatically gets a check on its status page; when one fails, the notice appears on the overview. With no customer instances there is nothing to see here. Leaving it empty turns monitoring off, nothing else changes.',
'monitoring_url' => 'Monitoring bridge URL',
'monitoring_url_hint' => 'For example http://kuma-bridge:8080. Leave blank to leave monitoring off.',

View File

@ -25,6 +25,10 @@ return [
'resolve_body' => 'It leaves this list. What you write here stays with it afterwards, together with your address.',
'note_hint' => 'For example: paid by transfer, ordered again, unreachable.',
'schedule_title' => 'Deadlines and fees',
'schedule_days_title' => 'Deadlines',
'schedule_fees_title' => 'Fees',
'schedule_fees_body' => 'In euros. Which level starts charging at all is on the left — before it, a reminder costs nothing.',
'unit_days' => 'days',
'schedule_body' => 'Days since the first failed charge. Level 0 is the notice and never costs anything — an expired card is not a refusal to pay.',
'day_0' => 'Notice',
'day_1' => '1st reminder',
@ -32,8 +36,8 @@ return [
'day_3' => '3rd reminder',
'day_4' => 'Shutdown',
'fee_from' => 'Fee from level',
'fee_2' => 'Fee, 2nd reminder (cents)',
'fee_3' => 'Fee, 3rd reminder (cents)',
'fee_2' => 'Fee, 2nd reminder',
'fee_3' => 'Fee, 3rd reminder',
'schedule_saved' => 'Deadlines and fees saved. They apply from the next daily run.',
'days_out_of_order' => 'Deadlines must ascend. As entered, a later reminder would come before an earlier one.',
'extend' => 'Extend deadline',

View File

@ -38,7 +38,13 @@
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span class="font-medium text-ink">{{ $peer->name }}</span>
{{-- Bei einem Host-Zugang der Name des HOSTS, nicht der gespeicherte.
Der Zugang bekommt seinen Namen einmal beim Anlegen; wird der
Host später umbenannt, zeigt die Liste weiter den alten. Genau
so stand hier „pve-fns-1", während der Host längst „fsn-01"
hieß zwei Namen für dieselbe Maschine, und man muss erst
wissen, dass es dieselbe ist. --}}
<span class="font-medium text-ink">{{ $peer->host?->name ?? $peer->name }}</span>
{{-- Die Plakette bleibt, obwohl die Gruppe daneben dasselbe sagt:
eine Zeile wandert beim Suchen aus ihrer Überschrift heraus,

View File

@ -3,6 +3,12 @@
'label' => null,
'type' => 'text',
'hint' => null,
// Die Einheit, im Feld statt in der Beschriftung: „Gebühr 2. Mahnung (Cent)"
// erklärt die Einheit an einer Stelle, die man beim schnellen Ändern nicht
// mehr liest. Ein € am rechten Rand des Feldes steht dort, wo der Blick
// ohnehin hinfällt — und lässt die Beschriftung wieder das benennen, worum
// es geht.
'suffix' => null,
])
@php
// $errors is shared on web requests; default it so the component also renders
@ -18,13 +24,29 @@
<label for="{{ $id }}" class="block text-sm font-medium text-body">{{ $label }}</label>
@endif
<input
id="{{ $id }}"
name="{{ $name }}"
type="{{ $type }}"
@if ($hasError) aria-invalid="true" aria-describedby="{{ $id }}-error" @endif
{{ $attributes->merge(['class' => $field]) }}
>
@if ($suffix)
<div class="relative">
<input
id="{{ $id }}"
name="{{ $name }}"
type="{{ $type }}"
@if ($hasError) aria-invalid="true" aria-describedby="{{ $id }}-error" @endif
{{ $attributes->merge(['class' => $field.' pr-12']) }}
>
{{-- aria-hidden: die Einheit gehört für einen Screenreader in die
Beschriftung, nicht als loses Wort hinter das Feld. --}}
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3.5 text-sm text-muted"
aria-hidden="true">{{ $suffix }}</span>
</div>
@else
<input
id="{{ $id }}"
name="{{ $name }}"
type="{{ $type }}"
@if ($hasError) aria-invalid="true" aria-describedby="{{ $id }}-error" @endif
{{ $attributes->merge(['class' => $field]) }}
>
@endif
@if ($hint && ! $hasError)
<p class="text-xs text-muted">{{ $hint }}</p>

View File

@ -21,23 +21,40 @@
</button>
<form wire:submit="saveSchedule" x-show="open" x-cloak class="border-t border-line px-6 py-5">
<p class="max-w-[70ch] text-sm text-muted">{{ __('payment_problems.schedule_body') }}</p>
{{-- Zwei Blöcke mit eigener Überschrift statt zwei Reihen untereinander.
Vorher standen Tage und Geldbeträge in derselben Gruppe: oben fünf
Felder mit Tagen, direkt darunter drei mit einer Stufennummer und
zwei Beträgen. Was welche Einheit hat, stand nur in den einzelnen
Beschriftungen und wer schnell etwas ändert, liest die nicht. --}}
<div class="space-y-6">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-muted">{{ __('payment_problems.schedule_days_title') }}</p>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('payment_problems.schedule_body') }}</p>
<div class="mt-4 grid gap-3 sm:grid-cols-5">
@foreach ([0, 1, 2, 3, 4] as $stufe)
<x-ui.input :name="'days.'.$stufe" type="number" min="0" max="365"
wire:model="days.{{ $stufe }}" :label="__('payment_problems.day_'.$stufe)" />
@endforeach
</div>
@error('days')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-3 grid gap-3 sm:grid-cols-5">
@foreach ([0, 1, 2, 3, 4] as $stufe)
<x-ui.input :name="'days.'.$stufe" type="number" min="0" max="365"
wire:model="days.{{ $stufe }}"
:label="__('payment_problems.day_'.$stufe)"
:suffix="__('payment_problems.unit_days')" />
@endforeach
</div>
@error('days')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<x-ui.input name="feeFromLevel" type="number" min="1" max="3"
wire:model="feeFromLevel" :label="__('payment_problems.fee_from')" />
<x-ui.input name="fees.2" type="number" min="0" wire:model="fees.2"
:label="__('payment_problems.fee_2')" />
<x-ui.input name="fees.3" type="number" min="0" wire:model="fees.3"
:label="__('payment_problems.fee_3')" />
<div class="border-t border-line pt-5">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">{{ __('payment_problems.schedule_fees_title') }}</p>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('payment_problems.schedule_fees_body') }}</p>
<div class="mt-3 grid gap-3 sm:grid-cols-3">
<x-ui.input name="feeFromLevel" type="number" min="1" max="3"
wire:model="feeFromLevel" :label="__('payment_problems.fee_from')" />
<x-ui.input name="fees.2" inputmode="decimal" wire:model="fees.2"
:label="__('payment_problems.fee_2')" suffix="" />
<x-ui.input name="fees.3" inputmode="decimal" wire:model="fees.3"
:label="__('payment_problems.fee_3')" suffix="" />
</div>
</div>
</div>
<div class="mt-5 border-t border-line pt-5">

View File

@ -385,6 +385,20 @@
</div>
@endforeach
{{-- Eintraege, die jemand von Hand in TRUSTED_RANGES
geschrieben hat. Sie standen bis hierher neben dem
Management-Netz mit demselben Vermerk „nicht
entfernbar" — und waren damit fuer den Betreiber
unveraenderlich, obwohl sie genau das nicht sind.
Wer umzieht, muss seine Bueroadresse aendern
koennen; sie gehoert in die Liste darunter. --}}
@foreach ($consoleEnvRanges as $range)
<div class="flex items-center justify-between gap-3 rounded-lg border border-warning-border bg-warning-bg px-3 py-2">
<span class="font-mono text-sm text-body">{{ $range }}</span>
<span class="text-right text-xs text-warning">{{ __('admin_settings.console_env_note') }}</span>
</div>
@endforeach
<p class="pt-3 text-xs font-semibold text-muted">{{ __('admin_settings.console_extra') }}</p>
@forelse ($consoleIps as $ip)
<div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">

View File

@ -0,0 +1,57 @@
<?php
use App\Models\Operator;
/**
* Was „nicht entfernbar" heisst, muss auch wirklich nicht entfernbar sein.
*
* In der Konsole standen alle Eintraege aus TRUSTED_RANGES untereinander mit
* demselben Vermerk „Management-VPN, nicht entfernbar" — auch eine Bueroadresse,
* die jemand einmal in die .env geschrieben hatte. Der Betreiber konnte sie
* damit nicht aendern und wusste nicht einmal, dass sie aus einer Datei kommt.
* Beim naechsten Umzug des Buero-Anschlusses waere das ein Aussperren gewesen.
*/
it('nennt eine von Hand eingetragene Adresse nicht unveraenderlich', function () {
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24', '127.0.0.1', '62.178.50.33']);
config()->set('admin_access.structural_ranges', ['10.66.0.0/24', '127.0.0.1']);
$html = $this->actingAs(admin(), 'operator')
->get(route('admin.settings', ['tab' => 'security']))
->assertOk()
->getContent();
// Der Vermerk „nicht entfernbar" darf nicht mehr auf die Bueroadresse
// zutreffen — sie steht in der Warnzeile, mit dem Weg heraus.
expect($html)->toContain(__('admin_settings.console_env_note'))
->and($html)->toContain('62.178.50.33');
});
it('laesst die zwei strukturellen Eintraege in Ruhe', function () {
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24', '127.0.0.1']);
$html = $this->actingAs(admin(), 'operator')
->get(route('admin.settings', ['tab' => 'security']))
->assertOk()
->getContent();
expect($html)->toContain(__('admin_settings.console_vpn_note'))
// Ohne handgemachte Eintraege gibt es auch nichts zu warnen.
->and($html)->not->toContain(__('admin_settings.console_env_note'));
});
it('nennt einen Host-Zugang so, wie der Host heute heisst', function () {
// Ein Zugang bekommt seinen Namen einmal beim Anlegen. Wird der Host spaeter
// umbenannt, zeigte die Liste weiter den alten: „pve-fns-1" neben einem
// Host, der laengst „fsn-01" hiess. Zwei Namen fuer dieselbe Maschine.
$host = \App\Models\Host::factory()->create(['name' => 'fsn-01']);
\App\Models\VpnPeer::factory()->create([
'host_id' => $host->id,
'name' => 'pve-fns-1',
'kind' => 'host',
]);
$html = $this->actingAs(admin(), 'operator')->get(route('admin.vpn'))->assertOk()->getContent();
expect($html)->toContain('fsn-01')
->and($html)->not->toContain('pve-fns-1');
});

View File

@ -0,0 +1,77 @@
<?php
use App\Livewire\Admin\PaymentProblems;
use App\Services\Billing\DunningSchedule;
use Livewire\Livewire;
// admin() helper defined in HostManagementTest.
/**
* Im Feld Euro, in der Datenbank Cent.
*
* Das Feld hiess „Gebühr 2. Mahnung (Cent)" und enthielt 500. Wer eine
* Mahngebühr einträgt, denkt in Euro und tippt „5" — aus fünf Euro wurden fünf
* Cent, und nichts hat widersprochen. Gespeichert wird weiter in Cent, wie
* ueberall sonst im Produkt; nur die Eingabe spricht die Sprache des Menschen.
*/
it('zeigt hinterlegte Gebuehren in Euro an', function () {
DunningSchedule::class; // Autoload
app(\App\Support\Settings::class);
\App\Support\Settings::set(DunningSchedule::FEES, [2 => 500, 3 => 1000]);
$fees = Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class)->get('fees');
expect($fees[2])->toBe('5')
->and($fees[3])->toBe('10');
});
it('speichert eine Eingabe in Euro als Cent', function () {
Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class)
->set('days', [0, 3, 10, 17, 24])
->set('feeFromLevel', 2)
->set('fees.2', '7,50')
->set('fees.3', '12')
->call('saveSchedule')
->assertHasNoErrors();
expect(DunningSchedule::feeCents(2))->toBe(750)
->and(DunningSchedule::feeCents(3))->toBe(1200);
});
it('rundet statt abzuschneiden', function () {
// (int) (19.99 * 100) ist in PHP 1998 — die Gleitkommazahl liegt knapp
// darunter. Ein Cent, der bei jedem Speichern verschwindet, faellt erst nach
// Monaten auf.
Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class)
->set('days', [0, 3, 10, 17, 24])
->set('feeFromLevel', 2)
->set('fees.2', '19,99')
->set('fees.3', '0')
->call('saveSchedule')
->assertHasNoErrors();
expect(DunningSchedule::feeCents(2))->toBe(1999);
});
it('nimmt auch einen Punkt als Dezimaltrenner', function () {
Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class)
->set('days', [0, 3, 10, 17, 24])
->set('feeFromLevel', 2)
->set('fees.2', '5.25')
->set('fees.3', '0')
->call('saveSchedule')
->assertHasNoErrors();
expect(DunningSchedule::feeCents(2))->toBe(525);
});
it('weist Unfug zurueck, statt ihn auf null zu runden', function () {
Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class)
->set('days', [0, 3, 10, 17, 24])
->set('feeFromLevel', 2)
->set('fees.2', 'fuenf')
->set('fees.3', '0')
->call('saveSchedule')
->assertHasErrors('fees.2');
});

View File

@ -86,3 +86,41 @@ it('ignores retry on a run that is not failed', function () {
expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_RUNNING);
Queue::assertNotPushed(AdvanceRunJob::class);
});
it('zaehlt einen fertigen Lauf in Liste und Karte gleich', function () {
// Zwei Zahlen fuer denselben Lauf: in der Liste stand 15/16, in der Karte
// daneben „16 von 16 abgeschlossen" — bei Status „Fertig" und 100 %. Die
// Liste rechnete `current_step + 1`, also „dieser Schritt laeuft gerade";
// bei einem fertigen Lauf laeuft aber keiner mehr.
$host = Host::factory()->create(['status' => 'active']);
$total = count(config('provisioning.pipelines.host'));
ProvisioningRun::factory()->create([
'subject_type' => Host::class, 'subject_id' => $host->id, 'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_COMPLETED,
// Der letzte ausgefuehrte Schritt, NICHT die Gesamtzahl — genau der
// Stand, bei dem die beiden Zahlen auseinanderliefen.
'current_step' => $total - 2,
]);
$rows = Livewire::actingAs(admin(), 'operator')->test(Provisioning::class)->viewData('rows');
expect($rows[0]['n'])->toBe($total.'/'.$total)
->and($rows[0]['percent'])->toBe(100);
});
it('zeigt bei einem laufenden Lauf weiter den Schritt, der gerade dran ist', function () {
// Die Gegenprobe: solange etwas laeuft, ist „dieser Schritt laeuft gerade"
// genau richtig, und die Korrektur oben darf das nicht kaputtmachen.
$host = Host::factory()->create(['status' => 'onboarding']);
$total = count(config('provisioning.pipelines.host'));
ProvisioningRun::factory()->create([
'subject_type' => Host::class, 'subject_id' => $host->id, 'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_RUNNING, 'current_step' => 3,
]);
$rows = Livewire::actingAs(admin(), 'operator')->test(Provisioning::class)->viewData('rows');
expect($rows[0]['n'])->toBe('4/'.$total);
});

View File

@ -115,12 +115,17 @@ it('lets the console set the deadlines and the fees', function () {
->test(PaymentProblems::class)
->set('days', [0, 5, 12, 20, 30])
->set('feeFromLevel', 3)
->set('fees', [2 => 500, 3 => 1500])
// EURO ins Feld, Cent in die Datenbank. Hier stand einmal 500 und 1500 —
// also Cent —, weil das Feld selbst in Cent rechnete. Wer eine
// Mahngebuehr eintraegt, denkt aber in Euro und tippt „5"; daraus wurden
// fuenf Cent, ohne dass etwas widersprochen haette.
->set('fees', [2 => '5', 3 => '15'])
->call('saveSchedule')
->assertHasNoErrors();
expect(DunningSchedule::dayOfLevel(1))->toBe(5)
->and(DunningSchedule::feeFromLevel())->toBe(3)
// Stufe 2 kostet nichts, weil die Gebuehr erst ab Stufe 3 greift.
->and(DunningSchedule::feeCents(2))->toBe(0)
->and(DunningSchedule::feeCents(3))->toBe(1500);
});

View File

@ -334,3 +334,48 @@ it('gibt dem Tunnel eine feste Adresse, damit gemerkte Stroeme nie ins Leere zei
// dabei nicht, also muss das Aufraeumen ausdruecklich ausgeloest werden.
->and($update)->toContain('"$net_migrated" == true');
});
it('haelt einen Waechter bereit, der einen halb liegengebliebenen Stapel selbst aufrichtet', function () {
// Der Vorfall: ein Update brach ab, der Stapel lag halb unten, der Tunnel
// war weg und die Seite antwortete eine Stunde lang 500 — bis jemand von
// Hand nachsah. Genau das soll nie wieder auf einen Menschen warten.
$watchdog = File::get(base_path('deploy/watchdog.sh'));
// Er fasst nichts an, solange ein Update laeuft: dieselbe Sperre wie der
// Update-Agent, und weg, wenn er sie nicht bekommt.
expect($watchdog)->toContain('flock -n 9 || exit 0')
// Die vier Fehlerbilder, die heute wirklich passiert sind.
->and($watchdog)->toContain('docker compose up -d')
->and($watchdog)->toContain('--force-recreate')
->and($watchdog)->toContain('wg-quick up wg0')
->and($watchdog)->toContain('php artisan up')
// Und eine Handbremse: wer die Seite bewusst unten haben will, soll sie
// unten halten koennen.
->and($watchdog)->toContain('maintenance-hold');
// Auf dem Wirt, per systemd — nicht als Container mit Docker-Socket, der
// genau dann tot waere, wenn man ihn braucht.
$installer = File::get(base_path('deploy/install-agent.sh'));
expect($installer)->toContain('clupilot-watchdog.service')
->and($installer)->toContain('clupilot-watchdog.timer')
->and($installer)->toContain('systemctl enable --now clupilot-watchdog.timer');
});
it('meldet nicht fertig, wenn der letzte Lauf abgebrochen ist', function () {
// Bricht ein Update mittendrin ab, steht der Checkout schon auf dem Ziel.
// Ein zweiter Aufruf meldete deshalb "Already up to date" und tat NICHTS —
// waehrend der Server halb unten stand. Der Commit ist die falsche Frage;
// die richtige ist, ob alles laeuft.
$update = File::get(base_path('deploy/update.sh'));
expect($update)->toContain('Already up to date')
->and($update)->toContain('unhealthy')
->and($update)->toContain('der Wartungsmodus ist noch an');
// Und die Fortschrittsanzeige darf ein Deployment nicht mehr toeten: gehoert
// storage/ nach einem frueheren Fehltritt root, scheiterte das mkdir in
// phase() — und mit `set -e` starb der Lauf an seiner ersten Zeile, ohne
// eine einzige Ausgabe.
expect($update)->toContain('mkdir -p "$(dirname "$PHASE_FILE")" 2>/dev/null || true');
});