176 lines
6.9 KiB
PHP
176 lines
6.9 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\ConfirmReleaseUpdateLock;
|
|
use App\Livewire\Admin\Settings as AdminSettings;
|
|
use App\Services\Deployment\UpdateChannel;
|
|
use Illuminate\Support\Facades\File;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Eine hängende Sperre aus der Konsole lösen.
|
|
*
|
|
* Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose
|
|
* exec` ohne `--kill-after`, der nach vier Minuten noch stand — die Sperre
|
|
* stundenlang, und jeder folgende Lauf wurde übersprungen. Der Griff dagegen
|
|
* lag per SSH auf dem Wirt. Er liegt jetzt in der Konsole.
|
|
*
|
|
* Die Konsole kann ihn nicht selbst ausführen: sie ist www-data in einem
|
|
* Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Sie legt deshalb
|
|
* eine Bitte ab, wie für alles andere hier auch — nur in einer EIGENEN Datei.
|
|
* Der Postkasten `update-request.json` taugt dafür nicht: er nimmt eine Bitte
|
|
* zur Zeit an, und genau die dreißig Minuten, in denen dort noch eine wartende
|
|
* Update-Anfrage liegt, sind die, in denen jemand entsperren will.
|
|
*/
|
|
beforeEach(function () {
|
|
File::deleteDirectory(storage_path('app/deploy'));
|
|
});
|
|
|
|
/** Ein Lebenszeichen, das eine echte Blockade meldet (zwei übersprungene Läufe). */
|
|
function blockedHeartbeat(string $heldBy = '4242 02:03 docker compose exec -T app'): void
|
|
{
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
|
'state' => 'idle',
|
|
'checked_at' => now()->subMinutes(5)->toIso8601String(),
|
|
'behind' => 0,
|
|
]));
|
|
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
|
|
'at' => now()->toIso8601String(),
|
|
'state' => 'blocked',
|
|
'since' => now()->subMinutes(5)->toIso8601String(),
|
|
'held_by' => $heldBy,
|
|
'skips' => 5,
|
|
]));
|
|
}
|
|
|
|
it('offers to release the lock to an operator who may', function () {
|
|
blockedHeartbeat();
|
|
|
|
Livewire::actingAs(operator('Owner'), 'operator')
|
|
->test(AdminSettings::class)
|
|
->assertSee(__('admin_settings.update_release_lock'))
|
|
->assertSee("component: 'admin.confirm-release-update-lock'", escape: false);
|
|
});
|
|
|
|
it('offers nothing of the sort to an operator who may not', function () {
|
|
// Die Warnung sieht jeder, der die Seite sieht. Den Griff nicht.
|
|
blockedHeartbeat();
|
|
|
|
Livewire::actingAs(operator('Support'), 'operator')
|
|
->test(AdminSettings::class)
|
|
->assertDontSee(__('admin_settings.update_release_lock'));
|
|
});
|
|
|
|
it('does not offer to release a lock nobody is holding', function () {
|
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
|
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
|
'state' => 'idle',
|
|
'checked_at' => now()->toIso8601String(),
|
|
'behind' => 0,
|
|
]));
|
|
|
|
Livewire::actingAs(operator('Owner'), 'operator')
|
|
->test(AdminSettings::class)
|
|
->assertDontSee(__('admin_settings.update_release_lock'));
|
|
});
|
|
|
|
it('names the process it is about to end, before it ends it', function () {
|
|
// Der Sinn der ganzen Rückfrage. PID und Kommandozeile stehen im
|
|
// Lebenszeichen; sie erst NACH dem Beenden zu zeigen wäre eine Meldung,
|
|
// keine Rückfrage.
|
|
blockedHeartbeat('4242 02:03 docker compose exec -T app');
|
|
|
|
Livewire::actingAs(operator('Owner'), 'operator')
|
|
->test(ConfirmReleaseUpdateLock::class)
|
|
->assertSee('4242 02:03 docker compose exec -T app');
|
|
});
|
|
|
|
it('leaves the deed to the page component, and only dispatches', function () {
|
|
// R23: das Modal mutiert nichts. Die Berechtigungsprüfung bleibt an der
|
|
// einen Stelle, an der sie schon steht.
|
|
blockedHeartbeat();
|
|
|
|
Livewire::actingAs(operator('Owner'), 'operator')
|
|
->test(ConfirmReleaseUpdateLock::class)
|
|
->call('confirm')
|
|
->assertDispatched('update-lock-release-confirmed');
|
|
|
|
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
|
|
});
|
|
|
|
it('leaves the request for the agent once the confirmation comes back', function () {
|
|
blockedHeartbeat();
|
|
$owner = operator('Owner');
|
|
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(AdminSettings::class)
|
|
->dispatch('update-lock-release-confirmed');
|
|
|
|
$request = json_decode(File::get(storage_path('app/deploy/unblock-request.json')), true);
|
|
|
|
expect($request['requested_by'])->toBe($owner->email)
|
|
->and($request['requested_at'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('refuses to release the lock for an operator without the capability', function () {
|
|
blockedHeartbeat();
|
|
|
|
Livewire::actingAs(operator('Support'), 'operator')
|
|
->test(AdminSettings::class)
|
|
->dispatch('update-lock-release-confirmed')
|
|
->assertForbidden();
|
|
|
|
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
|
|
});
|
|
|
|
it('is not held up by an update request already waiting in the mailbox', function () {
|
|
// Der Grund für die eigene Datei. Der Postkasten nimmt eine Bitte zur
|
|
// Zeit an — und ein blockierter Agent holt sie nie ab, also läge dort
|
|
// dreißig Minuten lang eine, die das Entsperren aussperrt.
|
|
blockedHeartbeat();
|
|
File::put(storage_path('app/deploy/update-request.json'), json_encode([
|
|
'requested_at' => now()->toIso8601String(),
|
|
'requested_by' => 'someone@example.com',
|
|
'kind' => 'run',
|
|
]));
|
|
|
|
expect(app(UpdateChannel::class)->requestUnblock('owner@example.com'))->toBeTrue()
|
|
->and(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeTrue();
|
|
});
|
|
|
|
it('does not queue a second release while the first is still waiting', function () {
|
|
blockedHeartbeat();
|
|
$channel = app(UpdateChannel::class);
|
|
|
|
expect($channel->requestUnblock('owner@example.com'))->toBeTrue()
|
|
->and($channel->requestUnblock('owner@example.com'))->toBeFalse();
|
|
});
|
|
|
|
it('says the release is on its way while the agent has not collected it', function () {
|
|
blockedHeartbeat();
|
|
app(UpdateChannel::class)->requestUnblock('owner@example.com');
|
|
|
|
expect(app(UpdateChannel::class)->state()['unblock_requested'])->toBeTrue();
|
|
});
|
|
|
|
it('reports back when the host helper was too old to do it', function () {
|
|
// Der stille Fehlschlag, den dieses Repo nicht duldet: ein Wirt, auf dem
|
|
// install-agent.sh seit dem neuen Schritt nicht mehr lief, kann nicht
|
|
// entsperren — und ein Knopf, der nichts tut und nichts sagt, schickt den
|
|
// Betreiber genau dorthin zurück, wo er ohne die Konsole schon war.
|
|
blockedHeartbeat();
|
|
File::put(storage_path('app/deploy/unblock-last-run.json'), json_encode([
|
|
'state' => 'failed',
|
|
'finished_at' => now()->toIso8601String(),
|
|
'error' => 'unblock_helper_old',
|
|
]));
|
|
|
|
$state = app(UpdateChannel::class)->state();
|
|
|
|
expect($state['unblock_error'])->toBe(__('admin_settings.update_error.unblock_helper_old'));
|
|
|
|
Livewire::actingAs(operator('Owner'), 'operator')
|
|
->test(AdminSettings::class)
|
|
->assertSee(__('admin_settings.update_error.unblock_helper_old'));
|
|
});
|