From 624b586887071358d1e0b0d7f80c1d3ad0726ba7 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 29 Jun 2026 16:29:56 -0400 Subject: [PATCH] chore: gate self-update prompts on published registry images (#1519) GitHub Releases appear before docker-publish.yml finishes pushing images. Probe Docker Hub and GHCR manifests before advertising a version as available. Sanitize registry probe debug logs for CodeQL log-injection. --- .../__tests__/fleet-update-hardening.test.ts | 2 +- backend/src/__tests__/monitor-service.test.ts | 70 ++++++-- backend/src/__tests__/version-check.test.ts | 149 ++++++++++++++++++ backend/src/services/MonitorService.ts | 22 ++- backend/src/utils/version-check.ts | 109 +++++++++++-- docs/features/remote-updates.mdx | 2 +- 6 files changed, 312 insertions(+), 42 deletions(-) create mode 100644 backend/src/__tests__/version-check.test.ts diff --git a/backend/src/__tests__/fleet-update-hardening.test.ts b/backend/src/__tests__/fleet-update-hardening.test.ts index edfb2fec..216d4a95 100644 --- a/backend/src/__tests__/fleet-update-hardening.test.ts +++ b/backend/src/__tests__/fleet-update-hardening.test.ts @@ -256,7 +256,7 @@ describe('forced-recheck throttle', () => { .set('Authorization', adminAuth); expect(first.status).toBe(200); expect(first.body.rechecked).toBe(true); - expect(invalidateSpy).toHaveBeenCalledWith('latest-version'); + expect(invalidateSpy).toHaveBeenCalledWith('latest-version-info'); invalidateSpy.mockClear(); diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index af59b611..868cac5c 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -16,6 +16,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine mockExecAsync, mockFetchLatestSenchoVersion, mockGetLatestVersion, + mockGetLatestVersionInfo, mockGetSenchoVersion, } = vi.hoisted(() => ({ mockGetGlobalSettings: vi.fn().mockReturnValue({}), @@ -45,6 +46,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine mockExecAsync: vi.fn().mockResolvedValue({ stdout: '' }), mockFetchLatestSenchoVersion: vi.fn().mockRejectedValue(new Error('not configured')), mockGetLatestVersion: vi.fn().mockResolvedValue(null), + mockGetLatestVersionInfo: vi.fn().mockResolvedValue(null), mockGetSenchoVersion: vi.fn().mockReturnValue(null), })); @@ -89,6 +91,7 @@ vi.mock('../services/FileSystemService', () => ({ vi.mock('../utils/version-check', () => ({ fetchLatestSenchoVersion: (...args: unknown[]) => mockFetchLatestSenchoVersion(...args), getLatestVersion: (...args: unknown[]) => mockGetLatestVersion(...args), + getLatestVersionInfo: (...args: unknown[]) => mockGetLatestVersionInfo(...args), })); vi.mock('../services/CapabilityRegistry', async () => { @@ -394,6 +397,7 @@ describe('MonitorService - host_alerts_enabled toggle', () => { // Restore default mock implementations so version-check state does not // leak into sibling describe blocks (F-11 suppression, version check). mockGetLatestVersion.mockResolvedValue(null); + mockGetLatestVersionInfo.mockResolvedValue(null); mockGetSenchoVersion.mockReturnValue(null); }); @@ -471,13 +475,13 @@ describe('MonitorService - host_alerts_enabled toggle', () => { it('still runs version check when disabled', async () => { mockGetSenchoVersion.mockReturnValue('0.45.0'); - mockGetLatestVersion.mockResolvedValue('0.46.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false }); const svc = MonitorService.getInstance(); (svc as any).lastVersionCheckAt = 0; await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0' }); - expect(mockGetLatestVersion).toHaveBeenCalled(); + expect(mockGetLatestVersionInfo).toHaveBeenCalled(); }); }); @@ -1082,7 +1086,7 @@ describe('MonitorService - Sencho version check', () => { it('dispatches notification when newer version available', async () => { mockGetSenchoVersion.mockReturnValue('0.45.0'); - mockGetLatestVersion.mockResolvedValue('0.46.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false }); mockGetSystemState.mockReturnValue(null); // No previous notification const svc = MonitorService.getInstance(); @@ -1098,7 +1102,7 @@ describe('MonitorService - Sencho version check', () => { it('does not re-notify for the same version', async () => { mockGetSenchoVersion.mockReturnValue('0.45.0'); - mockGetLatestVersion.mockResolvedValue('0.46.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false }); // Running version < last notified, so self-heal does NOT clear the key. mockGetSystemState.mockReturnValue('0.46.0'); @@ -1111,7 +1115,7 @@ describe('MonitorService - Sencho version check', () => { it('handles version check failure gracefully', async () => { mockGetSenchoVersion.mockReturnValue('0.45.0'); - mockGetLatestVersion.mockResolvedValue(null); // CacheService failed + no stale + mockGetLatestVersionInfo.mockResolvedValue(null); // CacheService failed + no stale const svc = MonitorService.getInstance(); (svc as any).lastVersionCheckAt = 0; @@ -1123,7 +1127,7 @@ describe('MonitorService - Sencho version check', () => { it('respects the 6-hour cooldown interval', async () => { mockGetSenchoVersion.mockReturnValue('0.45.0'); - mockGetLatestVersion.mockResolvedValue('0.46.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false }); mockGetSystemState.mockReturnValue(null); const svc = MonitorService.getInstance(); @@ -1131,14 +1135,14 @@ describe('MonitorService - Sencho version check', () => { (svc as any).lastVersionCheckAt = Date.now() - 1 * 60 * 60 * 1000; await (svc as any).evaluate(); - // getLatestVersion should not have been called since we're within cooldown - expect(mockGetLatestVersion).not.toHaveBeenCalled(); + // getLatestVersionInfo should not have been called since we're within cooldown + expect(mockGetLatestVersionInfo).not.toHaveBeenCalled(); }); it('skips version check when getSenchoVersion returns null', async () => { // Simulates the production-Docker scenario that previously leaked "0.0.0" mockGetSenchoVersion.mockReturnValue(null); - mockGetLatestVersion.mockResolvedValue('0.46.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false }); mockGetSystemState.mockReturnValue(null); const svc = MonitorService.getInstance(); @@ -1148,14 +1152,14 @@ describe('MonitorService - Sencho version check', () => { expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0')); expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything()); // Should not have even attempted the lookup. - expect(mockGetLatestVersion).not.toHaveBeenCalled(); + expect(mockGetLatestVersionInfo).not.toHaveBeenCalled(); }); // ── Regression coverage for PR: cooldown leak + dedup self-heal ─────── - it('does NOT advance cooldown when getLatestVersion returns null (retries next cycle)', async () => { + it('does NOT advance cooldown when getLatestVersionInfo returns null (retries next cycle)', async () => { mockGetSenchoVersion.mockReturnValue('0.45.0'); - mockGetLatestVersion.mockResolvedValue(null); + mockGetLatestVersionInfo.mockResolvedValue(null); mockGetSystemState.mockReturnValue(null); const svc = MonitorService.getInstance(); @@ -1165,13 +1169,29 @@ describe('MonitorService - Sencho version check', () => { await (svc as any).evaluate(); // Both evals should attempt the lookup since failures do not lock cooldown. - expect(mockGetLatestVersion).toHaveBeenCalledTimes(2); + expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2); expect((svc as any).lastVersionCheckAt).toBe(0); }); + it('does NOT advance cooldown while a GitHub release is pending registry publish', async () => { + mockGetSenchoVersion.mockReturnValue('0.93.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.93.0', publishPending: true }); + mockGetSystemState.mockReturnValue(null); + + const svc = MonitorService.getInstance(); + (svc as any).lastVersionCheckAt = 0; + + await (svc as any).evaluate(); + await (svc as any).evaluate(); + + expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2); + expect((svc as any).lastVersionCheckAt).toBe(0); + expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('available')); + }); + it('DOES advance cooldown on a successful lookup (prevents re-fetch inside window)', async () => { mockGetSenchoVersion.mockReturnValue('0.45.0'); - mockGetLatestVersion.mockResolvedValue('0.46.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false }); mockGetSystemState.mockReturnValue(null); const svc = MonitorService.getInstance(); @@ -1182,10 +1202,10 @@ describe('MonitorService - Sencho version check', () => { expect(firstCooldown).toBeGreaterThan(0); // Second eval immediately after: cooldown gate should block it. - mockGetLatestVersion.mockClear(); + mockGetLatestVersionInfo.mockClear(); await (svc as any).evaluate(); - expect(mockGetLatestVersion).not.toHaveBeenCalled(); + expect(mockGetLatestVersionInfo).not.toHaveBeenCalled(); // Exactly one dispatch across both evals. const availabilityDispatches = mockDispatchAlert.mock.calls.filter( (args: unknown[]) => typeof args[2] === 'string' && args[2].includes('available'), @@ -1198,7 +1218,7 @@ describe('MonitorService - Sencho version check', () => { // User has now upgraded to 0.46.0; a new release (0.47.0) just dropped. const store = wireStatefulSystemState({ last_sencho_update_notified_version: '0.46.0' }); mockGetSenchoVersion.mockReturnValue('0.46.0'); - mockGetLatestVersion.mockResolvedValue('0.47.0'); + mockGetLatestVersionInfo.mockResolvedValue({ version: '0.47.0', publishPending: false }); const svc = MonitorService.getInstance(); (svc as any).lastVersionCheckAt = 0; @@ -1207,6 +1227,22 @@ describe('MonitorService - Sencho version check', () => { expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.47.0')); expect(store.last_sencho_update_notified_version).toBe('0.47.0'); }); + + it('notifies once the registry publish completes after a pending GitHub release', async () => { + mockGetSenchoVersion.mockReturnValue('0.93.0'); + mockGetSystemState.mockReturnValue(null); + + const svc = MonitorService.getInstance(); + (svc as any).lastVersionCheckAt = 0; + + mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.93.0', publishPending: true }); + await (svc as any).evaluate(); + expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0')); + + mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.94.0', publishPending: false }); + await (svc as any).evaluate(); + expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0')); + }); }); // ── Per-container parallel fan-out ──────────────────────────────────── diff --git a/backend/src/__tests__/version-check.test.ts b/backend/src/__tests__/version-check.test.ts new file mode 100644 index 00000000..3dc99fe2 --- /dev/null +++ b/backend/src/__tests__/version-check.test.ts @@ -0,0 +1,149 @@ +/** + * Unit tests for Sencho upstream version lookup and registry publish gating. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockGetRemoteDigestResult = vi.fn(); +const mockFetch = vi.fn(); + +vi.stubGlobal('fetch', mockFetch); + +vi.mock('../services/registry-api', () => ({ + getRemoteDigestResult: (...args: unknown[]) => mockGetRemoteDigestResult(...args), +})); + +import { CacheService } from '../services/CacheService'; +import { + fetchLatestSenchoVersion, + fetchLatestSenchoVersionInfo, + getLatestVersion, + isSenchoVersionPublished, +} from '../utils/version-check'; + +function ghRelease(version: string) { + return { + ok: true, + json: async () => ({ tag_name: `v${version}` }), + }; +} + +function dockerHubTags(...versions: string[]) { + return { + ok: true, + json: async () => ({ + results: versions.map(name => ({ name })), + }), + }; +} + +describe('isSenchoVersionPublished', () => { + beforeEach(() => { + mockGetRemoteDigestResult.mockReset(); + }); + + it('returns true when either mirror has a pullable manifest', async () => { + mockGetRemoteDigestResult + .mockResolvedValueOnce({ ok: false, reason: 'not found' }) + .mockResolvedValueOnce({ ok: true, digest: 'sha256:abc' }); + + await expect(isSenchoVersionPublished('0.94.0')).resolves.toBe(true); + expect(mockGetRemoteDigestResult).toHaveBeenCalledTimes(2); + }); + + it('returns false when every mirror probe fails', async () => { + mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'not found' }); + + await expect(isSenchoVersionPublished('0.94.0')).resolves.toBe(false); + }); + + it('returns false for invalid semver input', async () => { + await expect(isSenchoVersionPublished('not-a-version')).resolves.toBe(false); + expect(mockGetRemoteDigestResult).not.toHaveBeenCalled(); + }); +}); + +describe('fetchLatestSenchoVersionInfo', () => { + beforeEach(() => { + mockFetch.mockReset(); + mockGetRemoteDigestResult.mockReset(); + mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'not found' }); + }); + + it('returns GitHub version when the registry manifest is published', async () => { + mockFetch + .mockResolvedValueOnce(ghRelease('0.94.0')) + .mockResolvedValueOnce(dockerHubTags('0.94.0', '0.93.0')); + mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' }); + + await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({ + version: '0.94.0', + publishPending: false, + }); + }); + + it('falls back to Docker Hub and marks publishPending when GitHub is ahead of the registry', async () => { + mockFetch + .mockResolvedValueOnce(ghRelease('0.94.0')) + .mockResolvedValueOnce(dockerHubTags('0.93.0')); + + await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({ + version: '0.93.0', + publishPending: true, + }); + expect(mockGetRemoteDigestResult).toHaveBeenCalled(); + }); + + it('uses Docker Hub when GitHub is unavailable', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce(dockerHubTags('0.93.0')); + + await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({ + version: '0.93.0', + publishPending: false, + }); + expect(mockGetRemoteDigestResult).not.toHaveBeenCalled(); + }); + + it('throws when both upstream lookups fail', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: false }); + + await expect(fetchLatestSenchoVersionInfo()).rejects.toThrow( + 'Both GitHub and Docker Hub version lookups failed', + ); + }); +}); + +describe('getLatestVersion cache', () => { + beforeEach(() => { + mockFetch.mockReset(); + mockGetRemoteDigestResult.mockReset(); + CacheService.getInstance().flush(); + }); + + afterEach(() => { + CacheService.getInstance().flush(); + }); + + it('returns the published semver from the cached lookup', async () => { + mockFetch + .mockResolvedValueOnce(ghRelease('0.94.0')) + .mockResolvedValueOnce(dockerHubTags('0.94.0')); + mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' }); + + await expect(getLatestVersion()).resolves.toBe('0.94.0'); + await expect(getLatestVersion()).resolves.toBe('0.94.0'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('fetchLatestSenchoVersion returns the version string only', async () => { + mockFetch + .mockResolvedValueOnce(ghRelease('0.94.0')) + .mockResolvedValueOnce(dockerHubTags('0.94.0')); + mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' }); + + await expect(fetchLatestSenchoVersion()).resolves.toBe('0.94.0'); + }); +}); diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 9552f7bf..cf9e0b9f 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -8,7 +8,7 @@ import { normalizeImageRef } from './DriftDetectionService'; import { NotificationService } from './NotificationService'; import { FleetUpdateTrackerService } from './FleetUpdateTrackerService'; import { isValidVersion, getSenchoVersion } from './CapabilityRegistry'; -import { getLatestVersion } from '../utils/version-check'; +import { getLatestVersionInfo } from '../utils/version-check'; import { isDebugEnabled } from '../utils/debug'; import { withTimeout, TimeoutError } from '../utils/withTimeout'; @@ -537,10 +537,10 @@ export class MonitorService { /** * Check GitHub/Docker Hub for a newer Sencho release and dispatch a - * one-shot notification. Uses getLatestVersion() which wraps CacheService - * (30 min TTL + inflight dedup + stale-on-error) so transient network - * blips do not cause gaps, and the check stays consistent with the Fleet - * update banner. + * one-shot notification. Uses getLatestVersionInfo() which wraps CacheService + * (30 min TTL when published, shorter while a GitHub release awaits registry + * publish + inflight dedup) so transient network blips do not cause gaps, + * and the check stays consistent with the Fleet update banner. * * The 6-hour cooldown gate prevents bell spam: it is only advanced on a * SUCCESSFUL lookup. A failed lookup retries on the next eval cycle @@ -564,15 +564,23 @@ export class MonitorService { return; } - const latest = await getLatestVersion(); - if (!isValidVersion(latest)) { + const versionInfo = await getLatestVersionInfo(); + if (!versionInfo || !isValidVersion(versionInfo.version)) { // Network failure (GitHub + Docker Hub both down, no stale cache). // Do NOT advance the cooldown so the next eval retries. if (isDebugEnabled()) console.debug('[Monitor:diag] Latest Sencho version unresolvable; will retry next cycle'); return; } + if (versionInfo.publishPending) { + // GitHub announced a release before the image is pullable. Retry on + // the next eval cycle without advancing the 6-hour cooldown. + if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho release pending registry publish; will retry next cycle'); + return; + } + this.lastVersionCheckAt = Date.now(); + const latest = versionInfo.version; const db = DatabaseService.getInstance(); const stateKey = MonitorService.SENCHO_UPDATE_NOTIFIED_KEY; diff --git a/backend/src/utils/version-check.ts b/backend/src/utils/version-check.ts index a531c92f..76284ef2 100644 --- a/backend/src/utils/version-check.ts +++ b/backend/src/utils/version-check.ts @@ -1,12 +1,24 @@ import semver from 'semver'; import { CacheService } from '../services/CacheService'; +import { getRemoteDigestResult } from '../services/registry-api'; +import { isDebugEnabled } from './debug'; +import { sanitizeForLog } from './safeLog'; /** * Fetches the latest Sencho release version from GitHub or Docker Hub. * Extracted from index.ts so both the fleet endpoint and MonitorService * can share the same lookup logic. + * + * GitHub releases/latest is the canonical semver source, but availability + * is gated on a pullable registry manifest so operators are not prompted + * before docker-publish.yml finishes pushing the image. */ +const SENCHO_PUBLISH_MIRRORS = [ + { registry: 'registry-1.docker.io', repo: 'saelix/sencho' }, + { registry: 'ghcr.io', repo: 'studio-saelix/sencho' }, +] as const; + async function fetchFromGitHub(): Promise { const res = await fetch('https://api.github.com/repos/studio-saelix/sencho/releases/latest', { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Sencho' }, @@ -33,48 +45,113 @@ async function fetchFromDockerHub(): Promise { return tags[0]; } -export async function fetchLatestSenchoVersion(): Promise { +/** True when at least one public mirror has a pullable manifest for the semver tag. */ +export async function isSenchoVersionPublished(version: string): Promise { + if (!semver.valid(version)) return false; + + const results = await Promise.all( + SENCHO_PUBLISH_MIRRORS.map(async ({ registry, repo }) => { + const result = await getRemoteDigestResult(registry, repo, version, null); + if (isDebugEnabled() && !result.ok) { + console.debug( + `[VersionCheck] Manifest probe failed for ${sanitizeForLog(registry)}/${sanitizeForLog(repo)}:${sanitizeForLog(version)}: ${sanitizeForLog(result.reason)}`, + ); + } + return result.ok; + }), + ); + return results.some(Boolean); +} + +export interface LatestVersionInfo { + version: string; + /** GitHub announced a newer release than the highest published registry tag. */ + publishPending: boolean; +} + +export async function fetchLatestSenchoVersionInfo(): Promise { + let gh: string | null = null; + let hub: string | null = null; + try { - const gh = await fetchFromGitHub(); - if (gh) return gh; + gh = await fetchFromGitHub(); } catch (err) { - // GitHub API fails for private repos or rate limits; try Docker Hub console.warn('[VersionCheck] GitHub fetch failed:', (err as Error).message); } + try { - const hub = await fetchFromDockerHub(); - if (hub) return hub; + hub = await fetchFromDockerHub(); } catch (err) { console.warn('[VersionCheck] Docker Hub fetch failed:', (err as Error).message); } - // Throw so CacheService falls back to a stale value if one exists, - // and so we do not poison the cache with null. + + if (gh && semver.valid(gh)) { + if (await isSenchoVersionPublished(gh)) { + return { version: gh, publishPending: false }; + } + if (hub && semver.valid(hub)) { + return { version: hub, publishPending: true }; + } + throw new Error(`GitHub release ${gh} is not yet published on any registry mirror`); + } + + if (hub && semver.valid(hub)) { + return { version: hub, publishPending: false }; + } + throw new Error('Both GitHub and Docker Hub version lookups failed'); } +export async function fetchLatestSenchoVersion(): Promise { + const info = await fetchLatestSenchoVersionInfo(); + return info.version; +} + /** * Cached wrapper shared by the Fleet endpoint and MonitorService. * CacheService provides TTL, inflight deduplication, and stale-on-error * fallback so transient network blips do not cause user-visible gaps. */ -const LATEST_VERSION_CACHE_KEY = 'latest-version'; +const LATEST_VERSION_INFO_CACHE_KEY = 'latest-version-info'; const LATEST_VERSION_CACHE_TTL = 30 * 60 * 1000; // 30 minutes +const PENDING_PUBLISH_CACHE_TTL = 3 * 60 * 1000; // 3 minutes -export async function getLatestVersion(forceRefresh = false): Promise { +let inflightLatestVersionInfo: Promise | null = null; + +export async function getLatestVersionInfo(forceRefresh = false): Promise { + const cache = CacheService.getInstance(); if (forceRefresh) { - CacheService.getInstance().invalidate(LATEST_VERSION_CACHE_KEY); + cache.invalidate(LATEST_VERSION_INFO_CACHE_KEY); } + + const cached = cache.get(LATEST_VERSION_INFO_CACHE_KEY); + if (cached) return cached; + + if (!inflightLatestVersionInfo) { + inflightLatestVersionInfo = (async () => { + try { + const info = await fetchLatestSenchoVersionInfo(); + const ttl = info.publishPending ? PENDING_PUBLISH_CACHE_TTL : LATEST_VERSION_CACHE_TTL; + cache.set(LATEST_VERSION_INFO_CACHE_KEY, info, ttl); + return info; + } finally { + inflightLatestVersionInfo = null; + } + })(); + } + try { - return await CacheService.getInstance().getOrFetch( - LATEST_VERSION_CACHE_KEY, - LATEST_VERSION_CACHE_TTL, - fetchLatestSenchoVersion, - ); + return await inflightLatestVersionInfo; } catch { return null; } } +export async function getLatestVersion(forceRefresh = false): Promise { + const info = await getLatestVersionInfo(forceRefresh); + return info?.version ?? null; +} + // --- Release details (includes body/notes for the changelog tab) --- export interface SenchoRelease { diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index ed898ea1..f3cf5cce 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -3,7 +3,7 @@ title: Remote Updates description: Pull the latest Sencho image and recreate the container on any node in the fleet, including the gateway, from the Fleet view. --- -Sencho can update any node in the fleet to the latest published release without ever opening an SSH session. The control instance opens the **Node updates** sheet, dispatches the update to one or more nodes, and watches each one come back online with the new version. +Sencho can update any node in the fleet to the latest published release without ever opening an SSH session. The control instance opens the **Node updates** sheet, dispatches the update to one or more nodes, and watches each one come back online with the new version. Update availability is confirmed against a pullable Docker image on the public registry mirrors, not merely the GitHub Release timestamp, so the **Update** button does not appear until the release image has finished publishing. This page covers the mechanism: prerequisites, what happens on a node during an update, how completion and failure are detected, and how to recover. The full UI tour for the Node updates sheet itself lives in [Fleet View](/features/fleet-view#node-updates).