Restart the workers automatically after saving .env, instead of handing the operator back to the shell

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 02:26:34 +02:00
parent 86bf4f26e0
commit 33e5b15099
10 changed files with 557 additions and 34 deletions

View File

@ -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,

View File

@ -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;
}

View File

@ -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" <<EOF
{
"state": "$(json_escape "$state")",
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"error": "$(json_escape "$error")"
}
EOF
mv -f "$RESTARTLAST.tmp" "$RESTARTLAST"
}
LOCAL_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')"
REMOTE_COMMIT=''
BEHIND=null
@ -261,12 +278,13 @@ if (( REQUEST_AGE_MINUTES > 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

View File

@ -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.',

View File

@ -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',

View File

@ -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.',

View File

@ -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',

View File

@ -240,11 +240,30 @@
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_backup_title') }}</p>
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_backup_body') }}</p>
</div>
{{-- 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. --}}
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_restart_title') }}</p>
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_restart_body') }}</p>
<pre class="mt-2 overflow-x-auto rounded border border-line bg-surface px-3 py-2 font-mono text-[11px] text-body">docker compose restart queue queue-provisioning scheduler reverb</pre>
<p class="mt-2 text-xs text-muted">{{ __('integrations.env_config_clear') }}</p>
@if ($envRestartWatching || $restart['restarting'])
<p class="mt-2 flex items-center gap-1.5 text-xs font-medium text-accent-text" wire:poll.2s>
<x-ui.icon name="refresh" class="size-4 animate-spin" />
{{ __('integrations.env_restarting') }}
</p>
@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. --}}
<x-ui.alert variant="warning" class="mt-2">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
<pre class="mt-2 overflow-x-auto rounded border border-line bg-surface px-3 py-2 font-mono text-[11px] text-body">docker compose restart queue queue-provisioning scheduler reverb</pre>
@endif
</div>
</div>
@endif

View File

@ -0,0 +1,276 @@
<?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 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'));
});

View File

@ -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. ----