fix(update): wait for actual new version before redirect (no more 502)

The update-progress page declared completion on two consecutive 200s from
the /up health route. But the OLD container keeps answering /up 200
throughout the minutes-long rebuild, so the page redirected prematurely
back into the teardown → 502 / white screen.

Now the page polls a lightweight public /version.json probe and finishes
only when the running version has actually moved past the pre-update one
(passed as ?from=), or after an observed down→up cycle for a same-version
redeploy. The 10-minute timeout + manual-reload fallback are unchanged.

- routes/web.php: add public GET /version.json; sanitise+pass ?from= to the view
- Versions/Index.php: include the installed version as ?from= in the redirect
- update-progress.blade.php: version-gated completion (fromVersion/sawDown)
- UpdateProgressTest: probe endpoint, embedded baseline, from-sanitisation, no /up poll

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation v0.9.30
boban 2026-06-20 21:02:21 +02:00
parent 37cb449af5
commit 05150bf89f
6 changed files with 104 additions and 24 deletions

View File

@ -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

View File

@ -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,
);
}

View File

@ -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

View File

@ -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).
--}}
<html lang="{{ app()->getLocale() }}">
@ -99,16 +102,22 @@
</div>
</div>
{{-- 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. --}}
<script>
(function () {
'use strict';
// ── Return URL ─────────────────────────────────────────────────────────
// ── Return URL + completion baseline ───────────────────────────────────
// Server-validated path from the route handler (defence-in-depth guard).
var returnUrl = {{ \Illuminate\Support\Js::from($returnPath) }};
var returnUrl = {{ \Illuminate\Support\Js::from($returnPath) }};
// The installed version at the moment the update was triggered. Completion = the running
// version has moved PAST this (the rebuilt app is live). The OLD container keeps answering
// during the long build, so a bare "/up is 200" is NOT completion — that was the 502 bug.
var fromVersion = {{ \Illuminate\Support\Js::from($fromVersion) }};
var sawDown = false; // set once a poll fails (teardown) — lets a same-version redeploy finish
// ── Phase timing heuristic (purely visual) ─────────────────────────────
// Approx durations (seconds) per phase before advancing the highlight.
@ -168,8 +177,8 @@
var timeoutEl = document.getElementById('js-status-timeout');
// ── Polling ────────────────────────────────────────────────────────────
var consecutiveOk = 0;
var STABLE_REQUIRED = 2;
var stableNew = 0; // consecutive polls that have seen the NEW app live
var STABLE_REQUIRED = 2; // require 2 in a row before redirecting (flap guard)
var pollInterval = null;
var tickInterval = null;
var done = false;
@ -219,31 +228,43 @@
function poll() {
if (done) return;
fetch('/up', { cache: 'no-store', method: 'GET' })
// Poll the version probe — NOT /up. The old container answers /up 200 throughout the
// build, so only a CHANGED running version (or an observed down→up cycle for a
// same-version redeploy) proves the rebuilt app is actually live.
fetch('/version.json', { cache: 'no-store', method: 'GET' })
.then(function (res) {
if (res.ok) {
consecutiveOk++;
if (consecutiveOk >= STABLE_REQUIRED) showDone();
if (!res.ok) { sawDown = true; stableNew = 0; return null; }
return res.json();
})
.then(function (data) {
if (data === null) return; // non-200 handled above
var v = (data && data.version) ? String(data.version) : '';
// New app live when: a known baseline exists and the version changed, OR the
// app went down and is back (same-version redeploy / empty baseline fallback).
var isNew = (fromVersion && v && v !== fromVersion) || (sawDown && v);
if (isNew) {
stableNew++;
if (stableNew >= STABLE_REQUIRED) showDone();
} else {
consecutiveOk = 0;
stableNew = 0; // still the old version (pre/during build) — keep waiting
}
})
.catch(function () {
// 502 / network error while the container is rebuilding — keep waiting
consecutiveOk = 0;
sawDown = true;
stableNew = 0;
});
}
tickInterval = setInterval(tick, 1000);
tick(); // immediate first tick
// Small initial delay before the first poll — the teardown starts right after
// the browser receives the redirect, so we give it a moment to go down before
// we start checking if it's back up (avoids a false-positive on the old instance).
// Start polling soon. Completion is gated on a CHANGED version (or a down→up cycle), so
// polling the still-up old instance early is harmless — it simply keeps waiting.
setTimeout(function () {
poll(); // first poll
pollInterval = setInterval(poll, 2500);
}, 3000);
pollInterval = setInterval(poll, 2000);
}, 1000);
}());
</script>

View File

@ -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');
});
});

View File

@ -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();