CluPilotCloud/tests/Feature/HostStepTest.php

184 lines
7.9 KiB
PHP

<?php
use Symfony\Component\Process\Process;
/**
* The root-owned half of an update.
*
* An update runs as the service account, so everything on the host that belongs
* to root — installing a package, writing a unit — is out of its reach, and each
* such thing turns into "log into every server once and paste this".
*
* The tempting fix is a sudo grant on deploy/install-agent.sh. That is root, not
* a grant: the service account owns the checkout and can rewrite the script it
* would be allowed to run privileged. So the privileged part is written OUT of
* the checkout to a root-owned path, and the grant names one exact command line.
*
* These tests run the helper as bash actually runs it — the whole point is what
* it REFUSES, and a refusal is an exit code.
*/
function hostStepScript(): string
{
$installer = file_get_contents(base_path('deploy/install-agent.sh'));
// The quoted delimiter matters and is asserted separately below: an
// unquoted heredoc would expand the installer's own variables into a file
// that is supposed to be fixed at write time.
expect($installer)->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 ');
});