\"\$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(<<"$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(<<"$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(<<"$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'); });