diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index cafe3ec..12ba19e 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -52,6 +52,20 @@ class DeploymentService */ private const UPDATE_FILE = 'app/restart-signal/update.request'; + /** + * Update start marker — written (best-effort) when an update is requested, on the same + * bind-mounted volume. The update-progress page reads it so its elapsed timer and live phase + * checklist are anchored to the SERVER-side start, not to page-load time — a browser refresh + * mid-update no longer resets the timer to zero nor drops the checklist back to phase one. + */ + private const UPDATE_STARTED_FILE = 'app/restart-signal/update-started.json'; + + /** + * Coarse update macro-stage the host updater (update.sh / install.sh) writes as it progresses + * (fetch → build → restart → migrate → done). Same bind-mounted directory. Read-only token. + */ + private const UPDATE_PHASE_FILE = 'app/restart-signal/update-phase.json'; + /** * The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists * (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is @@ -209,6 +223,58 @@ class DeploymentService return storage_path(self::UPDATE_FILE); } + /** Absolute path of the update-start marker on the shared (bind-mounted) volume. */ + public function updateStartedPath(): string + { + return storage_path(self::UPDATE_STARTED_FILE); + } + + /** + * Epoch seconds when the current update was requested, or null if there is no readable marker. + * The update-progress page anchors its elapsed timer and live phase feed to this so both + * survive a browser refresh instead of resetting on every page load. Never throws. + */ + public function updateStartedAt(): ?int + { + $path = $this->updateStartedPath(); + if (! is_file($path)) { + return null; + } + $data = json_decode((string) @file_get_contents($path), true); + $at = is_array($data) ? ($data['at'] ?? null) : null; + + return is_int($at) && $at > 0 ? $at : null; + } + + /** + * The current update macro-stage written by the host updater, or null when absent/unrecognised. + * Only ever returns a whitelisted stage token (never arbitrary file content). Shape: + * ['stage' => 'fetch'|'build'|'restart'|'migrate'|'done', 'at' => ]. Never throws. + * + * @return array{stage: string, at: int}|null + */ + public function updatePhase(): ?array + { + $path = storage_path(self::UPDATE_PHASE_FILE); + if (! is_file($path)) { + return null; + } + $data = json_decode((string) @file_get_contents($path), true); + if (! is_array($data) || ! in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done'], true)) { + return null; + } + // Require a real timestamp: the progress page's freshness logic ties a stage to THIS update + // via its `at`. A missing/zero/non-int `at` (corrupt file, or the host's date-failed `echo 0` + // fallback) must NOT pass as a current stage — otherwise a stale 'done' could trigger a + // premature completion redirect. Treat it as "no stage" and let the page fall back. + $at = $data['at'] ?? null; + if (! is_int($at) || $at <= 0) { + return null; + } + + return ['stage' => (string) $data['stage'], 'at' => $at]; + } + /** * Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped * clusev-update.path unit) reacts by running update.sh — git pull + idempotent re-install @@ -226,7 +292,19 @@ class DeploymentService // Returns false when the (bind-mounted) sentinel dir is not writable by the app user — // the caller surfaces that instead of showing a "running" state that never resolves. - return @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false; + $written = @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false; + + if ($written) { + // Best-effort start marker (a failure here must never fail the update): lets the + // progress page render a refresh-proof elapsed time and accept the live phase feed + // regardless of how long the current stage has been running. + @file_put_contents($this->updateStartedPath(), json_encode([ + 'at' => now()->timestamp, + 'from' => (string) config('clusev.version'), + ]).PHP_EOL); + } + + return $written; } /** True while an update request is pending (the host watcher clears it once handled). */ @@ -235,13 +313,14 @@ class DeploymentService return is_file($this->updateSignalPath()); } - /** Remove the update sentinel (tests / manual reset; the host watcher deletes it normally). */ + /** Remove the update sentinel + its start marker (tests / manual reset; the host watcher deletes + * the sentinel normally). */ public function clearUpdateRequest(): void { - $path = $this->updateSignalPath(); - - if (is_file($path)) { - @unlink($path); + foreach ([$this->updateSignalPath(), $this->updateStartedPath()] as $path) { + if (is_file($path)) { + @unlink($path); + } } } diff --git a/resources/views/update-progress.blade.php b/resources/views/update-progress.blade.php index 4bffc92..d24f01b 100644 --- a/resources/views/update-progress.blade.php +++ b/resources/views/update-progress.blade.php @@ -128,8 +128,16 @@ // previous update. The feed only became active one release after this shipped (the page // running DURING an update is the old version's) — so the heuristic below is the fallback. var pageStart = {{ now()->timestamp }}; + // Server epoch when THIS update was requested (or null on a pre-fix install). Both the + // elapsed timer and the phase-feed staleness guard are anchored to it, so a browser refresh + // mid-update keeps counting from the real start and keeps the checklist at the live phase. + var startedAt = {{ $startedAt !== null ? (int) $startedAt : 'null' }}; var STAGE_INDEX = { fetch: 0, build: 1, restart: 2, migrate: 3 }; - var feedActive = false; // set once /update-status.json returns a fresh stage + // True from the start when the server already saw a fresh stage for THIS update — then the + // checklist is driven by the real feed and the time heuristic stays off (so a refresh with a + // large elapsed time cannot make the heuristic overshoot past the real phase). Otherwise the + // heuristic is the fallback until /update-status.json answers. + var feedActive = {{ $hasFeed ? 'true' : 'false' }}; // ── Phase timing heuristic (fallback when no real feed is available) ─── var PHASE_KEYS = ['fetch', 'build', 'restart', 'migrate']; @@ -167,10 +175,15 @@ currentPhase = index; } - setPhaseActive(0); + // Resume at the live phase the server reported (not back to phase one) — refresh-proof. + setPhaseActive({{ (int) $initialPhase }}); // ── Elapsed timer ────────────────────────────────────────────────────── - var startTime = Date.now(); + // Anchored to the server-side start: (pageStart - startedAt) seconds have already elapsed at + // render; the client clock only adds the smooth delta since this page loaded. A refresh + // therefore resumes the true elapsed time instead of restarting from zero. + var clientLoad = Date.now(); + var baseElapsedMs = startedAt ? Math.max(0, pageStart - startedAt) * 1000 : 0; var elapsedEl = document.getElementById('js-elapsed-value'); var TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes @@ -224,7 +237,7 @@ } function tick() { - var elapsed = Date.now() - startTime; + var elapsed = baseElapsedMs + (Date.now() - clientLoad); if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed); @@ -294,12 +307,20 @@ .then(function (res) { return res.ok ? res.json() : null; }) .then(function (data) { if (!data || !data.stage) return; - // Ignore a stage file left over from a previous update (written before this load). - if (typeof data.at === 'number' && data.at > 0 && data.at < pageStart - 30) return; + // Only trust a stage we can tie to THIS update: we must know when the update + // began (startedAt) and the stage must have been written at/after it. No start + // marker, a missing/zero timestamp, or a stage predating this update → ignore the + // feed (the time heuristic drives). This both keeps a long-running stage of the + // current update (old timestamp, still valid) AND blocks a stale 'done' from a + // previous run triggering a premature completion on refresh. + if (!startedAt || typeof data.at !== 'number' || data.at < startedAt - 5) return; feedActive = true; if (data.stage === 'done') { showDone(); return; } var idx = STAGE_INDEX[data.stage]; - if (typeof idx === 'number' && idx > currentPhase) setPhaseActive(idx); + // The live feed is authoritative — the host writes stages monotonically forward, + // so apply it even if not greater than the current index, correcting any earlier + // time-heuristic overshoot rather than being suppressed by it. + if (typeof idx === 'number') setPhaseActive(idx); }) .catch(function () { /* feed unreachable mid-cutover — keep the last known phase */ }); } diff --git a/routes/web.php b/routes/web.php index 2964507..fb5484e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,19 +54,10 @@ Route::get('/version.json', fn () => response()->json(['version' => (string) con // run/update-phase.json (bind-mounted to storage/app/restart-signal/); the update-progress page // polls this to show REAL progress (fetch → build → restart → migrate → done) instead of a time // guess. PUBLIC and only ever echoes a whitelisted stage token — never any sensitive data. -Route::get('/update-status.json', function () { - $path = storage_path('app/restart-signal/update-phase.json'); - $stage = null; - $at = 0; - if (is_file($path)) { - $data = json_decode((string) @file_get_contents($path), true); - if (is_array($data) && in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done'], true)) { - $stage = $data['stage']; - $at = (int) ($data['at'] ?? 0); - } - } +Route::get('/update-status.json', function (DeploymentService $deployment) { + $phase = $deployment->updatePhase(); - return response()->json(['stage' => $stage, 'at' => $at]); + return response()->json(['stage' => $phase['stage'] ?? null, 'at' => $phase['at'] ?? 0]); })->name('update.status'); Route::middleware('guest')->group(function () { @@ -148,14 +139,35 @@ Route::middleware('auth')->group(function () { // Plain Blade view — deliberately NOT a Livewire component so it survives the // container teardown that follows an update request (R1/R2 exception, by design). - Route::get('/update-progress', function (Request $request) { + Route::get('/update-progress', function (Request $request, DeploymentService $deployment) { $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, 'fromVersion' => $from]); + // Server-side update start + current macro-stage so the page is REFRESH-PROOF: the + // elapsed timer counts from the real start, and the checklist resumes at the live phase + // (not back to phase one). A stage is only trusted when we know when THIS update began + // (startedAt, written by requestUpdate) AND the stage was written at/after it — so a + // leftover stage from a previous run can never drive the checklist or a premature finish. + $startedAt = $deployment->updateStartedAt(); + $phase = $deployment->updatePhase(); + $stageIndex = ['fetch' => 0, 'build' => 1, 'restart' => 2, 'migrate' => 3, 'done' => 4]; + $initialPhase = 0; + $hasFeed = false; // a fresh real stage exists → drive the checklist from the feed, not the time guess + if ($phase !== null && $startedAt !== null && $phase['at'] >= $startedAt - 5) { + $initialPhase = $stageIndex[$phase['stage']] ?? 0; + $hasFeed = true; + } + + return view('update-progress', [ + 'returnPath' => $return, + 'fromVersion' => $from, + 'startedAt' => $startedAt, + 'initialPhase' => $initialPhase, + 'hasFeed' => $hasFeed, + ]); })->name('update.progress'); }); }); diff --git a/tests/Feature/UpdateProgressTest.php b/tests/Feature/UpdateProgressTest.php index 203f454..caf0a77 100644 --- a/tests/Feature/UpdateProgressTest.php +++ b/tests/Feature/UpdateProgressTest.php @@ -287,4 +287,120 @@ class UpdateProgressTest extends TestCase @unlink($dir.'/update-phase.json'); } } + + // ── (c) resumable progress (survives a browser refresh) ─────────────────── + + public function test_request_update_writes_a_recent_start_marker(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + $before = now()->timestamp; + Livewire::test(Index::class)->call('requestUpdate'); + + $startedAt = app(DeploymentService::class)->updateStartedAt(); + $this->assertIsInt($startedAt, 'requestUpdate must record when the update was requested'); + $this->assertGreaterThanOrEqual($before, $startedAt); + $this->assertLessThanOrEqual(now()->timestamp + 1, $startedAt); + } + + public function test_update_started_at_is_null_without_a_marker(): void + { + $this->assertNull(app(DeploymentService::class)->updateStartedAt()); + } + + public function test_clear_update_request_also_removes_the_start_marker(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + Livewire::test(Index::class)->call('requestUpdate'); + $this->assertNotNull(app(DeploymentService::class)->updateStartedAt()); + + app(DeploymentService::class)->clearUpdateRequest(); + $this->assertNull(app(DeploymentService::class)->updateStartedAt()); + } + + public function test_update_progress_embeds_the_start_marker_when_present(): void + { + $deployment = app(DeploymentService::class); + @mkdir(dirname($deployment->updateStartedPath()), 0775, true); + file_put_contents($deployment->updateStartedPath(), json_encode(['at' => 1700000000, 'from' => '0.9.4'])); + + $body = $this->get(route('update.progress'))->content(); + + $this->assertStringContainsString( + 'var startedAt = 1700000000;', + $body, + 'the page must embed the server-side update start so the elapsed timer survives a refresh', + ); + } + + public function test_update_progress_embeds_null_start_when_absent(): void + { + $body = $this->get(route('update.progress'))->content(); + + $this->assertStringContainsString('var startedAt = null;', $body); + } + + public function test_update_progress_resumes_the_phase_from_the_feed_file(): void + { + $deployment = app(DeploymentService::class); + $dir = dirname($deployment->updateStartedPath()); + @mkdir($dir, 0775, true); + $now = now()->timestamp; + file_put_contents($deployment->updateStartedPath(), json_encode(['at' => $now - 120, 'from' => '0.9.4'])); + file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'restart', 'at' => $now - 90])); + + try { + $body = $this->get(route('update.progress'))->content(); + + // restart = index 2: the checklist must initialise at phase 2 (not reset to 0), even + // though the stage was written 90s ago — far past the old 30s staleness window. + $this->assertStringContainsString('setPhaseActive(2);', $body); + // A fresh real stage drives the checklist from the feed — the time heuristic stays off + // so a large elapsed time on refresh cannot overshoot past the real phase. + $this->assertStringContainsString('var feedActive = true;', $body); + } finally { + @unlink($dir.'/update-phase.json'); + } + } + + public function test_update_status_ignores_a_stage_without_a_valid_timestamp(): void + { + $dir = storage_path('app/restart-signal'); + @mkdir($dir, 0775, true); + // A whitelisted stage but NO timestamp must not pass as a current stage — otherwise a stale + // 'done' could drive a premature completion redirect on the progress page. + file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'done'])); + + try { + $this->getJson('/update-status.json') + ->assertOk() + ->assertJson(['stage' => null]); + } finally { + @unlink($dir.'/update-phase.json'); + } + } + + public function test_update_progress_ignores_a_phase_that_predates_this_update(): void + { + $deployment = app(DeploymentService::class); + $dir = dirname($deployment->updateStartedPath()); + @mkdir($dir, 0775, true); + $now = now()->timestamp; + // The start marker is NEWER than the leftover stage (a 'done' from a previous run) — the page + // must NOT resume it: it stays at phase one with the feed off (heuristic drives) so the old + // 'done' can't trigger a premature finish. + file_put_contents($deployment->updateStartedPath(), json_encode(['at' => $now, 'from' => '0.9.4'])); + file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'done', 'at' => $now - 300])); + + try { + $body = $this->get(route('update.progress'))->content(); + + $this->assertStringContainsString('setPhaseActive(0);', $body); + $this->assertStringContainsString('var feedActive = false;', $body); + } finally { + @unlink($dir.'/update-phase.json'); + } + } }