CluPilotCloud/tests/Feature/Admin/EnvRestartTest.php

284 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Livewire\Admin\Integrations;
use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Livewire\Livewire;
/**
* Saving .env used to end with a card telling the operator to run
* `docker compose restart queue queue-provisioning scheduler reverb` by
* hand — a shell step tacked onto the end of a console action, the exact
* complaint the update button drew before it got its own request/agent
* mechanism (App\Services\Deployment\UpdateChannel). This reuses that
* mechanism rather than inventing a new one or mounting the Docker socket
* into the web container: a third request KIND, "restart", picked up by
* deploy/update-agent.sh exactly the way "check" and "run" already are.
*
* Covers both halves: UpdateChannel/update-agent.sh's new kind, and
* App\Livewire\Admin\Integrations::saveEnv() wiring it in — config:clear
* happens immediately (no host agent needed for that), the restart is
* requested when an agent is there to collect it, and the operator is told
* plainly — not left to assume success — when one is not.
*/
function envRestartFilePath(string $seed = "OLD=value\n"): string
{
$path = storage_path('framework/testing/env-restart-'.Str::random(12).'.env');
File::ensureDirectoryExists(dirname($path));
File::put($path, $seed);
app()->instance(EnvFileEditor::class, new EnvFileEditor($path));
return $path;
}
/** Makes UpdateChannel::state()['agent_seen'] true — the agent checked in recently. */
function restartAgentAlive(): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
]));
}
function pendingRequestKind(): ?string
{
$path = storage_path('app/deploy/update-request.json');
return File::exists($path) ? (json_decode(File::get($path), true)['kind'] ?? null) : null;
}
/**
* The page, unlocked, with the .env tab open.
*
* The tab is where the editor lives since the page was split up; the unlock
* itself is a session fact and survives switching between them.
*/
function unlockedIntegrations($owner)
{
return Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->set('tab', 'env');
}
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
File::deleteDirectory(storage_path('app/deploy'));
});
afterEach(function () {
foreach (glob(storage_path('framework/testing/env-restart-*')) as $leftover) {
@unlink($leftover);
}
});
// ---- UpdateChannel: the new "restart" request kind. ----
it('writes a distinct restart request kind, separate from check and run', function () {
expect(app(UpdateChannel::class)->requestRestart('owner@example.com'))->toBeTrue();
expect(pendingRequestKind())->toBe('restart');
});
it('mutation target: reports a pending restart as "restarting", never as "running" — no maintenance overlay for four workers', function () {
// The exact bug this guards against: 'running' used to be everything
// that was not a "check" — which would also have caught "restart" and
// put an operator saving .env behind the full-screen deployment overlay
// (layouts/admin.blade.php) for something that takes seconds, not the
// minutes that overlay exists for.
restartAgentAlive();
app(UpdateChannel::class)->requestRestart('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['restarting'])->toBeTrue()
->and($state['running'])->toBeFalse()
->and($state['checking'])->toBeFalse();
});
it('does not queue a second restart, or anything else, while one is pending', function () {
$channel = app(UpdateChannel::class);
expect($channel->requestRestart('someone@example.com'))->toBeTrue()
->and($channel->requestRestart('someone@example.com'))->toBeFalse()
->and($channel->request('someone@example.com'))->toBeFalse()
->and($channel->requestCheck('someone@example.com'))->toBeFalse();
});
it('reports the outcome of the last restart, independent of the deployment fields', function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/restart-last-run.json'), json_encode([
'state' => 'succeeded',
'finished_at' => now()->toIso8601String(),
'error' => '',
]));
$state = app(UpdateChannel::class)->state();
expect($state['last_restart_state'])->toBe('succeeded')
->and($state['last_restart_finished_at'])->not->toBeNull()
->and($state['last_restart_error'])->toBeNull();
});
it('translates a failed restart into the operators language', function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/restart-last-run.json'), json_encode([
'state' => 'failed',
'finished_at' => now()->toIso8601String(),
'error' => 'restart_failed',
]));
expect(app(UpdateChannel::class)->state()['last_restart_error'])
->toBe(__('admin_settings.update_error.restart_failed'));
});
// ---- update-agent.sh: the host side. ----
it('mutation target: restarts only queue, queue-provisioning, scheduler and reverb — never app', function () {
$agent = File::get(base_path('deploy/update-agent.sh'));
expect($agent)->toContain('docker compose restart queue queue-provisioning scheduler reverb');
// The exact restart LIST, not merely "the word app does not appear
// anywhere" — the script legitimately says "app" elsewhere (the -u
// www-data exec calls, comments), so this has to isolate the argument
// list of this specific command.
preg_match('/docker compose restart ([a-z0-9_ -]+)/', $agent, $matches);
$services = explode(' ', trim($matches[1] ?? ''));
expect($services)->toContain('queue')
->and($services)->toContain('queue-provisioning')
->and($services)->toContain('scheduler')
->and($services)->toContain('reverb')
->and($services)->not->toContain('app');
});
it('branches on the restart kind and records its own outcome, apart from the deployment', function () {
$agent = File::get(base_path('deploy/update-agent.sh'));
expect($agent)->toContain('"$REQUEST_KIND" == "restart"')
->and($agent)->toContain('write_restart')
->and($agent)->toContain('RESTARTLAST');
});
// ---- App\Livewire\Admin\Integrations::saveEnv() wiring it in. ----
it('clears the config cache on every successful save, before anything about the agent is decided', function () {
$owner = operator('Owner');
envRestartFilePath();
Artisan::shouldReceive('call')->once()->with('config:clear');
unlockedIntegrations($owner)
->set('envContent', "NEW=value\n")
->call('saveEnv')
->assertHasNoErrors();
});
it('asks the agent to restart the workers once the agent is known to be alive, and says so', function () {
$owner = operator('Owner');
envRestartFilePath();
restartAgentAlive();
unlockedIntegrations($owner)
->set('envContent', "NEW=value\n")
->call('saveEnv')
->assertHasNoErrors()
->assertSet('envRestartWatching', true)
->assertDispatched('notify');
expect(pendingRequestKind())->toBe('restart');
});
it('mutation target: tells the operator plainly when no agent is running, instead of claiming a restart happened', function () {
$owner = operator('Owner');
$path = envRestartFilePath();
// No status file at all — the agent has never checked in.
$page = unlockedIntegrations($owner)
->set('envContent', "NEW=value\n")
->call('saveEnv')
->assertHasNoErrors()
->assertSet('envRestartWatching', false);
// Honest, not silent: no restart request was left behind either — there
// is nothing to collect it, so nothing was queued to expire quietly.
expect(pendingRequestKind())->toBeNull();
$backups = glob($path.'.bak-*');
expect($backups)->not->toBe([]);
$page->assertDispatched('notify', message: __('integrations.env_saved_no_agent', [
'backup' => basename($backups[0]),
]));
});
it('does not claim an automatic restart when another request is already in flight', function () {
$owner = operator('Owner');
$path = envRestartFilePath();
restartAgentAlive();
// Occupies the single request slot before .env is ever saved.
app(UpdateChannel::class)->requestCheck('someone-else@example.com');
$page = unlockedIntegrations($owner)
->set('envContent', "NEW=value\n")
->call('saveEnv')
->assertHasNoErrors()
->assertSet('envRestartWatching', false);
// The ORIGINAL request is untouched — saveEnv() must not silently steal
// the slot and overwrite what someone else asked for.
expect(pendingRequestKind())->toBe('check');
$backups = glob($path.'.bak-*');
$page->assertDispatched('notify', message: __('integrations.env_saved_restart_busy', [
'backup' => basename($backups[0]),
]));
});
it('shows the restart in progress, then reports it finished once the request is no longer pending', function () {
$owner = operator('Owner');
envRestartFilePath();
restartAgentAlive();
$page = unlockedIntegrations($owner)
->set('envContent', "NEW=value\n")
->call('saveEnv')
->assertSet('envRestartWatching', true);
expect(pendingRequestKind())->toBe('restart');
// Simulate the agent having picked the request up — exactly what
// deploy/update-agent.sh's restart branch does: consume the request file
// before acting on it.
File::delete(storage_path('app/deploy/update-request.json'));
// A trivial property round-trip, standing in for wire:poll's next tick —
// it is render() that notices the request is gone and reports it.
$page->set('dnsZone', $page->get('dnsZone'))
->assertSet('envRestartWatching', false)
->assertDispatched('notify', message: __('integrations.env_restart_done'));
});
it('explains the automatic restart, and shows the manual fallback only when no agent is reachable', function () {
$owner = operator('Owner');
envRestartFilePath();
unlockedIntegrations($owner)
->assertSee(__('integrations.env_restart_title'))
->assertSee(__('admin_settings.update_no_agent'))
->assertSee('docker compose restart queue queue-provisioning scheduler reverb');
restartAgentAlive();
unlockedIntegrations($owner)
->assertSee(__('integrations.env_restart_title'))
->assertDontSee(__('admin_settings.update_no_agent'));
});