<?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());
}

/**
 * Baut einen Testrumpf, der JA oder NEIN meldet — und FEHLT, wenn es die
 * Funktion gar nicht gibt.
 *
 * Ohne diese Unterscheidung ist jeder Test, der NEIN erwartet, grün, SOLANGE die
 * Funktion fehlt: `if gibtsnicht; then … else echo NEIN; fi` ist in sh schlicht
 * unwahr. Die rote Phase wäre dann aus dem falschen Grund rot und die grüne
 * bewiese nichts — genau die Sorte Attrappe, die hier schon einmal vier echte
 * Fehler durchgelassen hat.
 *
 * Die Ausgabe der Funktion selbst wird verworfen, damit `log`-Zeilen das Urteil
 * nicht verwässern und `toBe` statt `toContain` geprüft werden kann.
 */
function verdictBody(string $call): string
{
    $function = strtok($call, ' ');

    return "command -v {$function} >/dev/null 2>&1 || { echo FEHLT; exit 1; }\n"
        ."if {$call} >/dev/null 2>&1; then echo JA; else echo NEIN; fi";
}

/**
 * Ruft eine Funktion, die etwas ausgibt — und meldet FEHLT, wenn es sie nicht
 * gibt. Dieselbe Begründung wie bei verdictBody(): eine leere Ausgabe und eine
 * fehlende Funktion sind von außen nicht zu unterscheiden.
 */
function callBridgeFn(string $call, array $env = [], string $pre = ''): string
{
    $function = strtok($call, ' ');

    return runBridgeSh(
        "command -v {$function} >/dev/null 2>&1 || { echo FEHLT; exit 1; }\n{$call}",
        $env,
        $pre
    );
}

/** 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(
        verdictBody('interface_is_physical bond0'),
        ['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(
        verdictBody('interface_is_physical enp0s31f6'),
        ['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(
        verdictBody('bridge_is_up'),
        ['CLUPILOT_IP' => $ip]
    );

    expect($out)->toBe('NEIN');
});

it('liest kein Gateway aus einer Standardroute, die keins hat', function () {
    // `default dev ens3 scope link` — kein `via`. Ein `awk '{print $3}'` liefert
    // dort den KARTENNAMEN, und daraus würde `gateway ens3` in der Strophe.
    // Der Zeitgeber finge das ab, aber der Host wäre umsonst gescheitert.
    $ip = fakeIp($this->dir, [
        '-4 route show default' => 'default dev ens3 scope link',
    ]);

    expect(runBridgeSh('default_gateway', ['CLUPILOT_IP' => $ip]))->toBe('');
});

it('liest das Gateway hinter dem via, nicht an fester Feldnummer', function () {
    $ip = fakeIp($this->dir, [
        '-4 route show default' => 'default via 10.0.0.1 dev ens3 proto dhcp src 10.0.0.7 metric 100',
    ]);

    expect(runBridgeSh('default_gateway', ['CLUPILOT_IP' => $ip]))->toBe('10.0.0.1');
});

it('hält eine gatewaylose Karte nicht für eine geroutete Einzeladresse', function () {
    // Ohne Gateway gibt es weder eins im eigenen Subnetz noch eines ausserhalb.
    // `routed` zu melden hiesse `pointopoint` auf nichts.
    $ip = fakeIp($this->dir, [
        '-4 route show default' => 'default dev ens3 scope link',
        '-4 -o addr show dev ens3 scope global' => '2: ens3    inet 10.0.0.7/24 scope global ens3',
    ]);

    expect(runBridgeSh('detect_network_style ens3', ['CLUPILOT_IP' => $ip]))
        ->toBe('subnet');
});

it('schreibt ohne Gateway auch keine gateway-Zeile', 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:07');
    $out = $this->dir.'/interfaces';

    runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 ""', [
        'CLUPILOT_IP' => $ip,
        'CLUPILOT_SYS_NET' => $sys,
        'CLUPILOT_INTERFACES_FILE' => $out,
    ]);

    expect(file_get_contents($out))
        ->toContain('address 10.0.0.7/24')
        ->not->toContain('gateway')
        ->not->toContain('pointopoint');
});

it('hält jede Brückenfunktion 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.
    //
    // Die Liste wächst mit bridge.sh mit: wer eine Funktion hierher zieht und
    // die alte Fassung stehen lässt, fällt hier auf und nicht auf einem Host.
    //
    // 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')) ?: [],
    );

    $functions = [
        'detect_primary_interface',
        'detect_network_style',
        'bridge_exists',
        'bridge_carries_default_route',
        'bridge_has_address',
        'build_bridge',
        'write_bridge_stanza',
        'backup_network_config',
        'schedule_network_rollback',
        'cancel_network_rollback',
    ];

    foreach ($functions as $function) {
        $hits = [];
        foreach ($files as $file) {
            if (preg_match('/^'.$function.'\(\)/m', (string) file_get_contents($file))) {
                $hits[] = str_replace(base_path().'/', '', $file);
            }
        }

        expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh'], $function.' steht nicht genau einmal im Repo');
    }
});

/*
|--------------------------------------------------------------------------
| Die Strophe
|--------------------------------------------------------------------------
|
| Vier Anbieterfä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,
    ]);

    expect(file_get_contents($out))
        ->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,
    ]);

    expect(file_get_contents($out))
        ->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 ein gescheitertes `up` nimmt bei
        // `ifreload` die ganze 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,
    ]);

    expect(file_get_contents($out))
        ->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,
    ]);

    expect(file_get_contents($out))
        ->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,
    ]);

    expect(file_get_contents($out))
        ->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');
});

/*
|--------------------------------------------------------------------------
| 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');
    $doneAt = strpos($script, 'rolled-back');

    expect($stateAt)->not->toBeFalse()
        ->and($restoreAt)->not->toBeFalse()
        ->and($doneAt)->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($doneAt)->toBeGreaterThan($restoreAt);
});

it('legt die Marke NICHT an, wenn das Zurückspielen scheitert', function () {
    // Ohne diese Prüfung laufen tar und ifreload ins Leere, das Skript macht
    // weiter und setzt `rolled-back` — CluPilot meldet dann „der alte Zustand
    // ist zurück" und gibt auf, während der Host auf einer kaputten
    // Konfiguration steht. Das ursprüngliche network.sh hatte dafür `set -e`;
    // beim Umbau ist es verlorengegangen.
    $work = $this->dir.'/work';
    mkdir($work, 0o755, true);

    $script = runBridgeSh('render_rollback_script 5', [
        'CLUPILOT_WORK_DIR' => $work,
        // Ein Archiv, das es nicht gibt: tar scheitert.
        'CLUPILOT_NET_BACKUP' => $this->dir.'/gibtesnicht',
        'CLUPILOT_UNIT_DIR' => $this->dir.'/units',
    ]);
    file_put_contents($this->dir.'/rollback.sh', $script);

    $result = Process::run('sh '.$this->dir.'/rollback.sh');

    expect($result->exitCode())->not->toBe(0)
        ->and(file_exists($work.'/rolled-back'))->toBeFalse()
        // Das Urteil steht trotzdem — es wird VOR dem Zurückspielen geschrieben.
        ->and(file_get_contents($work.'/state'))->toBe('failed');
});

it('schaltet die Rücknahme den entmachteten Netzverwalter wieder ein', function () {
    // Sonst spielt sie eine /etc/network/interfaces zurück, die diese Maschine
    // nie getragen hat, während der Verwalter, der es tat, abgeschaltet bleibt.
    // Genau der tote Host, den der Zeitgeber verhindern soll.
    $work = $this->dir.'/work';
    $etc = $this->dir.'/etc';
    mkdir($work, 0o755, true);
    mkdir("{$etc}/cloud/cloud.cfg.d", 0o755, true);
    file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n");

    $env = [
        'CLUPILOT_WORK_DIR' => $work,
        'CLUPILOT_ETC' => $etc,
        'CLUPILOT_NET_BACKUP' => $this->dir.'/sicherung',
        'CLUPILOT_UNIT_DIR' => $this->dir.'/units',
        'CLUPILOT_INTERFACES_D' => $this->dir.'/interfaces.d',
    ];

    // Erst entmachten, dann das Skript fahren, das der Zeitgeber gestellt hat.
    runBridgeSh('disown_network_manager cloud-init ens3', $env);
    expect(file_exists("{$etc}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg"))->toBeTrue();

    // Eine Sicherung, damit tar nicht vorher scheitert.
    file_put_contents($this->dir.'/leer', "x\n");
    Process::run('tar czf '.$this->dir.'/sicherung.tar.gz -C '.$this->dir.' leer');

    $script = runBridgeSh('render_rollback_script 5', $env);
    file_put_contents($this->dir.'/rollback.sh', $script);
    Process::run('sh '.$this->dir.'/rollback.sh');

    expect(file_exists("{$etc}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg"))->toBeFalse();
});

it('nimmt eine verdrängte Strophe aus dem Quellverzeichnis heraus', function () {
    // `source /etc/network/interfaces.d/*` fasst auch eine umbenannte Datei —
    // der Stern nimmt sie mit. Umbenennen allein löst die Kollision also nicht.
    $etc = $this->dir.'/etc';
    $d = $this->dir.'/interfaces.d';
    $work = $this->dir.'/work';
    mkdir($etc, 0o755, true);
    mkdir($d, 0o755, true);
    mkdir($work, 0o755, true);
    file_put_contents("{$d}/50-cloud-init", "auto ens3\niface ens3 inet static\n    address 10.0.0.7/24\n");
    file_put_contents("{$d}/99-egal", "iface eth9 inet manual\n");

    runBridgeSh('disown_network_manager "" ens3', [
        'CLUPILOT_ETC' => $etc,
        'CLUPILOT_INTERFACES_D' => $d,
        'CLUPILOT_WORK_DIR' => $work,
    ]);

    // Nichts, was der Stern noch fassen könnte.
    expect(glob("{$d}/*"))->toBe(["{$d}/99-egal"])
        ->and(file_exists("{$work}/verdraengt/50-cloud-init"))->toBeTrue()
        // Und die Rücknahme muss wissen, wohin damit.
        ->and(file_get_contents("{$work}/moved-aside"))->toContain("{$d}/50-cloud-init");
});

it('stellt die Rücknahme eine verdrängte Strophe zurück', function () {
    $etc = $this->dir.'/etc';
    $d = $this->dir.'/interfaces.d';
    $work = $this->dir.'/work';
    mkdir($etc, 0o755, true);
    mkdir($d, 0o755, true);
    mkdir($work, 0o755, true);
    file_put_contents("{$d}/50-cloud-init", "iface ens3 inet static\n");

    $env = [
        'CLUPILOT_ETC' => $etc,
        'CLUPILOT_INTERFACES_D' => $d,
        'CLUPILOT_WORK_DIR' => $work,
        'CLUPILOT_NET_BACKUP' => $this->dir.'/sicherung',
        'CLUPILOT_UNIT_DIR' => $this->dir.'/units',
    ];

    runBridgeSh('disown_network_manager "" ens3', $env);
    expect(file_exists("{$d}/50-cloud-init"))->toBeFalse();

    file_put_contents($this->dir.'/leer', "x\n");
    Process::run('tar czf '.$this->dir.'/sicherung.tar.gz -C '.$this->dir.' leer');

    $script = runBridgeSh('render_rollback_script 5', $env);
    file_put_contents($this->dir.'/rollback.sh', $script);
    Process::run('sh '.$this->dir.'/rollback.sh');

    expect(file_exists("{$d}/50-cloud-init"))->toBeTrue();
});

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. Umgekehrt wäre es die Reihenfolge, die einen Host dauerhaft
    // unerreichbar macht.
    $bin = $this->dir.'/bin';
    mkdir($bin, 0o755, true);
    file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n");
    file_put_contents("{$bin}/ifreload", "#!/bin/sh\necho 'ifreload' >> '{$this->dir}/calls'\n");
    chmod("{$bin}/systemctl", 0o755);
    chmod("{$bin}/ifreload", 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($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()
        ->and(strpos($calls, 'start clupilot-network-rollback.timer'))
        ->toBeLessThan(strpos($calls, 'ifreload'))
        // Und die Sicherung muss existieren, bevor der Zeitgeber sie
        // überhaupt zurückspielen könnte.
        ->and(file_exists($this->dir.'/sicherung.tar.gz'))->toBeTrue();
});

it('bestellt beim Abbestellen alles ab, was gestellt wurde', function () {
    // Ein Zeitgeber, dessen Unit-Dateien liegen bleiben, sieht beim nächsten
    // Hinsehen aus wie eine ausstehende Rücknahme.
    $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);

    runBridgeSh(
        "schedule_network_rollback 5\ncancel_network_rollback",
        [
            'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl",
            'CLUPILOT_UNIT_DIR' => $this->dir.'/units',
            'CLUPILOT_SBIN_DIR' => $this->dir.'/sbin',
            'CLUPILOT_WORK_DIR' => $this->dir,
        ]
    );

    expect(file_get_contents($this->dir.'/calls'))->toContain('stop clupilot-network-rollback.timer')
        ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeFalse()
        ->and(file_exists($this->dir.'/units/clupilot-network-rollback.service'))->toBeFalse()
        ->and(file_exists($this->dir.'/sbin/clupilot-network-rollback.sh'))->toBeFalse();
});

/*
|--------------------------------------------------------------------------
| 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;
    file_put_contents("{$dir}/wg", "#!/bin/sh\nprintf '%s\\t%s\\n' '{$peer}' '{$when}'\n");
    chmod("{$dir}/wg", 0o755);

    return "{$dir}/wg";
}

/** Eine Brücke, die steht und trägt. */
function fakeUpBridge(string $dir): string
{
    return fakeIp($dir, [
        'link show vmbr0' => '5: vmbr0: <BROADCAST,MULTICAST,UP> mtu 1500',
        '-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',
    ]);
}

it('bestellt nicht ab, wenn der Tunnel steht, aber das Internet nicht erreichbar ist', function () {
    $ip = fakeUpBridge($this->dir);
    $wg = fakeWg($this->dir, 'HUBKEY=', 5);

    $out = runBridgeSh(
        verdictBody('bridge_proven'),
        [
            '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)->toBe('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 = fakeUpBridge($this->dir);
    $wg = fakeWg($this->dir, 'HUBKEY=', 3600);   // eine Stunde alt
    $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(
        verdictBody('bridge_proven'),
        [
            'CLUPILOT_IP' => $ip,
            'CLUPILOT_WG' => $wg,
            'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=',
            'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl",
            'CLUPILOT_HANDSHAKE_TRIES' => '1',
            'CLUPILOT_HANDSHAKE_WAIT' => '0',
        ],
        pre: 'http_get() { return 0; }'
    );

    expect($out)->toBe('NEIN')
        // Einmal nachhelfen gehört dazu: das ist zu diesem Zeitpunkt gefahrlos,
        // weil ifreload die SSH-Sitzung ohnehin schon mitgenommen hat, und
        // verwandelt einen hängenden Tunnel in einen laufenden statt in eine
        // Rücknahme.
        ->and(file_get_contents($this->dir.'/calls'))
        ->toContain('restart wg-quick@wg0');
});

it('bestellt ab, wenn beide Richtungen stimmen', function () {
    $ip = fakeUpBridge($this->dir);
    $wg = fakeWg($this->dir, 'HUBKEY=', 5);

    $out = runBridgeSh(
        verdictBody('bridge_proven'),
        [
            'CLUPILOT_IP' => $ip,
            'CLUPILOT_WG' => $wg,
            'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=',
        ],
        pre: 'http_get() { return 0; }'
    );

    expect($out)->toBe('JA');
});

it('erkennt einen Handshake von einem FREMDEN Peer nicht als den des Hubs', function () {
    // Auf demselben Hub liegen auch Mitarbeiter-Zugänge. Deren Handshake sagt
    // nichts über CluPilots eigenen Weg hierher.
    $wg = fakeWg($this->dir, 'IRGENDWER=', 5);

    $out = runBridgeSh(
        verdictBody('tunnel_handshake_fresh'),
        ['CLUPILOT_WG' => $wg, 'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=']
    );

    expect($out)->toBe('NEIN');
});

it('hält einen Handshake, der nie stattgefunden hat, nicht für frisch', function () {
    // WireGuard meldet 0 für einen Peer, mit dem noch nie gesprochen wurde.
    // Als Sekunden gelesen ist das 1970 — uralt, aber `0` ist auch der Wert,
    // den eine naive Prüfung für „vorhanden" hält.
    $wg = fakeWg($this->dir, 'HUBKEY=', 0);
    file_put_contents($this->dir.'/wg', "#!/bin/sh\nprintf '%s\\t%s\\n' 'HUBKEY=' '0'\n");

    $out = runBridgeSh(
        verdictBody('tunnel_handshake_fresh'),
        ['CLUPILOT_WG' => $this->dir.'/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 (curl, gnupg, ifupdown2, chrony). 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');
});

/*
|--------------------------------------------------------------------------
| 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.
    $driver = base_path('deploy/bootstrap/lib/bridge-run.sh');
    expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt');

    $pidAt = null;
    foreach (file($driver) ?: [] as $i => $line) {
        if (str_contains($line, '/pid"') && str_contains($line, '$$')) {
            $pidAt = $i;
            break;
        }
    }

    expect($pidAt)->not->toBeNull('keine Zeile schreibt $$ in die pid-Datei')
        ->and($pidAt)->toBeLessThan(60);
});

it('stellt den Zeitgeber vor dem ersten verändernden Aufruf', function () {
    // Dieselbe Zusicherung wie bei bridge.sh, hier auf der Ebene des Treibers:
    // die Reihenfolge der AUFRUFE im Skripttext. Umgekehrt wäre es die
    // Reihenfolge, die einen Host dauerhaft unerreichbar macht.
    $driver = base_path('deploy/bootstrap/lib/bridge-run.sh');
    expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt');

    $text = file_get_contents($driver);
    $backup = strpos($text, 'backup_network_config');
    $schedule = strpos($text, 'schedule_network_rollback');
    $disown = strpos($text, 'disown_network_manager');
    $build = strpos($text, 'build_bridge');

    expect($backup)->not->toBeFalse()
        ->and($schedule)->not->toBeFalse()
        ->and($disown)->not->toBeFalse()
        ->and($build)->not->toBeFalse()
        ->and($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 von außen.
    $driver = base_path('deploy/bootstrap/lib/bridge-run.sh');
    expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt');

    expect(file_get_contents($driver))->not->toContain('cancel_network_rollback');
});

it('lässt den Treiber von einer Shell parsen', function () {
    $driver = base_path('deploy/bootstrap/lib/bridge-run.sh');
    expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt');

    expect(Process::run(['sh', '-n', $driver])->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, denn zu seiner Zeit war alles in Ordnung.
    $etc = $this->dir.'/etc';
    mkdir("{$etc}/cloud", 0o755, true);
    file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n");

    expect(callBridgeFn('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(callBridgeFn('foreign_network_manager', ['CLUPILOT_ETC' => $etc]))
        ->toBe('');
});

it('entmachtet cloud-init im gesicherten Stand', function () {
    $etc = $this->dir.'/etc';
    mkdir("{$etc}/cloud", 0o755, true);
    file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n");

    runBridgeSh('disown_network_manager cloud-init ens3', [
        'CLUPILOT_ETC' => $etc,
        // Die Notiz, aus der die Rücknahme später liest, WAS entmachtet wurde.
        'CLUPILOT_WORK_DIR' => $this->dir.'/work',
    ]);

    expect(file_get_contents("{$etc}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg"))
        ->toContain('network: {config: disabled}');
});

/**
 * Baut eine Sandkiste, in der bridge-run.sh wirklich durchläuft.
 *
 * Der Kniff ist die `ip`-Attrappe: sie antwortet VOR dem `ifreload` anders als
 * danach, weil die `ifreload`-Attrappe eine Marke setzt. Ohne das könnte
 * `bridge_proven` nie wahr werden — die Brücke trägt die Standardroute ja erst,
 * nachdem umgestellt wurde.
 *
 * @return array{work:string, calls:string}
 */
function bridgeSandbox(string $dir, bool $bridgeComesUp = true, int $handshakeAge = 5, bool $withGateway = true): array
{
    $work = "{$dir}/work";
    $bin = "{$dir}/bin";
    $state = "{$dir}/state";
    foreach ([$work, $bin, $state, "{$dir}/units", "{$dir}/sbin", "{$dir}/etc"] as $d) {
        mkdir($d, 0o755, true);
    }

    copy(base_path('deploy/bootstrap/lib/bridge.sh'), "{$work}/bridge.sh");
    file_put_contents("{$dir}/interfaces", "# die Fassung von vorher\n");

    $up = $bridgeComesUp ? "[ -f '{$state}/bridged' ]" : 'false';
    // Eine Standardroute ohne `via` ist eine Link-Route. Sie hat kein Gateway,
    // und ein `awk '{ print $3 }'` läse dort den KARTENNAMEN.
    $route = $withGateway ? 'default via 10.0.0.1 dev $DEV' : 'default dev $DEV scope link';

    file_put_contents("{$bin}/ip", <<<SH
    #!/bin/sh
    if {$up}; then DEV=vmbr0; else DEV=enp0s31f6; fi
    case "\$*" in
      '-4 route show default') echo "{$route}" ;;
      '-4 -o addr show dev enp0s31f6 scope global')
          echo '2: enp0s31f6    inet 10.0.0.7/24 scope global enp0s31f6' ;;
      '-4 -o addr show dev vmbr0 scope global')
          {$up} && echo '5: vmbr0    inet 10.0.0.7/24 scope global vmbr0' ;;
      'link show vmbr0') {$up} || exit 1 ;;
      '-4 route show dev enp0s31f6')
          echo '10.0.0.0/24 proto kernel scope link src 10.0.0.7' ;;
      '-4 route get 10.0.0.1') echo '10.0.0.1 dev enp0s31f6 src 10.0.0.7' ;;
      '-6 -o addr show dev enp0s31f6 scope global') ;;
      '-6 route show default') ;;
      *) exit 1 ;;
    esac
    SH);

    file_put_contents("{$bin}/ifreload", "#!/bin/sh\ntouch '{$state}/bridged'\necho ifreload >> '{$dir}/calls'\n");
    file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$dir}/calls'\n");
    file_put_contents("{$bin}/wg", "#!/bin/sh\nprintf '%s\\t%s\\n' 'HUBKEY=' '".(time() - $handshakeAge)."'\n");
    file_put_contents("{$bin}/logger", "#!/bin/sh\nexit 0\n");
    foreach (['ip', 'ifreload', 'systemctl', 'wg', 'logger'] as $f) {
        chmod("{$bin}/{$f}", 0o755);
    }

    // `http_get` als Funktion: bridge.sh legt seine eigene nur unter einem
    // `command -v`-Schutz an, wer zuerst da ist behält recht — und env wird vor
    // bridge.sh gesourced.
    file_put_contents("{$work}/env", implode("\n", [
        "CLUPILOT_WORK_DIR={$work}",
        "CLUPILOT_IP={$bin}/ip",
        "CLUPILOT_IFRELOAD={$bin}/ifreload",
        "CLUPILOT_SYSTEMCTL={$bin}/systemctl",
        "CLUPILOT_WG={$bin}/wg",
        "CLUPILOT_INTERFACES_FILE={$dir}/interfaces",
        "CLUPILOT_INTERFACES_D={$dir}/interfaces.d",
        "CLUPILOT_NET_BACKUP={$dir}/sicherung",
        "CLUPILOT_UNIT_DIR={$dir}/units",
        "CLUPILOT_SBIN_DIR={$dir}/sbin",
        "CLUPILOT_SYS_NET={$dir}/sys",
        "CLUPILOT_ETC={$dir}/etc",
        'CLUPILOT_WG_HUB_PUBKEY=HUBKEY=',
        'CLUPILOT_HANDSHAKE_TRIES=1',
        'CLUPILOT_HANDSHAKE_WAIT=0',
        'http_get() { return 0; }',
        '',
    ]));

    fakeSysNet($dir, 'enp0s31f6', 'a8:a1:59:00:11:22');

    return ['work' => $work, 'calls' => "{$dir}/calls"];
}

it('fährt den ganzen Ablauf durch und meldet ok', function () {
    // Bis hierher prüfen die Tests, dass der Treiber DASTEHT — `sh -n` und die
    // Reihenfolge im Text. Keiner prüft, dass er LÄUFT. Genau das ist aber die
    // Datei, die auf einem echten Host das Netz umstellt.
    $box = bridgeSandbox($this->dir);

    $result = Process::path($box['work'])
        ->env(['CLUPILOT_WORK_DIR' => $box['work']])
        ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh'));

    expect(trim((string) file_get_contents($box['work'].'/state')))->toBe('ok')
        ->and(trim((string) file_get_contents($box['work'].'/phase')))->toBe('wartet auf CluPilot')
        ->and($result->exitCode())->toBe(0)
        // Die Strophe ist geschrieben und trägt die Karte als Port.
        ->and(file_get_contents($this->dir.'/interfaces'))->toContain('bridge-ports enp0s31f6')
        // Und die Rückfahrkarte steht noch: abbestellen ist CluPilots Sache.
        ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeTrue()
        ->and(file_get_contents($box['calls']))->not->toContain('stop clupilot-network-rollback.timer');
});

it('schreibt kein Gateway in die Strophe, wenn die Standardroute keins hat', function () {
    // Der Beweis über den PRODUKTIVPFAD, nicht über eine Einzelfunktion.
    // `detect_network_style` auf `default_gateway()` umzustellen half nichts,
    // solange bridge-run.sh sein Gateway weiter mit `awk '{ print $3 }'` las:
    // der Treiber reichte `ens3` an build_bridge weiter, und in der Strophe
    // stand `gateway ens3`. Ein Test an der Bibliothek allein hätte das nie
    // gesehen.
    $box = bridgeSandbox($this->dir, withGateway: false);

    Process::path($box['work'])
        ->env(['CLUPILOT_WORK_DIR' => $box['work']])
        ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh'));

    $stanza = (string) file_get_contents($this->dir.'/interfaces');

    expect($stanza)->toContain('address 10.0.0.7/24')
        ->not->toContain('gateway enp0s31f6')
        ->not->toContain('pointopoint')
        // Die Link-Route geht sonst beim Umbau verloren.
        ->toContain('up ip route add default dev vmbr0');
});

