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
parent
86bf4f26e0
commit
33e5b15099
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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'));
|
||||
});
|
||||
|
|
@ -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. ----
|
||||
|
|
|
|||
Loading…
Reference in New Issue