$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 queue-provisioning. 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:queue-provisioning"') // 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:'); $hub = preg_split('/^ queue-provisioning:$/m', $compose)[1] ?? ''; $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; expect($hub)->not->toContain('networks:'); expect(File::get(base_path('docker/nginx/default.conf'))) ->toContain('set $terminal_upstream queue-provisioning:8082;'); }); it('haengt den Tunnel nicht an das Leben eines Warteschlangen-Arbeiters', 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 — Zeitüberlauf nach 2100 // Sekunden, fataler Fehler, Speicherlimit, `queue:restart` —, endete der // Container, und mit ihm fielen ALLE WireGuard-Sitzungen: die des Betreibers // am Telefon wie die jedes Hosts. Von außen las sich das als „das VPN ist // unzuverlässig"; die Ursache stand in einer Zeile, die von Warteschlangen // handelt. $compose = File::get(base_path('docker-compose.yml')); $hub = preg_split('/^ queue-provisioning:$/m', $compose)[1] ?? ''; $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; // Kein `exec` auf den Arbeiter — das ist genau das Ersetzen von Prozess 1, // das den Container am Arbeiter sterben ließ. expect($hub)->not->toContain('exec php artisan queue:work') ->and($hub)->toContain('docker/provisioning-worker.sh'); $runner = File::get(base_path('docker/provisioning-worker.sh')); expect($runner)->toContain('wg-quick up wg0') // Die Schleife ist der ganze Punkt: fällt der Arbeiter, kommt er wieder, // ohne dass der Namensraum stirbt. ->and($runner)->toContain('while true') // Und SIGTERM wird weitergereicht, sonst wartet `docker compose stop` // zehn Sekunden und schlägt dann mitten in einem Schritt mit SIGKILL zu. ->and($runner)->toContain('trap stop TERM INT'); });