*/ 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, // When the agent will next look. A queued update sits until the // timer fires, and "in a few minutes" is exactly the answer that // leaves an operator refreshing the page wondering whether the // button did anything at all. 'next_check_at' => $agentAlive ? $this->nextCheckAt($checkedAt, $status) : null, // 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 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; } /** * The latest moment a queued update can still be picked up. * * Normally one interval after the agent's last check. But a systemd timer * that ran late leaves a last check already older than its interval, and * `checked_at + interval` is then a time in the PAST — the page would * promise a start that has demonstrably not happened. In that case the only * honest upper bound is one interval from now. * * @param array $status */ private function nextCheckAt(?Carbon $checkedAt, array $status): ?Carbon { if ($checkedAt === null) { return null; } $interval = $this->checkInterval($status); $due = $checkedAt->copy()->addMinutes($interval); return $due->isFuture() ? $due : Carbon::now()->addMinutes($interval); } /** * How often the agent looks, in minutes. * * Reported by the agent rather than assumed here: the interval lives in the * systemd timer, and a server whose timer was tuned would otherwise be * given a next-check time that quietly never arrives. * * @param array $status */ private function checkInterval(array $status): int { $minutes = (int) ($status['check_interval_minutes'] ?? 0); // Clamped, not trusted: this value ends up in a promise to the operator, // and a corrupt file must not produce "next check in 1970" or a time so // far out that the page looks broken. return $minutes >= 1 && $minutes <= 60 ? $minutes : 5; } /** * 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; } } }