2879 lines
117 KiB
Markdown
2879 lines
117 KiB
Markdown
# vmbr0 automatisch bauen — Umsetzungsplan
|
||
|
||
> **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:** Ein neuer Pipeline-Schritt `EnsureNetworkBridge` baut `vmbr0` auf einem Debian-Proxmox-Host selbsttätig, unter einer Rückfahrkarte, die den Host auch dann zurückholt, wenn die Brücke ihn vom Netz nimmt.
|
||
|
||
**Architecture:** Derselbe Mechanismus wie `BuildVmTemplate`: eine Shell-Bibliothek (`lib/bridge.sh`) und ein Treiber (`lib/bridge-run.sh`) werden wortgleich auf den Host geladen und abgekoppelt gefahren; der PHP-Schritt fragt eine Statusdatei ab und verbraucht dafür `poll()`, nicht `retry()`. Der Treiber stellt vor jeder Änderung einen systemd-Zeitgeber, der die alte Netzkonfiguration zurückspielt; abbestellt wird er von CluPilot, erst nachdem es sich über den Tunnel neu verbunden und die Brücke nachgeprüft hat.
|
||
|
||
**Tech Stack:** PHP 8 / Laravel 13.8, Pest, POSIX `sh` (dash auf Debian), `ifupdown2`, systemd, WireGuard.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-01-network-bridge-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- **Der Zeitgeber steht vor jeder Änderung.** Keine Zeile, die Netzkonfiguration anfasst, darf vor `schedule_network_rollback` laufen. Das ist die Zusicherung, die Task 5 prüft.
|
||
- **Nur der Zeitgeber stellt zurück.** Kein zweiter Rücknahmeweg — weder im Treiber noch in PHP. `giveUp()` bestellt den Zeitgeber **nicht** ab.
|
||
- **Abbestellt wird erst, wenn beide Richtungen stimmen:** `http_get "$CLUPILOT_PROBE_URL"` **und** frischer WireGuard-Handshake (180 s) vom konfigurierten Hub.
|
||
- **Kein `ping`.** Hetzners `Debian-trixie-…-base` hat kein `iputils-ping`, `PrepareBaseSystem` installiert es nicht (`curl gnupg ifupdown2 chrony`). Es wird auch nicht nachinstalliert.
|
||
- **Alles aus dem laufenden Zustand:** `ip -4 addr`, `ip -4 route`, `/sys/class/net/…`. Die Datei des Anbieters ist höchstens Zweitsignal.
|
||
- **Kein PHP-Nachbau der Netzlogik.** Der Schritt lädt und fährt; ein Test vergleicht Byte für Byte gegen die Repo-Datei.
|
||
- **Eine Fassung.** Nach Task 1 gibt es `detect_primary_interface` genau einmal im Repo.
|
||
- **POSIX `sh`**, kein Bash-ismus. Jeder Befehl, den der Schritt absetzt, muss `sh -n` bestehen.
|
||
- **Arbeitsverzeichnis auf dem Host:** `/var/lib/clupilot/bridge`.
|
||
- **Fristen:** Zeitgeber 5 min, Schritt-Frist 15 min, `maxDuration()` 3600.
|
||
- **Kommentarsprache:** Shell-Bibliotheken auf Deutsch (wie `template.sh`, `network.sh`), PHP-Docblocks auf Englisch (wie `BuildVmTemplate.php`). Betreibertexte Deutsch.
|
||
- **R22 gilt:** eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach werden Restbefunde geparkt.
|
||
- **Tests laufen im Container** (R8, Docker-first). Nackt gibt es auf dieser Maschine kein `php`:
|
||
```
|
||
docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest
|
||
```
|
||
Das gilt auch für `pint`. Der `clupilot`-Helfer hat dafür **kein** Unterkommando.
|
||
- **`CommandResult::failure(int $exitCode = 1, string $stderr = '')`** — zwei Parameter. `CommandResult::success(string $stdout = '')`.
|
||
- **Zwischenstände dürfen unfertig sein, aber nicht scharf.** Task 6 lässt bewusst ein `fail('not implemented yet')` stehen, das Task 7 ersetzt. Das ist gefahrlos, weil der Schritt erst in **Task 9** in die Pipeline kommt — bis dahin kann ihn kein Lauf erreichen. Wer die Reihenfolge umstellt, nimmt sich diese Sicherheit.
|
||
|
||
---
|
||
|
||
### Task 1: `bridge.sh` — Erkennung, allein lauffähig
|
||
|
||
Die Brückenhälfte aus `network.sh` wird zu einer eigenen, selbstgenügsamen Bibliothek. Diese Task bringt nur die **Erkennung** hinüber (lesen, nichts verändern) und macht sie ohne die vier geborgten Helfer lauffähig.
|
||
|
||
**Files:**
|
||
- Create: `deploy/bootstrap/lib/bridge.sh`
|
||
- Modify: `deploy/bootstrap/lib/proxmox.sh:132-135` (Kopie von `detect_primary_interface` entfernen)
|
||
- Modify: `deploy/bootstrap/clupilot-bootstrap.sh:968` (`lib/bridge.sh` **vor** `lib/proxmox.sh` sourcen)
|
||
- Modify: `deploy/bootstrap/lib/network.sh:44-103` (Erkennungsteil entfernen, Verweis-Kommentar setzen)
|
||
- Test: `tests/Feature/Provisioning/BridgeScriptTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts.
|
||
- Produces (alle in `bridge.sh`, POSIX `sh`):
|
||
- `detect_primary_interface() -> stdout: iface-Name` (leer, wenn keine Standardroute)
|
||
- `interface_is_physical(iface) -> exit 0/1`
|
||
- `address_is_dynamic(iface) -> exit 0/1`
|
||
- `detect_network_style(iface) -> stdout: dhcp|routed|subnet`
|
||
- `bridge_exists(name) -> exit 0/1`
|
||
- `bridge_carries_default_route(name) -> exit 0/1`
|
||
- `bridge_has_address(name) -> exit 0/1`
|
||
- `bridge_is_up() -> exit 0/1` (die drei zusammen, gegen `$CLUPILOT_BRIDGE`)
|
||
- Variablen mit Vorgaben: `CLUPILOT_BRIDGE`, `CLUPILOT_INTERFACES_FILE`, `CLUPILOT_INTERFACES_D`, `CLUPILOT_SYS_NET`, `CLUPILOT_IP`, `CLUPILOT_PROBE_URL`, `CLUPILOT_IFRELOAD`, `CLUPILOT_ROLLBACK_UNIT`, `CLUPILOT_NET_BACKUP`, `CLUPILOT_WORK_DIR`, `CLUPILOT_WG_HUB_PUBKEY`, `CLUPILOT_WG_HANDSHAKE_MAX_AGE`
|
||
- `log(msg)` und `http_get(url)`, **beide unter einem Definitionsschutz** (`command -v`), damit `clupilot-bootstrap.sh` seine eigenen behält.
|
||
|
||
`CLUPILOT_IP` ist der Kniff, der die Erkennung ohne Host prüfbar macht: statt `ip` direkt aufzurufen, ruft jede Funktion `"$CLUPILOT_IP"`. Ein Test setzt das auf ein Attrappen-Skript, das aufgezeichnete Ausgaben zurückgibt — dieselbe Technik wie `CLUPILOT_STORAGE_CFG` beim Vorlagenbau.
|
||
|
||
- [ ] **Step 1: Den Testfall schreiben**
|
||
|
||
`tests/Feature/Provisioning/BridgeScriptTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
use Illuminate\Support\Facades\Process;
|
||
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| bridge.sh, gegen eine echte Shell gefahren
|
||
|--------------------------------------------------------------------------
|
||
|
|
||
| Der Rest der Testsuite liest die Befehle dieses Schritts als Zeichenketten —
|
||
| FakeRemoteShell führt keinen davon aus. Die Strophe, die diese Bibliothek
|
||
| schreibt, ist aber genau das, was eine Maschine umbringt, wenn sie falsch
|
||
| ist. Deshalb wird bridge.sh hier mit `sh` wirklich ausgeführt, gegen
|
||
| aufgezeichnete Ausgaben von `ip` statt gegen einen Host.
|
||
|
|
||
| `CLUPILOT_IP` zeigt dabei auf ein Attrappen-Skript, `CLUPILOT_SYS_NET` auf
|
||
| einen Baum aus Textdateien. Beides ist die einzige Berührung mit dem System,
|
||
| die die Bibliothek hat.
|
||
*/
|
||
|
||
/**
|
||
* Legt eine `ip`-Attrappe an, die auf feste Argumentmuster feste Ausgaben gibt.
|
||
*
|
||
* @param array<string, string> $answers Argumentmuster => Ausgabe
|
||
*/
|
||
function fakeIp(string $dir, array $answers): string
|
||
{
|
||
$cases = '';
|
||
foreach ($answers as $pattern => $output) {
|
||
$cases .= " '{$pattern}') cat <<'OUT'\n{$output}\nOUT\n ;;\n";
|
||
}
|
||
|
||
$script = "#!/bin/sh\ncase \"\$*\" in\n{$cases} *) exit 1 ;;\nesac\n";
|
||
file_put_contents("{$dir}/ip", $script);
|
||
chmod("{$dir}/ip", 0o755);
|
||
|
||
return "{$dir}/ip";
|
||
}
|
||
|
||
/**
|
||
* Fährt eine Zeile Shell mit bridge.sh im Rücken und gibt stdout zurück.
|
||
*
|
||
* `$pre` läuft VOR dem Sourcen. Damit kann ein Test `log` oder `http_get` selbst
|
||
* definieren — bridge.sh legt beide unter einem `command -v`-Schutz an, wer
|
||
* zuerst da ist, behält recht. Das ist zugleich die Probe auf diesen Schutz und
|
||
* spart dem Test eine Abhängigkeit von curl im Container.
|
||
*/
|
||
function runBridgeSh(string $body, array $env = [], string $pre = ''): string
|
||
{
|
||
$lib = base_path('deploy/bootstrap/lib/bridge.sh');
|
||
$exports = '';
|
||
foreach ($env as $key => $value) {
|
||
$exports .= "export {$key}=".escapeshellarg($value)."\n";
|
||
}
|
||
|
||
$result = Process::input("{$exports}{$pre}\n. '{$lib}'\n{$body}\n")->run('sh');
|
||
|
||
return trim($result->output());
|
||
}
|
||
|
||
/** Ein /sys/class/net-Baum aus Textdateien. */
|
||
function fakeSysNet(string $dir, string $iface, string $mac, bool $physical = true): string
|
||
{
|
||
$root = "{$dir}/sys";
|
||
mkdir("{$root}/{$iface}", 0o755, true);
|
||
file_put_contents("{$root}/{$iface}/address", $mac."\n");
|
||
if ($physical) {
|
||
mkdir("{$root}/{$iface}/device", 0o755, true);
|
||
}
|
||
|
||
return $root;
|
||
}
|
||
|
||
beforeEach(function () {
|
||
$this->dir = sys_get_temp_dir().'/bridge-'.bin2hex(random_bytes(6));
|
||
mkdir($this->dir, 0o755, true);
|
||
});
|
||
|
||
it('nimmt die Karte, über die die Standardroute geht — nicht die erste beste', function () {
|
||
// Eine Maschine mit zwei Karten hat oft eine angeschlossene und eine nicht.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6 proto static',
|
||
]);
|
||
|
||
expect(runBridgeSh('detect_primary_interface', ['CLUPILOT_IP' => $ip]))
|
||
->toBe('enp0s31f6');
|
||
});
|
||
|
||
it('erkennt eine geroutete Einzeladresse an der /32', function () {
|
||
// Hetzner dediziert. Das eigene Subnetz besteht aus der eigenen Adresse —
|
||
// ein Gateway darin kann es nicht geben, es braucht pointopoint.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6',
|
||
'-4 -o addr show dev enp0s31f6 scope global' => '2: enp0s31f6 inet 49.12.121.79/32 scope global enp0s31f6\ valid_lft forever preferred_lft forever',
|
||
]);
|
||
|
||
expect(runBridgeSh('detect_network_style enp0s31f6', ['CLUPILOT_IP' => $ip]))
|
||
->toBe('routed');
|
||
});
|
||
|
||
it('erkennt DHCP am laufenden Zustand, nicht an der Datei des Anbieters', function () {
|
||
// Der Kernel markiert eine geleaste Adresse als `dynamic`. Das ist überall
|
||
// gleich; wie der Anbieter es aufgeschrieben hat, nicht.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show default' => 'default via 10.0.0.1 dev ens3',
|
||
'-4 -o addr show dev ens3 scope global' => '2: ens3 inet 10.0.0.7/24 brd 10.0.0.255 scope global dynamic ens3\ valid_lft 3521sec preferred_lft 3521sec',
|
||
]);
|
||
|
||
expect(runBridgeSh('detect_network_style ens3', [
|
||
'CLUPILOT_IP' => $ip,
|
||
// Absichtlich auf eine Datei zeigen, die es nicht gibt: die Antwort darf
|
||
// nicht davon abhängen.
|
||
'CLUPILOT_INTERFACES_FILE' => $this->dir.'/gibtesnicht',
|
||
]))->toBe('dhcp');
|
||
});
|
||
|
||
it('hält eine Bridge und einen Bond für keine physische Karte', function () {
|
||
// bridge_ports auf einem Bond oder einer bestehenden Bridge ist falsch: die
|
||
// Brücke nähme sich ihren eigenen Unterbau als Port.
|
||
$sys = fakeSysNet($this->dir, 'bond0', 'aa:bb:cc:dd:ee:ff', physical: false);
|
||
mkdir("{$sys}/bond0/bonding", 0o755, true);
|
||
|
||
$out = runBridgeSh(
|
||
'if interface_is_physical bond0; then echo JA; else echo NEIN; fi',
|
||
['CLUPILOT_SYS_NET' => $sys]
|
||
);
|
||
|
||
expect($out)->toBe('NEIN');
|
||
});
|
||
|
||
it('hält eine gewöhnliche Karte für eine physische', function () {
|
||
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
|
||
|
||
$out = runBridgeSh(
|
||
'if interface_is_physical enp0s31f6; then echo JA; else echo NEIN; fi',
|
||
['CLUPILOT_SYS_NET' => $sys]
|
||
);
|
||
|
||
expect($out)->toBe('JA');
|
||
});
|
||
|
||
it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', function () {
|
||
// Eine vmbr0 ohne Adresse und ohne Standardroute ist eine Brücke im Sinne
|
||
// von `ip link show` und sonst nichts. Der alte Schritt prüfte genau das
|
||
// und warf das Ergebnis weg.
|
||
$ip = fakeIp($this->dir, [
|
||
'link show vmbr0' => '5: vmbr0: <BROADCAST,MULTICAST,UP> mtu 1500',
|
||
'-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6',
|
||
'-4 -o addr show dev vmbr0 scope global' => '',
|
||
]);
|
||
|
||
$out = runBridgeSh(
|
||
'if bridge_is_up; then echo JA; else echo NEIN; fi',
|
||
['CLUPILOT_IP' => $ip]
|
||
);
|
||
|
||
expect($out)->toBe('NEIN');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: FAIL — `deploy/bootstrap/lib/bridge.sh` existiert nicht, `sh` bricht mit „No such file or directory" ab.
|
||
|
||
- [ ] **Step 3: `bridge.sh` anlegen (Erkennungsteil)**
|
||
|
||
`deploy/bootstrap/lib/bridge.sh`:
|
||
|
||
```sh
|
||
# shellcheck shell=sh
|
||
#
|
||
# Die Brücke über die primäre Netzkarte — Erkennen, Sichern, Zeitgeber, Bauen,
|
||
# Nachsehen.
|
||
#
|
||
# ---------------------------------------------------------------------------
|
||
# Warum das eine eigene Datei ist
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Diese Zeilen standen in `network.sh`, geschrieben für den stillgelegten
|
||
# Rettungssystem-Weg, und borgten sich vier Dinge von woanders:
|
||
# `detect_primary_interface` aus `proxmox.sh`, `log`, `http_get` und
|
||
# `CLUPILOT_PROBE_URL` aus `clupilot-bootstrap.sh`. Der Debian-Weg lädt sie
|
||
# einzeln auf den Host und fährt sie dort — geborgte Helfer wären dann nicht da.
|
||
#
|
||
# Also allein lauffähig. `network.sh` behält seine WireGuard- und
|
||
# nftables-Hälfte; diese Datei wird von beiden Wegen benutzt und existiert genau
|
||
# einmal. Eine zweite Fassung wären zwei Installationen, die bei jeder
|
||
# Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst auf,
|
||
# wenn jemand sie benutzt.
|
||
#
|
||
# ---------------------------------------------------------------------------
|
||
# Warum aus dem LAUFENDEN Zustand abgeleitet wird
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Nicht aus `/etc/network/interfaces`. Was läuft, ist bei jedem Anbieter gleich
|
||
# strukturiert; wie es aufgeschrieben wurde, nicht. Das ist der Teil, der
|
||
# Hetzner und netcup zugleich trägt.
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stellschrauben — überschreibbar, damit die Bibliothek ohne Host prüfbar ist
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CLUPILOT_BRIDGE="${CLUPILOT_BRIDGE:-vmbr0}"
|
||
CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}"
|
||
CLUPILOT_INTERFACES_FILE="${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}"
|
||
CLUPILOT_INTERFACES_D="${CLUPILOT_INTERFACES_D:-/etc/network/interfaces.d}"
|
||
CLUPILOT_SYS_NET="${CLUPILOT_SYS_NET:-/sys/class/net}"
|
||
CLUPILOT_IP="${CLUPILOT_IP:-ip}"
|
||
CLUPILOT_IFRELOAD="${CLUPILOT_IFRELOAD:-ifreload}"
|
||
CLUPILOT_PROBE_URL="${CLUPILOT_PROBE_URL:-http://deb.debian.org/}"
|
||
CLUPILOT_ROLLBACK_UNIT="${CLUPILOT_ROLLBACK_UNIT:-clupilot-network-rollback}"
|
||
CLUPILOT_NET_BACKUP="${CLUPILOT_NET_BACKUP:-/var/lib/clupilot/interfaces.vor-der-bruecke}"
|
||
CLUPILOT_WG_HUB_PUBKEY="${CLUPILOT_WG_HUB_PUBKEY:-}"
|
||
CLUPILOT_WG_HANDSHAKE_MAX_AGE="${CLUPILOT_WG_HANDSHAKE_MAX_AGE:-180}"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Die zwei Helfer, die diese Datei früher geborgt hat
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Unter Definitionsschutz: `clupilot-bootstrap.sh` bringt eigene mit, und wer
|
||
# zuerst da ist, behält recht. Sonst überschriebe das Laden dieser Bibliothek
|
||
# die Protokollierung des Rettungssystem-Wegs.
|
||
|
||
if ! command -v log >/dev/null 2>&1; then
|
||
log() {
|
||
printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1"
|
||
}
|
||
fi
|
||
|
||
if ! command -v http_get >/dev/null 2>&1; then
|
||
http_get() {
|
||
if command -v curl >/dev/null 2>&1; then
|
||
curl -fsSL --retry 3 --retry-delay 2 --max-time 20 "$1"
|
||
else
|
||
wget -qO- --timeout=20 "$1"
|
||
fi
|
||
}
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feststellen, was da ist
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die
|
||
# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und
|
||
# eine nicht.
|
||
detect_primary_interface() {
|
||
"$CLUPILOT_IP" -4 route show default 2>/dev/null \
|
||
| awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }'
|
||
}
|
||
|
||
# Ist das eine physische Karte?
|
||
#
|
||
# `bridge_ports` auf einem Bond oder einer bestehenden Bridge ist falsch — die
|
||
# Brücke nähme sich ihren eigenen Unterbau als Port, und was dabei herauskommt,
|
||
# ist von hier aus nicht mehr zu reparieren. VLAN-Geräte und Bonds haben keinen
|
||
# `device`-Verweis; die beiden weiteren Prüfungen sind der Gürtel dazu.
|
||
interface_is_physical() {
|
||
_if="${1:-}"
|
||
|
||
[ -n "$_if" ] || return 1
|
||
[ -e "${CLUPILOT_SYS_NET}/${_if}/device" ] || return 1
|
||
[ ! -d "${CLUPILOT_SYS_NET}/${_if}/bridge" ] || return 1
|
||
[ ! -d "${CLUPILOT_SYS_NET}/${_if}/bonding" ] || return 1
|
||
|
||
return 0
|
||
}
|
||
|
||
# Kam die Adresse per DHCP?
|
||
#
|
||
# Aus dem laufenden Zustand: der Kernel markiert eine geleaste Adresse mit
|
||
# `dynamic`. Das steht bei jedem Anbieter gleich da — anders als die Datei, in
|
||
# die er es geschrieben hat.
|
||
address_is_dynamic() {
|
||
"$CLUPILOT_IP" -4 -o addr show dev "$1" scope global 2>/dev/null \
|
||
| grep -q '[[:space:]]dynamic[[:space:]]'
|
||
}
|
||
|
||
# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall
|
||
# richtig ist, nimmt den anderen vom Netz.
|
||
#
|
||
# - `dhcp` — die Adresse kommt per DHCP (Cloud-Produkte).
|
||
# - `routed` — geroutete Einzeladresse, Gateway AUSSERHALB des eigenen Subnetzes
|
||
# (Hetzner-dediziert mit /32). Braucht eine pointopoint-Route,
|
||
# sonst findet der Kernel das Gateway nicht.
|
||
# - `subnet` — gewöhnliches Subnetz, Gateway darin.
|
||
detect_network_style() {
|
||
_iface="${1:-}"
|
||
[ -n "$_iface" ] || _iface="$(detect_primary_interface)"
|
||
|
||
# Laufender Zustand zuerst. Die Datei des Anbieters ist nur das Zweitsignal
|
||
# — sie sagt, was jemand aufgeschrieben hat, nicht was gilt.
|
||
if address_is_dynamic "$_iface"; then
|
||
printf 'dhcp'
|
||
return 0
|
||
fi
|
||
|
||
if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" \
|
||
"$CLUPILOT_INTERFACES_FILE" "${CLUPILOT_INTERFACES_D}"/* 2>/dev/null; then
|
||
printf 'dhcp'
|
||
return 0
|
||
fi
|
||
|
||
_cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
|
||
_gw="$("$CLUPILOT_IP" -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
|
||
_prefix="${_cidr##*/}"
|
||
|
||
# /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway
|
||
# darin kann es nicht geben.
|
||
if [ "$_prefix" = '32' ]; then
|
||
printf 'routed'
|
||
return 0
|
||
fi
|
||
|
||
# Liegt das Gateway im eigenen Subnetz? `ip route get` beantwortet das, ohne
|
||
# dass dieses Skript Netzmasken rechnen muss — und rechnet dabei mit
|
||
# derselben Logik, die der Kernel später anwendet.
|
||
if [ -n "$_gw" ] && "$CLUPILOT_IP" -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then
|
||
printf 'subnet'
|
||
return 0
|
||
fi
|
||
|
||
printf 'routed'
|
||
}
|
||
|
||
bridge_exists() {
|
||
"$CLUPILOT_IP" link show "${1:-$CLUPILOT_BRIDGE}" >/dev/null 2>&1
|
||
}
|
||
|
||
# Trägt die Brücke wirklich den Verkehr, oder existiert sie nur?
|
||
#
|
||
# Eine `vmbr0` ohne Adresse und ohne Vorgaberoute ist eine Brücke im Sinne von
|
||
# `ip link show` und sonst nichts. Der alte Schritt prüfte genau das und warf das
|
||
# Ergebnis weg; sein Kommentar behauptete, er halte die Abwesenheit fest, und er
|
||
# hielt nichts fest.
|
||
bridge_carries_default_route() {
|
||
_want="${1:-$CLUPILOT_BRIDGE}"
|
||
_dev="$("$CLUPILOT_IP" -4 route show default 2>/dev/null \
|
||
| awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')"
|
||
|
||
[ "$_dev" = "$_want" ]
|
||
}
|
||
|
||
bridge_has_address() {
|
||
[ -n "$("$CLUPILOT_IP" -4 -o addr show dev "${1:-$CLUPILOT_BRIDGE}" scope global 2>/dev/null | awk '{ print $4; exit }')" ]
|
||
}
|
||
|
||
# Die drei zusammen. „Da" reicht nicht — sie muss tragen.
|
||
bridge_is_up() {
|
||
bridge_exists "$CLUPILOT_BRIDGE" \
|
||
&& bridge_carries_default_route "$CLUPILOT_BRIDGE" \
|
||
&& bridge_has_address "$CLUPILOT_BRIDGE"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: PASS, sechs Tests.
|
||
|
||
- [ ] **Step 5: Die alte Fassung entfernen**
|
||
|
||
In `deploy/bootstrap/lib/proxmox.sh` den Block `detect_primary_interface() { … }` (samt Kommentar, Zeilen 130–135) **löschen** und dort einen Verweis hinterlassen:
|
||
|
||
```sh
|
||
# `detect_primary_interface` steht in lib/bridge.sh — eine Fassung, zwei
|
||
# Benutzer. clupilot-bootstrap.sh lädt bridge.sh vor dieser Datei; die Funktion
|
||
# wird erst zur Aufrufzeit aufgelöst, also ist die Reihenfolge unkritisch.
|
||
```
|
||
|
||
In `deploy/bootstrap/lib/network.sh` die Zeilen 40–103 (Variablen `CLUPILOT_ROLLBACK_UNIT`/`CLUPILOT_NET_BACKUP`, `bridge_exists`, `bridge_carries_default_route`, `bridge_has_address`, `detect_network_style`) **löschen** und ersetzen durch:
|
||
|
||
```sh
|
||
# Die Brücke steht jetzt in lib/bridge.sh — Erkennen, Sichern, Zeitgeber, Bauen,
|
||
# Nachsehen, alles beisammen und allein lauffähig, weil der Debian-Weg sie
|
||
# einzeln auf den Host lädt. clupilot-bootstrap.sh sourced sie vor dieser Datei.
|
||
# Hier bleibt, was Kunden-VMs, WireGuard und nftables angeht.
|
||
```
|
||
|
||
In `deploy/bootstrap/clupilot-bootstrap.sh` vor Zeile 968 einfügen:
|
||
|
||
```sh
|
||
# shellcheck source=lib/bridge.sh
|
||
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/bridge.sh"
|
||
```
|
||
|
||
- [ ] **Step 6: Test „eine Fassung" schreiben und laufen lassen**
|
||
|
||
Ans Ende von `tests/Feature/Provisioning/BridgeScriptTest.php`:
|
||
|
||
```php
|
||
it('hält detect_primary_interface an genau einer Stelle im Repo', function () {
|
||
// Zwei Fassungen wären zwei Installationen, die bei jeder Proxmox-Version
|
||
// nachgezogen werden müssten — und die zweite fiele erst auf, wenn jemand
|
||
// sie benutzt. Genau die Entscheidung, die den Rettungssystem-Weg
|
||
// stillgelegt hat.
|
||
// array_merge, NICHT `+`: bei numerischen Schlüsseln behält der
|
||
// Vereinigungsoperator die linke Seite und wirft die rechte still weg. Und
|
||
// PHPs glob() kennt kein rekursives `**`, also zwei Muster.
|
||
$files = array_merge(
|
||
glob(base_path('deploy/bootstrap/lib/*.sh')) ?: [],
|
||
glob(base_path('deploy/bootstrap/*.sh')) ?: [],
|
||
);
|
||
|
||
$hits = [];
|
||
foreach ($files as $file) {
|
||
if (preg_match('/^detect_primary_interface\(\)/m', (string) file_get_contents($file))) {
|
||
$hits[] = str_replace(base_path().'/', '', $file);
|
||
}
|
||
}
|
||
|
||
expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh']);
|
||
});
|
||
|
||
it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () {
|
||
// network.sh und proxmox.sh werden in dieser Task beschnitten. Ein
|
||
// verrutschter Schnitt fiele sonst erst auf dem Rettungssystem-Weg auf, und
|
||
// der wird selten gefahren.
|
||
foreach (glob(base_path('deploy/bootstrap/lib/*.sh')) as $file) {
|
||
expect(Process::run(['sh', '-n', $file])->successful())
|
||
->toBeTrue('kein gültiges POSIX-sh: '.$file);
|
||
}
|
||
});
|
||
```
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: PASS, acht Tests.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add deploy/bootstrap/lib/bridge.sh deploy/bootstrap/lib/network.sh deploy/bootstrap/lib/proxmox.sh deploy/bootstrap/clupilot-bootstrap.sh tests/Feature/Provisioning/BridgeScriptTest.php
|
||
git commit -m "bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `bridge.sh` — die Strophe
|
||
|
||
Der wertvollste Teil des Vorhabens: was hier falsch herauskommt, nimmt die Maschine vom Netz. Deshalb wird der Generator gegen eine echte Shell gefahren und gegen vier Anbieterfälle geprüft.
|
||
|
||
**Files:**
|
||
- Modify: `deploy/bootstrap/lib/bridge.sh` (anhängen)
|
||
- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `detect_network_style`, `CLUPILOT_IP`, `CLUPILOT_SYS_NET`, `CLUPILOT_INTERFACES_FILE`, `CLUPILOT_BRIDGE`, `log` aus Task 1.
|
||
- Produces:
|
||
- `extra_routes(iface, gw) -> stdout: 0..n Zeilen " up ip route add … || true"`
|
||
- `static_ipv6_on(iface) -> stdout: CIDR oder leer`
|
||
- `ipv6_gateway() -> stdout: Adresse oder leer`
|
||
- `write_bridge_stanza(iface, style, cidr, gw)` — schreibt nach `$CLUPILOT_INTERFACES_FILE`, verändert sonst nichts, kein `ifreload`
|
||
- `build_bridge(iface, style, cidr, gw)` — ruft `write_bridge_stanza` und danach `"$CLUPILOT_IFRELOAD" -a`
|
||
|
||
Getrennt, weil der Test die Strophe prüfen will, ohne ein Netz neu zu laden.
|
||
|
||
- [ ] **Step 1: Die vier Anbieterfälle als Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`:
|
||
|
||
```php
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| Die Strophe
|
||
|--------------------------------------------------------------------------
|
||
|
|
||
| Vier Fälle, und sie unterscheiden sich in genau der Zeile, die entscheidet,
|
||
| ob die Maschine danach noch da ist.
|
||
*/
|
||
|
||
it('schreibt für eine geroutete /32 eine pointopoint-Strophe', function () {
|
||
// Hetzner dediziert. Ohne `pointopoint` findet der Kernel keinen Weg zum
|
||
// Gateway: die Route zeigte auf ein Netz, in dem das Gateway nicht liegt,
|
||
// und die Maschine wäre still weg.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev enp0s31f6' => "49.12.121.65 scope link\ndefault via 49.12.121.65",
|
||
'-6 -o addr show dev enp0s31f6 scope global' => '',
|
||
'-6 route show default' => '',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
|
||
$out = $this->dir.'/interfaces';
|
||
|
||
runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $out,
|
||
]);
|
||
|
||
$stanza = file_get_contents($out);
|
||
|
||
expect($stanza)
|
||
->toContain('address 49.12.121.79/32')
|
||
->toContain('pointopoint 49.12.121.65')
|
||
->toContain('gateway 49.12.121.65')
|
||
->toContain('bridge-ports enp0s31f6')
|
||
->toContain('iface enp0s31f6 inet manual')
|
||
// Festgenagelt, damit die Brücke nicht irgendeine MAC wählt: manche
|
||
// Anbieter binden den Switchport an die der Karte.
|
||
->toContain('hwaddress ether a8:a1:59:00:11:22')
|
||
->toContain('auto vmbr0');
|
||
});
|
||
|
||
it('schreibt für ein gewöhnliches Subnetz Adresse und Gateway ohne pointopoint', function () {
|
||
// netcup und die meisten anderen. `pointopoint` wäre hier falsch.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev ens3' => '192.168.10.0/24 proto kernel scope link src 192.168.10.7',
|
||
'-6 -o addr show dev ens3 scope global' => '',
|
||
'-6 route show default' => '',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:01');
|
||
$out = $this->dir.'/interfaces';
|
||
|
||
runBridgeSh('write_bridge_stanza ens3 subnet 192.168.10.7/24 192.168.10.1', [
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $out,
|
||
]);
|
||
|
||
$stanza = file_get_contents($out);
|
||
|
||
expect($stanza)
|
||
->toContain('address 192.168.10.7/24')
|
||
->toContain('gateway 192.168.10.1')
|
||
->not->toContain('pointopoint')
|
||
// Die Route zum eigenen Subnetz gehört NICHT in die Zusatzrouten: die
|
||
// legt der Kernel selbst an, sobald die Adresse steht. Ein zweites Mal
|
||
// hinzufügen scheitert und nimmt bei `ifreload` die Strophe mit.
|
||
->not->toContain('up ip route add 192.168.10.0/24');
|
||
});
|
||
|
||
it('schreibt für DHCP keine Adresse in die Strophe', function () {
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev ens3' => '',
|
||
'-6 -o addr show dev ens3 scope global' => '',
|
||
'-6 route show default' => '',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:02');
|
||
$out = $this->dir.'/interfaces';
|
||
|
||
runBridgeSh('write_bridge_stanza ens3 dhcp 10.0.0.7/24 10.0.0.1', [
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $out,
|
||
]);
|
||
|
||
$stanza = file_get_contents($out);
|
||
|
||
expect($stanza)
|
||
->toContain('iface vmbr0 inet dhcp')
|
||
->not->toContain('address 10.0.0.7')
|
||
->toContain('bridge-ports ens3');
|
||
});
|
||
|
||
it('nimmt eine statische globale IPv6 samt link-local Gateway mit', function () {
|
||
// Hetzners Form: 2a01:…::2/64 mit fe80::1. Statisch, also derselbe Dreisatz
|
||
// wie bei v4 — und damit der Fall, der laut Entwurf nichts kostet.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev enp0s31f6' => '',
|
||
'-6 -o addr show dev enp0s31f6 scope global' => '2: enp0s31f6 inet6 2a01:4f8:1c1c::2/64 scope global \ valid_lft forever preferred_lft forever',
|
||
'-6 route show default' => 'default via fe80::1 dev enp0s31f6 metric 1024',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
|
||
$out = $this->dir.'/interfaces';
|
||
|
||
runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $out,
|
||
]);
|
||
|
||
$stanza = file_get_contents($out);
|
||
|
||
expect($stanza)
|
||
->toContain('iface vmbr0 inet6 static')
|
||
->toContain('address 2a01:4f8:1c1c::2/64')
|
||
->toContain('gateway fe80::1');
|
||
});
|
||
|
||
it('lässt SLAAC-IPv6 liegen und sagt es ins Protokoll', function () {
|
||
// Proxmox setzt forwarding=1, und der Kernel verwirft
|
||
// Router-Advertisements dann ohne accept_ra=2. Das sauber hinzubekommen
|
||
// wäre ein zweiter Satz Fehlerfälle in genau dem Fenster, das diese
|
||
// Konstruktion zu überleben versucht — und v6 trägt hier keinen Verkehr.
|
||
// Still verlieren gilt trotzdem nicht.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev ens3' => '',
|
||
'-6 -o addr show dev ens3 scope global' => '2: ens3 inet6 2a01:4f8:aaaa::17/64 scope global dynamic \ valid_lft 86000sec preferred_lft 14000sec',
|
||
'-6 route show default' => 'default via fe80::1 dev ens3',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:03');
|
||
$out = $this->dir.'/interfaces';
|
||
|
||
$log = runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $out,
|
||
]);
|
||
|
||
expect(file_get_contents($out))->not->toContain('inet6 static')
|
||
->and($log)->toContain('IPv6');
|
||
});
|
||
|
||
it('nimmt die Zusatzrouten des Anbieters mit auf die Brücke', function () {
|
||
// Was der Anbieter extra eingetragen hat, verliert der Host sonst — und es
|
||
// fällt erst auf, wenn jemand das Ziel dahinter braucht.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev enp0s31f6' => "49.12.121.65 scope link\n10.128.0.0/16 via 49.12.121.65\n49.12.121.64/26 proto kernel scope link src 49.12.121.79",
|
||
'-6 -o addr show dev enp0s31f6 scope global' => '',
|
||
'-6 route show default' => '',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
|
||
$out = $this->dir.'/interfaces';
|
||
|
||
runBridgeSh('write_bridge_stanza enp0s31f6 subnet 49.12.121.79/26 49.12.121.65', [
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $out,
|
||
]);
|
||
|
||
$stanza = file_get_contents($out);
|
||
|
||
expect($stanza)
|
||
->toContain('up ip route add 10.128.0.0/16 via 49.12.121.65 dev vmbr0')
|
||
// Weder die Link-Route zum Gateway noch die Kernel-Route zum eigenen
|
||
// Subnetz: beide entstehen von selbst, und ein zweiter Versuch
|
||
// scheitert und nimmt die Strophe mit.
|
||
->not->toContain('up ip route add 49.12.121.65 ')
|
||
->not->toContain('up ip route add 49.12.121.64/26');
|
||
});
|
||
|
||
it('schreibt eine Strophe, die ein Mensch als von CluPilot erkennt', function () {
|
||
// Wer in sechs Monaten in diese Datei sieht, muss wissen, wer sie
|
||
// geschrieben hat und woraus.
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev ens3' => '',
|
||
'-6 -o addr show dev ens3 scope global' => '',
|
||
'-6 route show default' => '',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:04');
|
||
$out = $this->dir.'/interfaces';
|
||
|
||
runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $out,
|
||
]);
|
||
|
||
expect(file_get_contents($out))
|
||
->toContain('Von CluPilot geschrieben')
|
||
->toContain('EnsureNetworkBridge');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: FAIL — `write_bridge_stanza: not found`.
|
||
|
||
- [ ] **Step 3: Den Generator anhängen**
|
||
|
||
An `deploy/bootstrap/lib/bridge.sh`:
|
||
|
||
```sh
|
||
# ---------------------------------------------------------------------------
|
||
# Die Strophe
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Zusatzrouten des Anbieters — alles auf der Karte, was der Kernel nicht von
|
||
# selbst wieder anlegt.
|
||
#
|
||
# Ausgelassen werden zwei Sorten, und beide mit Grund: die Kernel-Route zum
|
||
# eigenen Subnetz (`proto kernel`) entsteht, sobald die Adresse steht, und die
|
||
# Link-Route zum Gateway legt `pointopoint` an. Sie trotzdem einzutragen ließe
|
||
# `ip route add` scheitern — und ein gescheitertes `up` nimmt bei `ifreload`
|
||
# die ganze Strophe mit.
|
||
extra_routes() {
|
||
_iface="$1"
|
||
_gw="$2"
|
||
|
||
"$CLUPILOT_IP" -4 route show dev "$_iface" 2>/dev/null | while IFS= read -r _route; do
|
||
[ -n "$_route" ] || continue
|
||
|
||
case "$_route" in
|
||
default*) continue ;;
|
||
"${_gw} "*|"${_gw}") continue ;;
|
||
*'proto kernel'*) continue ;;
|
||
esac
|
||
|
||
printf ' up ip route add %s dev %s || true\n' "$_route" "$CLUPILOT_BRIDGE"
|
||
done
|
||
}
|
||
|
||
# Eine statische, globale IPv6-Adresse — oder nichts.
|
||
#
|
||
# `dynamic` (SLAAC/DHCPv6) und `temporary` (Privacy Extensions) fliegen raus:
|
||
# was der Kernel selbst vergibt, gehört nicht in eine Datei geschrieben.
|
||
static_ipv6_on() {
|
||
"$CLUPILOT_IP" -6 -o addr show dev "$1" scope global 2>/dev/null \
|
||
| grep -v '[[:space:]]dynamic[[:space:]]' \
|
||
| grep -v '[[:space:]]temporary[[:space:]]' \
|
||
| awk '{ print $4; exit }'
|
||
}
|
||
|
||
ipv6_gateway() {
|
||
"$CLUPILOT_IP" -6 route show default 2>/dev/null \
|
||
| awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }'
|
||
}
|
||
|
||
# Schreibt die Strophe — und sonst nichts. Kein `ifreload`, kein Sichern, kein
|
||
# Zeitgeber. Getrennt von build_bridge, damit genau das prüfbar ist, was eine
|
||
# Maschine umbringt, ohne dafür ein Netz neu laden zu müssen.
|
||
write_bridge_stanza() {
|
||
_iface="$1"
|
||
_style="$2"
|
||
_cidr="$3"
|
||
_gw="$4"
|
||
|
||
_ip4="${_cidr%%/*}"
|
||
_mac="$(cat "${CLUPILOT_SYS_NET}/${_iface}/address" 2>/dev/null)"
|
||
|
||
case "$_style" in
|
||
dhcp)
|
||
_inet="iface ${CLUPILOT_BRIDGE} inet dhcp"
|
||
_addr=''
|
||
;;
|
||
routed)
|
||
# Gateway außerhalb des eigenen Subnetzes. Ohne `pointopoint` findet
|
||
# der Kernel keinen Weg dorthin: die Route zeigte auf ein Netz, in
|
||
# dem das Gateway nicht liegt, und die Maschine wäre still weg.
|
||
_inet="iface ${CLUPILOT_BRIDGE} inet static"
|
||
_addr=" address ${_ip4}/32
|
||
pointopoint ${_gw}
|
||
gateway ${_gw}"
|
||
;;
|
||
*)
|
||
_inet="iface ${CLUPILOT_BRIDGE} inet static"
|
||
_addr=" address ${_cidr}
|
||
gateway ${_gw}"
|
||
;;
|
||
esac
|
||
|
||
# Festgenagelt: eine Brücke wählt sonst die kleinste MAC ihrer Ports, und
|
||
# manche Anbieter binden den Switchport an die der Karte. Bei einem Port ist
|
||
# das dieselbe — aber „ist dieselbe" und „bleibt dieselbe" sind zweierlei.
|
||
_hw=''
|
||
[ -n "$_mac" ] && _hw=" hwaddress ether ${_mac}"
|
||
|
||
_inet6=''
|
||
_v6="$(static_ipv6_on "$_iface")"
|
||
_v6gw="$(ipv6_gateway)"
|
||
|
||
if [ -n "$_v6" ] && [ -n "$_v6gw" ]; then
|
||
_inet6="
|
||
iface ${CLUPILOT_BRIDGE} inet6 static
|
||
address ${_v6}
|
||
gateway ${_v6gw}"
|
||
elif [ -n "$_v6" ] || [ -n "$_v6gw" ]; then
|
||
log 'IPv6 wird NICHT mit auf die Brücke genommen: keine statische globale Adresse mit Standardgateway (SLAAC/DHCPv6 werden bewusst nicht nachgebaut — Proxmox setzt forwarding=1, und der Kernel verwirft RAs dann ohne accept_ra=2).'
|
||
fi
|
||
|
||
cat > "$CLUPILOT_INTERFACES_FILE" <<EOF
|
||
# Von CluPilot geschrieben (Schritt EnsureNetworkBridge), weil auf dieser
|
||
# Maschine keine ${CLUPILOT_BRIDGE} lag: Proxmox auf Debian legt keine an, nur
|
||
# der ISO-Installer tut das.
|
||
#
|
||
# Abgeleitet aus dem LAUFENDEN Zustand, nicht aus der Datei des Anbieters.
|
||
# Anbieterform: ${_style}. Karte: ${_iface}.
|
||
#
|
||
# Die vorherige Fassung liegt unter ${CLUPILOT_NET_BACKUP}.tar.gz.
|
||
source ${CLUPILOT_INTERFACES_D}/*
|
||
|
||
auto lo
|
||
iface lo inet loopback
|
||
|
||
iface ${_iface} inet manual
|
||
|
||
auto ${CLUPILOT_BRIDGE}
|
||
${_inet}
|
||
${_addr}
|
||
${_hw}
|
||
bridge-ports ${_iface}
|
||
bridge-stp off
|
||
bridge-fd 0
|
||
$(extra_routes "$_iface" "$_gw")${_inet6}
|
||
EOF
|
||
}
|
||
|
||
# Strophe schreiben und anwenden. Ab hier ist die Leitung in Gefahr.
|
||
build_bridge() {
|
||
write_bridge_stanza "$@"
|
||
|
||
log 'Strophe geschrieben:'
|
||
cat "$CLUPILOT_INTERFACES_FILE"
|
||
|
||
if command -v "$CLUPILOT_IFRELOAD" >/dev/null 2>&1; then
|
||
"$CLUPILOT_IFRELOAD" -a
|
||
else
|
||
systemctl restart networking
|
||
fi
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: PASS, fünfzehn Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php
|
||
git commit -m "bridge.sh: die Strophe, gegen vier Anbieterfaelle geprueft"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `bridge.sh` — die Rückfahrkarte
|
||
|
||
Sichern, Zeitgeber, Abbestellen. Der Zeitgeber ist der einzige Grund, warum dieser Schritt die Netzkonfiguration überhaupt anfassen darf.
|
||
|
||
**Files:**
|
||
- Modify: `deploy/bootstrap/lib/bridge.sh` (anhängen)
|
||
- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `log`, `CLUPILOT_NET_BACKUP`, `CLUPILOT_ROLLBACK_UNIT`, `CLUPILOT_WORK_DIR`, `CLUPILOT_INTERFACES_FILE`, `CLUPILOT_INTERFACES_D` aus Task 1.
|
||
- Produces:
|
||
- `backup_network_config()` — `tar czf "${CLUPILOT_NET_BACKUP}.tar.gz"`
|
||
- `render_rollback_script() -> stdout` (der Inhalt, nicht die Datei — damit prüfbar)
|
||
- `schedule_network_rollback(minuten)` — schreibt Skript + `.service` + `.timer`, `daemon-reload`, `start`
|
||
- `cancel_network_rollback()`
|
||
- `CLUPILOT_SYSTEMCTL` (Vorgabe `systemctl`), `CLUPILOT_UNIT_DIR` (Vorgabe `/etc/systemd/system`), `CLUPILOT_SBIN_DIR` (Vorgabe `/usr/local/sbin`) — überschreibbar für den Test
|
||
|
||
- [ ] **Step 1: Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`:
|
||
|
||
```php
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| Die Rückfahrkarte
|
||
|--------------------------------------------------------------------------
|
||
*/
|
||
|
||
it('schreibt ein Rücknahme-Skript, das den Grund festhält BEVOR es zurückspielt', function () {
|
||
// Ohne diese zwei Zeilen liest der Schritt nach dem Wiederverbinden
|
||
// `running` und pollt gegen einen Lauf, den es nicht mehr gibt — der
|
||
// Zeitgeber beendet den Treiber ja gerade nicht. Und zuerst schreiben, nicht
|
||
// zuletzt: ein halb geglücktes Zurückspielen soll das Urteil trotzdem
|
||
// hinterlassen.
|
||
$script = runBridgeSh('render_rollback_script 5', [
|
||
'CLUPILOT_WORK_DIR' => '/var/lib/clupilot/bridge',
|
||
]);
|
||
|
||
$stateAt = strpos($script, "printf 'failed'");
|
||
$restoreAt = strpos($script, 'tar xzf');
|
||
|
||
expect($stateAt)->not->toBeFalse()
|
||
->and($restoreAt)->not->toBeFalse()
|
||
->and($stateAt)->toBeLessThan($restoreAt)
|
||
->and($script)->toContain('/var/lib/clupilot/bridge/note')
|
||
// Das Signal, auf das CluPilot wartet: erst wenn das da ist, ist der
|
||
// alte Zustand wirklich zurück.
|
||
->and($script)->toContain('rolled-back')
|
||
->and(strpos($script, 'rolled-back'))->toBeGreaterThan($restoreAt);
|
||
});
|
||
|
||
it('räumt der Zeitgeber sich nach dem Feuern selbst weg', function () {
|
||
// Sonst liegen Unit-Dateien herum, die aussehen, als stünde noch eine
|
||
// Rücknahme aus.
|
||
expect(runBridgeSh('render_rollback_script 5'))
|
||
->toContain('clupilot-network-rollback.timer');
|
||
});
|
||
|
||
it('stellt den Zeitgeber, bevor irgendetwas verändert wird', function () {
|
||
// Die tragende Zusicherung des ganzen Entwurfs. Geprüft an der Reihenfolge
|
||
// der Aufrufe, die schedule_network_rollback und build_bridge absetzen.
|
||
$bin = $this->dir.'/bin';
|
||
mkdir($bin, 0o755, true);
|
||
file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n");
|
||
chmod("{$bin}/systemctl", 0o755);
|
||
|
||
$ip = fakeIp($this->dir, [
|
||
'-4 route show dev ens3' => '',
|
||
'-6 -o addr show dev ens3 scope global' => '',
|
||
'-6 route show default' => '',
|
||
]);
|
||
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:05');
|
||
file_put_contents("{$bin}/ifreload", "#!/bin/sh\necho 'ifreload' >> '{$this->dir}/calls'\n");
|
||
chmod("{$bin}/ifreload", 0o755);
|
||
mkdir($this->dir.'/units', 0o755, true);
|
||
mkdir($this->dir.'/sbin', 0o755, true);
|
||
file_put_contents($this->dir.'/interfaces', "# alt\n");
|
||
|
||
runBridgeSh(
|
||
"backup_network_config\nschedule_network_rollback 5\nbuild_bridge ens3 subnet 10.0.0.7/24 10.0.0.1",
|
||
[
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_SYS_NET' => $sys,
|
||
'CLUPILOT_INTERFACES_FILE' => $this->dir.'/interfaces',
|
||
'CLUPILOT_INTERFACES_D' => $this->dir.'/interfaces.d',
|
||
'CLUPILOT_NET_BACKUP' => $this->dir.'/sicherung',
|
||
'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl",
|
||
'CLUPILOT_IFRELOAD' => "{$bin}/ifreload",
|
||
'CLUPILOT_UNIT_DIR' => $this->dir.'/units',
|
||
'CLUPILOT_SBIN_DIR' => $this->dir.'/sbin',
|
||
'CLUPILOT_WORK_DIR' => $this->dir,
|
||
]
|
||
);
|
||
|
||
$calls = file_get_contents($this->dir.'/calls');
|
||
|
||
expect(strpos($calls, 'start clupilot-network-rollback.timer'))->not->toBeFalse()
|
||
->and(strpos($calls, 'ifreload'))->not->toBeFalse()
|
||
// Umgekehrt wäre die Reihenfolge, die einen Host dauerhaft unerreichbar
|
||
// macht.
|
||
->and(strpos($calls, 'start clupilot-network-rollback.timer'))
|
||
->toBeLessThan(strpos($calls, 'ifreload'))
|
||
// Und die Sicherung muss existieren, bevor der Zeitgeber sie
|
||
// zurückspielen könnte.
|
||
->and(file_exists($this->dir.'/sicherung.tar.gz'))->toBeTrue();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: FAIL — `render_rollback_script: not found`.
|
||
|
||
- [ ] **Step 3: Die Rückfahrkarte anhängen**
|
||
|
||
Zuerst die drei neuen Stellschrauben oben in `bridge.sh` zu den anderen:
|
||
|
||
```sh
|
||
CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}"
|
||
CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}"
|
||
CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}"
|
||
```
|
||
|
||
Dann anhängen:
|
||
|
||
```sh
|
||
# ---------------------------------------------------------------------------
|
||
# Die Selbstrücknahme
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen.
|
||
#
|
||
# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf
|
||
# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die
|
||
# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die
|
||
# Netzkonfiguration überhaupt anfassen darf.
|
||
#
|
||
# Abbestellt wird er NICHT hier. Das tut CluPilot, nachdem es sich neu verbunden
|
||
# und nachgesehen hat — der Treiber kann über seine eigene Erreichbarkeit nur
|
||
# raten.
|
||
|
||
backup_network_config() {
|
||
mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")"
|
||
tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \
|
||
-C / "${CLUPILOT_INTERFACES_FILE#/}" \
|
||
$( [ -d "$CLUPILOT_INTERFACES_D" ] && printf '%s' "${CLUPILOT_INTERFACES_D#/}" ) \
|
||
2>/dev/null
|
||
}
|
||
|
||
# Der Inhalt des Rücknahme-Skripts — als Text, nicht als Datei.
|
||
#
|
||
# Getrennt, damit die Reihenfolge darin prüfbar ist, ohne systemd zu brauchen.
|
||
# Und die Reihenfolge ist der Punkt: das Urteil wird geschrieben, BEVOR
|
||
# zurückgespielt wird, damit auch ein halb geglücktes Zurückspielen einen Grund
|
||
# hinterlässt. `rolled-back` kommt zuletzt — das ist das Signal, auf das CluPilot
|
||
# wartet, und es darf erst stehen, wenn der alte Zustand wirklich zurück ist.
|
||
render_rollback_script() {
|
||
_minutes="${1:-5}"
|
||
|
||
cat <<EOF
|
||
#!/bin/sh
|
||
# Von CluPilot gestellt (Schritt EnsureNetworkBridge). Spielt die
|
||
# Netzkonfiguration von VOR der Brücke zurück, weil sie binnen ${_minutes}
|
||
# Minuten nicht abbestellt wurde — und abbestellen kann CluPilot nur, wenn es
|
||
# den Host über den Tunnel wieder erreicht.
|
||
printf 'failed' > '${CLUPILOT_WORK_DIR}/state'
|
||
printf 'Die Bruecke nahm den Host vom Netz; die Netzkonfiguration von vorher wurde zurueckgespielt.' > '${CLUPILOT_WORK_DIR}/note'
|
||
|
||
tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C /
|
||
if command -v ifreload >/dev/null 2>&1; then
|
||
ifreload -a
|
||
else
|
||
systemctl restart networking
|
||
fi
|
||
|
||
logger -t clupilot 'Netzkonfiguration zurueckgespielt: die Bruecke hat den Host vom Netz genommen.'
|
||
|
||
# Erst jetzt. Vorher hiesse es: zurueckgespielt, obwohl es noch laeuft.
|
||
: > '${CLUPILOT_WORK_DIR}/rolled-back'
|
||
|
||
# Selbst wegraeumen, sonst liegen Unit-Dateien herum, die aussehen, als stuende
|
||
# noch eine Ruecknahme aus.
|
||
rm -f '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer' \\
|
||
'${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service'
|
||
systemctl daemon-reload 2>/dev/null || true
|
||
EOF
|
||
}
|
||
|
||
# Stellt den Zeitgeber. Ab hier gibt es einen Weg zurück, und erst ab hier darf
|
||
# irgendetwas am Netz verändert werden.
|
||
schedule_network_rollback() {
|
||
_minutes="${1:-5}"
|
||
|
||
mkdir -p "$CLUPILOT_SBIN_DIR" "$CLUPILOT_UNIT_DIR" "$CLUPILOT_WORK_DIR"
|
||
|
||
render_rollback_script "$_minutes" > "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh"
|
||
chmod 700 "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh"
|
||
|
||
cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" <<EOF
|
||
[Unit]
|
||
Description=CluPilot: Netzkonfiguration zurückspielen, wenn die Brücke den Host vom Netz nimmt
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh
|
||
EOF
|
||
|
||
cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" <<EOF
|
||
[Unit]
|
||
Description=CluPilot: Frist für die Selbstrücknahme der Netzumstellung
|
||
|
||
[Timer]
|
||
OnActiveSec=${_minutes}min
|
||
AccuracySec=1s
|
||
EOF
|
||
|
||
"$CLUPILOT_SYSTEMCTL" daemon-reload
|
||
"$CLUPILOT_SYSTEMCTL" start "${CLUPILOT_ROLLBACK_UNIT}.timer"
|
||
log "Selbstrücknahme steht: ohne Abbestellung läuft sie in ${_minutes} Minuten"
|
||
}
|
||
|
||
cancel_network_rollback() {
|
||
"$CLUPILOT_SYSTEMCTL" stop "${CLUPILOT_ROLLBACK_UNIT}.timer" 2>/dev/null || true
|
||
rm -f "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" \
|
||
"${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" \
|
||
"${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh"
|
||
"$CLUPILOT_SYSTEMCTL" daemon-reload 2>/dev/null || true
|
||
log 'Selbstrücknahme abbestellt'
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: PASS, achtzehn Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php
|
||
git commit -m "bridge.sh: die Rueckfahrkarte, und der Grund steht vor dem Zurueckspielen"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `bridge.sh` — nachsehen, beide Richtungen
|
||
|
||
Die Bedingung zum Abbestellen. Nur „komme ich raus" zu prüfen ist notwendig und nicht hinreichend — und kein `ping`.
|
||
|
||
**Files:**
|
||
- Modify: `deploy/bootstrap/lib/bridge.sh` (anhängen)
|
||
- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `bridge_is_up`, `http_get`, `log`, `CLUPILOT_PROBE_URL`, `CLUPILOT_WG_HUB_PUBKEY`, `CLUPILOT_WG_HANDSHAKE_MAX_AGE` aus Task 1/3.
|
||
- Produces:
|
||
- `internet_reachable() -> exit 0/1`
|
||
- `tunnel_handshake_fresh() -> exit 0/1`
|
||
- `bridge_proven() -> exit 0/1` — die drei zusammen, mit dem einen `wg`-Neustart
|
||
- `CLUPILOT_WG` (Vorgabe `wg`), `CLUPILOT_HANDSHAKE_TRIES` (Vorgabe `15`), `CLUPILOT_HANDSHAKE_WAIT` (Vorgabe `2`)
|
||
|
||
- [ ] **Step 1: Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`:
|
||
|
||
```php
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| Nachsehen — beide Richtungen
|
||
|--------------------------------------------------------------------------
|
||
*/
|
||
|
||
/** Eine `wg`-Attrappe, die einen Handshake von genau einem Peer meldet. */
|
||
function fakeWg(string $dir, string $peer, int $secondsAgo): string
|
||
{
|
||
$when = time() - $secondsAgo;
|
||
$script = "#!/bin/sh\nprintf '%s\\t%s\\n' '{$peer}' '{$when}'\n";
|
||
file_put_contents("{$dir}/wg", $script);
|
||
chmod("{$dir}/wg", 0o755);
|
||
|
||
return "{$dir}/wg";
|
||
}
|
||
|
||
it('bestellt nicht ab, wenn der Tunnel steht, aber das Internet nicht erreichbar ist', function () {
|
||
$ip = fakeIp($this->dir, [
|
||
'link show vmbr0' => '5: vmbr0: <BROADCAST,MULTICAST,UP>',
|
||
'-4 route show default' => 'default via 10.0.0.1 dev vmbr0',
|
||
'-4 -o addr show dev vmbr0 scope global' => '5: vmbr0 inet 10.0.0.7/24 scope global vmbr0',
|
||
]);
|
||
$wg = fakeWg($this->dir, 'HUBKEY=', 5);
|
||
|
||
$out = runBridgeSh(
|
||
'if bridge_proven; then echo JA; else echo NEIN; fi',
|
||
[
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_WG' => $wg,
|
||
'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=',
|
||
],
|
||
// Kein Weg nach draußen — als Funktion gesetzt statt über eine Adresse,
|
||
// damit der Test nicht davon abhängt, ob im Container curl liegt.
|
||
pre: 'http_get() { return 1; }'
|
||
);
|
||
|
||
expect($out)->toContain('NEIN');
|
||
});
|
||
|
||
it('bestellt NICHT ab, wenn das Internet erreichbar ist, der Tunnel aber schal', function () {
|
||
// Der Fehlerfall, für den diese Prüfung existiert: ifreload bringt vmbr0
|
||
// sauber hoch, die Maschine erreicht das Internet, der Treiber wäre
|
||
// zufrieden — aber wg0 kommt nicht zurück. Dann lebt der Host öffentlich,
|
||
// CluPilot ist ausgesperrt, und wer hier abbestellt, hat die Rückfahrkarte
|
||
// weggeworfen.
|
||
$ip = fakeIp($this->dir, [
|
||
'link show vmbr0' => '5: vmbr0: <BROADCAST,MULTICAST,UP>',
|
||
'-4 route show default' => 'default via 10.0.0.1 dev vmbr0',
|
||
'-4 -o addr show dev vmbr0 scope global' => '5: vmbr0 inet 10.0.0.7/24 scope global vmbr0',
|
||
]);
|
||
// Handshake ist eine Stunde alt.
|
||
$wg = fakeWg($this->dir, 'HUBKEY=', 3600);
|
||
$bin = $this->dir.'/bin';
|
||
mkdir($bin, 0o755, true);
|
||
file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n");
|
||
chmod("{$bin}/systemctl", 0o755);
|
||
|
||
$out = runBridgeSh(
|
||
'if bridge_proven; then echo JA; else echo NEIN; fi',
|
||
[
|
||
'CLUPILOT_IP' => $ip,
|
||
'CLUPILOT_WG' => $wg,
|
||
'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=',
|
||
'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl",
|
||
'CLUPILOT_HANDSHAKE_TRIES' => '1',
|
||
'CLUPILOT_HANDSHAKE_WAIT' => '0',
|
||
],
|
||
// Der Weg nach draußen steht — genau das ist der Punkt des Tests.
|
||
pre: 'http_get() { return 0; }'
|
||
);
|
||
|
||
expect($out)->toContain('NEIN')
|
||
// Einmal nachhelfen gehört dazu: das ist zu diesem Zeitpunkt gefahrlos,
|
||
// weil ifreload die SSH-Sitzung ohnehin schon mitgenommen hat.
|
||
->and(file_get_contents($this->dir.'/calls'))
|
||
->toContain('restart wg-quick@wg0');
|
||
});
|
||
|
||
it('erkennt einen Handshake von einem FREMDEN Peer nicht als den des Hubs', function () {
|
||
// Ein Mitarbeiter-Zugang auf demselben Hub hätte den Beweis sonst
|
||
// erbracht, ohne dass CluPilot selbst durchkommt.
|
||
$wg = fakeWg($this->dir, 'IRGENDWER=', 5);
|
||
|
||
$out = runBridgeSh('if tunnel_handshake_fresh; then echo JA; else echo NEIN; fi', [
|
||
'CLUPILOT_WG' => $wg,
|
||
'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=',
|
||
]);
|
||
|
||
expect($out)->toBe('NEIN');
|
||
});
|
||
|
||
it('benutzt an keiner Stelle ping', function () {
|
||
// Hetzners Debian-Basis hat kein iputils-ping, und PrepareBaseSystem
|
||
// installiert es nicht. Eine Prüfung damit sagte immer „nicht erreichbar",
|
||
// der Zeitgeber spielte immer zurück, der Schritt käme nie durch — dieselbe
|
||
// Falle, die ConfigureWireguard schon einmal erwischt hat.
|
||
expect(file_get_contents(base_path('deploy/bootstrap/lib/bridge.sh')))
|
||
->not->toMatch('/(^|[^-\w])ping\s/m');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: FAIL — `bridge_proven: not found`.
|
||
|
||
- [ ] **Step 3: Nachsehen anhängen**
|
||
|
||
Neue Stellschrauben oben in `bridge.sh`:
|
||
|
||
```sh
|
||
CLUPILOT_WG="${CLUPILOT_WG:-wg}"
|
||
CLUPILOT_HANDSHAKE_TRIES="${CLUPILOT_HANDSHAKE_TRIES:-15}"
|
||
CLUPILOT_HANDSHAKE_WAIT="${CLUPILOT_HANDSHAKE_WAIT:-2}"
|
||
```
|
||
|
||
Dann anhängen:
|
||
|
||
```sh
|
||
# ---------------------------------------------------------------------------
|
||
# Nachsehen — beide Richtungen
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Kein `ping`. Hetzners `Debian-trixie-…-base` bringt kein `iputils-ping` mit,
|
||
# und `PrepareBaseSystem` installiert es nicht (es holt curl, gnupg, ifupdown2,
|
||
# chrony). Eine Prüfung damit sagte IMMER „nicht erreichbar", der Zeitgeber
|
||
# spielte IMMER zurück, und der Schritt käme nie durch. Dieselbe Falle hatte
|
||
# `ConfigureWireguard` schon einmal, und sie kostete eine Übernahme.
|
||
|
||
internet_reachable() {
|
||
http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1
|
||
}
|
||
|
||
# Steht der Tunnel?
|
||
#
|
||
# Wortgleich die Prüfung aus v1.3.85: frischer Handshake vom KONFIGURIERTEN Hub.
|
||
# Nicht „irgendein Peer hat gehandshaked" — auf demselben Hub liegen auch
|
||
# Mitarbeiter-Zugänge, und deren Handshake beweist nichts über CluPilots Weg
|
||
# hierher.
|
||
tunnel_handshake_fresh() {
|
||
[ -n "$CLUPILOT_WG_HUB_PUBKEY" ] || return 1
|
||
|
||
_now="$(date +%s)"
|
||
_hs="$("$CLUPILOT_WG" show wg0 latest-handshakes 2>/dev/null \
|
||
| awk -v k="$CLUPILOT_WG_HUB_PUBKEY" '$1 == k { print $2; exit }')"
|
||
|
||
[ -n "$_hs" ] || return 1
|
||
[ "$_hs" -gt 0 ] 2>/dev/null || return 1
|
||
[ "$(( _now - _hs ))" -lt "$CLUPILOT_WG_HANDSHAKE_MAX_AGE" ]
|
||
}
|
||
|
||
# Die Bedingung zum Abbestellen.
|
||
#
|
||
# Nur „komme ich raus" zu prüfen ist notwendig und NICHT hinreichend. Der
|
||
# Fehlerfall: `ifreload -a` bringt die Brücke sauber hoch, die Maschine erreicht
|
||
# das Internet, der Treiber wäre zufrieden — aber wg0 kommt nicht zurück.
|
||
# Ergebnis: der Host lebt öffentlich, CluPilot ist ausgesperrt, und die
|
||
# Rückfahrkarte wurde gerade weggeworfen. Von Hand reparierbar, und genau das
|
||
# soll dieser Schritt ja verhindern.
|
||
#
|
||
# wg0.conf enthält keine Gerätebindung (siehe ConfigureWireguard::renderConfig),
|
||
# der Tunnel hängt also nicht wörtlich an der alten Karte, sondern an der
|
||
# Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die Größe,
|
||
# die der Umbau anfasst. Das macht den Fall nicht unwahrscheinlicher, nur
|
||
# unauffälliger: kein Fehler im Protokoll, nur ein Handshake, der ausbleibt.
|
||
bridge_proven() {
|
||
if ! bridge_is_up; then
|
||
log "Die Brücke trägt die Standardroute oder die Adresse nicht"
|
||
return 1
|
||
fi
|
||
|
||
if ! internet_reachable; then
|
||
log "Kein Weg nach draußen (${CLUPILOT_PROBE_URL})"
|
||
return 1
|
||
fi
|
||
|
||
if tunnel_handshake_fresh; then
|
||
return 0
|
||
fi
|
||
|
||
# Einmal nachhelfen. Gefahrlos: die SSH-Sitzung ist zu diesem Zeitpunkt
|
||
# ohnehin weg, `ifreload` hat sie mitgenommen. Ein hängender Tunnel wird so
|
||
# zu einem laufenden statt zu einer Rücknahme.
|
||
log 'Handshake schal — wg-quick@wg0 einmal neu starten'
|
||
"$CLUPILOT_SYSTEMCTL" restart wg-quick@wg0 >/dev/null 2>&1 || true
|
||
|
||
_i=0
|
||
while [ "$_i" -lt "$CLUPILOT_HANDSHAKE_TRIES" ]; do
|
||
if tunnel_handshake_fresh; then
|
||
log 'Tunnel steht nach dem Neustart von wg0'
|
||
return 0
|
||
fi
|
||
[ "$CLUPILOT_HANDSHAKE_WAIT" -gt 0 ] && sleep "$CLUPILOT_HANDSHAKE_WAIT"
|
||
_i=$(( _i + 1 ))
|
||
done
|
||
|
||
log 'Tunnel steht auch nach dem Neustart von wg0 nicht — CluPilot käme nicht zurück'
|
||
return 1
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: PASS, zweiundzwanzig Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php
|
||
git commit -m "bridge.sh: nachsehen in beide Richtungen, und kein ping"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `bridge-run.sh` — der Treiber
|
||
|
||
Verdrahtet Task 1–4 in der einen Reihenfolge, die stimmen muss, und übernimmt das Netz von einem Fremdverwalter, falls einer da ist.
|
||
|
||
**Files:**
|
||
- Create: `deploy/bootstrap/lib/bridge-run.sh`
|
||
- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus `bridge.sh`.
|
||
- Produces: ein Skript, das per `sh bridge-run.sh` läuft und `state`/`pid`/`phase`/`note`/`bridge.log` in `$CLUPILOT_WORK_DIR` schreibt. Liest `$CLUPILOT_WORK_DIR/env`.
|
||
- Produces (in `bridge.sh`, weil es dorthin gehört): `foreign_network_manager() -> stdout: cloud-init|networkd|network-manager|unbekannt|""` und `disown_network_manager()`.
|
||
|
||
- [ ] **Step 1: Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`:
|
||
|
||
```php
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| Der Treiber
|
||
|--------------------------------------------------------------------------
|
||
*/
|
||
|
||
it('schreibt die PID als allererstes', function () {
|
||
// Der Startbefehl wartet darauf, dass hier etwas steht, und bis dahin gilt
|
||
// der Lauf als nicht angelaufen. Steht es weiter unten, liest ein früher
|
||
// Poll `running`-aber-nicht-lebendig und erklärt einen gesunden Lauf für tot.
|
||
$lines = file(base_path('deploy/bootstrap/lib/bridge-run.sh'));
|
||
$pidAt = null;
|
||
foreach ($lines as $i => $line) {
|
||
if (str_contains($line, '/pid"') && str_contains($line, '$$')) {
|
||
$pidAt = $i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
expect($pidAt)->not->toBeNull()
|
||
->and($pidAt)->toBeLessThan(60);
|
||
});
|
||
|
||
it('stellt den Zeitgeber vor dem ersten veraendernden Aufruf', function () {
|
||
// Dieselbe Zusicherung wie in Task 3, hier auf der Ebene des Treibers: die
|
||
// Reihenfolge der AUFRUFE im Skripttext.
|
||
$driver = file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh'));
|
||
|
||
$schedule = strpos($driver, 'schedule_network_rollback');
|
||
$disown = strpos($driver, 'disown_network_manager');
|
||
$build = strpos($driver, 'build_bridge');
|
||
$backup = strpos($driver, 'backup_network_config');
|
||
|
||
expect($backup)->toBeLessThan($schedule)
|
||
->and($schedule)->toBeLessThan($disown)
|
||
->and($disown)->toBeLessThan($build);
|
||
});
|
||
|
||
it('bestellt den Zeitgeber nirgends selbst ab', function () {
|
||
// Nur der Zeitgeber stellt zurück, und nur CluPilot bestellt ihn ab — nach
|
||
// dem Wiederverbinden. Ein Treiber, der das selbst täte, rät über seine
|
||
// eigene Erreichbarkeit.
|
||
expect(file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh')))
|
||
->not->toContain('cancel_network_rollback');
|
||
});
|
||
|
||
it('laesst den Treiber von einer Shell parsen', function () {
|
||
expect(Process::run(['sh', '-n', base_path('deploy/bootstrap/lib/bridge-run.sh')])->successful())
|
||
->toBeTrue();
|
||
});
|
||
|
||
it('erkennt cloud-init als Fremdverwalter des Netzes', function () {
|
||
// Aus dem laufenden Zustand abzuleiten löst das LESEN, nicht das SCHREIBEN.
|
||
// Führt cloud-init das Netz, griffe die Brücke entweder sofort nicht oder,
|
||
// schlimmer, sie griffe jetzt und würde beim nächsten Neustart wieder
|
||
// eingesammelt — mit Kunden darauf. Der Zeitgeber fängt diesen Fall NICHT ab.
|
||
$etc = $this->dir.'/etc';
|
||
mkdir("{$etc}/cloud", 0o755, true);
|
||
file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n");
|
||
|
||
expect(runBridgeSh('foreign_network_manager', ['CLUPILOT_ETC' => $etc]))
|
||
->toBe('cloud-init');
|
||
});
|
||
|
||
it('meldet keinen Fremdverwalter auf einer Maschine mit reinem ifupdown', function () {
|
||
$etc = $this->dir.'/etc';
|
||
mkdir($etc, 0o755, true);
|
||
|
||
expect(runBridgeSh('foreign_network_manager', ['CLUPILOT_ETC' => $etc]))
|
||
->toBe('');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: FAIL — `bridge-run.sh` existiert nicht, `foreign_network_manager: not found`.
|
||
|
||
- [ ] **Step 3: Fremdverwalter-Erkennung an `bridge.sh` anhängen**
|
||
|
||
Neue Stellschraube oben:
|
||
|
||
```sh
|
||
CLUPILOT_ETC="${CLUPILOT_ETC:-/etc}"
|
||
```
|
||
|
||
Anhängen:
|
||
|
||
```sh
|
||
# ---------------------------------------------------------------------------
|
||
# Fremdverwalter
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Aus dem laufenden Zustand abzuleiten löst das LESEN. Es löst nicht das
|
||
# SCHREIBEN: führt cloud-init das Netz, ist `/etc/network/interfaces` nicht die
|
||
# Stelle, an der die Wahrheit steht. Die Brücke griffe entweder sofort nicht
|
||
# oder — schlimmer — sie griffe jetzt und würde beim nächsten Neustart wieder
|
||
# eingesammelt. Dann läuft die Übernahme grün durch und der Host verliert seine
|
||
# Brücke Monate später, mit Kunden darauf.
|
||
#
|
||
# Der Zeitgeber fängt diesen Fall NICHT ab. Deshalb steht das hier.
|
||
|
||
# Wer führt das Netz? Leer heißt: reines ifupdown, und der Weg ist frei.
|
||
foreign_network_manager() {
|
||
if [ -f "${CLUPILOT_ETC}/cloud/cloud.cfg" ] || [ -d "${CLUPILOT_ETC}/cloud/cloud.cfg.d" ]; then
|
||
printf 'cloud-init'
|
||
return 0
|
||
fi
|
||
|
||
if [ -d "${CLUPILOT_ETC}/systemd/network" ] \
|
||
&& [ -n "$(ls -A "${CLUPILOT_ETC}/systemd/network" 2>/dev/null)" ]; then
|
||
printf 'networkd'
|
||
return 0
|
||
fi
|
||
|
||
if [ -d "${CLUPILOT_ETC}/NetworkManager/system-connections" ] \
|
||
&& [ -n "$(ls -A "${CLUPILOT_ETC}/NetworkManager/system-connections" 2>/dev/null)" ]; then
|
||
printf 'network-manager'
|
||
return 0
|
||
fi
|
||
|
||
printf ''
|
||
}
|
||
|
||
# Entmachtet den erkannten Verwalter — im gesicherten Stand, also unter dem
|
||
# Zeitgeber. Was hier schiefgeht, holt er zurück.
|
||
disown_network_manager() {
|
||
case "$1" in
|
||
cloud-init)
|
||
mkdir -p "${CLUPILOT_ETC}/cloud/cloud.cfg.d"
|
||
printf 'network: {config: disabled}\n' \
|
||
> "${CLUPILOT_ETC}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg"
|
||
log 'cloud-init: Netzteil abgeschaltet'
|
||
;;
|
||
networkd)
|
||
"$CLUPILOT_SYSTEMCTL" disable --now systemd-networkd.socket >/dev/null 2>&1 || true
|
||
"$CLUPILOT_SYSTEMCTL" mask systemd-networkd >/dev/null 2>&1 || true
|
||
log 'systemd-networkd maskiert'
|
||
;;
|
||
network-manager)
|
||
"$CLUPILOT_SYSTEMCTL" disable --now NetworkManager >/dev/null 2>&1 || true
|
||
"$CLUPILOT_SYSTEMCTL" mask NetworkManager >/dev/null 2>&1 || true
|
||
log 'NetworkManager maskiert'
|
||
;;
|
||
'')
|
||
;;
|
||
esac
|
||
|
||
# Der kleinere Verwandte: bleibt in interfaces.d eine Strophe liegen, die
|
||
# dieselbe Karte beansprucht, hat der Host zwei Stellen, die seine Adresse
|
||
# vergeben — und die Strophe hier behält `source interfaces.d/*`.
|
||
_iface="${2:-}"
|
||
if [ -n "$_iface" ] && [ -d "$CLUPILOT_INTERFACES_D" ]; then
|
||
for _f in "$CLUPILOT_INTERFACES_D"/*; do
|
||
[ -f "$_f" ] || continue
|
||
if grep -qE "iface[[:space:]]+${_iface}[[:space:]]" "$_f" 2>/dev/null; then
|
||
mv "$_f" "${_f}.von-clupilot-beiseitegelegt"
|
||
log "Kollidierende Strophe beiseitegelegt: ${_f}"
|
||
fi
|
||
done
|
||
fi
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Den Treiber anlegen**
|
||
|
||
`deploy/bootstrap/lib/bridge-run.sh`:
|
||
|
||
```sh
|
||
#!/bin/sh
|
||
# shellcheck shell=sh
|
||
#
|
||
# Treiber für den Brückenbau, wenn er aus der CluPilot-Pipeline kommt.
|
||
#
|
||
# `App\Provisioning\Steps\Host\EnsureNetworkBridge` lädt diese Datei zusammen mit
|
||
# `bridge.sh` auf den Host und startet sie abgekoppelt. Die eigentliche Arbeit
|
||
# macht `bridge.sh` — hier steht nur, in welcher Reihenfolge, und wie der
|
||
# Fortschritt zurückgemeldet wird.
|
||
#
|
||
# ---------------------------------------------------------------------------
|
||
# Warum abgekoppelt
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Nicht wegen der Dauer — der Bau ist in Sekunden durch. Sondern weil die
|
||
# SSH-Verbindung MITTEN im Befehl stirbt: `ifreload -a` nimmt die Leitung, über
|
||
# die der Befehl läuft. Ein synchroner Aufruf hätte keinen Rückgabewert, sondern
|
||
# eine Leiche.
|
||
#
|
||
# ---------------------------------------------------------------------------
|
||
# Die Reihenfolge, und warum sie nicht verhandelbar ist
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# sichern → Zeitgeber → übernehmen → umstellen → nachsehen.
|
||
#
|
||
# Der Zeitgeber steht VOR jeder Änderung. Er ist der einzige Grund, warum dieses
|
||
# Skript die Netzkonfiguration überhaupt anfassen darf: kommt der Host nicht
|
||
# zurück, spielt er den alten Zustand ein, und der Schritt landet in einer
|
||
# Wiederholung statt auf einem toten Server.
|
||
#
|
||
# Abbestellt wird er hier NICHT. Das tut CluPilot, nachdem es sich über den
|
||
# Tunnel neu verbunden und nachgesehen hat. Dieses Skript kann über seine eigene
|
||
# Erreichbarkeit von außen nur raten.
|
||
#
|
||
# ---------------------------------------------------------------------------
|
||
# Rückmeldung
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# state running | ok | failed
|
||
# pid die PID dieses Skripts — und, weil es per `setsid` gestartet
|
||
# wird, zugleich die seiner PROZESSGRUPPE
|
||
# phase die laufende Phase, für die Fortschrittszeile in der Konsole
|
||
# note der Grund im Fehlerfall
|
||
# rolled-back vom Zeitgeber angelegt, wenn er zurückgespielt hat
|
||
# bridge.log alles
|
||
#
|
||
# `state` allein ist keine Aussage über den Lauf: stirbt das Skript, bleibt dort
|
||
# für immer `running` stehen, weil niemand mehr da ist, der es ändert. Deshalb
|
||
# fragt der Schritt zusätzlich `kill -0` gegen `pid`.
|
||
|
||
set -u
|
||
|
||
CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}"
|
||
|
||
# Als ALLERERSTES, vor jedem Einlesen und jeder Prüfung: der Startbefehl wartet
|
||
# darauf, dass hier etwas steht, und bis dahin gilt der Lauf als noch nicht
|
||
# angelaufen. `$$` und nicht `$!` auf der anderen Seite, weil `setsid` dazwischen
|
||
# liegt — und weil `setsid` daraus eine eigene Sitzung macht, ist diese Zahl
|
||
# zugleich die Prozessgruppe, an die ein Abbruch geschickt wird.
|
||
echo $$ > "${CLUPILOT_WORK_DIR}/pid"
|
||
|
||
# shellcheck source=/dev/null
|
||
. "${CLUPILOT_WORK_DIR}/env"
|
||
# shellcheck source=lib/bridge.sh
|
||
. "${CLUPILOT_WORK_DIR}/bridge.sh"
|
||
|
||
phase() {
|
||
printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/phase"
|
||
log "--- $1"
|
||
}
|
||
|
||
fail() {
|
||
printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/note"
|
||
printf 'failed' > "${CLUPILOT_WORK_DIR}/state"
|
||
log "ABBRUCH: $1"
|
||
exit 1
|
||
}
|
||
|
||
# Ein Abbruch, den keine Zeile hier abgefangen hat (kein Speicher, ein Signal),
|
||
# darf nicht als `running` liegen bleiben und den Schritt gegen eine Leiche
|
||
# pollen lassen. Der Schritt erkennt das zwar auch an `kill -0`, aber ein Grund
|
||
# ist besser als eine Vermutung.
|
||
on_exit() {
|
||
_code=$?
|
||
if [ "$_code" -ne 0 ] && [ "$(cat "${CLUPILOT_WORK_DIR}/state" 2>/dev/null)" = 'running' ]; then
|
||
printf 'Der Brueckenbau brach unerwartet ab (Rueckgabewert %s); siehe bridge.log' "$_code" \
|
||
> "${CLUPILOT_WORK_DIR}/note"
|
||
printf 'failed' > "${CLUPILOT_WORK_DIR}/state"
|
||
fi
|
||
}
|
||
trap on_exit EXIT
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ablauf
|
||
# ---------------------------------------------------------------------------
|
||
|
||
cd "$CLUPILOT_WORK_DIR" || exit 1
|
||
|
||
phase 'Zustand feststellen'
|
||
|
||
_iface="$(detect_primary_interface)"
|
||
[ -n "$_iface" ] || fail 'Keine Karte traegt die Standardroute — auf dieser Maschine ist nicht abzuleiten, worueber eine Bruecke gehen soll.'
|
||
|
||
interface_is_physical "$_iface" \
|
||
|| fail "Die Karte mit der Standardroute (${_iface}) ist keine physische Karte, sondern eine Bruecke, ein Bond oder ein VLAN. bridge_ports darauf waere falsch."
|
||
|
||
_style="$(detect_network_style "$_iface")"
|
||
_cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
|
||
_gw="$("$CLUPILOT_IP" -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
|
||
|
||
[ "$_style" = 'dhcp' ] || [ -n "$_cidr" ] \
|
||
|| fail "Keine globale IPv4-Adresse auf ${_iface} — nichts, was auf eine Bruecke ziehen koennte."
|
||
|
||
log "Karte ${_iface}, Form ${_style}, Adresse ${_cidr:-per DHCP}, Gateway ${_gw:-keins}"
|
||
|
||
_foreign="$(foreign_network_manager)"
|
||
case "$_foreign" in
|
||
''|cloud-init|networkd|network-manager) ;;
|
||
*) fail "Unbekannter Netzverwalter (${_foreign}) — hier wird nicht ins Blaue gebaut." ;;
|
||
esac
|
||
[ -n "$_foreign" ] && log "Fremdverwalter erkannt: ${_foreign}"
|
||
|
||
# --- ab hier wird verändert ---
|
||
|
||
phase 'sichern'
|
||
backup_network_config || fail 'Die bestehende Netzkonfiguration liess sich nicht sichern — ohne Rueckfahrkarte wird hier nichts umgestellt.'
|
||
|
||
phase 'Zeitgeber stellen'
|
||
schedule_network_rollback "${CLUPILOT_ROLLBACK_MINUTES:-5}"
|
||
|
||
phase 'uebernehmen'
|
||
disown_network_manager "$_foreign" "$_iface"
|
||
|
||
phase 'umstellen'
|
||
build_bridge "$_iface" "$_style" "$_cidr" "$_gw"
|
||
|
||
phase 'nachsehen'
|
||
bridge_proven || fail 'Die Bruecke steht, aber der Weg nach draussen oder der Tunnel fehlt. Der Zeitgeber spielt den alten Zustand zurueck.'
|
||
|
||
phase 'wartet auf CluPilot'
|
||
printf 'ok' > "${CLUPILOT_WORK_DIR}/state"
|
||
log 'Bruecke steht und traegt; der Zeitgeber laeuft weiter, bis CluPilot ihn abbestellt.'
|
||
exit 0
|
||
```
|
||
|
||
- [ ] **Step 5: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php
|
||
```
|
||
|
||
Erwartet: PASS, achtundzwanzig Tests.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add deploy/bootstrap/lib/bridge.sh deploy/bootstrap/lib/bridge-run.sh tests/Feature/Provisioning/BridgeScriptTest.php
|
||
git commit -m "bridge-run.sh: der Treiber, und der Zeitgeber steht vor jeder Aenderung"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `EnsureNetworkBridge` — die Abkürzung und der Guard
|
||
|
||
Der Schritt, aber nur sein billigster Teil: schon da heißt nichts anfassen, und was keine physische Karte ist, wird nicht gebaut. Noch kein Start, noch kein Poll.
|
||
|
||
**Files:**
|
||
- Create: `app/Provisioning/Steps/Host/EnsureNetworkBridge.php`
|
||
- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `HostStep::keyLogin()`, `HostStep::host()`, `RemoteShell`, `StepResult`.
|
||
- Produces:
|
||
- `EnsureNetworkBridge::key(): 'ensure_network_bridge'`
|
||
- `EnsureNetworkBridge::WORK_DIR = '/var/lib/clupilot/bridge'`
|
||
- `EnsureNetworkBridge::maxDuration(): 3600`
|
||
|
||
- [ ] **Step 1: Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/HostStepsTest.php` (und `use App\Provisioning\Steps\Host\EnsureNetworkBridge;` oben ergänzen):
|
||
|
||
```php
|
||
// --- EnsureNetworkBridge ---
|
||
//
|
||
// Der Schritt, der an dem Ast sägt, auf dem er sitzt: SSH läuft hier über den
|
||
// Tunnel, und WireGuard hängt an derselben Karte, die in die Brücke wandert.
|
||
// Deshalb: erst der Zeitgeber, dann umstellen, dann neu verbinden, nachsehen,
|
||
// abbestellen.
|
||
|
||
/** Was der Host auf die Zustandsfrage antwortet. */
|
||
function scriptBridgeState(FakeRemoteShell $shell, bool $up, string $iface = 'enp0s31f6', bool $physical = true): void
|
||
{
|
||
$shell->script('clupilot-bridge-state', CommandResult::success(implode("\n", [
|
||
'up='.($up ? 'yes' : 'no'),
|
||
'iface='.$iface,
|
||
'physical='.($physical ? 'yes' : 'no'),
|
||
'route=default via 49.12.121.65 dev '.($up ? 'vmbr0' : $iface),
|
||
'',
|
||
])));
|
||
}
|
||
|
||
/** Was der Host auf die Statusfrage antwortet. */
|
||
function scriptBridgeStatus(FakeRemoteShell $shell, string $state, bool $alive = false, string $phase = '', string $note = '', bool $rolledBack = false): void
|
||
{
|
||
$shell->script('clupilot-bridge-status', CommandResult::success(implode("\n", [
|
||
'state='.$state,
|
||
'alive='.($alive ? 'yes' : 'no'),
|
||
'phase='.$phase,
|
||
'note='.$note,
|
||
'rolledback='.($rolledBack ? 'yes' : 'no'),
|
||
'',
|
||
])));
|
||
}
|
||
|
||
it('fasst einen Host, der die Brücke schon hat, nicht an', function () {
|
||
// pve-fns-1 hat sie von Hand. Ein Wiederanlauf, der sie umbaut, baut ein
|
||
// funktionierendes Netz um — und riskiert dafür genau das, wofür es die
|
||
// Rückfahrkarte gibt.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: true);
|
||
|
||
expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('advance')
|
||
->and($s['shell']->files())->toBe([])
|
||
->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
|
||
});
|
||
|
||
it('baut keine Brücke auf einem Bond oder einer bestehenden Brücke', function () {
|
||
// bridge_ports darauf ist falsch: die Brücke nähme sich ihren eigenen
|
||
// Unterbau als Port, und was dabei herauskommt, ist aus der Ferne nicht mehr
|
||
// zu reparieren.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false, iface: 'bond0', physical: false);
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
expect($result->type)->toBe('fail')
|
||
->and($result->reason)->toContain('bond0')
|
||
->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
|
||
});
|
||
|
||
it('gibt auf, wenn gar keine Karte die Standardroute trägt', function () {
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false, iface: '', physical: false);
|
||
|
||
expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Brücke schon|Bond oder|keine Karte"
|
||
```
|
||
|
||
Erwartet: FAIL — `Class "App\Provisioning\Steps\Host\EnsureNetworkBridge" not found`.
|
||
|
||
- [ ] **Step 3: Den Schritt anlegen (nur Abkürzung und Guard)**
|
||
|
||
`app/Provisioning/Steps/Host/EnsureNetworkBridge.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Provisioning\Steps\Host;
|
||
|
||
use App\Models\ProvisioningRun;
|
||
use App\Provisioning\StepResult;
|
||
use App\Services\Ssh\RemoteShell;
|
||
|
||
/**
|
||
* Builds vmbr0 on a Debian-installed Proxmox host, under a timer that puts the
|
||
* old network configuration back if the bridge takes the machine off the net.
|
||
*
|
||
* Proxmox installed on top of Debian does not create vmbr0 — only the ISO
|
||
* installer writes that bridge into /etc/network/interfaces. BuildVmTemplate
|
||
* creates the golden image with `--net0 virtio,bridge=vmbr0` and every clone
|
||
* inherits it, so a host without the bridge serves no customer at all.
|
||
*
|
||
* ConfigureProxmox used to refuse here and send an operator to the keyboard.
|
||
* That was right while nobody could do this safely from a remote shell. What
|
||
* makes it safe is not cleverness about network layouts — it is the way back.
|
||
*
|
||
* ---------------------------------------------------------------------------
|
||
* The step saws through the branch it is sitting on
|
||
* ---------------------------------------------------------------------------
|
||
*
|
||
* By this point SSH runs over the WireGuard tunnel (keyLogin prefers wg_ip once
|
||
* the handshake is proven, and RebootIntoPveKernel has proven it), and wg0 hangs
|
||
* off the same NIC that is about to become a bridge port. A wrong bridge takes
|
||
* the tunnel AND the public address with it.
|
||
*
|
||
* So the order is: arm the rollback timer, change, reconnect, check, cancel. If
|
||
* the connection does not come back, the timer restores the old configuration
|
||
* and this step lands in a retry rather than on a dead server.
|
||
*
|
||
* ---------------------------------------------------------------------------
|
||
* Why it runs detached
|
||
* ---------------------------------------------------------------------------
|
||
*
|
||
* Not because it is slow — the build takes seconds. Because `ifreload -a` takes
|
||
* the line the command itself is running over. A synchronous call would not
|
||
* return a value; it would return a corpse. So the driver is started with
|
||
* `nohup setsid` and this step comes back every 20 seconds, each poll its own
|
||
* short job.
|
||
*
|
||
* The shell library is SHIPPED, never re-implemented here — same as
|
||
* BuildVmTemplate. A PHP copy of the provider-shape rules would be two
|
||
* installations to keep level, and the second one only reveals itself to
|
||
* whoever runs it.
|
||
*/
|
||
class EnsureNetworkBridge extends HostStep
|
||
{
|
||
/** Where the script, its env and its status files live on the host. */
|
||
public const WORK_DIR = '/var/lib/clupilot/bridge';
|
||
|
||
/** The rollback timer's fuse, in minutes. */
|
||
private const ROLLBACK_MINUTES = 5;
|
||
|
||
/**
|
||
* The step's own deadline — deliberately three times the timer's fuse.
|
||
*
|
||
* By the time this step gives up, the rollback has necessarily already run,
|
||
* so "gave up" always means "the host is back on its old configuration".
|
||
* That is the line that makes "a retry rather than a dead server" true.
|
||
*/
|
||
private const DEADLINE_MINUTES = 15;
|
||
|
||
/** One automatic attempt, plus one for the case an operator fixed something. */
|
||
private const MAX_ATTEMPTS = 2;
|
||
|
||
public function __construct(private RemoteShell $shell) {}
|
||
|
||
public function key(): string
|
||
{
|
||
return 'ensure_network_bridge';
|
||
}
|
||
|
||
public function maxDuration(): int
|
||
{
|
||
// Above DEADLINE_MINUTES so the step's own deadline decides the failure
|
||
// and can say what went wrong, rather than the generic step timeout —
|
||
// which would additionally spend a retry. Same shape as
|
||
// RebootIntoPveKernel and BuildVmTemplate.
|
||
return 3600;
|
||
}
|
||
|
||
public function execute(ProvisioningRun $run): StepResult
|
||
{
|
||
$host = $this->host($run);
|
||
$this->keyLogin($this->shell, $host);
|
||
|
||
$state = $this->readBridgeState();
|
||
|
||
// Already there means touch nothing. The same shortcut as "the template
|
||
// exists and reports template: 1" — and here it matters more, because
|
||
// rebuilding a working network is the one thing that can end this badly.
|
||
if ($state['up']) {
|
||
return StepResult::advance();
|
||
}
|
||
|
||
if ($state['iface'] === '') {
|
||
return StepResult::fail(
|
||
'No interface on this host carries the default route, so there is nothing to derive a bridge '.
|
||
'from. Check the machine on the provider console.'
|
||
);
|
||
}
|
||
|
||
// bridge_ports on a bond or an existing bridge is wrong: the bridge
|
||
// would take its own substrate as a port, and what comes out of that
|
||
// cannot be repaired from a remote shell.
|
||
if (! $state['physical']) {
|
||
return StepResult::fail(
|
||
'The interface carrying the default route ('.$state['iface'].') is not a physical NIC — it is a '.
|
||
'bridge, a bond or a VLAN. CluPilot will not put a bridge on top of it. Build vmbr0 by hand in '.
|
||
'/etc/network/interfaces, apply it with `ifreload -a`, confirm you still have SSH, then retry. '.
|
||
'Current default route: '.($state['route'] ?: '(none reported)').'.'
|
||
);
|
||
}
|
||
|
||
// Start, poll and cancel arrive in the next task.
|
||
return StepResult::fail('not implemented yet');
|
||
}
|
||
|
||
/**
|
||
* What the host says about its bridge and its primary NIC, in one round trip.
|
||
*
|
||
* Derived from the RUNNING state — `ip`, `/sys/class/net` — never from the
|
||
* provider's file. What runs is structured the same everywhere; how it was
|
||
* written down is not, and that is the part that carries Hetzner and netcup
|
||
* at once.
|
||
*
|
||
* @return array{up:bool, iface:string, physical:bool, route:string}
|
||
*/
|
||
private function readBridgeState(): array
|
||
{
|
||
$out = $this->shell->run(implode("\n", [
|
||
': clupilot-bridge-state',
|
||
'B=vmbr0',
|
||
'IF=$(ip -4 route show default 2>/dev/null | awk \'{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }\')',
|
||
'UP=no',
|
||
'if ip link show "$B" >/dev/null 2>&1 && [ "$IF" = "$B" ] && [ -n "$(ip -4 -o addr show dev "$B" scope global 2>/dev/null)" ]; then UP=yes; fi',
|
||
'PHY=no',
|
||
'if [ -n "$IF" ] && [ -e "/sys/class/net/$IF/device" ] && [ ! -d "/sys/class/net/$IF/bridge" ] && [ ! -d "/sys/class/net/$IF/bonding" ]; then PHY=yes; fi',
|
||
'printf \'up=%s\n\' "$UP"',
|
||
'printf \'iface=%s\n\' "$IF"',
|
||
'printf \'physical=%s\n\' "$PHY"',
|
||
'printf \'route=%s\n\' "$(ip -4 route show default 2>/dev/null | head -1)"',
|
||
]))->stdout;
|
||
|
||
$parsed = ['up' => 'no', 'iface' => '', 'physical' => 'no', 'route' => ''];
|
||
|
||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
||
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
|
||
if (array_key_exists($key, $parsed)) {
|
||
$parsed[$key] = trim($value);
|
||
}
|
||
}
|
||
|
||
return [
|
||
'up' => $parsed['up'] === 'yes',
|
||
'iface' => $parsed['iface'],
|
||
'physical' => $parsed['physical'] === 'yes',
|
||
'route' => $parsed['route'],
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Brücke schon|Bond oder|keine Karte"
|
||
```
|
||
|
||
Erwartet: PASS, drei Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php
|
||
git commit -m "EnsureNetworkBridge: schon da heisst nichts anfassen, und keine Bruecke auf einer Bruecke"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `EnsureNetworkBridge` — Start und Verbindungsabriss
|
||
|
||
Der Teil, der über Leben und Tod des Laufs entscheidet: die Dateien wortgleich hochladen, abgekoppelt starten, und den erwarteten Verbindungsabriss als `poll` behandeln statt als `retry`.
|
||
|
||
**Files:**
|
||
- Modify: `app/Provisioning/Steps/Host/EnsureNetworkBridge.php`
|
||
- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `readBridgeState()` aus Task 6, `FakeRemoteShell::$failConnect`.
|
||
- Produces: Run-Kontext-Schlüssel `bridge_deadline` (ISO-8601) und `bridge_attempts` (int).
|
||
|
||
- [ ] **Step 1: Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/HostStepsTest.php`:
|
||
|
||
```php
|
||
it('lädt bridge.sh und bridge-run.sh wortgleich hoch und startet abgekoppelt', function () {
|
||
// Byte für Byte. In dem Moment, in dem diese Datei aus einer PHP-Vorlage
|
||
// gerendert statt gelesen wird, gibt es wieder zwei Fassungen der
|
||
// Anbieterformen — das eine, was der Entwurf verbietet.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
$files = $s['shell']->files();
|
||
|
||
expect($result->type)->toBe('poll')
|
||
->and($files['/var/lib/clupilot/bridge/bridge.sh'] ?? null)
|
||
->toBe(file_get_contents(base_path('deploy/bootstrap/lib/bridge.sh')))
|
||
->and($files['/var/lib/clupilot/bridge/bridge-run.sh'] ?? null)
|
||
->toBe(file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh')))
|
||
// Der Hub-Schlüssel muss mit, sonst kann der Treiber den Handshake
|
||
// nicht gegen den RICHTIGEN Peer prüfen.
|
||
->and($files['/var/lib/clupilot/bridge/env'] ?? '')->toContain('CLUPILOT_WG_HUB_PUBKEY=')
|
||
->and($files['/var/lib/clupilot/bridge/env'] ?? '')->toContain('CLUPILOT_ROLLBACK_MINUTES=5')
|
||
->and($s['shell']->ran('nohup'))->toBeTrue()
|
||
->and($s['shell']->ran('setsid'))->toBeTrue()
|
||
->and($run->fresh()->context('bridge_deadline'))->not->toBeNull();
|
||
});
|
||
|
||
it('pollt weiter, wenn der Host während der Umstellung nicht antwortet', function () {
|
||
// DIE tragende Zeile. RunRunner verwandelt jede geworfene Ausnahme in ein
|
||
// retry(), und retry verbraucht das Versuchskonto — ungefangen brennt der
|
||
// Verbindungsabriss die fünf Versuche in wenigen Minuten durch und lässt den
|
||
// Lauf scheitern, BEVOR der Host wieder da ist.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
$s['shell']->failConnect = true;
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
expect($result->type)->toBe('poll')
|
||
->and($result->reason)->toContain('Brücke');
|
||
});
|
||
|
||
it('lässt einen Verbindungsfehler VOR der Umstellung ein gewöhnlicher bleiben', function () {
|
||
// Ohne Termin hat dieser Lauf nichts angefasst. Dann ist ein
|
||
// Verbindungsfehler kein erwarteter Abriss, sondern ein Fehler — und darf
|
||
// das Versuchskonto kosten, statt eine Stunde lang gepollt zu werden.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host);
|
||
proveTunnel($run, $host);
|
||
$s['shell']->failConnect = true;
|
||
|
||
expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('retry');
|
||
});
|
||
|
||
it('startet keinen zweiten Treiber, solange der erste lebt', function () {
|
||
// Zwei Treiber auf einer /etc/network/interfaces, beide mit eigenem
|
||
// Zeitgeber: der eine bestellt ab, was der andere gestellt hat.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
scriptBridgeStatus($s['shell'], 'running', alive: true, phase: 'umstellen');
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
expect($result->type)->toBe('poll')
|
||
->and($result->reason)->toContain('umstellen')
|
||
->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
|
||
});
|
||
|
||
it('gibt auf, wenn der Treiber tot ist, obwohl die Statusdatei running sagt', function () {
|
||
// Die Datei sagt running und wird es immer — niemand ist mehr da, der es
|
||
// ändert. Darauf zu pollen wartet eine Viertelstunde gegen eine Leiche.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
scriptBridgeStatus($s['shell'], 'running', alive: false, rolledBack: true);
|
||
|
||
expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="wortgleich hoch|während der Umstellung|VOR der Umstellung|zweiten Treiber|Treiber tot"
|
||
```
|
||
|
||
Erwartet: FAIL — `not implemented yet`.
|
||
|
||
- [ ] **Step 3: `execute()` umbauen und Start/Status ergänzen**
|
||
|
||
In `EnsureNetworkBridge.php` `use` ergänzen:
|
||
|
||
```php
|
||
use App\Services\Wireguard\WireguardHub;
|
||
use Illuminate\Support\Carbon;
|
||
use Throwable;
|
||
```
|
||
|
||
Konstruktor:
|
||
|
||
```php
|
||
public function __construct(private RemoteShell $shell, private WireguardHub $hub) {}
|
||
```
|
||
|
||
`execute()` ersetzen:
|
||
|
||
```php
|
||
public function execute(ProvisioningRun $run): StepResult
|
||
{
|
||
$host = $this->host($run);
|
||
|
||
// The connection dropping is the EXPECTED case here, not an error:
|
||
// `ifreload -a` takes the line this step is speaking over. Left
|
||
// uncaught, RunRunner turns the exception into retry() — and retry
|
||
// spends the attempt budget, five of which are gone in minutes, long
|
||
// before the host is back.
|
||
try {
|
||
$this->keyLogin($this->shell, $host);
|
||
} catch (Throwable $e) {
|
||
return $this->whileDisconnected($run, $e);
|
||
}
|
||
|
||
$state = $this->readBridgeState();
|
||
|
||
// Already there means touch nothing. The same shortcut as "the template
|
||
// exists and reports template: 1" — and here it matters more, because
|
||
// rebuilding a working network is the one thing that can end this badly.
|
||
//
|
||
// It is also the success path: reaching this line means SSH arrived over
|
||
// the tunnel AND vmbr0 carries the default route with an address. That
|
||
// is bridge_proven, asked from the outside, which is the strongest form
|
||
// of the question there is. Only now is the timer cancelled.
|
||
if ($state['up']) {
|
||
if ($run->context('bridge_deadline') !== null) {
|
||
$this->cancelRollback();
|
||
$run->forgetContext('bridge_deadline');
|
||
}
|
||
|
||
return StepResult::advance();
|
||
}
|
||
|
||
if ($state['iface'] === '') {
|
||
return StepResult::fail(
|
||
'No interface on this host carries the default route, so there is nothing to derive a bridge '.
|
||
'from. Check the machine on the provider console.'
|
||
);
|
||
}
|
||
|
||
// bridge_ports on a bond or an existing bridge is wrong: the bridge
|
||
// would take its own substrate as a port, and what comes out of that
|
||
// cannot be repaired from a remote shell.
|
||
if (! $state['physical']) {
|
||
return StepResult::fail(
|
||
'The interface carrying the default route ('.$state['iface'].') is not a physical NIC — it is a '.
|
||
'bridge, a bond or a VLAN. CluPilot will not put a bridge on top of it. Build vmbr0 by hand in '.
|
||
'/etc/network/interfaces, apply it with `ifreload -a`, confirm you still have SSH, then retry. '.
|
||
'Current default route: '.($state['route'] ?: '(none reported)').'.'
|
||
);
|
||
}
|
||
|
||
// Nothing on the host is cleaned up when a run ends, so a status file
|
||
// can be older than this run. The deadline in the run context is the
|
||
// only thing that tells a first visit from a lost one: it is written
|
||
// when THIS run starts a build and cleared again by giveUp().
|
||
if ($run->context('bridge_deadline') === null) {
|
||
return $this->start($run);
|
||
}
|
||
|
||
$status = $this->readStatus();
|
||
|
||
return match ($status['state']) {
|
||
'' => $this->giveUp(
|
||
$run,
|
||
'The bridge build was started but left no status behind, so it never really began. Check '.
|
||
self::WORK_DIR.' and the free space on the host, then retry.'
|
||
),
|
||
'running' => $this->whileRunning($run, $status),
|
||
// Reaching here means the driver said ok while vmbr0 does NOT carry
|
||
// the default route — so the timer got there first, or is about to.
|
||
'ok' => $this->awaitRollback($run, $status, 'The bridge build reported success, but vmbr0 does not carry the default route.'),
|
||
'failed' => $this->awaitRollback($run, $status, $status['note'] ?: 'no reason recorded'),
|
||
default => $this->giveUp($run, 'The bridge build left an unreadable state ("'.$status['state'].'").'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The host did not answer.
|
||
*
|
||
* With a deadline in the context, this run has changed the network and the
|
||
* silence is the change taking effect — poll, do not retry. Without one,
|
||
* nothing has been touched and an unreachable host is an ordinary
|
||
* connection error that may cost an attempt.
|
||
*/
|
||
private function whileDisconnected(ProvisioningRun $run, Throwable $e): StepResult
|
||
{
|
||
$deadline = $run->context('bridge_deadline');
|
||
|
||
if ($deadline === null) {
|
||
return StepResult::retry(20, 'host not reachable: '.$e->getMessage());
|
||
}
|
||
|
||
if (now()->greaterThan(Carbon::parse($deadline))) {
|
||
$run->forgetContext('bridge_deadline');
|
||
|
||
return StepResult::fail(
|
||
'The host did not come back over the tunnel after the bridge was applied, and the deadline of '.
|
||
self::DEADLINE_MINUTES.' minutes has passed. The rollback timer will have restored the previous '.
|
||
'network configuration '.self::ROLLBACK_MINUTES.' minutes after the change, so the machine should '.
|
||
'be reachable on its old settings — check it, then retry. Last error: '.$e->getMessage()
|
||
);
|
||
}
|
||
|
||
return StepResult::poll(20, 'warte darauf, dass der Host über die neue Brücke wieder antwortet');
|
||
}
|
||
|
||
/** Ship the library, write the env, launch detached. */
|
||
private function start(ProvisioningRun $run): StepResult
|
||
{
|
||
$attempts = (int) $run->context('bridge_attempts', 0);
|
||
|
||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||
return StepResult::fail(
|
||
'CluPilot tried to build vmbr0 automatically '.$attempts.' times and the host did not come back '.
|
||
'both ways each time; the previous network configuration was restored. Build the bridge by hand '.
|
||
'in /etc/network/interfaces (bridge_ports = the NIC carrying the default route, host address and '.
|
||
'gateway moved onto vmbr0), apply it with `ifreload -a`, confirm you still have SSH, then retry '.
|
||
'this run. The stanza CluPilot wrote is in '.self::WORK_DIR.'/bridge.log.'
|
||
);
|
||
}
|
||
|
||
$this->shell->putFile(self::WORK_DIR.'/bridge.sh', $this->asset('lib/bridge.sh'));
|
||
$this->shell->putFile(self::WORK_DIR.'/bridge-run.sh', $this->asset('lib/bridge-run.sh'));
|
||
|
||
// The hub key so the driver can check the handshake against the RIGHT
|
||
// peer: staff peers live on the same hub, and their handshake says
|
||
// nothing about CluPilot's own way in.
|
||
$this->shell->putFile(self::WORK_DIR.'/env', implode("\n", [
|
||
'# Written by CluPilot at the start of every bridge build. Do not edit.',
|
||
'CLUPILOT_WORK_DIR='.self::WORK_DIR,
|
||
'CLUPILOT_ROLLBACK_MINUTES='.self::ROLLBACK_MINUTES,
|
||
'CLUPILOT_WG_HUB_PUBKEY='.escapeshellarg(trim($this->hub->publicKey())),
|
||
'',
|
||
]));
|
||
|
||
$run->mergeContext([
|
||
'bridge_deadline' => now()->addMinutes(self::DEADLINE_MINUTES)->toIso8601String(),
|
||
'bridge_attempts' => $attempts + 1,
|
||
]);
|
||
|
||
// `setsid` puts the driver in a session of its own, so the pid it
|
||
// records is also its PROCESS GROUP id — which is what makes giving up
|
||
// possible later. The pid therefore comes from the script (`$$`), not
|
||
// from `$!` here: with setsid in between, `$!` can be a process that is
|
||
// already gone. Waiting for the file closes the gap that creates.
|
||
$this->shell->run(implode("\n", [
|
||
': clupilot-bridge-start',
|
||
'W='.self::WORK_DIR,
|
||
'mkdir -p "$W"',
|
||
'rm -f "$W/rolled-back"',
|
||
"printf 'running' > \"\$W/state\"",
|
||
"printf 'starting' > \"\$W/phase\"",
|
||
': > "$W/note"',
|
||
': > "$W/pid"',
|
||
'nohup setsid sh "$W/bridge-run.sh" </dev/null >> "$W/bridge.log" 2>&1 &',
|
||
'i=0; while [ "$i" -lt 15 ] && [ ! -s "$W/pid" ]; do sleep 1; i=$((i + 1)); done',
|
||
]));
|
||
|
||
return StepResult::poll(20, 'Netzbrücke wird gebaut');
|
||
}
|
||
|
||
/** @param array{state:string, alive:string, phase:string, note:string, rolledback:string} $status */
|
||
private function whileRunning(ProvisioningRun $run, array $status): StepResult
|
||
{
|
||
if ($status['alive'] !== 'yes') {
|
||
// A kill, an OOM, a reboot. The file says running and always will —
|
||
// there is nobody left to change it. The timer is still armed, so
|
||
// the machine gets its old configuration back either way.
|
||
return $this->awaitRollback(
|
||
$run,
|
||
$status,
|
||
'The bridge build stopped running without reporting a result — it was killed or the host restarted.'
|
||
);
|
||
}
|
||
|
||
$deadline = $run->context('bridge_deadline');
|
||
if ($deadline !== null && now()->greaterThan(Carbon::parse($deadline))) {
|
||
return $this->giveUp(
|
||
$run,
|
||
'The bridge build passed its deadline of '.self::DEADLINE_MINUTES.' minutes while still in phase "'.
|
||
($status['phase'] ?: 'unknown').'". Check '.self::WORK_DIR.'/bridge.log on the host.'
|
||
);
|
||
}
|
||
|
||
return StepResult::poll(20, 'Netzbrücke wird gebaut: '.($status['phase'] ?: 'läuft'));
|
||
}
|
||
|
||
/**
|
||
* The four facts about a build in flight, plus whether the timer has fired.
|
||
*
|
||
* `alive` is computed on the host because that is the only place the answer
|
||
* exists: the pid means nothing here.
|
||
*
|
||
* @return array{state:string, alive:string, phase:string, note:string, rolledback:string}
|
||
*/
|
||
private function readStatus(): array
|
||
{
|
||
$out = $this->shell->run(implode("\n", [
|
||
': clupilot-bridge-status',
|
||
'W='.self::WORK_DIR,
|
||
'S=$(cat "$W/state" 2>/dev/null)',
|
||
'P=$(cat "$W/pid" 2>/dev/null)',
|
||
'A=no',
|
||
'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null; then A=yes; fi',
|
||
'R=no',
|
||
'if [ -f "$W/rolled-back" ]; then R=yes; fi',
|
||
"printf 'state=%s\\n' \"\$S\"",
|
||
"printf 'alive=%s\\n' \"\$A\"",
|
||
"printf 'phase=%s\\n' \"\$(head -1 \"\$W/phase\" 2>/dev/null)\"",
|
||
"printf 'note=%s\\n' \"\$(head -1 \"\$W/note\" 2>/dev/null)\"",
|
||
"printf 'rolledback=%s\\n' \"\$R\"",
|
||
]))->stdout;
|
||
|
||
$status = ['state' => '', 'alive' => 'no', 'phase' => '', 'note' => '', 'rolledback' => 'no'];
|
||
|
||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
||
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
|
||
if (array_key_exists($key, $status)) {
|
||
$status[$key] = trim($value);
|
||
}
|
||
}
|
||
|
||
return $status;
|
||
}
|
||
|
||
/** The shipped file, read from the repo — never rendered from a string in here. */
|
||
private function asset(string $relative): string
|
||
{
|
||
return (string) file_get_contents(base_path('deploy/bootstrap/'.$relative));
|
||
}
|
||
```
|
||
|
||
`awaitRollback()`, `cancelRollback()` und `giveUp()` folgen in Task 8; für diesen Testlauf genügen Rümpfe:
|
||
|
||
```php
|
||
/** @param array{state:string, alive:string, phase:string, note:string, rolledback:string} $status */
|
||
private function awaitRollback(ProvisioningRun $run, array $status, string $reason): StepResult
|
||
{
|
||
return $this->giveUp($run, $reason);
|
||
}
|
||
|
||
private function cancelRollback(): void {}
|
||
|
||
private function giveUp(ProvisioningRun $run, string $reason): StepResult
|
||
{
|
||
$run->forgetContext('bridge_deadline');
|
||
|
||
return StepResult::fail($reason);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="wortgleich hoch|während der Umstellung|VOR der Umstellung|zweiten Treiber|Treiber tot"
|
||
```
|
||
|
||
Erwartet: PASS, fünf Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php
|
||
git commit -m "EnsureNetworkBridge: Start abgekoppelt, und der Verbindungsabriss wird gepollt statt gezaehlt"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: `EnsureNetworkBridge` — abbestellen, warten, aufgeben
|
||
|
||
Die drei Enden: abbestellen erst nach der Nachprüfung, auf die Rücknahme warten statt sofort zu scheitern, und aufgeben, ohne die Rückfahrkarte wegzuwerfen.
|
||
|
||
**Files:**
|
||
- Modify: `app/Provisioning/Steps/Host/EnsureNetworkBridge.php`
|
||
- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `readStatus()`, `readBridgeState()`, Kontext-Schlüssel aus Task 7.
|
||
- Produces: keine neuen öffentlichen Namen.
|
||
|
||
- [ ] **Step 1: Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/HostStepsTest.php`:
|
||
|
||
```php
|
||
it('bestellt den Zeitgeber erst ab, nachdem die Brücke nachgeprüft ist', function () {
|
||
// Reihenfolge, nicht Geschmack. Abbestellen heißt: die Rückfahrkarte
|
||
// wegwerfen. Wer das vor der Nachprüfung tut, tut es auf Verdacht.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: true);
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
$recorded = $s['shell']->recorded();
|
||
$stateAt = null;
|
||
$cancelAt = null;
|
||
foreach ($recorded as $i => $command) {
|
||
if ($stateAt === null && str_contains($command, 'clupilot-bridge-state')) {
|
||
$stateAt = $i;
|
||
}
|
||
if ($cancelAt === null && str_contains($command, 'clupilot-bridge-cancel')) {
|
||
$cancelAt = $i;
|
||
}
|
||
}
|
||
|
||
expect($result->type)->toBe('advance')
|
||
->and($stateAt)->not->toBeNull()
|
||
->and($cancelAt)->not->toBeNull()
|
||
->and($stateAt)->toBeLessThan($cancelAt)
|
||
// Und der Termin ist weg, sonst liest ein späterer Lauf ihn als „mitten
|
||
// in einer Umstellung".
|
||
->and($run->fresh()->context('bridge_deadline'))->toBeNull();
|
||
});
|
||
|
||
it('wartet auf die Rücknahme, statt einen halb umgestellten Host liegen zu lassen', function () {
|
||
// Solange der Zeitgeber aussteht, steckt die Maschine mitten in einer
|
||
// Umstellung. Ein fail() ließe sie dort liegen, und der Nächste, der
|
||
// hinsieht, fände einen halben Host ohne Erklärung.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
scriptBridgeStatus($s['shell'], 'failed', note: 'Tunnel steht nicht', rolledBack: false);
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
expect($result->type)->toBe('poll')
|
||
// Und der Termin bleibt stehen, sonst gilt der nächste Besuch als
|
||
// erster und startet einen zweiten Treiber.
|
||
->and($run->fresh()->context('bridge_deadline'))->not->toBeNull();
|
||
});
|
||
|
||
it('scheitert mit dem Grund des Treibers, sobald die Rücknahme durch ist', function () {
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
scriptBridgeStatus($s['shell'], 'failed', note: 'Tunnel steht nicht', rolledBack: true);
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
expect($result->type)->toBe('fail')
|
||
->and($result->reason)->toContain('Tunnel steht nicht')
|
||
->and($run->fresh()->context('bridge_deadline'))->toBeNull();
|
||
});
|
||
|
||
it('bestellt den Zeitgeber beim Aufgeben NICHT ab', function () {
|
||
// Er ist die Rückfahrkarte. Steht er noch, hat er seinen Grund — und wer ihn
|
||
// beim Aufgeben abbestellt, lässt den Host genau in dem Zustand stehen, für
|
||
// den er gestellt wurde.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->subMinute()->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
scriptBridgeStatus($s['shell'], 'running', alive: true, phase: 'nachsehen');
|
||
|
||
expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail')
|
||
->and($s['shell']->ran('clupilot-bridge-cancel'))->toBeFalse();
|
||
});
|
||
|
||
it('versucht es höchstens zweimal je Lauf', function () {
|
||
// Der zweite ist für den Fall, dass der Betreiber zwischen den Versuchen
|
||
// etwas repariert hat. Danach die Handarbeit-Meldung — ein dritter Versuch
|
||
// gegen dieselbe Maschine wäre nur dieselbe Viertelstunde noch einmal.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_attempts' => 2]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
|
||
$result = app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
expect($result->type)->toBe('fail')
|
||
->and($result->reason)->toContain('by hand')
|
||
->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
|
||
});
|
||
|
||
it('erlaubt einem Betreiber, nach einem Fehlschlag von vorn anzufangen', function () {
|
||
// giveUp() löscht den Termin — sonst macht die Erst-Besuch-Prüfung aus
|
||
// jedem Retry nach einem Fehlschlag einen sofortigen zweiten Fehlschlag,
|
||
// ohne dass der Host überhaupt angefasst wurde.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
|
||
proveTunnel($run, $host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
scriptBridgeStatus($s['shell'], 'failed', note: 'irgendwas', rolledBack: true);
|
||
|
||
app(EnsureNetworkBridge::class)->execute($run);
|
||
|
||
expect($run->fresh()->context('bridge_deadline'))->toBeNull()
|
||
// Der Zähler NICHT — sonst ist der Deckel von zwei Versuchen keiner.
|
||
->and($run->fresh()->context('bridge_attempts'))->not->toBeNull();
|
||
});
|
||
|
||
it('schickt nur Shell, die eine Shell auch parsen kann', function () {
|
||
// FakeRemoteShell führt keinen dieser Befehle aus. Ein verrutschtes
|
||
// Anführungszeichen käme sonst zuerst auf einem echten Host zur Ausführung
|
||
// — und der Schritt, den es kaputtmacht, ist der, der das Netz umstellt.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
proveTunnel(hostRun($host), $host);
|
||
|
||
$run = hostRun($host);
|
||
scriptBridgeState($s['shell'], up: false);
|
||
app(EnsureNetworkBridge::class)->execute($run); // state + start
|
||
|
||
scriptBridgeStatus($s['shell'], 'failed', note: 'nope', rolledBack: true);
|
||
app(EnsureNetworkBridge::class)->execute(
|
||
hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()])
|
||
); // state + status + log + reset
|
||
|
||
$s2 = fakeServices();
|
||
proveTunnel(hostRun($host), $host);
|
||
scriptBridgeState($s2['shell'], up: true);
|
||
app(EnsureNetworkBridge::class)->execute(
|
||
hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()])
|
||
); // state + cancel
|
||
|
||
$checked = 0;
|
||
foreach (array_merge($s['shell']->recorded(), $s2['shell']->recorded()) as $command) {
|
||
expect(Process::input($command)->run('sh -n')->successful())
|
||
->toBeTrue("kein gültiges POSIX-sh:\n".$command);
|
||
$checked++;
|
||
}
|
||
|
||
expect($checked)->toBeGreaterThanOrEqual(6);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Zeitgeber erst ab|wartet auf die Rücknahme|Grund des Treibers|NICHT ab|höchstens zweimal|von vorn anzufangen|parsen kann"
|
||
```
|
||
|
||
Erwartet: FAIL — `clupilot-bridge-cancel` wird nie abgesetzt, `awaitRollback` gibt sofort auf.
|
||
|
||
- [ ] **Step 3: Die drei Enden richtig bauen**
|
||
|
||
Die Rümpfe aus Task 7 ersetzen:
|
||
|
||
```php
|
||
/**
|
||
* Wait for the rollback to complete, and only then fail.
|
||
*
|
||
* Not immediately: while the timer is still pending the machine is in the
|
||
* middle of a change, and a fail() would leave it there — the next person to
|
||
* look would find half a host with no explanation of why.
|
||
*
|
||
* The rollback script writes `failed` plus a reason BEFORE it restores, so
|
||
* even a partial restore leaves a verdict, and touches `rolled-back` as its
|
||
* LAST action. That file is the signal: it is only there once the old
|
||
* configuration is genuinely back.
|
||
*
|
||
* The wait is capped by the step's deadline, which is three times the
|
||
* timer's fuse — so this cannot poll forever.
|
||
*
|
||
* @param array{state:string, alive:string, phase:string, note:string, rolledback:string} $status
|
||
*/
|
||
private function awaitRollback(ProvisioningRun $run, array $status, string $reason): StepResult
|
||
{
|
||
$deadline = $run->context('bridge_deadline');
|
||
$expired = $deadline !== null && now()->greaterThan(Carbon::parse($deadline));
|
||
|
||
if ($status['rolledback'] !== 'yes' && ! $expired) {
|
||
return StepResult::poll(
|
||
20,
|
||
'die Brücke trägt nicht — warte auf die Rücknahme des Zeitgebers'
|
||
);
|
||
}
|
||
|
||
return $this->giveUp(
|
||
$run,
|
||
$reason.' The rollback timer has put the previous network configuration back'.
|
||
($status['rolledback'] === 'yes' ? '' : ' (or is about to — its fuse is '.self::ROLLBACK_MINUTES.' minutes)').
|
||
', so the machine is reachable on its old settings.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Throw the return ticket away — and never before the bridge has been
|
||
* checked. Reaching the caller means SSH arrived over the tunnel and vmbr0
|
||
* carries the default route with an address, which is the proof from the
|
||
* outside.
|
||
*/
|
||
private function cancelRollback(): void
|
||
{
|
||
$this->shell->run(implode("\n", [
|
||
': clupilot-bridge-cancel',
|
||
'U=clupilot-network-rollback',
|
||
'systemctl stop "$U.timer" 2>/dev/null || true',
|
||
'rm -f "/etc/systemd/system/$U.timer" "/etc/systemd/system/$U.service" "/usr/local/sbin/$U.sh"',
|
||
'systemctl daemon-reload 2>/dev/null || true',
|
||
]));
|
||
}
|
||
|
||
/**
|
||
* Fail, and leave nothing behind that would make a retry read this verdict
|
||
* again — the status on the host and the deadline in the run, which is what
|
||
* execute() reads to tell a first visit from a lost one.
|
||
*
|
||
* `bridge_attempts` deliberately SURVIVES: it is the cap on how often
|
||
* CluPilot will drive a live host's network into a rollback, and clearing it
|
||
* here would make that cap meaningless.
|
||
*
|
||
* The rollback timer is deliberately NOT cancelled. It is the return ticket;
|
||
* if it is still armed, that is because nobody has proven the host came back
|
||
* — and cancelling it while giving up would leave the machine in exactly the
|
||
* state the timer exists for.
|
||
*/
|
||
private function giveUp(ProvisioningRun $run, string $reason): StepResult
|
||
{
|
||
$run->forgetContext('bridge_deadline');
|
||
|
||
$tail = trim($this->shell->run(
|
||
': clupilot-bridge-log'."\n".'tail -n 25 '.self::WORK_DIR.'/bridge.log 2>/dev/null'
|
||
)->stdout);
|
||
|
||
// Stop the driver BEFORE the status files go, never after. Clearing them
|
||
// makes the step retryable, and a retry meeting a driver still running
|
||
// gets two of them on one /etc/network/interfaces, each with a timer of
|
||
// its own — one cancelling what the other armed.
|
||
//
|
||
// The whole process group (`-$P`), because the driver spends its time
|
||
// waiting on an ifreload. The cmdline guard is against a recycled pid:
|
||
// this file can be minutes old, and killing a stranger's process group
|
||
// would be a far worse bug than the one being handled.
|
||
$this->shell->run(implode("\n", [
|
||
': clupilot-bridge-reset',
|
||
'W='.self::WORK_DIR,
|
||
'P=$(cat "$W/pid" 2>/dev/null)',
|
||
'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null && tr "\\0" " " < /proc/"$P"/cmdline 2>/dev/null | grep -q bridge-run; then',
|
||
' kill -TERM -"$P" 2>/dev/null || kill -TERM "$P" 2>/dev/null',
|
||
' i=0; while [ "$i" -lt 10 ] && kill -0 "$P" 2>/dev/null; do sleep 1; i=$((i + 1)); done',
|
||
' kill -KILL -"$P" 2>/dev/null || kill -KILL "$P" 2>/dev/null',
|
||
'fi',
|
||
'rm -f "$W/state" "$W/pid" "$W/note" "$W/rolled-back"',
|
||
]));
|
||
|
||
return StepResult::fail($tail === '' ? $reason : $reason.' Last lines: '.$tail);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Zeitgeber erst ab|wartet auf die Rücknahme|Grund des Treibers|NICHT ab|höchstens zweimal|von vorn anzufangen|parsen kann"
|
||
```
|
||
|
||
Erwartet: PASS, sieben Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php
|
||
git commit -m "EnsureNetworkBridge: abbestellen nach der Nachpruefung, aufgeben ohne die Rueckfahrkarte wegzuwerfen"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Verdrahten
|
||
|
||
Der Schritt in die Pipeline, die Beschriftung in beide Sprachen, `bridge.sh` ins Bootstrap-Archiv — und die ganze Suite grün.
|
||
|
||
**Files:**
|
||
- Modify: `config/provisioning.php` (Pipeline `host`, zwischen `RebootIntoPveKernel` und `ConfigureProxmox`)
|
||
- Modify: `lang/de/hosts.php:136-152`, `lang/en/hosts.php` (Block `step`)
|
||
- Modify: `tests/Feature/Host/BootstrapArchiveTest.php:43` (bridge.sh und bridge-run.sh erwarten)
|
||
- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `EnsureNetworkBridge` aus Task 6–8.
|
||
- Produces: nichts Neues.
|
||
|
||
- [ ] **Step 1: Test schreiben**
|
||
|
||
Anhängen an `tests/Feature/Provisioning/HostStepsTest.php`:
|
||
|
||
```php
|
||
it('baut die Brücke nach dem Neustart und vor ConfigureProxmox', function () {
|
||
// Nach dem Neustart, weil dort ifupdown2 steht und der Tunnel gerade
|
||
// bewiesen hat, dass er einen Neustart überlebt. Vor ConfigureProxmox, weil
|
||
// dessen refuseWithoutBridge() die Nachprüfung ist — bauen UND prüfen,
|
||
// dasselbe Paar wie BuildVmTemplate → VerifyVmTemplate.
|
||
$pipeline = config('provisioning.pipelines.host');
|
||
|
||
$bridge = array_search(EnsureNetworkBridge::class, $pipeline, true);
|
||
$reboot = array_search(RebootIntoPveKernel::class, $pipeline, true);
|
||
$proxmox = array_search(ConfigureProxmox::class, $pipeline, true);
|
||
|
||
expect($bridge)->not->toBeFalse()
|
||
->and($reboot)->toBeLessThan($bridge)
|
||
->and($bridge)->toBeLessThan($proxmox);
|
||
});
|
||
|
||
it('lässt ConfigureProxmox die Nachprüfung bleiben', function () {
|
||
// Der Schritt davor baut. Dieser prüft. Beides, nicht eines von beiden —
|
||
// sonst meldet ein Bau, der still danebenging, trotzdem Erfolg.
|
||
$s = fakeServices();
|
||
$host = Host::factory()->active()->create();
|
||
$run = hostRun($host);
|
||
proveTunnel($run, $host);
|
||
// CommandResult::failure(int $exitCode = 1, string $stderr = '') — zwei
|
||
// Parameter, nicht drei.
|
||
$s['shell']->script('ip link show vmbr0', CommandResult::failure(1, 'Device does not exist'));
|
||
|
||
expect(app(ConfigureProxmox::class)->execute($run)->type)->toBe('fail');
|
||
});
|
||
|
||
it('beschriftet den Schritt in beiden Sprachen', function () {
|
||
// Die Konsole zeigt eine Zeile pro Schritt. Eine ohne Beschriftung zeigt den
|
||
// Schlüssel — und der Betreiber sieht beim Zusehen 'hosts.step.…'.
|
||
expect(trans('hosts.step.ensure_network_bridge', [], 'de'))->not->toBe('hosts.step.ensure_network_bridge')
|
||
->and(trans('hosts.step.ensure_network_bridge', [], 'en'))->not->toBe('hosts.step.ensure_network_bridge');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Testlauf, der scheitern muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="nach dem Neustart und vor|Nachprüfung bleiben|beiden Sprachen"
|
||
```
|
||
|
||
Erwartet: FAIL — `EnsureNetworkBridge` steht nicht in der Pipeline, die Beschriftung fehlt.
|
||
|
||
- [ ] **Step 3: Verdrahten**
|
||
|
||
In `config/provisioning.php`, in `pipelines.host`, zwischen `Host\RebootIntoPveKernel::class` und `Host\ConfigureProxmox::class`:
|
||
|
||
```php
|
||
// Nach dem Neustart, weil dort ifupdown2 steht (PrepareBaseSystem
|
||
// installiert es) und der Tunnel gerade bewiesen hat, dass er einen
|
||
// Neustart überlebt.
|
||
//
|
||
// Vor ConfigureProxmox, dessen refuseWithoutBridge() unverändert
|
||
// stehen bleibt und damit zur Nachprüfung wird — dasselbe Paar wie
|
||
// BuildVmTemplate → VerifyVmTemplate. Ein Bau, der still danebenging,
|
||
// fällt dann hier auf und nicht erst beim ersten bezahlten Klon.
|
||
Host\EnsureNetworkBridge::class,
|
||
```
|
||
|
||
In `lang/de/hosts.php`, im Block `step`, nach `'reboot_into_pve_kernel'`:
|
||
|
||
```php
|
||
'ensure_network_bridge' => 'Netzbrücke vmbr0 bauen',
|
||
```
|
||
|
||
In `lang/en/hosts.php` an derselben Stelle:
|
||
|
||
```php
|
||
'ensure_network_bridge' => 'Build the vmbr0 network bridge',
|
||
```
|
||
|
||
In `tests/Feature/Host/BootstrapArchiveTest.php` bei Zeile 43 ergänzen:
|
||
|
||
```php
|
||
->toContain('bootstrap/lib/bridge.sh')
|
||
->toContain('bootstrap/lib/bridge-run.sh')
|
||
```
|
||
|
||
- [ ] **Step 4: Testlauf, der bestehen muss**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="nach dem Neustart und vor|Nachprüfung bleiben|beiden Sprachen"
|
||
```
|
||
|
||
Erwartet: PASS, drei Tests.
|
||
|
||
- [ ] **Step 5: Die ganze Suite**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest
|
||
```
|
||
|
||
Erwartet: alles grün. Ein Fehlschlag in `BootstrapArchiveTest` oder `HostStepsTest` heißt, dass eine Nachbarschaft übersehen wurde — nicht, dass der Test falsch ist.
|
||
|
||
- [ ] **Step 6: Lint**
|
||
|
||
```bash
|
||
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pint --dirty
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add config/provisioning.php lang/de/hosts.php lang/en/hosts.php tests/Feature/Host/BootstrapArchiveTest.php tests/Feature/Provisioning/HostStepsTest.php
|
||
git commit -m "EnsureNetworkBridge in die Pipeline: nach dem Neustart, vor der Nachpruefung"
|
||
```
|
||
|
||
---
|
||
|
||
## Nach dem Plan
|
||
|
||
1. **R15 / Codex-Review** über den Diff, nach `docs/…/clupilot-r15-codex-review`. Höchstens zwei Runden ohne P1 (R22.2); Restbefunde als Folgepunkte.
|
||
2. **Version ziehen und Marke setzen** nach dem Release-Verfahren — Aktualisierungen hängen an `v*`-Marken.
|
||
3. **Abnahme auf echter Hardware.** Zwei Läufe, und der zweite ist der wichtigere:
|
||
- Frische Debian-13-Maschine ohne Brücke: Host anlegen, zusehen, `active` — ohne einen Handgriff.
|
||
- `pve-fns-1`, das die Brücke schon von Hand hat: der Schritt muss `advance()` melden und **nichts** anfassen.
|
||
4. **Nicht in diesem Schnitt** (bekannt, notiert): `network.sh:365` benutzt weiterhin `ping` in `wireguard_handshake_proven` — der stillgelegte Rettungssystem-Weg.
|