277 lines
10 KiB
PHP
277 lines
10 KiB
PHP
<?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;
|
||
}
|
||
|
||
function unlockedIntegrations($owner)
|
||
{
|
||
return Livewire::actingAs($owner, 'operator')
|
||
->test(Integrations::class)
|
||
->set('confirmablePassword', 'password')
|
||
->call('confirmPassword');
|
||
}
|
||
|
||
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 operator’s 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'));
|
||
});
|