From 276f926d57b905290ba2f255bf37074f0f970f8a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 05:33:17 +0200 Subject: [PATCH] Add the update button, and give it a host-side agent that can actually update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel had no way to update the installation, and could not have one on its own: it runs as www-data inside a container, and the update restarts that container. A button that shelled out would kill its own web server mid-response, and would need the application to hold host-level credentials — a far worse thing to own than an out-of-date checkout. So the button is a mailbox. The panel writes a request into the checkout; a systemd timer on the host, running as the service account, consumes it, runs deploy/update.sh and writes back what happened. The same timer answers the question the panel cannot answer alone — is there anything to update — which needs a git fetch, and therefore credentials the application does not have. The parts that were wrong before review, all of which would have shipped as a button that looks fine and does nothing: - The agent was only installed by install.sh, which the installed base never runs again. Every existing server would have shown the button permanently disabled. It has its own root entry point now, install.sh delegates to it, and update.sh says so when the unit is missing. - On a release-pinned server the checkout is detached, so it followed "origin/HEAD" and then ran an update that deliberately stays on the pinned tag: updates advertised, nothing applied. It now compares TAGS in release mode and passes the target release through. - On a branch other than main it advertised that branch's commits and then deployed main's, because update.sh defaults to main. - A request written while the agent was down was executed whenever the agent next started, days later. - A single status file meant the routine five-minute check overwrote a failed update with "idle" before anyone saw it. - An agent that had been stopped left the button enabled forever, because a status file written once counted as an agent. - The agent's error messages were German strings rendered into an English interface; it reports codes now and the panel translates them. Also: the add-address button in the console-access panel was as tall as the field plus its hint, because the hint was a sibling inside the flex row. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Settings.php | 22 ++ app/Services/Deployment/UpdateChannel.php | 261 ++++++++++++++++++ deploy/install-agent.sh | 59 ++++ deploy/install.sh | 9 + deploy/update-agent.sh | 192 +++++++++++++ deploy/update.sh | 9 + lang/de/admin_settings.php | 21 ++ lang/en/admin_settings.php | 21 ++ .../views/livewire/admin/settings.blade.php | 85 +++++- tests/Feature/Admin/UpdateButtonTest.php | 187 +++++++++++++ 10 files changed, 861 insertions(+), 5 deletions(-) create mode 100644 app/Services/Deployment/UpdateChannel.php create mode 100755 deploy/install-agent.sh create mode 100755 deploy/update-agent.sh create mode 100644 tests/Feature/Admin/UpdateButtonTest.php diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 9b82532..9aa7bd7 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -5,6 +5,7 @@ namespace App\Livewire\Admin; use App\Models\Customer; use App\Models\User; use App\Models\VpnPeer; +use App\Services\Deployment\UpdateChannel; use App\Support\Settings as AppSettings; use App\Provisioning\Jobs\ApplyVpnPeer; use Illuminate\Support\Facades\DB; @@ -322,6 +323,25 @@ class Settings extends Component } + /** + * Ask the host-side agent to update this installation. + * + * Deliberately a request rather than an action: see UpdateChannel. The + * button cannot report success, because success means this container is + * restarted out from under the response — so it reports that the update was + * asked for, and the page shows the outcome once the agent has written it. + */ + public function requestUpdate(): void + { + $this->authorize('site.manage'); + + $accepted = app(UpdateChannel::class)->request(auth()->user()->email); + + $this->dispatch('notify', message: __($accepted + ? 'admin_settings.update_requested' + : 'admin_settings.update_already_requested')); + } + public function render() { $staff = User::query() @@ -338,6 +358,8 @@ class Settings extends Component ]); return view('livewire.admin.settings', [ + 'update' => app(UpdateChannel::class)->state(), + 'updateLog' => app(UpdateChannel::class)->lastLog(), 'sitePublic' => AppSettings::bool('site.public', true), 'canManageSite' => auth()->user()?->can('site.manage') ?? false, // Shown so nobody has to guess why they still see the real site. diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php new file mode 100644 index 0000000..c419fe3 --- /dev/null +++ b/app/Services/Deployment/UpdateChannel.php @@ -0,0 +1,261 @@ + + */ + public function state(): array + { + $release = Release::current(); + $status = $this->readJson(self::STATUS); + $lastRun = $this->readJson(self::LAST_RUN); + $request = $this->pendingRequest(); + + $checkedAt = $this->timestamp($status['checked_at'] ?? null); + $agentAlive = $this->agentIsAlive($status); + + // A figure from an agent that has since stopped is not current, and + // "three updates behind" from last week reads exactly like now. + $behind = $agentAlive && isset($status['behind']) ? (int) $status['behind'] : null; + + return [ + 'version' => $release->version, + 'commit' => $release->commit, + 'source' => $release->source, + 'deployed_at' => $release->deployedAt, + + // Null means "we do not know", which is not the same as "up to + // date" and must not be shown as it. + 'behind' => $behind, + 'available' => $behind !== null && $behind > 0, + 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, + 'checked_at' => $checkedAt, + + 'agent_seen' => $agentAlive, + 'running' => $agentAlive && ($request !== null || ($status['state'] ?? null) === 'running'), + 'requested_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null, + 'requested_by' => $request['requested_by'] ?? null, + + 'last_state' => $lastRun['state'] ?? null, + 'last_finished_at' => $this->timestamp($lastRun['finished_at'] ?? null), + // 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), + ]; + } + + /** + * Has the agent checked in recently enough to be considered alive? + * + * @param array $status + */ + private function agentIsAlive(array $status): bool + { + $checkedAt = $this->timestamp($status['checked_at'] ?? null); + + return $checkedAt !== null + && $checkedAt->gt(Carbon::now()->subMinutes(self::AGENT_STALE_AFTER_MINUTES)); + } + + /** Is the agent in the middle of a run right now? */ + public function isRunning(): bool + { + $status = $this->readJson(self::STATUS); + + return ($status['state'] ?? null) === 'running' && $this->agentIsAlive($status); + } + + /** Is an update run already asked for, and still current? */ + public function pendingRequest(): ?array + { + $request = $this->readJson(self::REQUEST); + + if ($request === []) { + return null; + } + + $at = $this->timestamp($request['requested_at'] ?? null); + + if ($at === null || $at->lt(Carbon::now()->subMinutes(self::REQUEST_EXPIRES_MINUTES))) { + return null; + } + + return $request; + } + + /** + * Ask the agent to update. + * + * Returns false when a request is already outstanding — clicking twice must + * not queue two runs, and the second click is much more likely to be + * impatience than intent. + */ + public function request(string $by): bool + { + // Both conditions, not just the request file. The agent DELETES the + // request before it starts — 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. + if ($this->pendingRequest() !== null || $this->isRunning()) { + return false; + } + + $this->write(self::REQUEST, json_encode([ + 'requested_at' => Carbon::now()->toIso8601String(), + 'requested_by' => $by, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return true; + } + + /** + * The agent's failure, in the operator's language. + * + * The agent is a shell script and is not translated; it reports a code and + * the translation happens here. Anything unrecognised is passed through + * rather than swallowed — a message nobody has worded yet still beats + * silence when an update is failing. + */ + private function errorMessage(array $status): ?string + { + $code = isset($status['error']) ? trim((string) $status['error']) : ''; + + if ($code === '') { + return null; + } + + $key = 'admin_settings.update_error.'.$code; + + if (! \Illuminate\Support\Facades\Lang::has($key)) { + return $code; + } + + return __($key, ['code' => (string) ($status['exit_code'] ?? '?')]); + } + + /** The tail of the last run, for showing what went wrong. */ + public function lastLog(int $lines = 40): ?string + { + $path = storage_path('app/'.self::LOG); + + try { + if (! File::exists($path)) { + return null; + } + + $all = preg_split('/\R/', trim((string) File::get($path))) ?: []; + + return implode("\n", array_slice($all, -$lines)); + } catch (Throwable) { + return null; + } + } + + /** @return array */ + private function readJson(string $relative): array + { + try { + $path = storage_path('app/'.$relative); + + if (! File::exists($path)) { + return []; + } + + $decoded = json_decode((string) File::get($path), true); + + return is_array($decoded) ? $decoded : []; + } catch (Throwable) { + return []; + } + } + + private function write(string $relative, string $contents): void + { + $path = storage_path('app/'.$relative); + File::ensureDirectoryExists(dirname($path)); + File::put($path, $contents); + } + + private function timestamp(mixed $value): ?Carbon + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return Carbon::parse($value); + } catch (Throwable) { + return null; + } + } +} diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh new file mode 100755 index 0000000..b441b84 --- /dev/null +++ b/deploy/install-agent.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Installs the update agent's systemd timer. Root, once per machine, idempotent. +# +# Separate from install.sh because the installed base never runs install.sh +# again — existing servers upgrade through deploy/update.sh, which runs as the +# service account and cannot write to /etc/systemd. Without this as its own +# entry point, every server that already exists would show the update button +# permanently disabled. +# +# sudo bash deploy/install-agent.sh +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_USER="${APP_USER:-clupilot}" + +[[ $EUID -eq 0 ]] || { echo "Please run as root: sudo bash deploy/install-agent.sh" >&2; exit 1; } +id -u "$APP_USER" >/dev/null 2>&1 || { echo "No such account: $APP_USER" >&2; exit 1; } + +install -o "$APP_USER" -g "$APP_USER" -d "$ROOT/storage/app/deploy" +chmod +x "$ROOT/deploy/update-agent.sh" + +cat > /etc/systemd/system/clupilot-update-agent.service < /etc/systemd/system/clupilot-update-agent.timer <<'EOF' +[Unit] +Description=Check for CluPilot updates and run requested ones + +[Timer] +# Every five minutes: fast enough that the button feels like it did something, +# slow enough that fetching from the repository is not a nuisance. +OnBootSec=3min +OnUnitActiveSec=5min +AccuracySec=30s + +[Install] +WantedBy=timers.target +EOF + +systemctl daemon-reload +systemctl enable --now clupilot-update-agent.timer >/dev/null + +# Run it once now, so the panel has a status to show instead of "never reported +# in" for the first five minutes. +systemctl start clupilot-update-agent.service || true + +echo "Update agent installed and running (clupilot-update-agent.timer)." diff --git a/deploy/install.sh b/deploy/install.sh index 72d846c..12a4309 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -414,6 +414,15 @@ ufw allow 443/tcp >/dev/null ufw allow 51820/udp >/dev/null ufw --force enable >/dev/null +# ── 8b. Update agent ───────────────────────────────────────────────────────── +# The panel's update button cannot run the update itself: it is www-data inside +# a container, and the update restarts that container. It writes a request into +# the checkout instead, and a host-side timer picks it up. Installed by its own +# script, because the installed base upgrades through deploy/update.sh and would +# otherwise never get it. +log "Installing the update agent" +APP_USER="$APP_USER" bash "$INSTALL_DIR/deploy/install-agent.sh" + # ── 9. Done ────────────────────────────────────────────────────────────────── PUBLIC_IP="$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo '')" cat <"$LOCK" +flock -n 9 || exit 0 + +MODE="$(release_mode)" +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" +TARGET_RELEASE='' + +json_escape() { + printf '%s' "${1-}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr -d '\n\r' +} + +write_status() { + local state="$1" error="${2-}" + cat > "$STATUS.tmp" < "$LASTRUN.tmp" </dev/null || echo '')")", + "error": "$(json_escape "$error")", + "exit_code": ${EXIT_CODE:-null} +} +EOF + mv -f "$LASTRUN.tmp" "$LASTRUN" +} + +LOCAL_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')" +REMOTE_COMMIT='' +BEHIND=null +FETCH_ERROR='' + +# ── Is there anything newer? ────────────────────────────────────────────── +# Two different questions depending on what this installation follows. A pinned +# server does not care what landed on main — it moves between TAGS, and asking +# the branch would advertise updates that pressing the button cannot apply. +if [[ "$MODE" == "release" ]]; then + CURRENT_TAG="$(release_source)"; CURRENT_TAG="${CURRENT_TAG#refs/tags/}" + + if git fetch --quiet --tags --force origin 2>/dev/null; then + # Newest by version order, not by tag date: v1.10.0 is newer than v1.9.0 + # and sorts earlier alphabetically. + NEWEST_TAG="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null | head -1)" + + if [[ -n "$NEWEST_TAG" && "$NEWEST_TAG" != "$CURRENT_TAG" ]]; then + # Count only tags actually ahead of the deployed one. + BEHIND="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null \ + | awk -v cur="$CURRENT_TAG" '$0 == cur {exit} {n++} END {print n+0}')" + [[ "${BEHIND:-0}" -gt 0 ]] && TARGET_RELEASE="$NEWEST_TAG" + REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')" + else + BEHIND=0 + fi + else + # A code, not a sentence: the panel is translated and this script is + # not, so a German string here would surface in the English interface. + FETCH_ERROR='repo_unreachable' + fi +else + if [[ "$BRANCH" == "HEAD" ]]; then + # Detached, but not pinned to a release either — nothing sane to follow. + FETCH_ERROR='detached_no_release' + elif git fetch --quiet origin "$BRANCH" 2>/dev/null; then + REMOTE_COMMIT="$(git rev-parse "origin/$BRANCH" 2>/dev/null || echo '')" + if [[ -n "$LOCAL_COMMIT" && -n "$REMOTE_COMMIT" ]]; then + BEHIND="$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0)" + fi + else + # Reported, not swallowed: "cannot reach the repository" and "already up + # to date" look identical from the panel otherwise, and only one is fine. + # A code, not a sentence: the panel is translated and this script is + # not, so a German string here would surface in the English interface. + FETCH_ERROR='repo_unreachable' + fi +fi + +# ── Is an update asked for, and is it still current? ────────────────────── +if [[ ! -f "$REQUEST" ]]; then + write_status idle "$FETCH_ERROR" + exit 0 +fi + +REQUEST_AGE_MINUTES="$(( ( $(date +%s) - $(stat -c %Y "$REQUEST" 2>/dev/null || date +%s) ) / 60 ))" + +if (( REQUEST_AGE_MINUTES > REQUEST_EXPIRES_MINUTES )); then + # Written while this agent was stopped. Running it now is not what was + # asked for — it was asked for then. + rm -f "$REQUEST" + write_status idle "$FETCH_ERROR" + exit 0 +fi + +# Consumed BEFORE the run, not after: update.sh restarts the container stack and +# may well kill this shell with it. A request left in place would be picked up +# again on the next tick and update in a loop. +rm -f "$REQUEST" + +write_status running "$FETCH_ERROR" + +set +e +if [[ "$MODE" == "release" && -n "$TARGET_RELEASE" ]]; then + RELEASE="$TARGET_RELEASE" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1 +else + # BRANCH passed explicitly: update.sh defaults to main, so an installation + # following anything else would be told about ITS branch and then handed + # main's commits. + BRANCH="$BRANCH" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1 +fi +RESULT=$? +set -e + +FINISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +LOCAL_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')" + +EXIT_CODE="$RESULT" + +if [[ $RESULT -eq 0 ]]; then + BEHIND=0 + TARGET_RELEASE='' + write_run succeeded '' + write_status idle '' +else + write_run failed 'update_failed' + write_status idle "$FETCH_ERROR" +fi + +exit 0 diff --git a/deploy/update.sh b/deploy/update.sh index 991794b..b869626 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -220,4 +220,13 @@ printf '%s' "$after" > "$STATE_FILE" release_remember "$mode" "$source_ref" release_write_manifest "$after" "$source_ref" "$mode" +# The panel's update button needs a host-side timer, and this script cannot +# install it: it runs as the service account and /etc/systemd is root's. Say so +# once per update rather than leaving the button disabled without explanation. +if ! systemctl list-unit-files clupilot-update-agent.timer >/dev/null 2>&1 \ + || ! systemctl is-enabled --quiet clupilot-update-agent.timer 2>/dev/null; then + printf '\033[1;33m !\033[0m %s\n' "Update agent not installed — the panel's update button stays disabled." + printf ' %s\n' "Install it once: sudo bash $(pwd)/deploy/install-agent.sh" +fi + log "Done — $(release_version) on $(git rev-parse --short HEAD) (${source_ref})" diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 70dead6..bc76799 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -75,4 +75,25 @@ return [ 'console_locked' => 'Konsole eingeschränkt — nur noch VPN und gelistete Adressen.', 'console_unlocked' => 'Einschränkung aufgehoben.', 'console_lock_refused' => 'Nicht eingeschränkt: :ip steht auf keiner Liste, Sie hätten sich damit ausgesperrt.', + + 'update_title' => 'Version & Aktualisierung', + 'update_version' => 'Läuft', + 'update_source' => 'Quelle', + 'update_deployed' => 'Ausgerollt', + 'update_checked' => 'Zuletzt geprüft', + 'update_now' => 'Jetzt aktualisieren', + 'update_running' => 'Läuft gerade', + 'update_available' => ':n Aktualisierung(en) verfügbar', + 'update_current' => 'Aktuell', + 'update_unknown' => 'Unbekannt', + 'update_requested' => 'Aktualisierung angefordert — sie startet innerhalb weniger Minuten.', + 'update_already_requested' => 'Eine Aktualisierung ist bereits angefordert.', + 'update_no_agent' => 'Der Update-Dienst auf dem Server läuft nicht. Einmalig auf dem Server einrichten: sudo bash /opt/clupilot/deploy/install-agent.sh', + 'update_log' => 'Protokoll des letzten Laufs', + + 'update_error' => [ + '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.', + ], ]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 1ec01b2..bb21bac 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -75,4 +75,25 @@ return [ 'console_locked' => 'Console restricted — VPN and listed addresses only.', 'console_unlocked' => 'Restriction lifted.', 'console_lock_refused' => 'Not restricted: :ip is on no list, so this would have locked you out.', + + 'update_title' => 'Version & update', + 'update_version' => 'Running', + 'update_source' => 'Source', + 'update_deployed' => 'Deployed', + 'update_checked' => 'Last checked', + 'update_now' => 'Update now', + 'update_running' => 'Running', + 'update_available' => ':n update(s) available', + 'update_current' => 'Up to date', + 'update_unknown' => 'Unknown', + 'update_requested' => 'Update requested — it starts within a few minutes.', + 'update_already_requested' => 'An update has already been requested.', + 'update_no_agent' => 'The server-side update service is not running. Set it up once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh', + 'update_log' => 'Log of the last run', + + 'update_error' => [ + '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.', + ], ]; diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index fc718f0..1c75260 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -4,6 +4,76 @@

