diff --git a/CHANGELOG.md b/CHANGELOG.md index ef07e4c..f3de307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.9.30] - 2026-06-20 + +### Behoben +- **Update löst keinen 502 mehr aus.** Die Fortschritts-Seite erkannte „fertig" bislang an zwei + aufeinanderfolgenden `200` auf der Health-Route `/up` — doch der **alte** Container antwortet dort + während des minutenlangen Neuaufbaus weiter mit `200`, sodass zu früh zurückgeleitet wurde, + mitten in den Stack-Neustart hinein → 502/Weißbild. Die Seite pollt jetzt einen schlanken + Versions-Endpoint (`/version.json`) und gilt erst dann als fertig, wenn die **laufende Version + tatsächlich über die Ausgangsversion hinaus** gewechselt ist (bzw. nach einem beobachteten + down→up-Zyklus bei gleicher Version). Erst dann erfolgt der automatische Rücksprung. + ## [0.9.29] - 2026-06-20 ### Geändert diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 4ab2dd1..a0a4841 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -231,8 +231,10 @@ class Index extends Component ? $parsedPrev : route('dashboard', absolute: false); + // Pass the pre-update version: the progress page finishes only when the running version + // moves past it (the old container answers /up throughout the build — see the page). return $this->redirect( - route('update.progress', ['return' => $returnPath]), + route('update.progress', ['return' => $returnPath, 'from' => $installed]), navigate: false, ); } diff --git a/config/clusev.php b/config/clusev.php index f0ff3bb..1251b08 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.29', + 'version' => '0.9.30', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/resources/views/update-progress.blade.php b/resources/views/update-progress.blade.php index e5fcd17..402d458 100644 --- a/resources/views/update-progress.blade.php +++ b/resources/views/update-progress.blade.php @@ -3,7 +3,10 @@ Self-contained update-progress page. NO Livewire, NO Alpine, NO external JS. Loaded into the browser BEFORE the container teardown triggered by the update request. While the backend is down the page is already client-side; its inline - JS polling loop tolerates 502s and detects recovery when /up returns 200. + JS polling loop tolerates 502s and only finishes once /version.json reports a + version DIFFERENT from the pre-update one (or a down→up cycle for a same-version + redeploy). The old container keeps answering /up 200 throughout the long build, + so /up alone would redirect prematurely back into the rebuild — that was the 502. Modelled on errors/layout.blade.php (same token-only CSS, same wordmark). --}} @@ -99,16 +102,22 @@ - {{-- Inline JS — no external dependencies. Reads ?return= for the redirect target. - Polls /up every 2500 ms; requires 2 consecutive 200s before redirecting (flap guard). - Times out after 10 minutes and shows a manual-reload prompt. --}} + {{-- Inline JS — no external dependencies. Reads ?return= (redirect target) and ?from= (the + pre-update version). Polls /version.json every 2000 ms and finishes only when the running + version has moved past ?from= (or, for a same-version redeploy, after a down→up cycle), + stable for 2 polls. Times out after 10 minutes and shows a manual-reload prompt. --}} diff --git a/routes/web.php b/routes/web.php index 5c72fed..baa9d6b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -42,6 +42,11 @@ Route::get('/_caddy/ask', function (Request $request, DeploymentService $deploym return response('ok', 200); })->name('caddy.ask'); +// Lightweight PUBLIC version probe. The update-progress page polls this to detect when the +// rebuilt app is actually live (the running version moved past the pre-update one) — the OLD +// container keeps answering /up throughout the long build, so /up alone can't signal completion. +Route::get('/version.json', fn () => response()->json(['version' => (string) config('clusev.version')]))->name('version.json'); + Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); @@ -113,8 +118,11 @@ Route::middleware('auth')->group(function () { Route::get('/update-progress', function (Request $request) { $parsed = parse_url((string) $request->query('return', ''), PHP_URL_PATH); $return = (is_string($parsed) && str_starts_with($parsed, '/') && ! str_starts_with($parsed, '//')) ? $parsed : '/'; + // Pre-update version (sanitised to the semver charset). The page finishes only once the + // running version has moved past this — see resources/views/update-progress.blade.php. + $from = preg_replace('/[^0-9A-Za-z.\-]/', '', (string) $request->query('from', '')); - return view('update-progress', ['returnPath' => $return]); + return view('update-progress', ['returnPath' => $return, 'fromVersion' => $from]); })->name('update.progress'); }); }); diff --git a/tests/Feature/UpdateProgressTest.php b/tests/Feature/UpdateProgressTest.php index 753aaa6..85211da 100644 --- a/tests/Feature/UpdateProgressTest.php +++ b/tests/Feature/UpdateProgressTest.php @@ -54,6 +54,11 @@ class UpdateProgressTest extends TestCase $redirectUrl, 'requestUpdate must redirect to the update-progress page', ); + $this->assertStringContainsString( + 'from=0.9.4', + $redirectUrl, + 'the redirect must carry the pre-update version so the page can detect a real change', + ); } public function test_request_update_still_writes_sentinel_and_sets_redis_flag(): void @@ -172,17 +177,50 @@ class UpdateProgressTest extends TestCase ); } - public function test_update_progress_page_polls_up_endpoint_in_its_js(): void + public function test_update_progress_page_polls_version_endpoint_in_its_js(): void { $body = $this->get(route('update.progress'))->content(); $this->assertStringContainsString( + "fetch('/version.json'", + $body, + 'the inline JS must poll /version.json so the old container (200 on /up during the build) cannot trigger a premature redirect', + ); + $this->assertStringNotContainsString( "fetch('/up'", $body, - 'the inline JS must poll /up to detect when the app comes back', + 'the page must NOT poll /up — the old container answers 200 there throughout the build (the 502 bug)', ); } + public function test_version_endpoint_returns_running_version_as_json(): void + { + config()->set('clusev.version', '1.2.3'); + + $this->getJson('/version.json') + ->assertOk() + ->assertExactJson(['version' => '1.2.3']); + } + + public function test_update_progress_page_embeds_the_from_version_baseline(): void + { + config()->set('clusev.version', '0.9.4'); + + $body = $this->get(route('update.progress', ['from' => '0.9.4']))->content(); + + // The pre-update version must reach the JS so completion can be gated on a CHANGE. + $this->assertStringContainsString('var fromVersion', $body); + $this->assertStringContainsString('0.9.4', $body); + } + + public function test_update_progress_page_sanitises_the_from_version(): void + { + // A hostile ?from= must be stripped to the semver charset before it reaches the JS literal. + $body = $this->get(route('update.progress', ['from' => '"+alert(1)+"']))->content(); + + $this->assertStringNotContainsString('alert(1)', $body); + } + public function test_update_progress_page_contains_phase_labels(): void { $body = $this->get(route('update.progress'))->content();