it('nimmt Karte und Gateway aus DERSELBEN Standardroute', function () {
    // Bei zwei Standardrouten nahm `detect_primary_interface` das `dev` der
    // ersten und `default_gateway` das `via` der zweiten — die Brücke bekäme
    // die Karte der einen mit dem Gateway der anderen.
    $ip = fakeIp($this->dir, [
        '-4 route show default' => "default dev ens3 scope link\ndefault via 10.0.0.1 dev ens4 metric 200",
    ]);

    expect(runBridgeSh('detect_primary_interface', ['CLUPILOT_IP' => $ip]))->toBe('ens3')
        ->and(runBridgeSh('default_gateway', ['CLUPILOT_IP' => $ip]))->toBe('');
});

it('meldet failed und lässt den Zeitgeber stehen, wenn die Brücke nicht trägt', function () {
    // Der Fall, für den die ganze Konstruktion gebaut ist. Der Treiber räumt
    // NICHT selbst auf — er überlässt es dem Zeitgeber und sagt nur, warum.
    $box = bridgeSandbox($this->dir, bridgeComesUp: false);

    $result = Process::path($box['work'])
        ->env(['CLUPILOT_WORK_DIR' => $box['work']])
        ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh'));

    expect(trim((string) file_get_contents($box['work'].'/state')))->toBe('failed')
        ->and($result->exitCode())->toBe(1)
        ->and(file_get_contents($box['work'].'/note'))->toContain('Zeitgeber')
        ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeTrue()
        ->and(file_get_contents($box['calls']))->not->toContain('stop clupilot-network-rollback.timer');
});

it('meldet failed, wenn die Brücke steht, der Tunnel aber schal bleibt', function () {
    // Öffentlich erreichbar und trotzdem ausgesperrt: die Richtung, die der
    // Treiber ohne die Handshake-Prüfung für Erfolg gehalten hätte.
    $box = bridgeSandbox($this->dir, handshakeAge: 3600);

    Process::path($box['work'])
        ->env(['CLUPILOT_WORK_DIR' => $box['work']])
        ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh'));

    expect(trim((string) file_get_contents($box['work'].'/state')))->toBe('failed')
        ->and(file_get_contents($box['calls']))->toContain('restart wg-quick@wg0')
        ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeTrue();
});

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);
    }
});
