<?php

use App\Support\NextcloudOcc;
use Illuminate\Support\Facades\File;

/**
 * Nothing runs inside a container without saying who it is running as.
 *
 * `docker compose exec` is root unless told otherwise, and the deployment
 * scripts relied on that default. When `artisan optimize` failed during a
 * deployment it wrote its error into storage/logs/laravel.log AS ROOT — and
 * from that moment the application could not append to its own log. Monolog
 * threw on every attempt, and a throw while logging is a 500 on every page that
 * logs, with nothing written down to say why.
 *
 * It surfaced days later as a 500 on the VPN config download, that being one of
 * the few pages writing a log line on its way through, and was chased through
 * the VPN code, the encryption key and the session before anyone looked at the
 * owner of a file.
 *
 * docker/entrypoint.sh had it right all along: it drops to www-data for exactly
 * these commands. The rule is only that the user is named — `-u root` is a fine
 * answer where root is what is wanted (the ownership repair in update.sh), as
 * long as somebody chose it.
 */
it('never runs a command in the app container as whoever docker felt like', function () {
    $offenders = [];

    foreach (File::glob(base_path('deploy/*.sh')) as $path) {
        foreach (preg_split('/\R/', File::get($path)) ?: [] as $i => $line) {
            if (! str_contains($line, 'docker compose exec')) {
                continue;
            }

            // Only the app container. The gateway and the queues are other
            // images with other users, and none of them own this checkout.
            if (! preg_match('/docker compose exec\b[^|]*?\bapp\b/', $line)) {
                continue;
            }

            if (preg_match('/\s-u\s+\S+/', $line)) {
                continue;
            }

            // Help text counts. Telling an operator to run it as root is how
            // the file ends up owned by root in the first place.
            $offenders[] = basename($path).':'.($i + 1).' — '.trim($line);
        }
    }

    expect($offenders)->toBe([]);
});

it('never runs occ in a customer guest as whoever docker felt like either', function () {
    // The same default, the same lesson, a different subsystem — and it was learned
    // here second. `docker compose exec` is root, the official Nextcloud image's
    // console.php exits 1 unless the caller owns config/config.php (www-data), and
    // the prefix was pasted into five separate files, so every occ call in the
    // product was failing on every instance. The ones that go through
    // CustomerStep::guest() turned that into a retry and then a failed run for a
    // machine that was otherwise finished.
    //
    // So there is one builder, and this refuses a second: nothing in app/ may spell
    // the invocation out by hand, which is the only way a sixth call site can be
    // stopped from getting it wrong again.
    $offenders = [];

    foreach (File::allFiles(app_path()) as $file) {
        if ($file->getExtension() !== 'php'
            || $file->getRelativePathname() === 'Support/NextcloudOcc.php') {
            continue;
        }

        if (str_contains(File::get($file->getRealPath()), 'docker compose exec')) {
            $offenders[] = $file->getRelativePathname();
        }
    }

    expect($offenders)->toBe([])
        // And the one builder names the user Nextcloud insists on.
        ->and(NextcloudOcc::command('status'))
        ->toContain('docker compose exec -T -u www-data ');
});

it('repairs ownership before it needs it, not after', function () {
    // A server already carrying the damage has to heal on its next deployment.
    // Nothing else ever will: a root-owned log file stays root-owned, and the
    // page that trips over it is nowhere near the deployment that caused it.
    $update = File::get(base_path('deploy/update.sh'));

    expect($update)->toContain('normalise_ownership')
        // Every directory the deployment writes into, and node_modules by name:
        // the first version sampled the top-level owner and skipped the
        // recursion when it matched, which is how a root-owned
        // node_modules/.vite-temp under a www-data-owned node_modules failed
        // the build in maintenance mode.
        ->and($update)->toContain('! -user www-data -exec chown www-data:www-data')
        ->and($update)->toContain('node_modules')
        // Und das Heimatverzeichnis von www-data. Es stand nicht auf der Liste,
        // weil es außerhalb des Checkouts liegt — und genau daran brach ein
        // Deployment mit Code 243 ab: npm wollte ~/.npm anlegen und durfte
        // nicht (EACCES, errno -13; 256 minus 13 ist 243). Der Server blieb im
        // Wartungsmodus stehen, die Kundenseite antwortete mit 500.
        ->and($update)->toContain('/var/www/.npm');

    // Before the first unprivileged step, or it cannot help: composer and npm
    // would already have failed on a root-owned vendor directory.
    $repair = strpos($update, "\nnormalise_ownership\n");
    $maintenance = strpos($update, 'php artisan down --retry=60');

    expect($repair)->not->toBeFalse()
        ->and($maintenance)->not->toBeFalse()
        ->and($repair)->toBeLessThan($maintenance);
});

