toContain("cat > \"\$HOST_STEP\" <<'EOF'"); // betweenFirst, NOT between: `between` cuts at the LAST delimiter, and this // installer has several here-documents after this one — so it handed back // the helper plus everything down to the final EOF, and the extra `EOF` // line then ran as a command. The helper was fine; the measurement was not. $body = Str::betweenFirst($installer, "cat > \"\$HOST_STEP\" <<'EOF'\n", "\nEOF\n"); // The extraction is part of what these tests trust, so it is checked at // both ends rather than only at the start. expect($body)->toContain('CONTRACT=') ->and(rtrim($body))->toEndWith('esac') ->and($body)->not->toContain("\nEOF"); return $body; } function runHostStep(string $args, array $onPath = []): Process { $dir = sys_get_temp_dir().'/clupilot-host-step-'.bin2hex(random_bytes(6)); mkdir($dir.'/bin', 0755, true); file_put_contents($dir.'/clupilot-host-step', hostStepScript()); chmod($dir.'/clupilot-host-step', 0755); // A stub apt-get that fails loudly. Any test that does not expect an // install will see it in the output rather than silently tolerating one. file_put_contents($dir.'/bin/apt-get', "#!/bin/sh\necho 'APT-GET WAS CALLED' >&2\nexit 1\n"); chmod($dir.'/bin/apt-get', 0755); foreach ($onPath as $name => $exit) { file_put_contents($dir."/bin/{$name}", "#!/bin/sh\nexit {$exit}\n"); chmod($dir."/bin/{$name}", 0755); } $process = Process::fromShellCommandline( 'PATH='.escapeshellarg($dir.'/bin').':/usr/bin:/bin ' .escapeshellarg($dir.'/clupilot-host-step').' '.$args ); $process->run(); exec('rm -rf '.escapeshellarg($dir)); return $process; } it('refuses a step it does not know by name', function () { // The whole safety argument rests on this. sudoers permits one exact // command line, and this is the second of the two locks: even reached some // other way, the helper does nothing it was not built to do. $process = runHostStep('install-whatever-i-like'); expect($process->getExitCode())->toBe(64) ->and($process->getErrorOutput())->toContain('unknown step') ->and($process->getErrorOutput())->not->toContain('APT-GET WAS CALLED'); }); it('refuses being run with no step at all', function () { // `set -u` would otherwise end this as an unbound-variable error, which is // the right outcome by accident and reads as a bug in the helper. $process = runHostStep(''); expect($process->getExitCode())->toBe(64) ->and($process->getErrorOutput())->toContain('unknown step'); }); it('does not run a package manager when rsync is already there', function () { $process = runHostStep('ensure-rsync', ['rsync' => 0]); expect($process->getExitCode())->toBe(0) ->and($process->getErrorOutput())->not->toContain('APT-GET WAS CALLED'); }); it('says so rather than pretending, on a host with no apt-get', function () { // Nothing here is Debian-only by design, but the install step is. Silence // would leave an operator believing rsync is on a machine where it is not, // and they would find out from a backup that collected nothing. $dir = sys_get_temp_dir().'/clupilot-host-step-'.bin2hex(random_bytes(6)); mkdir($dir.'/bin', 0755, true); file_put_contents($dir.'/clupilot-host-step', hostStepScript()); chmod($dir.'/clupilot-host-step', 0755); // An empty PATH, and bash named outright — the helper's own `#!/usr/bin/env // bash` would not find an interpreter otherwise, and that 127 looks exactly // like the refusal being tested here. $process = new Process( [trim((string) shell_exec('command -v bash')), $dir.'/clupilot-host-step', 'ensure-rsync'], null, ['PATH' => $dir.'/bin'], ); $process->run(); exec('rm -rf '.escapeshellarg($dir)); expect($process->getExitCode())->toBe(3) ->and($process->getErrorOutput())->toContain('no apt-get'); }); it('reports the contract version the updater compares against', function () { $process = runHostStep('contract'); expect($process->getExitCode())->toBe(0) ->and(trim($process->getOutput()))->toMatch('/^\d+$/'); // And the two halves agree. A helper written by an older installer is the // failure mode this number exists for; if update.sh asked for a version the // current installer never writes, every up-to-date server would be told to // run the installer again forever. $needs = Str::between(file_get_contents(base_path('deploy/update.sh')), 'HOST_STEP_NEEDS=', "\n"); expect((int) trim($process->getOutput()))->toBe((int) trim($needs)); }); it('grants one command line, not a script the service account can rewrite', function () { $installer = file_get_contents(base_path('deploy/install-agent.sh')); expect($installer) // Root's, at a path under root's — the service account cannot edit what // it is allowed to run. This is the entire safety argument. ->toContain('HOST_STEP=/usr/local/sbin/clupilot-host-step') ->toContain('chown root:root "$HOST_STEP"') ->toContain('chmod 0755 "$HOST_STEP"') // Validated before it counts. A malformed fragment in /etc/sudoers.d // breaks sudo for everyone on the machine, not only for this grant. ->toContain('visudo -cf /etc/sudoers.d/clupilot-host-step') // Both halves must mean the same path. ->and(file_get_contents(base_path('deploy/update.sh'))) ->toContain('HOST_STEP=/usr/local/sbin/clupilot-host-step'); // Every sudo grant this installer writes, read as sudoers reads it. preg_match_all('/^\S+\s+ALL=\(root\)\s+NOPASSWD:\s*(.+)$/m', $installer, $m); expect($m[1])->not->toBeEmpty(); foreach ($m[1] as $granted) { // An argument, always. A grant on a bare path permits that path with // ANY arguments — so every step ever added to the helper would be // covered by a decision taken before it existed. expect(trim($granted))->toContain(' ') // And never anything inside the checkout, which the service account // owns: it could rewrite the file and the grant would follow. ->not->toContain('$ROOT') ->not->toContain('deploy/'); } expect($m[1])->toContain('$HOST_STEP ensure-rsync'); }); it('never lets a host package stop a deployment', function () { // rsync is wanted by whoever collects the invoice archive, minutes or hours // later. An update that died because a package manager was busy would be a // far bigger problem than a backup waiting for the next run. $update = file_get_contents(base_path('deploy/update.sh')); $call = Str::between($update, 'if ! command -v rsync', 'phase maintenance_off'); expect($call)->toContain('sudo -n "$HOST_STEP" ensure-rsync >/dev/null 2>&1') ->and($call)->toContain('warn '); });