clusev/app/Livewire/Versions/Index.php

479 lines
19 KiB
PHP

<?php
namespace App\Livewire\Versions;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Services\DeploymentService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Version & releases page. Everything shown here is real: the installed version comes from
* config, the latest published release is the newest git TAG in the channel — read from the
* project's public Git host over its read-only tag API (the production image ships no .git, so
* a local read would always be empty; local .git is only a dev fallback) — and the release
* history is the project's own CHANGELOG.md parsed BY VERSION. There is no in-app updater
* (Clusev ships as a Docker image; updating is a host-side image pull + migrate, see the page);
* the check honestly compares the installed version against the newest tag in the channel.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Only these channels are ever offered to users — there is no 'dev' channel. */
private const CHANNELS = ['stable', 'beta'];
/**
* Cache (Redis in prod) marker for an in-flight update: holds the version that was running
* when the update was requested. Persisted in Redis — NOT the sentinel file (which the host
* watcher consumes before update.sh runs) — so the "update läuft" state survives BOTH a page
* reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a
* backstop so a failed update can never wedge the banner. Completion = running version > base.
*/
private const UPDATE_FLAG = 'clusev:update-in-progress';
public ?string $lastChecked = null;
public ?string $updateStatus = null;
/** 'current' | 'update' | null — drives the banner tone. */
public ?string $updateState = null;
/** Set once an update has been requested this session — the stack is rebuilding. */
public bool $updateRequested = false;
/** Running version captured WHEN the update was requested — completion = version moved past it. */
public ?string $updateBaseVersion = null;
/** Poll ticks since the update request, to time out a stuck/failed update instead of spinning. */
public int $updatePolls = 0;
/**
* On load, in order:
* 1. If an update is in flight (Redis marker): either announce it just finished (running
* version now past the marked base → success toast + clear) or resume the "läuft" state +
* poll. This is what makes a reload mid-update show the right thing (the marker lives in
* Redis, surviving both the reload and the app rebuild).
* 2. Otherwise surface an available update straight from the cached check (no network), so the
* button shows immediately when the badge already knows a newer version — no re-click.
*/
public function mount(): void
{
$installed = (string) config('clusev.version');
$marker = Cache::get(self::UPDATE_FLAG);
if (is_array($marker) && isset($marker['base'])) {
if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) {
// Finished while the operator was away (or reloaded) — announce + clear.
Cache::forget(self::UPDATE_FLAG);
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $installed]));
return;
}
// Still running — resume the notice + polling.
$this->updateRequested = true;
$this->updateBaseVersion = (string) $marker['base'];
return;
}
$latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
if ($latest !== null && $this->isNewer($latest, $installed)) {
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
}
}
/**
* Poll while an update runs (driven by wire:poll on the "running" notice). Completion is
* detected purely from the running version moving PAST the value captured at request time —
* the rebuilt container reports the new version, so no host round-trip is needed. Times out
* after ~5 min so a failed update surfaces a hint instead of spinning forever.
*/
public function pollUpdate(): void
{
if (! $this->updateRequested) {
return;
}
$this->updatePolls++;
$current = (string) config('clusev.version');
$this->updateBaseVersion ??= $current;
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false;
$this->updatePolls = 0;
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $current, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $current]));
return;
}
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false;
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
}
}
/**
* Honest update check: compare the installed version against the newest release
* tag visible in the channel. No external server, no stars, no CVE feed.
*/
public function checkUpdates(): void
{
$this->lastChecked = now()->format('H:i');
$channel = $this->channel();
$installed = (string) config('clusev.version');
// Force a fresh remote lookup — this is the explicit "check for updates" action.
$latest = $this->resolveLatestTag($channel, forceRemote: true);
if ($latest === null) {
$this->updateState = 'current';
$this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]);
} elseif ($this->isNewer($latest, $installed)) {
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
} else {
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
}
}
/**
* Operator-triggered update: write the update sentinel a host watcher reacts to by running
* update.sh (git pull + idempotent re-install). Re-checks that a newer release than the
* installed one is actually published in the channel (never fires blindly), is per-user
* throttled (auto-expiring — never a control-plane lockout) and audited. The container never
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
* say so and do not await a result.
*/
public function requestUpdate(DeploymentService $deployment): void
{
$channel = $this->channel();
$installed = (string) config('clusev.version');
$latest = $this->resolveLatestTag($channel, forceRemote: true);
// Reflect the freshly-checked state, then refuse if there is nothing newer to apply.
$this->lastChecked = now()->format('H:i');
if ($latest === null || ! $this->isNewer($latest, $installed)) {
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
return;
}
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
$key = 'update-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return;
}
RateLimiter::hit($key, 600);
$deployment->requestUpdate();
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
// moves past this base. 15 min TTL > the poll timeout, so a reload can still resume.
Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15));
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.update_request',
'target' => $installed.' → '.$latest.' ('.$channel.')',
'ip' => request()->ip(),
]);
$this->updateRequested = true;
$this->updateBaseVersion = $installed; // completion = the running version moves past this
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_started'));
}
/** Resolve the configured channel, clamped to the user-facing set. */
private function channel(): string
{
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
}
/**
* Newest release tag for the channel. Prefers the public Git host's tag API (the
* production image ships NO .git, so a local read would always be empty there), and
* falls back to local .git tags for dev. The remote result is cached so a page render
* never makes a network call — only the explicit "check for updates" action does, by
* passing $forceRemote. Returns null when nothing usable is found anywhere.
*/
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
{
$key = 'clusev:latest-release:'.$channel;
if ($forceRemote) {
$remote = $this->fetchRemoteLatestTag($channel);
if ($remote !== null) {
Cache::put($key, $remote, now()->addMinutes(30));
return $remote;
}
// Remote unreachable — fall through to whatever we last cached / can read locally.
}
$cached = Cache::get($key);
if (is_string($cached) && $cached !== '') {
return $cached;
}
return $this->localLatestTag($channel);
}
/**
* Latest tag from the public Git host's API (Gitea: /api/v1/repos/<owner>/<repo>/tags).
* Anonymous + read-only (the repo is public) — no token ever leaves the panel. Returns
* null on any failure (offline, non-2xx, malformed) so the caller degrades gracefully.
*/
private function fetchRemoteLatestTag(string $channel): ?string
{
$repo = (string) config('clusev.repository');
if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
return null;
}
[, $base, $owner, $name] = $m;
try {
$res = Http::timeout(5)->acceptJson()
->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]);
if (! $res->successful()) {
return null;
}
$names = array_map(
static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
(array) $res->json(),
);
return $this->newestVersion($names, $channel);
} catch (\Throwable $e) {
report($e);
return null;
}
}
/**
* Newest release tag from local .git, without needing the git binary. Reads loose refs
* under refs/tags plus packed-refs. Empty in the production image (no .git) — that is why
* the remote API is the primary source above.
*/
private function localLatestTag(string $channel): ?string
{
$root = base_path('.git');
$tags = [];
$dir = $root.'/refs/tags';
if (is_dir($dir)) {
foreach (scandir($dir) ?: [] as $entry) {
if ($entry !== '.' && $entry !== '..' && is_file($dir.'/'.$entry)) {
$tags[] = $entry;
}
}
}
foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) {
if (preg_match('#\srefs/tags/(\S+)$#', trim($line), $m)) {
$tags[] = $m[1];
}
}
return $this->newestVersion($tags, $channel);
}
/**
* Pick the newest semantic version from a list of tag names, scoped to the channel:
* 'stable' accepts only plain vX.Y.Z; 'beta' also accepts prereleases (vX.Y.Z-…), so a
* beta user is still offered a newer stable. Leading "v" is normalised off the result.
*
* @param array<int, string> $tagNames
*/
private function newestVersion(array $tagNames, string $channel): ?string
{
$pattern = $channel === 'beta'
? '/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/'
: '/^v?\d+\.\d+\.\d+$/';
$versions = [];
foreach (array_unique($tagNames) as $tag) {
$tag = trim((string) $tag);
if ($tag !== '' && preg_match($pattern, $tag)) {
$versions[] = ltrim($tag, 'vV');
}
}
if ($versions === []) {
return null;
}
usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
return $versions[0];
}
/** Is the given tag a newer semantic version than the installed one? */
private function isNewer(string $tag, string $installed): bool
{
return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>');
}
/**
* Resolve the deployed commit + branch. In production this comes from config (install.sh
* baked it into .env from the host's .git — the prod image ships none); in dev it falls back
* to a live .git read. Branch defaults to the channel only when nothing is known.
*/
private function build(): array
{
$sha = config('clusev.build.sha');
$branch = config('clusev.build.branch');
if ($sha || $branch) {
return ['sha' => $sha, 'branch' => $branch ?: $this->channel()];
}
$root = base_path('.git');
$head = @file_get_contents($root.'/HEAD');
if ($head === false) {
return ['sha' => null, 'branch' => $this->channel()];
}
$head = trim($head);
if (! str_starts_with($head, 'ref: ')) {
return ['sha' => substr($head, 0, 7), 'branch' => $this->channel()];
}
$ref = substr($head, 5);
$branch = preg_replace('#^refs/heads/#', '', $ref);
$sha = @file_get_contents($root.'/'.$ref);
if ($sha === false) {
// packed-refs fallback
foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) {
if (str_ends_with(trim($line), ' '.$ref)) {
$sha = explode(' ', trim($line))[0];
break;
}
}
}
return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
}
/**
* Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its
* version, optional date, and the changes grouped by Keep-a-Changelog
* subsection (Hinzugefügt / Geändert / Behoben / Sicherheit / …), newest
* first (Unreleased on top).
*
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
*/
private function releases(): array
{
$path = base_path('CHANGELOG.md');
if (! is_file($path)) {
return [];
}
$releases = [];
$current = null;
$group = null;
foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) {
// Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]"
if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) {
if ($current !== null) {
$releases[] = $current;
}
$version = trim($m[1]);
$unreleased = strcasecmp($version, 'Unreleased') === 0;
$current = [
'version' => $version,
'date' => isset($m[2]) ? trim($m[2]) : null,
'unreleased' => $unreleased,
'groups' => [],
];
$group = null;
continue;
}
if ($current === null) {
continue;
}
// Subsection header: "### Hinzugefügt"
if (preg_match('/^###\s+(.+?)\s*$/', $line, $m)) {
$group = trim($m[1]);
$current['groups'][$group] ??= [];
continue;
}
// Bullet line under the active subsection (handles wrapped continuations).
if ($group !== null && preg_match('/^\s*-\s+(.+?)\s*$/', $line, $m)) {
$current['groups'][$group][] = trim($m[1]);
continue;
}
if ($group !== null && $current['groups'][$group] !== []
&& preg_match('/^\s{2,}(\S.*)$/', $line, $m)) {
// Continuation of the previous bullet (Keep-a-Changelog wrapping).
$last = array_key_last($current['groups'][$group]);
$current['groups'][$group][$last] .= ' '.trim($m[1]);
}
}
if ($current !== null) {
$releases[] = $current;
}
// Drop empty nodes (e.g. a bare Unreleased) and link-reference tails.
return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== []));
}
public function render()
{
$releases = $this->releases();
// Passive (no network): cached remote result if a check has run, else local .git.
$latestTag = $this->resolveLatestTag($this->channel());
// A stale cache can hold a "latest" older than what's now installed (e.g. right after an
// update, before the next check). Never present yourself as behind a release you've
// already passed — hide it until a fresh check resolves the true latest.
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), ltrim((string) config('clusev.version'), 'vV'), '<')) {
$latestTag = null;
}
return view('livewire.versions.index', [
'version' => config('clusev.version'),
'channel' => $this->channel(),
'repository' => config('clusev.repository'),
'license' => config('clusev.license'),
'build' => $this->build(),
'releases' => $releases,
'latestTag' => $latestTag,
])->title(__('versions.title'));
}
}