it('gibt www-data sein eigenes Heimatverzeichnis, statt ihm nur eine neue Nummer zu geben', function () {
    // `usermod -o -u` vergibt die Nummer und schreibt KEINE vorhandene Datei
    // um. Ohne das chown danach gehört /var/www weiter der Nummer 33 aus dem
    // Basis-Abbild, und www-data kann in seinem eigenen Zuhause nichts anlegen
    // — weder ~/.npm noch ~/.composer.
    $dockerfile = File::get(base_path('docker/php/Dockerfile'));

    $usermod = strpos($dockerfile, 'usermod');
    $chown = strpos($dockerfile, 'chown -R www-data:www-data /var/www');

    expect($usermod)->not->toBeFalse()
        ->and($chown)->not->toBeFalse()
        // Danach, nicht davor: vor der Umnummerierung würde es auf die alte
        // Nummer chownen und wäre unmittelbar wieder falsch.
        ->and($chown)->toBeGreaterThan($usermod);
});

/**
 * Das Update darf sich nicht selbst unter den Füßen wegziehen.
 *
 * `update.sh` checkt einen neuen Stand in denselben Baum aus, aus dem es läuft
 * — es ersetzt also SICH SELBST auf der Platte. Bash liest ein Skript aber
 * nachlaufend ab einer Byte-Position; ändert sich die Datei mitten im Lauf,
 * liest es an derselben Position im neuen Text weiter. Liegt die dann vor dem
 * Punkt, an dem der Lauf schon war, führt das Skript einen Abschnitt ein
 * zweites Mal aus.
 *
 * Genau das war v1.3.97: die Freigabe fügte 23 Zeilen oberhalb des Checkouts
 * ein, alles danach rutschte, und das Update drehte sich im Kreis. v1.3.96 lief
 * sauber — sie hatte diese Datei nicht angefasst. Der Fehler trifft also nur
 * Freigaben, die das Update selbst ändern, und blieb deshalb lange unsichtbar.
 */
it('runs from a copy of itself, so a checkout cannot rewrite it mid-flight', function () {
    $update = File::get(base_path('deploy/update.sh'));

    $reexec = strpos($update, 'exec bash "$CLUPILOT_UPDATE_COPY"');
    $checkout = strpos($update, 'git checkout --quiet --detach');

    expect($reexec)->not->toBeFalse('update.sh startet sich nicht aus einer Kopie neu')
        ->and($checkout)->not->toBeFalse()
        // Vor dem Checkout, sonst hilft es nicht: die Kopie muss stehen, BEVOR
        // die Originaldatei getauscht werden kann.
        ->and($reexec)->toBeLessThan($checkout);

    // Und die Kopie liegt außerhalb des Checkouts — läge sie darin, träfe sie
    // derselbe Tausch.
    expect($update)->toContain('mktemp /tmp/clupilot-update-');
});

it('reicht den Terminal-Pfad durch, ohne ihn weiter zu oeffnen als noetig', function () {
    $nginx = File::get(base_path('docker/nginx/default.conf'));

    // Genau dieser eine Ort, kein Präfix: ein `location /terminal` machte
    // jeden Unterpfad zum Weg in den Container.
    expect($nginx)->toContain('location = /terminal/ws')
        ->and($nginx)->not->toContain('location /terminal')
        ->and($nginx)->toContain('terminal:8082')
        ->and($nginx)->toContain('proxy_pass http://$terminal_upstream')
        ->and($nginx)->toContain('Upgrade $http_upgrade');

    // Und das Ticket reist im Kopf, nicht in der Adresszeile — sonst stünde es
    // im Zugriffsprotokoll jedes Reverse Proxy auf der Strecke. Der Browser ist
    // die Seite, die das entscheidet.
    expect(File::get(base_path('resources/js/terminal.js')))
        ->not->toContain('/terminal/ws?');
});

