*/ public function state(): array { $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); $agentAlive = $this->agentIsAlive($status); // Missing on a request an older panel wrote — see KIND_RUN. $requestKind = $request['kind'] ?? self::KIND_RUN; // 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, // The tag an update would install, e.g. "v1.1.0". An update is // always to a released version now — a commit landing on main is // not an update, and the console has nothing to say about it. 'target_release' => $agentAlive && ! empty($status['target_release']) ? (string) $status['target_release'] : null, 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, 'checked_at' => $checkedAt, 'agent_seen' => $agentAlive, // 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_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, // Is the agent woken the moment a request lands, or does the // request wait for the next tick? // // Reported by the agent rather than decided here: the trigger is a // systemd path unit on the host (deploy/install-agent.sh), which // the panel cannot see from inside its container, and a server // that has not re-run install-agent.sh since this landed still // waits for the timer. It decides whether the console may say // "now" or has to name a time — and promising "now" on an // installation that will take five minutes is the failure this // whole field exists to avoid. 'instant' => $agentAlive && ($status['request_watch'] ?? null) === true, // The step the deployment script is on — a key, translated here. // Read live from the phase file; older agents write none, so its // absence is normal rather than a fault. 'phase' => $this->phase($this->currentPhase($status)), 'started_at' => $this->timestamp($status['started_at'] ?? null), 'last_state' => $lastRun['state'] ?? null, 'last_phase' => $this->phase($lastRun['phase'] ?? null), 'last_started_at' => $this->timestamp($lastRun['started_at'] ?? 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), // 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), ]; } /** * The deployment step, as something a German interface can print. * * The script writes a key precisely so this stays translatable. An unknown * key — a newer script against older translations — yields null rather than * a raw identifier in the middle of a sentence. */ private function phase(mixed $key): ?string { if (! is_string($key) || $key === '') { return null; } $line = __('admin_settings.update_phase.'.$key); return is_string($line) && $line !== 'admin_settings.update_phase.'.$key ? $line : null; } /** * The step key update.sh last announced, or null. * * Its own tiny file rather than a field in the status document: update.sh * runs as the service account on the host and rewrites this at every step, * while the agent that owns the status document is blocked waiting for it. * * Only while a run is genuinely in flight, and only if the step is newer * than that run. A failed run deliberately LEAVES its file behind so the * failing step can be named; without these two checks, queueing the next * update would show it as already at "Migrating the database" — a step * nothing has started — and hide the one thing the operator needs, which is * when it will actually begin. * * @param array $status */ private function currentPhase(array $status): ?string { if (($status['state'] ?? null) !== 'running') { return null; } $path = storage_path('app/'.self::PHASE); if (! is_file($path)) { return null; } // Deliberately forgiving: this is a progress hint, and a half-written // line during the moment update.sh replaces it must not throw on a page // an operator is watching a deployment from. $parts = explode("\t", (string) @file_get_contents($path), 2); $key = trim($parts[0] ?? ''); if ($key === '') { return null; } // Left over from a manual run of update.sh on the shell: newer than the // last agent status, older than this run. $startedAt = $this->timestamp($status['started_at'] ?? null); $phaseAt = $this->timestamp(trim($parts[1] ?? '')); if ($startedAt !== null && $phaseAt !== null && $phaseAt->lt($startedAt)) { return null; } return $key; } /* * There was a nextCheckAt() here, and a checkInterval() feeding it, so the * console could count down to the agent's next tick. Both are gone. * * It could not be made honest. `checked_at + interval` is in the past * whenever the interval the agent reports is shorter than the timer * actually installed on that host — which is every server that has not * re-run install-agent.sh — and the fallback then returned "now + interval" * afresh on every poll. The operator watched it run down four seconds and * snap back to a full minute, forever. Reported exactly that way. * * It is also no longer needed: the request wakes the agent (the path unit * in deploy/install-agent.sh), so there is no wait to describe. */ /** * 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 — an actual run, downtime and all. * * 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 { return $this->submit($by, self::KIND_RUN); } /** * Ask the agent only to look — never to apply anything. * * The agent already fetches on every tick regardless of this; the request * exists so an operator who asks gets an answer at the moment they ask, * and so the answer never runs through the "run" branch — no maintenance * mode, no restart, for a question that was only "is there something new". * * A question must not sit in a queue. Where the host runs the path unit * from deploy/install-agent.sh this file wakes the agent within a second; * where it does not, the timer is the fallback and the console says so * rather than counting down to a start that is not happening — see * 'instant' in state(). */ public function requestCheck(string $by): bool { 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); } /** * Ask the host to mint a collection key for one archive directory. * * The path is passed rather than looked up on the host, because the panel * is the only side that knows which destination the operator pressed the * button on. */ public function requestArchiveKey(string $by, string $path, string $label): bool { return $this->submit($by, self::KIND_ARCHIVE_KEY, [ 'archive_path' => $path, 'label' => $label, ]); } /** * Take the minted key, if one is waiting. * * Taking, not reading: the file is deleted in the same call. A private key * that stays on disk after somebody has seen it is a private key on disk. * * @return array{created_at:string, label:string, path:string, private_key:string}|null */ public function takeArchiveKey(): ?array { $data = $this->readJson(self::ARCHIVE_KEY); if ($data === [] || ! isset($data['private_key'])) { return null; } try { File::delete(storage_path('app/'.self::ARCHIVE_KEY)); } catch (Throwable) { // Shown once regardless. Failing to delete it is a reason to warn, // not a reason to withhold the key from the person who asked for it // — they would simply ask again and leave a second one behind. } return $data; } /** @param array $extra */ private function submit(string $by, string $kind, array $extra = []): 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 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; } $this->write(self::REQUEST, json_encode([ 'requested_at' => Carbon::now()->toIso8601String(), 'requested_by' => $by, 'kind' => $kind, ] + $extra, 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 (! 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; } } }