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')); });