it('laesst die Terminal-Bruecke dort stehen, wo ein Host ueberhaupt erreichbar ist', function () {
    // Der app-Container erreicht keinen einzigen Host — gemessen, nicht
    // vermutet: wg0 lebt im Netz-Namensraum von vpn-hub. Wer diese
    // Zeile für Aufräumen hält, bekommt einen Container, der startet, gesund
    // aussieht und bei jeder Sitzung in eine Zeitüberschreitung läuft.
    $compose = File::get(base_path('docker-compose.yml'));

    $block = preg_split('/^  terminal:$/m', $compose)[1] ?? '';
    $block = preg_split('/^  \S/m', $block)[0] ?? '';

    expect($block)->not->toBe('')
        ->and($block)->toContain('network_mode: "service:vpn-hub"')
        // Kein eigener Port nach außen: erreichbar allein über nginx. Ein
        // Dienst in fremdem Namensraum könnte ihn ohnehin nicht veröffentlichen
        // — Docker lehnt das Compose-File dann komplett ab.
        ->and($block)->not->toContain('ports:');

    // Und nginx muss ihn unter dem Namen des Namensraum-Eigentümers ansprechen,
    // nicht unter einem eigenen: ein Container ohne eigenes Netz hat keinen
    // eigenen DNS-Eintrag.
    //
    // Hier stand einmal ein Netz-Alias `terminal` auf queue-provisioning, damit
    // in der nginx-Konfiguration ein sprechender Name steht. Der Preis dafür war
    // hoch und fiel erst im Betrieb auf: ein Eintrag unter `networks:` ist Teil
    // der Netzkonfiguration eines Dienstes, und Compose BAUT einen Container NEU,
    // sobald die sich ändert. Neu gebaut heißt neue Adresse im Compose-Netz,
    // heißt neu geschriebene Weiterleitungsregeln für den veröffentlichten
    // UDP-Port 51820 — auf dem jede bestehende WireGuard-Sitzung liegt. Ein
    // hübscherer Name in einer Konfigurationszeile hat sämtliche Tunnel gekostet.
    //
    // Deshalb: kein Alias. Der Dienstname des Nachbarn tut es auch.
    expect($compose)->not->toContain('aliases:');

    // Ein `networks:`-Block am Hub selbst ist dagegen erlaubt und noetig — er
    // traegt die FESTE Adresse. Der Unterschied: ein Alias war reine Kosmetik
    // und hat einen Neubau ausgeloest, die feste Adresse macht einen Neubau
    // folgenlos. Was hier nicht stehen darf, steht eine Zeile hoeher.
    $hub = preg_split('/^  vpn-hub:$/m', $compose)[1] ?? '';
    $hub = preg_split('/^  \S/m', $hub)[0] ?? '';
    expect($hub)->toContain('ipv4_address:');

    // Angesprochen wird der Container, dem der Namensraum GEHOERT. Ein
    // Mitbewohner hat keinen eigenen DNS-Eintrag.
    expect(File::get(base_path('docker/nginx/default.conf')))
        ->toContain('set $terminal_upstream vpn-hub:8082;');
});

it('haengt den Tunnel an gar keinen Warteschlangen-Arbeiter mehr', function () {
    // Der Fall, der den Betreiber unterwegs erwischt hat: wg0 stand im
    // Namensraum von queue-provisioning, und dessen Prozess 1 war
    // `php artisan queue:work`. Endete der Arbeiter — Zeitueberlauf nach 2100
    // Sekunden, fataler Fehler, Speicherlimit, `queue:restart` —, endete der
    // Container, und mit ihm fielen ALLE WireGuard-Sitzungen. Und weil das
    // Abbild `clupilot-app` bei fast jeder Freigabe neu gebaut wird, riss es
    // ausserdem bei jedem Ausrollen ab.
    //
    // Jetzt besitzt ein eigener Container den Namensraum, mit eigenem Abbild,
    // das sich fast nie aendert.
    $compose = File::get(base_path('docker-compose.yml'));

    $hub = preg_split('/^  vpn-hub:$/m', $compose)[1] ?? '';
    $hub = preg_split('/^  \S/m', $hub)[0] ?? '';

    expect($hub)->not->toBe('')
        // Eigenes Abbild, nicht clupilot-app: das ist der ganze Punkt.
        ->and($hub)->toContain('image: clupilot-vpn-hub:dev')
        ->and($hub)->not->toContain('clupilot-app')
        // Er besitzt den Namensraum, also traegt ER den Port, das Geraet und
        // die Netz-Einstellung.
        ->and($hub)->toContain(':51820/udp')
        ->and($hub)->toContain('/dev/net/tun')
        ->and($hub)->toContain('net.ipv4.ip_forward=1');

    // Der Arbeiter steigt nur noch ein — ohne Port, ohne Geraet.
    $worker = preg_split('/^  queue-provisioning:$/m', $compose)[1] ?? '';
    $worker = preg_split('/^  \S/m', $worker)[0] ?? '';

    expect($worker)->toContain('network_mode: "service:vpn-hub"')
        ->and($worker)->not->toContain('51820')
        ->and($worker)->not->toContain('/dev/net/tun')
        // NET_ADMIN bleibt: `wg set` fuer jede Zugangsaenderung laeuft hier.
        ->and($worker)->toContain('NET_ADMIN');

    // Und wg0 wird nicht mehr vom Arbeiterskript hochgezogen, sondern vom Hub.
    expect(File::get(base_path('docker/provisioning-worker.sh')))
        ->not->toContain('wg-quick up wg0')
        ->and($worker)->toContain('docker/provisioning-worker.sh');

    $entry = File::get(base_path('docker/vpn-hub/entrypoint.sh'));

    expect($entry)->toContain('wg-quick up wg0')
        // Prozess 1 darf nur enden, wenn Docker ihn beendet.
        ->and($entry)->toContain('while true')
        ->and($entry)->toContain('trap stop TERM INT');
});

it('startet den Tunnel-Container beim Ausrollen nicht neu', function () {
    // `docker compose restart queue-provisioning` baut dessen Netz-Namensraum
    // neu auf, und darin lebt wg0. Für ein Update, das nur PHP-Code ändert,
    // reisst das jede WireGuard-Sitzung ab — die des Betreibers am Telefon wie
    // die jedes Hosts. Genau so ist es passiert.
    //
    // Seit der Arbeiter dort in einer Schleife läuft, genügt ein Signal: der
    // Arbeiter beendet sich nach dem laufenden Auftrag, die Schleife startet ihn
    // mit neuem Code neu, der Container bleibt stehen.
    $update = File::get(base_path('deploy/update.sh'));

    expect($update)->not->toContain('restart queue queue-provisioning')
        ->and($update)->toContain('queue-provisioning php artisan queue:restart');

    // Und wenn `up -d` ihn doch neu baut (neues Abbild, geänderte
    // Konfiguration), müssen die gemerkten UDP-Ströme weg — sonst zeigen sie
    // weiter auf den alten Container, und ein Host mit festem Quellport kommt
    // nie zurück.
    expect($update)->toContain('conntrack -D -p udp --dport')
        ->and($update)->toContain('hub_before')
        ->and($update)->toContain('hub_after');
});

