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(); +});