$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'); // 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); });