From 33e5b15099d286145725a07ee6330a31eb300f46 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 02:26:34 +0200 Subject: [PATCH] Restart the workers automatically after saving .env, instead of handing the operator back to the shell Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Integrations.php | 102 ++++++- app/Services/Deployment/UpdateChannel.php | 79 ++++- deploy/update-agent.sh | 51 +++- lang/de/admin_settings.php | 1 + lang/de/integrations.php | 25 +- lang/en/admin_settings.php | 1 + lang/en/integrations.php | 25 +- .../livewire/admin/integrations.blade.php | 23 +- tests/Feature/Admin/EnvRestartTest.php | 276 ++++++++++++++++++ tests/Feature/Admin/IntegrationsPageTest.php | 8 + 10 files changed, 557 insertions(+), 34 deletions(-) create mode 100644 tests/Feature/Admin/EnvRestartTest.php diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 3e6b199..d2c6c6c 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -3,11 +3,13 @@ namespace App\Livewire\Admin; use App\Livewire\Concerns\ConfirmsPassword; +use App\Services\Deployment\UpdateChannel; use App\Services\Env\EnvFileEditor; use App\Services\Env\InvalidEnvContentException; use App\Services\Secrets\SecretVault; use App\Support\ProvisioningSettings; use App\Support\Settings; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Layout; @@ -85,6 +87,21 @@ class Integrations extends Component */ public bool $envLoaded = false; + /** + * True from the moment saveEnv() successfully asks the agent to restart + * the workers, until render() observes the request is no longer pending. + * + * Drives the small inline "wird neu gestartet" indicator on this page — + * not the full-screen maintenance overlay in layouts/admin.blade.php, + * which is for an actual deployment. A worker restart is four containers + * coming back in a few seconds, this page (and the console around it) + * never goes offline for it, and `app` — the container answering this + * very poll — is never among them. wire:poll on this page's own + * component is therefore enough to watch it finish; nothing here needs + * the client-side watcher an in-flight deployment does. + */ + public bool $envRestartWatching = false; + protected function confirmationGuard(): string { return 'operator'; @@ -200,9 +217,14 @@ class Integrations extends Component // ---- The .env editor — Part B. secrets.manage + confirmed password. ---- /** - * Validate, back up, write. EnvFileEditor does the actual work and the - * actual refusing; this only translates its one exception into a form - * error instead of a 500, and reports where the backup landed. + * Validate, back up, write — then do the two things saving this file used + * to leave as homework: clear the config cache (this container can do + * that itself, no host privileges needed) and ask the host-side agent to + * restart queue, queue-provisioning, scheduler and reverb (UpdateChannel + * — it cannot be done from in here; see that class for why). + * + * EnvFileEditor does the write itself and the actual refusing; this only + * translates its one exception into a form error instead of a 500. */ public function saveEnv(): void { @@ -218,11 +240,55 @@ class Integrations extends Component return; } + // Unconditional, and deliberately not gated on + // app()->configurationIsCached() first: Illuminate's own ConfigClear + // command is already exactly that guard — it deletes the cache file + // if one exists and does nothing if there is none, so adding a check + // here would only save a single is_file() call in the common case at + // the cost of a second source of truth to keep in sync with it. This + // dev machine has no cache right now (checked: no + // bootstrap/cache/config.php), but deploy/update.sh and + // deploy/install-agent.sh both run `config:cache` as standard + // practice, so a real installation usually does — and while one is + // active, Laravel skips loading .env on every request AT ALL + // (LoadEnvironmentVariables bootstraps straight from the cached + // values instead). Left uncleared there, the value just written would + // stay invisible — not merely to the four workers below, to this + // application too — until the cache was rebuilt by hand. + Artisan::call('config:clear'); + + $channel = app(UpdateChannel::class); + $agentAlive = $channel->state()['agent_seen']; + $by = Auth::guard('operator')->user()?->email ?? 'console'; + // '' means there was no previous file to protect — EnvFileEditor's // own docblock explains why that is not an error. - $this->dispatch('notify', message: __('integrations.env_saved', [ - 'backup' => $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file'), - ])); + $backupName = $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file'); + + if (! $agentAlive) { + // Honest, not silent: the values ARE saved, but nothing is going + // to pick them up on its own. Pretending otherwise here is worse + // than the manual-restart card this replaces — see + // admin_settings.update_no_agent for the same shape on updates. + $this->envRestartWatching = false; + $this->dispatch('notify', message: __('integrations.env_saved_no_agent', ['backup' => $backupName])); + + return; + } + + if (! $channel->requestRestart($by)) { + // The single request slot is already taken by something else + // (a check, an update, or another restart just asked for) — rare, + // but real, and not something to paper over with a message that + // says "restarting" when nothing was actually queued. + $this->envRestartWatching = false; + $this->dispatch('notify', message: __('integrations.env_saved_restart_busy', ['backup' => $backupName])); + + return; + } + + $this->envRestartWatching = true; + $this->dispatch('notify', message: __('integrations.env_saved_restarting', ['backup' => $backupName])); } /** ConfirmSaveEnv dispatches this back (R23) — see that class. */ @@ -287,11 +353,35 @@ class Integrations extends Component $this->envLoaded = false; } + $restart = app(UpdateChannel::class)->state(); + + // The transition the small inline indicator exists to show: a restart + // THIS component asked for is no longer pending. Read off + // 'requested_at' rather than 'restarting': the latter is gated on + // agent_seen (see UpdateChannel::state()), so if the agent went + // quiet in the few seconds this was being waited on, 'restarting' + // would already read false EVEN THOUGH THE REQUEST FILE IS STILL + // SITTING THERE, unread — and this would announce "restarted" + // for a restart that never ran. 'requested_at' answers the only + // question that matters here — is a request still on disk — with no + // opinion about the agent either way. Fires once — the instant it + // does, envRestartWatching drops so the next poll (or the one after, + // on a slower host with no on-demand wake) does not dispatch it + // again. wire:poll on the .env section is what keeps calling + // render() while this is being waited on; see $envRestartWatching's + // own docblock for why that is enough and the full-page deployment + // overlay is not needed here. + if ($this->envRestartWatching && $restart['requested_at'] === null) { + $this->envRestartWatching = false; + $this->dispatch('notify', message: __('integrations.env_restart_done')); + } + return view('livewire.admin.integrations', [ 'canSecrets' => $canSecrets, 'canInfra' => $canInfra, 'unlocked' => $unlocked, 'usable' => $vault->isUsable(), + 'restart' => $restart, 'entries' => collect(SecretVault::REGISTRY) ->map(fn (array $meta, string $key) => [ 'key' => $key, diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 394534f..2c6c0e2 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -29,10 +29,13 @@ use Throwable; * * The request carries a KIND, not a flag: "check" (look only — the agent * already fetches on every tick regardless, this just answers sooner than the - * next one would on its own) or "run" (apply it, downtime and all). Two - * different things an operator can ask for, not one request with a modifier — - * an operator who only wants to know whether anything is new must not be - * routed through a real deployment to find out. + * next one would on its own), "run" (apply it, downtime and all), or + * "restart" (queue, queue-provisioning, scheduler, reverb only — picking up a + * freshly-saved .env without a deployment). Three different things an + * operator can ask for, not one request with a modifier — an operator who + * only wants to know whether anything is new must not be routed through a + * real deployment to find out, and one who only changed a credential must not + * be routed through one either. * * Everything here degrades to "unknown" rather than throwing. A broken or * absent status file must leave the settings page readable — it is the page an @@ -56,6 +59,15 @@ final class UpdateChannel */ private const KIND_RUN = 'run'; + /** + * A request to restart the four workers that only read .env at their own + * startup — queue, queue-provisioning, scheduler, reverb — and nothing + * else. Not a deployment: no fetch is consulted, no maintenance mode, no + * release is involved. `app` is deliberately never in that list — see + * update-agent.sh's handling of this kind for why. + */ + private const KIND_RESTART = 'restart'; + /** Written by the agent after every check and every run. */ private const STATUS = 'deploy/update-status.json'; @@ -70,6 +82,15 @@ final class UpdateChannel /** Tail of the last run, for the operator to read without shell access. */ private const LOG = 'deploy/update-last-run.log'; + /** + * The outcome of the last worker RESTART, kept apart from LAST_RUN. + * + * A restart is not a deployment and update.sh never runs for it, so it + * gets its own tiny record rather than borrowing the deployment's — the + * two must be able to fail independently and be reported independently. + */ + private const RESTART_LAST_RUN = 'deploy/restart-last-run.json'; + /** * The step deploy/update.sh is on, written by update.sh itself. * @@ -115,6 +136,7 @@ final class UpdateChannel $release = Release::current(); $status = $this->readJson(self::STATUS); $lastRun = $this->readJson(self::LAST_RUN); + $restartLastRun = $this->readJson(self::RESTART_LAST_RUN); $request = $this->pendingRequest(); $checkedAt = $this->timestamp($status['checked_at'] ?? null); @@ -146,15 +168,29 @@ final class UpdateChannel 'checked_at' => $checkedAt, 'agent_seen' => $agentAlive, - // A pending CHECK must never read as "running": nothing is being - // applied, the site never enters maintenance mode, and the - // full-screen overlay exists only for the case where it does. + // A pending CHECK or RESTART must never read as "running": nothing + // is being deployed, the site never enters maintenance mode, and + // the full-screen overlay (layouts/admin.blade.php) exists only + // for the case where it does. A worker restart used to slip + // through this check by only excluding "check" — any kind that + // was not a check counted as "running" — which would have shown + // an operator saving .env the same maintenance screen as a real + // deployment, for four containers that come back in seconds. + // Matched on KIND_RUN explicitly instead, so a future kind added + // here has to opt IN to the overlay rather than getting it by + // accident. 'running' => $agentAlive && ( - ($request !== null && $requestKind !== self::KIND_CHECK) + ($request !== null && $requestKind === self::KIND_RUN) || ($status['state'] ?? null) === 'running' ), // A pending check, still waiting for the agent to pick it up. 'checking' => $agentAlive && $request !== null && $requestKind === self::KIND_CHECK, + // A pending — or in-flight — worker restart. The agent handles a + // restart within the same tick it consumes the request (no + // maintenance mode, no phase file), so this is only ever true for + // the short window before that tick runs; see RESTART_LAST_RUN + // below for what happened once it has. + 'restarting' => $agentAlive && $request !== null && $requestKind === self::KIND_RESTART, 'requested_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null, 'requested_by' => $request['requested_by'] ?? null, @@ -184,6 +220,12 @@ final class UpdateChannel // The run's own failure first; a check-level problem (the // repository being unreachable) only when the last run was fine. 'last_error' => $this->errorMessage($lastRun) ?? $this->errorMessage($status), + + // The last worker restart, independent of the deployment fields + // above — see RESTART_LAST_RUN. + 'last_restart_state' => $restartLastRun['state'] ?? null, + 'last_restart_finished_at' => $this->timestamp($restartLastRun['finished_at'] ?? null), + 'last_restart_error' => $this->errorMessage($restartLastRun), ]; } @@ -340,14 +382,31 @@ final class UpdateChannel return $this->submit($by, self::KIND_CHECK); } + /** + * Ask the agent to restart the four workers that only read .env at their + * own startup — never `app`. See update-agent.sh's handling of this kind + * for why `app` is excluded and always will be. + * + * The caller (App\Livewire\Admin\Integrations::saveEnv()) only calls this + * when the agent is known to be alive; a submit() with no agent to + * collect it would sit until it expires and tell the operator nothing. + * Whether the agent is reachable at all is what makes the difference + * between "handled automatically" and "saved, but still on the old + * values" — the operator is told which one happened, never left to guess. + */ + public function requestRestart(string $by): bool + { + return $this->submit($by, self::KIND_RESTART); + } + private function submit(string $by, string $kind): bool { // Both conditions, not just the request file. The agent DELETES the // request before it starts a run — so between that moment and the end // of the run there is nothing pending, and a second click from a // stale tab would queue a whole redundant deployment for the next - // tick. One request slot for both kinds: the agent handles one tick - // at a time regardless of what it is asked for. + // tick. One request slot for all three kinds: the agent handles one + // tick at a time regardless of what it is asked for. if ($this->pendingRequest() !== null || $this->isRunning()) { return false; } diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 70ba34e..805a7ff 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -34,6 +34,10 @@ PHASE_FILE="$STATE_DIR/update-phase" # one file, the next idle tick a minute later would overwrite a failure with # "idle" — and an operator would usually never see that the update failed. LASTRUN="$STATE_DIR/update-last-run.json" +# The outcome of the last worker RESTART — its own file, not folded into +# LASTRUN: a restart is not a deployment (no fetch, no maintenance mode, no +# update.sh), and the two must be able to fail independently. +RESTARTLAST="$STATE_DIR/restart-last-run.json" LOCK="$STATE_DIR/.agent.lock" # The reverse proxy's console allowlist, generated from the one the owner keeps # in the console. Without this the proxy has its own hard-coded list that runs @@ -194,6 +198,19 @@ EOF mv -f "$LASTRUN.tmp" "$LASTRUN" } +# The outcome of the last worker restart. Survives until the next one. +write_restart() { + local state="$1" error="${2-}" + cat > "$RESTARTLAST.tmp" < REQUEST_EXPIRES_MINUTES )); then exit 0 fi -# What was actually asked for: a real run, or only the check the block above -# already performed. A distinct field, not a flag folded into the existing -# request — "check" and "run" are two different things the panel can ask -# for, not one request with a modifier. Read before the file is consumed; -# a request written by an older panel has no such field and is treated as a -# run, which is everything a request has ever meant until now. +# What was actually asked for: a real run, only the check the block above +# already performed, or a worker restart. A distinct field, not a flag folded +# into the existing request — "check", "run" and "restart" are three +# different things the panel can ask for, not one request with a modifier. +# Read before the file is consumed; a request written by an older panel has +# no such field and is treated as a run, which is everything a request has +# ever meant until now. REQUEST_KIND="$(sed -n 's/.*"kind"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" 2>/dev/null | head -1)" # Consumed BEFORE anything below runs, not after: update.sh restarts the @@ -283,6 +301,27 @@ if [[ "$REQUEST_KIND" == "check" ]]; then exit 0 fi +if [[ "$REQUEST_KIND" == "restart" ]]; then + # queue, queue-provisioning, scheduler, reverb — and ONLY those four. + # `app` is deliberately never in this list: it is the container serving + # THIS very request (the panel wrote the request from inside it), so + # restarting it would kill the request that asked for the restart before + # the agent could even answer it. It is also unnecessary — PHP-FPM + # bootstraps a fresh Laravel application on every request it serves, so + # `app` already picks up whatever the panel just wrote to .env (and + # cleared from the config cache, which the panel also does itself, see + # App\Livewire\Admin\Integrations::saveEnv()) without being touched here. + # Not a deployment: no fetch was needed for this, no maintenance mode, no + # phase file, no update.sh. + if docker compose restart queue queue-provisioning scheduler reverb >/dev/null 2>&1; then + write_restart succeeded + else + write_restart failed restart_failed + fi + write_status idle "$FETCH_ERROR" + exit 0 +fi + # Only ever to a tag, and only to one that is genuinely newer. Checked BEFORE # anything announces a run: a request with nothing to install is answered, not # obeyed. The console does not offer the button in that state, so this is a diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index c9fa55f..efb3c83 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -120,6 +120,7 @@ return [ 'repo_unreachable' => 'Das Repository ist vom Server aus nicht erreichbar.', 'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.', 'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.', + 'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue queue-provisioning scheduler reverb.', ], 'update_last_run' => 'Letzter Lauf: :state, :when.', diff --git a/lang/de/integrations.php b/lang/de/integrations.php index 48a90b4..ce3254c 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -53,14 +53,29 @@ return [ 'env_backup_title' => 'Sicherung', 'env_backup_body' => 'Vor jedem Speichern wird eine Kopie neben .env abgelegt (Dateiname .env.bak-Zeitstempel). Sie werden nicht automatisch gelöscht.', - 'env_restart_title' => 'Was Speichern nicht tut', - 'env_restart_body' => 'queue, queue-provisioning, scheduler und reverb lesen .env nur beim eigenen Start — ein hier gespeicherter Wert wirkt dort erst nach einem Neustart. Ohne ihn sieht es aus, als hätte der Editor nichts getan.', - 'env_config_clear' => 'Ist der Konfigurations-Cache aktiv, zusätzlich: docker compose exec -T app php artisan config:clear.', + // queue, queue-provisioning, scheduler und reverb lesen .env nur beim + // eigenen Start. Bis hierher war das Homework — „starten Sie das jetzt + // selbst neu" — dieselbe Klage wie beim Update-Knopf davor: über die + // Kommandozeile nacharbeiten, was die Konsole schon wusste. Speichern + // löst das jetzt selbst aus (App\Livewire\Admin\Integrations::saveEnv()): + // den Konfigurations-Cache leeren (sofort, ohne den Host-Agenten — dafür + // braucht es keine besonderen Rechte) und den Neustart beim Agenten + // anfordern (App\Services\Deployment\UpdateChannel::requestRestart()). + 'env_restart_title' => 'Was beim Speichern automatisch passiert', + 'env_restart_body' => 'queue, queue-provisioning, scheduler und reverb lesen .env nur beim eigenen Start. Speichern hier löst deshalb automatisch aus: der Konfigurations-Cache wird geleert, und diese vier Dienste werden neu gestartet. Diese Konsole selbst (app) wird dabei nie angefasst — sie bedient gerade die Anfrage, die das ausgelöst hat, und ihr PHP liest .env bei jeder neuen Anfrage ohnehin frisch.', + 'env_restarting' => 'Dienste werden neu gestartet …', + 'env_restart_done' => 'Dienste neu gestartet.', 'env_save_title' => '.env wirklich überschreiben?', - 'env_save_body' => 'Sie enthält jedes Zugangsdatum im System. Eine Sicherung wird zuvor angelegt — betroffene Dienste übernehmen die Änderung trotzdem erst nach einem Neustart.', + 'env_save_body' => 'Sie enthält jedes Zugangsdatum im System. Eine Sicherung wird zuvor angelegt, und die betroffenen Dienste werden nach dem Speichern automatisch neu gestartet.', 'env_save_confirm' => 'Überschreiben', - 'env_saved' => 'Gespeichert. Sicherung: :backup. Betroffene Dienste übernehmen die Änderung erst nach einem Neustart.', + // Drei Ausgänge nach dem Schreiben, nie eine geschönte Erfolgsmeldung: der + // Neustart wurde angefordert, oder er konnte es nicht — weil der + // Host-Agent nicht läuft oder weil schon etwas anderes läuft — und dann + // steht hier der manuelle Befehl statt eines Versprechens. + 'env_saved_restarting' => 'Gespeichert. Sicherung: :backup. Konfigurations-Cache geleert — queue, queue-provisioning, scheduler und reverb werden jetzt neu gestartet.', + 'env_saved_restart_busy' => 'Gespeichert. Sicherung: :backup. Es läuft gerade schon eine andere Anfrage an den Update-Dienst — der Neustart entfällt diesmal automatisch. Bitte manuell ausführen: docker compose restart queue queue-provisioning scheduler reverb.', + 'env_saved_no_agent' => 'Gespeichert. Sicherung: :backup. Der Update-Dienst auf dem Server läuft nicht — queue, queue-provisioning, scheduler und reverb halten noch die alten Werte. Bitte manuell ausführen: docker compose restart queue queue-provisioning scheduler reverb.', // Nur wenn keine .env vorher existierte — dann gibt es nichts zu sichern. 'env_no_previous_file' => 'keine, da zuvor keine Datei existierte', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index a6653f8..440dfc1 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -120,6 +120,7 @@ return [ 'repo_unreachable' => 'The repository cannot be reached from the server.', 'detached_no_release' => 'The checkout is on no branch and pinned to no release.', 'update_failed' => 'The update failed (code :code). See the log.', + 'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue queue-provisioning scheduler reverb.', ], 'update_last_run' => 'Last run: :state, :when.', diff --git a/lang/en/integrations.php b/lang/en/integrations.php index 91643cd..f80f0ba 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -53,14 +53,29 @@ return [ 'env_backup_title' => 'Backup', 'env_backup_body' => 'A copy is placed beside .env before every save (filename .env.bak-timestamp). Nothing deletes them automatically.', - 'env_restart_title' => 'What saving does not do', - 'env_restart_body' => 'queue, queue-provisioning, scheduler and reverb only read .env at their own startup — a value saved here does not take effect there until they restart. Without a restart, it looks like the editor did nothing.', - 'env_config_clear' => 'With the config cache active, also run: docker compose exec -T app php artisan config:clear.', + // queue, queue-provisioning, scheduler and reverb only read .env at + // their own startup. Until here that was homework — "go restart that + // yourself" — the same complaint as the update button before it: working + // back over the command line what the console already knew. Saving now + // triggers that itself (App\Livewire\Admin\Integrations::saveEnv()): + // clearing the config cache (immediately, no host agent needed for that) + // and asking the agent for the restart + // (App\Services\Deployment\UpdateChannel::requestRestart()). + 'env_restart_title' => 'What happens automatically on save', + 'env_restart_body' => 'queue, queue-provisioning, scheduler and reverb only read .env at their own startup. Saving here therefore triggers this automatically: the configuration cache is cleared, and these four services are restarted. This console itself (app) is never touched — it is serving the very request that triggered this, and its PHP reads .env fresh on every request anyway.', + 'env_restarting' => 'Restarting services …', + 'env_restart_done' => 'Services restarted.', 'env_save_title' => 'Really overwrite .env?', - 'env_save_body' => 'It holds every credential in the system. A backup is made first — affected services still only pick up the change after a restart.', + 'env_save_body' => 'It holds every credential in the system. A backup is made first, and the affected services are restarted automatically after saving.', 'env_save_confirm' => 'Overwrite', - 'env_saved' => 'Saved. Backup: :backup. Affected services pick up the change only after a restart.', + // Three outcomes after writing, never a glossed-over success message: the + // restart was requested, or it could not be — because the host agent is + // not running, or because something else is already in flight — and then + // the manual command is shown here instead of a promise. + 'env_saved_restarting' => 'Saved. Backup: :backup. Configuration cache cleared — queue, queue-provisioning, scheduler and reverb are restarting now.', + 'env_saved_restart_busy' => 'Saved. Backup: :backup. Another request to the update service is already in flight — the restart does not happen automatically this time. Please run manually: docker compose restart queue queue-provisioning scheduler reverb.', + 'env_saved_no_agent' => 'Saved. Backup: :backup. The server-side update service is not running — queue, queue-provisioning, scheduler and reverb still hold the old values. Please run manually: docker compose restart queue queue-provisioning scheduler reverb.', // Only when no .env existed before — then there is nothing to back up. 'env_no_previous_file' => 'none, no file existed before this', diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index 9bdeecd..b037a4c 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -240,11 +240,30 @@

{{ __('integrations.env_backup_title') }}

{{ __('integrations.env_backup_body') }}

+ {{-- What saving now does automatically (config:clear here, + the restart via UpdateChannel) — not homework anymore, + see App\Livewire\Admin\Integrations::saveEnv(). The + spinner below is this component's own wire:poll, not + the full-screen deployment overlay: a restart of four + containers is seconds, not minutes, and `app` — the + container answering this very poll — is never among + them, so this page never goes offline for it. --}}

{{ __('integrations.env_restart_title') }}

{{ __('integrations.env_restart_body') }}

-
docker compose restart queue queue-provisioning scheduler reverb
-

{{ __('integrations.env_config_clear') }}

+ + @if ($envRestartWatching || $restart['restarting']) +

+ + {{ __('integrations.env_restarting') }} +

+ @elseif (! $restart['agent_seen']) + {{-- Honest, not silent: told plainly, with the + fallback right here — same shape as the update + card's own admin_settings.update_no_agent. --}} + {{ __('admin_settings.update_no_agent') }} +
docker compose restart queue queue-provisioning scheduler reverb
+ @endif
@endif diff --git a/tests/Feature/Admin/EnvRestartTest.php b/tests/Feature/Admin/EnvRestartTest.php new file mode 100644 index 0000000..f62acc7 --- /dev/null +++ b/tests/Feature/Admin/EnvRestartTest.php @@ -0,0 +1,276 @@ +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')); +}); diff --git a/tests/Feature/Admin/IntegrationsPageTest.php b/tests/Feature/Admin/IntegrationsPageTest.php index 58960ca..6e1eb5b 100644 --- a/tests/Feature/Admin/IntegrationsPageTest.php +++ b/tests/Feature/Admin/IntegrationsPageTest.php @@ -48,6 +48,14 @@ afterEach(function () { beforeEach(function () { config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + + // saveEnv() now reads/writes UpdateChannel state (config:clear + a + // restart request) — the same shared storage/app/deploy files + // UpdateButtonTest.php exercises. Cleared here for the same reason that + // file clears it in its own beforeEach: PHPUnit/Pest run every test file + // in one process, so a leftover status or request from whichever file ran + // last would otherwise decide whether these tests see an agent as alive. + File::deleteDirectory(storage_path('app/deploy')); }); // ---- Reachability: either capability opens the page, neither 403s it. ----