{{ __('admin_settings.subtitle') }}

+ {{-- Version & update --}} + @if ($canManageSite) +
+
+
+
+

{{ __('admin_settings.update_title') }}

+ @if ($update['running']) + + {{ __('admin_settings.update_running') }} + + @elseif ($update['available']) + + {{ __('admin_settings.update_available', ['n' => $update['behind']]) }} + + @elseif ($update['behind'] === 0) + + {{ __('admin_settings.update_current') }} + + @else + {{-- Never shown as "up to date": not knowing and being + current are different answers. --}} + + {{ __('admin_settings.update_unknown') }} + + @endif +
+ +
+
{{ __('admin_settings.update_version') }}:
+
{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif
+ @if ($update['source']) +
{{ __('admin_settings.update_source') }}:
+
{{ $update['source'] }}
+ @endif + @if ($update['deployed_at']) +
{{ __('admin_settings.update_deployed') }}:
+
{{ $update['deployed_at']->diffForHumans() }}
+ @endif + @if ($update['checked_at']) +
{{ __('admin_settings.update_checked') }}:
+
{{ $update['checked_at']->diffForHumans() }}
+ @endif +
+
+ + {{-- Bound, not @disabled(): the directive compiles to inline PHP + inside the component tag, and Blade then no longer + recognises the tag as a component at all. --}} + + {{ __('admin_settings.update_now') }} + +
+ + @if (! $update['agent_seen']) + {{ __('admin_settings.update_no_agent') }} + @elseif ($update['last_error']) + {{ $update['last_error'] }} + @endif + + @if ($updateLog) +
+ {{ __('admin_settings.update_log') }} +
{{ $updateLog }}
+
+ @endif +
+ @endif + {{-- My account --}} @if ($canManageSite)
@@ -83,12 +153,17 @@

{{ __('admin_settings.console_none') }}

@endforelse -
-
- + {{-- The hint sits under the row, not inside it: as a sibling of + the field it made the flex row as tall as field-plus-hint, + and the button stretched to match. --}} + +
+
+ +
+ {{ __('admin_settings.console_add') }}
- {{ __('admin_settings.console_add') }} +

{{ __('admin_settings.console_ip_hint') }}

diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php new file mode 100644 index 0000000..b116f75 --- /dev/null +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -0,0 +1,187 @@ +state(); + + expect($state['agent_seen'])->toBeFalse() + ->and($state['behind'])->toBeNull() + ->and($state['available'])->toBeFalse(); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_no_agent')); +}); + +it('never reports "up to date" when it simply does not know', function () { + // Null and zero are different answers. Showing the first as the second is + // how an installation sits three versions behind looking healthy. + expect(app(UpdateChannel::class)->state()['available'])->toBeFalse(); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_unknown')) + ->assertDontSee(__('admin_settings.update_current')); +}); + +it('reports an available update once the agent has looked', function () { + writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 3]); + + $state = app(UpdateChannel::class)->state(); + + expect($state['available'])->toBeTrue()->and($state['behind'])->toBe(3); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_available', ['n' => 3])); +}); + +it('leaves a request the agent can find', function () { + writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->call('requestUpdate') + ->assertHasNoErrors(); + + $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); + + expect($request['requested_by'])->toBeString()->not->toBeEmpty() + ->and($request['requested_at'])->toBeString(); +}); + +it('does not queue a second run when the button is pressed twice', function () { + $channel = app(UpdateChannel::class); + + expect($channel->request('someone@example.com'))->toBeTrue() + // Impatience, not intent — and two overlapping runs of update.sh fight + // over the checkout. + ->and($channel->request('someone@example.com'))->toBeFalse(); +}); + +it('forgets a request nobody ever collected', function () { + // Written while the agent was down. Firing it days later, whenever the + // agent happens to come back, is not what anyone asked for. + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-request.json'), json_encode([ + 'requested_at' => Carbon::now()->subHours(3)->toIso8601String(), + 'requested_by' => 'someone@example.com', + ])); + + $channel = app(UpdateChannel::class); + + expect($channel->pendingRequest())->toBeNull() + ->and($channel->state()['running'])->toBeFalse() + // And a fresh request is accepted again rather than blocked forever. + ->and($channel->request('someone@example.com'))->toBeTrue(); +}); + +it('needs the capability, not merely an operator account', function () { + Livewire::actingAs(operator('Support')) + ->test(AdminSettings::class) + ->call('requestUpdate') + ->assertForbidden(); +}); + +it('survives a status file that is corrupt', function () { + // This is the page an operator opens when something is already wrong. It + // must not be the second thing that is broken. + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), '{ this is not json'); + + $state = app(UpdateChannel::class)->state(); + + expect($state['agent_seen'])->toBeFalse(); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertOk(); +}); + +function writeStatus(array $status): void +{ + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode($status)); +} + +function writeLastRun(array $run): void +{ + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-last-run.json'), json_encode($run)); +} + +it('stops trusting an agent that has gone quiet', function () { + // A status file written once by an agent since stopped would otherwise + // leave the button enabled forever, writing requests nobody collects. + writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 2]); + + $state = app(UpdateChannel::class)->state(); + + expect($state['agent_seen'])->toBeFalse() + ->and($state['behind'])->toBeNull() + ->and($state['available'])->toBeFalse(); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_no_agent')); +}); + +it('shows the agent’s failure in the operator’s language', function () { + // The agent is a shell script and is not translated; it reports a code. + writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); + writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 3]); + + expect(app(UpdateChannel::class)->state()['last_error']) + ->toBe(__('admin_settings.update_error.update_failed', ['code' => '3'])); + + // An unknown code is passed through rather than swallowed: a message + // nobody has worded yet still beats silence while an update is failing. + writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'something_new']); + + expect(app(UpdateChannel::class)->state()['last_error'])->toBe('something_new'); +}); + +it('still shows a failed update after the next routine check', function () { + // The check runs every five minutes. Writing both into one file meant a + // failure was usually gone before anyone looked at it. + writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]); + writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]); + + $state = app(UpdateChannel::class)->state(); + + expect($state['last_state'])->toBe('failed') + ->and($state['last_error'])->toBe(__('admin_settings.update_error.update_failed', ['code' => '2'])); +}); + +it('refuses a second request while the agent is mid-run', function () { + // The agent deletes the request before it starts, so between that moment + // and the end of the run there is nothing "pending" to block on. + writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); + + $channel = app(UpdateChannel::class); + + expect($channel->isRunning())->toBeTrue() + ->and($channel->request('someone@example.com'))->toBeFalse(); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +});