CluPilotCloud/tests/Feature/UpdateLockReleaseOnTheHostT...

228 lines
9.2 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Die Wirtsseite des Entsperrens.
*
* Zwei Stücke, beide auf dem Wirt:
*
* 1. Der Agent liest die Bitte im BLOCKIERTEN Zweig — das ist der einzige
* Zweig, den ein blockierter Lauf erreicht.
* 2. Der root-eigene Helfer beendet, was die Sperre offen hält.
*
* Das zweite Stück ist die Grenze, an der ein Fehler teuer wird: hier läuft
* etwas als root und beendet Prozesse. Es wird deshalb ausgeführt, nicht
* begutachtet — mit genau EINER Änderung am echten Skript, dem Pfad der
* Sperrdatei. Der Test prüft vorher, dass die Zeile, die er ersetzt, auch
* wirklich so dasteht; wird der Helfer umgebaut, fällt das hier auf und nicht
* erst auf einem Server.
*
* Im Betrieb ist derselbe Pfad nicht umzulenken: die Datei gehört root, der
* Dienstbenutzer darf sie nicht schreiben, und sudo räumt die Umgebung ab.
*/
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
/**
* Der Helfer als eigenständiges Skript, mit umgelenkter Sperrdatei.
*/
function hostStepWithLockAt(string $lock): string
{
$installer = File::get(base_path('deploy/install-agent.sh'));
$start = strpos($installer, "cat > \"\$HOST_STEP\" <<'EOF'\n");
expect($start)->not->toBeFalse('install-agent.sh schreibt den Helfer nicht mehr aus einem Here-Dokument.');
$start += strlen("cat > \"\$HOST_STEP\" <<'EOF'\n");
$script = substr($installer, $start, strpos($installer, "\nEOF\n", $start) - $start);
$hardcoded = 'LOCK=/opt/clupilot/storage/app/deploy/.agent.lock';
expect($script)->toContain($hardcoded);
$path = tempnam(sys_get_temp_dir(), 'host-step-').'.sh';
File::put($path, str_replace($hardcoded, "LOCK=$lock", $script));
chmod($path, 0755);
return $path;
}
/**
* ── Der Agent nimmt die Bitte im blockierten Zweig an ─────────────────────
*/
it('answers the release request even though it never got the lock', function () {
// Der ganze Grund für die eigene Datei: ein blockierter Lauf steigt aus,
// bevor er den Postkasten überhaupt ansieht. Läse er die Bitte dort,
// erreichte sie ihn genau dann nie, wenn sie gebraucht wird.
//
// Auf dieser Maschine gibt es keinen root-eigenen Helfer — und genau das
// muss die Konsole erfahren, statt dass der Knopf still nichts tut.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::put($dir.'/unblock-request.json', json_encode([
'requested_at' => now()->toIso8601String(),
'requested_by' => 'owner@example.com',
]));
$result = Process::path(base_path())->timeout(60)->run(<<<'BASH'
set -e
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
halter=$!
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
bash deploy/update-agent.sh >/dev/null 2>&1
kill $halter 2>/dev/null || true
BASH);
expect($result->successful())->toBeTrue($result->errorOutput());
$last = json_decode(File::get($dir.'/unblock-last-run.json'), true);
expect($last['state'])->toBe('failed')
->and($last['error'])->toBe('unblock_helper_missing')
// Verbraucht, nicht liegengelassen: eine Bitte, die bleibt, zündet bei
// der nächsten Blockade ungefragt.
->and(File::exists($dir.'/unblock-request.json'))->toBeFalse();
});
it('throws away a release request that has been lying around too long', function () {
// Dieselbe Frist wie im Postkasten. Eine Bitte, die abgelegt wurde, als
// niemand sie abholen konnte, ist keine Anweisung für irgendwann.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::put($dir.'/unblock-request.json', json_encode([
'requested_at' => now()->subHours(3)->toIso8601String(),
'requested_by' => 'owner@example.com',
]));
touch($dir.'/unblock-request.json', now()->subHours(3)->timestamp);
Process::path(base_path())->timeout(60)->run(<<<'BASH'
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
halter=$!
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
bash deploy/update-agent.sh >/dev/null 2>&1
kill $halter 2>/dev/null || true
BASH);
expect(File::exists($dir.'/unblock-request.json'))->toBeFalse()
->and(File::exists($dir.'/unblock-last-run.json'))->toBeFalse();
});
it('quietly drops a release request once the lock has freed itself', function () {
// Der Lauf, der die Sperre BEKOMMT, findet die Bitte noch liegen. Sie
// einfach liegen zu lassen hiesse, sie bei der nächsten Blockade
// ungefragt auszuführen.
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::put($dir.'/unblock-request.json', json_encode([
'requested_at' => now()->toIso8601String(),
'requested_by' => 'owner@example.com',
]));
Process::path(base_path())->timeout(60)->run(<<<'BASH'
stub="$(mktemp -d)"
printf '#!/bin/sh\nexit 1\n' > "$stub/git"
printf '#!/bin/sh\nexit 1\n' > "$stub/docker"
chmod +x "$stub/git" "$stub/docker"
PATH="$stub:$PATH" bash deploy/update-agent.sh >/dev/null 2>&1
rm -rf "$stub"
BASH);
expect(File::exists($dir.'/unblock-request.json'))->toBeFalse();
});
/**
* ── Der root-eigene Helfer ────────────────────────────────────────────────
*/
it('ends the process that is holding the lock open', function () {
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(60)->run(<<<BASH
bash -c 'exec 9>"$tmp/.agent.lock"; sleep 60' &
pid=\$!
sleep 0.3
bash {$helper} release-update-lock
sleep 0.3
kill -0 "\$pid" 2>/dev/null && echo "LEBT" || echo "BEENDET"
BASH);
expect($result->output())->toContain('BEENDET');
});
it('does not end the process that asked it to', function () {
// Im Betrieb hat der blockierte Agent die Sperrdatei SELBST offen — sein
// `exec 9>` ist geglückt, nur das `flock` nicht — und er ist der Vorfahr
// des Helfers. Wer alle Offenhalter beendet, beendet den Anrufer mitten
// im Anruf, und die Konsole erführe nie, was passiert ist.
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(60)->run(<<<BASH
exec 9>"$tmp/.agent.lock"
bash {$helper} release-update-lock
echo "ANRUFER LEBT NOCH"
BASH);
// Und es bleibt bei „da ist nichts". Der Helfer bekommt den Deskriptor
// des Anrufers vererbt (unter sudo nicht, im Test schon) und darf seine
// EIGENEN Kindprozesse — jede Kommandosubstitution ist eine — nicht für
// Halter halten: die sind bei jedem Nachsehen andere, die Runde käme nie
// zum Ende, und am Schluss stünde „konnte nicht beendet werden" über
// einer Sperre, die niemand hält.
expect($result->successful())->toBeTrue($result->errorOutput())
->and($result->output())->toContain('ANRUFER LEBT NOCH')
->and($result->errorOutput())->toContain('nothing holds');
});
it('insists when the holder ignores the polite request', function () {
// `timeout 45 docker compose exec` ohne `--kill-after` war genau das:
// etwas, das ein SIGTERM aussitzt. Eine Frist ohne Nachdruck ist keine
// Frist, sondern eine Bitte.
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(120)->run(<<<BASH
bash -c 'trap "" TERM; exec 9>"$tmp/.agent.lock"; sleep 120' &
pid=\$!
sleep 0.3
bash {$helper} release-update-lock
sleep 0.3
kill -0 "\$pid" 2>/dev/null && echo "LEBT" || echo "BEENDET"
BASH);
expect($result->output())->toContain('BEENDET');
});
it('says so instead of pretending when nothing holds the lock', function () {
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
File::ensureDirectoryExists($tmp);
touch($tmp.'/.agent.lock');
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
$result = Process::timeout(60)->run("bash {$helper} release-update-lock");
expect($result->successful())->toBeTrue()
->and($result->output().$result->errorOutput())->toContain('nothing');
});
/**
* ── Der Vertrag zwischen Skript, sudoers und Updater ──────────────────────
*/
it('grants the new step in sudoers and raises the contract for it', function () {
// Ein Schritt, den der Helfer kann und sudoers nicht erlaubt, scheitert
// im Betrieb — und ein Wirt mit altem Helfer täte still gar nichts. Beide
// Enden gehören zusammengehalten, sonst merkt es niemand.
$installer = File::get(base_path('deploy/install-agent.sh'));
$updater = File::get(base_path('deploy/update.sh'));
expect($installer)->toContain('release-update-lock)')
->and($installer)->toContain('$HOST_STEP release-update-lock')
->and($installer)->toContain('CONTRACT=3')
->and($updater)->toContain('HOST_STEP_NEEDS=3');
});