mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
return tags[0];
|
||||
}
|
||||
|
||||
export async function fetchLatestSenchoVersion(): Promise<string> {
|
||||
/** True when at least one public mirror has a pullable manifest for the semver tag. */
|
||||
export async function isSenchoVersionPublished(version: string): Promise<boolean> {
|
||||
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<LatestVersionInfo> {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
let inflightLatestVersionInfo: Promise<LatestVersionInfo> | null = null;
|
||||
|
||||
export async function getLatestVersionInfo(forceRefresh = false): Promise<LatestVersionInfo | null> {
|
||||
const cache = CacheService.getInstance();
|
||||
if (forceRefresh) {
|
||||
CacheService.getInstance().invalidate(LATEST_VERSION_CACHE_KEY);
|
||||
cache.invalidate(LATEST_VERSION_INFO_CACHE_KEY);
|
||||
}
|
||||
|
||||
const cached = cache.get<LatestVersionInfo>(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<string>(
|
||||
LATEST_VERSION_CACHE_KEY,
|
||||
LATEST_VERSION_CACHE_TTL,
|
||||
fetchLatestSenchoVersion,
|
||||
);
|
||||
return await inflightLatestVersionInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(forceRefresh = false): Promise<string | null> {
|
||||
const info = await getLatestVersionInfo(forceRefresh);
|
||||
return info?.version ?? null;
|
||||
}
|
||||
|
||||
// --- Release details (includes body/notes for the changelog tab) ---
|
||||
|
||||
export interface SenchoRelease {
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user