it('gibt dem Tunnel eine feste Adresse, damit gemerkte Stroeme nie ins Leere zeigen', function () {
    // Die Wurzel des ganzen Uebels: Docker traegt den veroeffentlichten UDP-Port
    // als Weiterleitung auf die Container-Adresse ein, und der Kernel merkt sich
    // jeden laufenden Strom samt dieser Adresse. Bekam der Container beim Neubau
    // eine andere, zeigten alle gemerkten Stroeme ins Leere — und sie verfallen
    // nicht, weil WireGuards Lebenszeichen sie alle 25 Sekunden auffrischt.
    //
    // Mit fester Adresse entsteht nach einem Neubau exakt dieselbe Weiterleitung.
    // Nachgemessen: 172.18.0.240 vor und nach `up -d --force-recreate vpn-hub`.
    $compose = File::get(base_path('docker-compose.yml'));

    $hub = preg_split('/^  vpn-hub:$/m', $compose)[1] ?? '';
    $hub = preg_split('/^  \S/m', $hub)[0] ?? '';

    expect($hub)->toContain('ipv4_address:')
        // Ueber .env aenderbar, falls das Subnetz auf einem Server belegt ist.
        ->and($hub)->toContain('CLUPILOT_VPN_HUB_IP');

    // Eine feste Adresse geht nur in einem Netz mit erklaertem Subnetz.
    expect($compose)->toContain('CLUPILOT_NET_SUBNET');

    // Und die Umstellung darauf muss GEORDNET passieren: Docker kann ein
    // bestehendes Netz nicht umdefinieren, es muss neu angelegt werden — und das
    // scheitert, solange auch nur ein Container daranhaengt. `up -d` allein
    // bricht dann mit "network … has active endpoints" ab und laesst den Stapel
    // halb unten stehen. Beim Bauen genau so passiert.
    $update = File::get(base_path('deploy/update.sh'));

    expect($update)->toContain('net_migrated')
        ->and($update)->toContain('docker compose down --remove-orphans')
        // Nach der Umstellung zeigen die gemerkten Stroeme auf die alte,
        // automatisch vergebene Adresse — der Vergleich der Container-IDs greift
        // dabei nicht, also muss das Aufraeumen ausdruecklich ausgeloest werden.
        ->and($update)->toContain('"$net_migrated" == true');
});

it('haelt einen Waechter bereit, der einen halb liegengebliebenen Stapel selbst aufrichtet', function () {
    // Der Vorfall: ein Update brach ab, der Stapel lag halb unten, der Tunnel
    // war weg und die Seite antwortete eine Stunde lang 500 — bis jemand von
    // Hand nachsah. Genau das soll nie wieder auf einen Menschen warten.
    $watchdog = File::get(base_path('deploy/watchdog.sh'));

    // Er fasst nichts an, solange ein Update laeuft: dieselbe Sperre wie der
    // Update-Agent, und weg, wenn er sie nicht bekommt.
    expect($watchdog)->toContain('flock -n 9 || exit 0')
        // Die vier Fehlerbilder, die heute wirklich passiert sind.
        ->and($watchdog)->toContain('docker compose up -d')
        ->and($watchdog)->toContain('--force-recreate')
        ->and($watchdog)->toContain('wg-quick up wg0')
        ->and($watchdog)->toContain('php artisan up')
        // Und eine Handbremse: wer die Seite bewusst unten haben will, soll sie
        // unten halten koennen.
        ->and($watchdog)->toContain('maintenance-hold');

    // Auf dem Wirt, per systemd — nicht als Container mit Docker-Socket, der
    // genau dann tot waere, wenn man ihn braucht.
    $installer = File::get(base_path('deploy/install-agent.sh'));

    expect($installer)->toContain('clupilot-watchdog.service')
        ->and($installer)->toContain('clupilot-watchdog.timer')
        ->and($installer)->toContain('systemctl enable --now clupilot-watchdog.timer');
});

it('meldet nicht fertig, wenn der letzte Lauf abgebrochen ist', function () {
    // Bricht ein Update mittendrin ab, steht der Checkout schon auf dem Ziel.
    // Ein zweiter Aufruf meldete deshalb "Already up to date" und tat NICHTS —
    // waehrend der Server halb unten stand. Der Commit ist die falsche Frage;
    // die richtige ist, ob alles laeuft.
    $update = File::get(base_path('deploy/update.sh'));

    expect($update)->toContain('Already up to date')
        ->and($update)->toContain('unhealthy')
        ->and($update)->toContain('der Wartungsmodus ist noch an');

    // Und die Fortschrittsanzeige darf ein Deployment nicht mehr toeten: gehoert
    // storage/ nach einem frueheren Fehltritt root, scheiterte das mkdir in
    // phase() — und mit `set -e` starb der Lauf an seiner ersten Zeile, ohne
    // eine einzige Ausgabe.
    expect($update)->toContain('mkdir -p "$(dirname "$PHASE_FILE")" 2>/dev/null || true');
});
