bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht

Die Bruecken-Erkennung stand in network.sh, geschrieben fuer den
stillgelegten Rettungssystem-Weg, und borgte 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 laedt die Bibliothek EINZELN auf den Host und faehrt sie
dort — geborgte Helfer waeren dann nicht da. Also allein lauffaehig, mit
log und http_get unter einem command-v-Schutz, damit der Bootstrap seine
eigenen behaelt.

Neu und aus dem laufenden Zustand abgeleitet:
- interface_is_physical — bridge_ports auf einem Bond oder einer
  bestehenden Bridge ist falsch, und aus der Ferne nicht reparierbar.
- address_is_dynamic — der Kernel markiert eine geleaste Adresse, das
  steht bei jedem Anbieter gleich da. Die Datei des Anbieters ist nur
  noch das Zweitsignal.

Geprueft gegen eine echte sh mit aufgezeichneten ip-Ausgaben, plus die
Zusicherung, dass es detect_primary_interface im Repo genau einmal gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
nexxo 2026-08-01 12:34:26 +02:00
parent 1e570f17fc
commit 81df08aaa6
5 changed files with 396 additions and 66 deletions

View File

@ -965,6 +965,10 @@ main() {
CLUPILOT_BOOTSTRAP_DIR="$(resolve_bootstrap_dir)"
# shellcheck source=lib/report.sh
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/report.sh"
# VOR proxmox.sh und network.sh: bridge.sh hält seit dem Debian-Weg die
# Brücke samt `detect_primary_interface`, und beide greifen darauf zu.
# shellcheck source=lib/bridge.sh
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/bridge.sh"
# shellcheck source=lib/proxmox.sh
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/proxmox.sh"
# shellcheck source=lib/network.sh

View File

@ -0,0 +1,184 @@
# 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"
}

View File

@ -37,70 +37,20 @@
# Die Sperre steht deshalb in Abschnitt `registered` (Task 9), an derselben
# Stelle wie früher: nach dem bewiesenen Handshake, nach Traefik.
CLUPILOT_ROLLBACK_UNIT='clupilot-network-rollback'
CLUPILOT_NET_BACKUP='/var/lib/clupilot/interfaces.vor-der-bruecke'
# ---------------------------------------------------------------------------
# Feststellen, was da ist
# Feststellen, was da ist — steht jetzt in lib/bridge.sh
# ---------------------------------------------------------------------------
bridge_exists() {
ip link show "${1:-vmbr0}" >/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() {
_dev="$(ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')"
[ "$_dev" = "${1:-vmbr0}" ]
}
bridge_has_address() {
[ -n "$(ip -4 -o addr show dev "${1:-vmbr0}" scope global 2>/dev/null | awk '{ print $4; exit }')" ]
}
# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall
# richtig ist, nimmt den anderen vom Netz.
# `bridge_exists`, `bridge_carries_default_route`, `bridge_has_address`,
# `detect_network_style`, `detect_primary_interface` und die beiden Variablen
# `CLUPILOT_ROLLBACK_UNIT`/`CLUPILOT_NET_BACKUP` sind nach `lib/bridge.sh`
# gezogen, samt der Helfer, die sie sich früher aus `clupilot-bootstrap.sh`
# borgten.
#
# - `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)"
if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" /etc/network/interfaces /etc/network/interfaces.d/* 2>/dev/null; then
printf 'dhcp'
return 0
fi
_cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
_gw="$(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" ] && ip -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then
printf 'subnet'
return 0
fi
printf 'routed'
}
# Der Grund ist der Debian-Weg: dort lädt `EnsureNetworkBridge` die Bibliothek
# EINZELN auf den Host und fährt sie, und geborgte Helfer wären dann nicht da.
# `clupilot-bootstrap.sh` sourced `bridge.sh` vor dieser Datei, hier ändert sich
# also nichts — außer, dass es die Brücke nur noch einmal im Repo gibt.
# ---------------------------------------------------------------------------
# Die Selbstrücknahme

View File

@ -126,12 +126,10 @@ detect_boot_mode() {
fi
}
# 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() {
ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }'
}
# `detect_primary_interface` steht in lib/bridge.sh — eine Fassung, zwei
# Benutzer. clupilot-bootstrap.sh lädt bridge.sh vor dieser Datei, und die
# Funktion wird ohnehin erst zur Aufrufzeit aufgelöst, also ist die Reihenfolge
# unkritisch.
# Setzt CLUPILOT_NET_CIDR, _GATEWAY, _DNS, _MAC aus dem, was das Rettungssystem
# gerade benutzt.

View File

@ -0,0 +1,194 @@
<?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 steht bei
// jedem Anbieter gleich da — anders als die Datei, in die er es geschrieben
// hat.
$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; sein Kommentar behauptete, er halte die
// Abwesenheit fest, und er hielt nichts fest.
$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');
});
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 beim Herauslösen 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);
}
});