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._
|
_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
|
## [0.9.29] - 2026-06-20
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
|
||||||
|
|
@ -231,8 +231,10 @@ class Index extends Component
|
||||||
? $parsedPrev
|
? $parsedPrev
|
||||||
: route('dashboard', absolute: false);
|
: 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(
|
return $this->redirect(
|
||||||
route('update.progress', ['return' => $returnPath]),
|
route('update.progress', ['return' => $returnPath, 'from' => $installed]),
|
||||||
navigate: false,
|
navigate: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
// 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
|
// 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
|
// 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.
|
Self-contained update-progress page. NO Livewire, NO Alpine, NO external JS.
|
||||||
Loaded into the browser BEFORE the container teardown triggered by the update
|
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
|
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).
|
Modelled on errors/layout.blade.php (same token-only CSS, same wordmark).
|
||||||
--}}
|
--}}
|
||||||
<html lang="{{ app()->getLocale() }}">
|
<html lang="{{ app()->getLocale() }}">
|
||||||
|
|
@ -99,16 +102,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Inline JS — no external dependencies. Reads ?return= for the redirect target.
|
{{-- Inline JS — no external dependencies. Reads ?return= (redirect target) and ?from= (the
|
||||||
Polls /up every 2500 ms; requires 2 consecutive 200s before redirecting (flap guard).
|
pre-update version). Polls /version.json every 2000 ms and finishes only when the running
|
||||||
Times out after 10 minutes and shows a manual-reload prompt. --}}
|
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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ── Return URL ─────────────────────────────────────────────────────────
|
// ── Return URL + completion baseline ───────────────────────────────────
|
||||||
// Server-validated path from the route handler (defence-in-depth guard).
|
// 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) ─────────────────────────────
|
// ── Phase timing heuristic (purely visual) ─────────────────────────────
|
||||||
// Approx durations (seconds) per phase before advancing the highlight.
|
// Approx durations (seconds) per phase before advancing the highlight.
|
||||||
|
|
@ -168,8 +177,8 @@
|
||||||
var timeoutEl = document.getElementById('js-status-timeout');
|
var timeoutEl = document.getElementById('js-status-timeout');
|
||||||
|
|
||||||
// ── Polling ────────────────────────────────────────────────────────────
|
// ── Polling ────────────────────────────────────────────────────────────
|
||||||
var consecutiveOk = 0;
|
var stableNew = 0; // consecutive polls that have seen the NEW app live
|
||||||
var STABLE_REQUIRED = 2;
|
var STABLE_REQUIRED = 2; // require 2 in a row before redirecting (flap guard)
|
||||||
var pollInterval = null;
|
var pollInterval = null;
|
||||||
var tickInterval = null;
|
var tickInterval = null;
|
||||||
var done = false;
|
var done = false;
|
||||||
|
|
@ -219,31 +228,43 @@
|
||||||
|
|
||||||
function poll() {
|
function poll() {
|
||||||
if (done) return;
|
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) {
|
.then(function (res) {
|
||||||
if (res.ok) {
|
if (!res.ok) { sawDown = true; stableNew = 0; return null; }
|
||||||
consecutiveOk++;
|
return res.json();
|
||||||
if (consecutiveOk >= STABLE_REQUIRED) showDone();
|
})
|
||||||
|
.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 {
|
} else {
|
||||||
consecutiveOk = 0;
|
stableNew = 0; // still the old version (pre/during build) — keep waiting
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
// 502 / network error while the container is rebuilding — keep waiting
|
// 502 / network error while the container is rebuilding — keep waiting
|
||||||
consecutiveOk = 0;
|
sawDown = true;
|
||||||
|
stableNew = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
tickInterval = setInterval(tick, 1000);
|
tickInterval = setInterval(tick, 1000);
|
||||||
tick(); // immediate first tick
|
tick(); // immediate first tick
|
||||||
|
|
||||||
// Small initial delay before the first poll — the teardown starts right after
|
// Start polling soon. Completion is gated on a CHANGED version (or a down→up cycle), so
|
||||||
// the browser receives the redirect, so we give it a moment to go down before
|
// polling the still-up old instance early is harmless — it simply keeps waiting.
|
||||||
// we start checking if it's back up (avoids a false-positive on the old instance).
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
poll(); // first poll
|
poll(); // first poll
|
||||||
pollInterval = setInterval(poll, 2500);
|
pollInterval = setInterval(poll, 2000);
|
||||||
}, 3000);
|
}, 1000);
|
||||||
|
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ Route::get('/_caddy/ask', function (Request $request, DeploymentService $deploym
|
||||||
return response('ok', 200);
|
return response('ok', 200);
|
||||||
})->name('caddy.ask');
|
})->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::middleware('guest')->group(function () {
|
||||||
Route::get('/login', Auth\Login::class)->name('login');
|
Route::get('/login', Auth\Login::class)->name('login');
|
||||||
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
|
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) {
|
Route::get('/update-progress', function (Request $request) {
|
||||||
$parsed = parse_url((string) $request->query('return', ''), PHP_URL_PATH);
|
$parsed = parse_url((string) $request->query('return', ''), PHP_URL_PATH);
|
||||||
$return = (is_string($parsed) && str_starts_with($parsed, '/') && ! str_starts_with($parsed, '//')) ? $parsed : '/';
|
$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');
|
})->name('update.progress');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ class UpdateProgressTest extends TestCase
|
||||||
$redirectUrl,
|
$redirectUrl,
|
||||||
'requestUpdate must redirect to the update-progress page',
|
'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
|
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();
|
$body = $this->get(route('update.progress'))->content();
|
||||||
|
|
||||||
$this->assertStringContainsString(
|
$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'",
|
"fetch('/up'",
|
||||||
$body,
|
$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
|
public function test_update_progress_page_contains_phase_labels(): void
|
||||||
{
|
{
|
||||||
$body = $this->get(route('update.progress'))->content();
|
$body = $this->get(route('update.progress'))->content();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue