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
parent
d91d548d18
commit
5ae2dc41fc
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue