228 lines
9.2 KiB
PHP
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');
|
|
});
|