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
parent
37cb449af5
commit
05150bf89f
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue