fix(notifications): stop Sencho version notifications from silently skipping (#594)

* fix(notifications): stop Sencho version notifications from silently skipping

Three independent defects combined to make version-update notifications
silently fail even while Fleet overview correctly surfaced an update button:

- The in-memory 6-hour cooldown was advanced before the network fetch,
  so a single transient failure at boot could lock the check for the
  rest of the container lifetime. Moved the cooldown update inside the
  success branch so failures retry on the next eval cycle.
- MonitorService called the raw version fetch directly, bypassing the
  CacheService wrapper (TTL, inflight dedup, stale-on-error) that Fleet
  uses, so the two paths could diverge. Unified both on a shared
  getLatestVersion() helper in utils/version-check.ts.
- The dedup key could carry stale state from a previous build and never
  self-clear. It now self-heals when the running version reaches the
  previously-notified version, so future releases re-fire as expected.

Added diagnostic logs gated on debug mode for each skip branch, plus
three regression tests covering cooldown-on-failure, cooldown-on-success,
and dedup self-heal.

* docs(notifications): drop legacy-upgrade framing from alerts troubleshooting

Sencho has not shipped publicly, so troubleshooting entries written in
'this used to happen but now does Y' mode reference a past that does not
exist for any reader. Rewrote the version-notification, image-update,
and crash-alert troubleshooting entries to describe current behavior
positively without referring to prior builds, upgrade paths, or legacy
fixes.

* chore(security): accept CVE-2026-33810 in bundled Docker CLI 29.4.0

Trivy now flags CVE-2026-33810 (Go stdlib crypto/x509 DNS constraint
bypass, fixed in Go 1.26.2) in the Docker CLI static binary we ship.
Docker CLI 29.4.0 is the latest upstream release and still links Go
1.26.1; no newer static binary exists yet.

Same exposure profile as the already-accepted CVE-2026-32280: the
Docker CLI and compose plugin only validate certificates from
well-known registry CAs and the local Docker socket, not from
attacker-controlled CAs with crafted DNS name constraints. Revisit
on the next Docker CLI release that rebuilds against Go 1.26.2 or
later.
This commit is contained in:
Anso
2026-04-14 16:34:28 -04:00
committed by GitHub
parent f8a7abc07b
commit 6b8c369745
6 changed files with 207 additions and 85 deletions
+77 -24
View File
@@ -6,7 +6,7 @@ import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { NotificationService } from './NotificationService';
import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
import { fetchLatestSenchoVersion } from '../utils/version-check';
import { getLatestVersion } from '../utils/version-check';
import { isDebugEnabled } from '../utils/debug';
const execAsync = promisify(exec);
@@ -76,6 +76,7 @@ export class MonitorService {
// Sencho version check cooldown (6 hours between external API calls)
private lastVersionCheckAt = 0;
private static readonly VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
private static readonly SENCHO_UPDATE_NOTIFIED_KEY = 'last_sencho_update_notified_version';
private constructor() { }
@@ -209,30 +210,82 @@ export class MonitorService {
}
// 4. Sencho version update check (runs once per VERSION_CHECK_INTERVAL_MS)
if (Date.now() - this.lastVersionCheckAt > MonitorService.VERSION_CHECK_INTERVAL_MS) {
this.lastVersionCheckAt = Date.now();
try {
// Resolve from the packaged manifest: process.env.npm_package_version is
// only set by npm scripts, so it is undefined in Docker (node dist/index.js).
const currentVersion = getSenchoVersion();
const latest = await fetchLatestSenchoVersion();
if (isValidVersion(latest) && isValidVersion(currentVersion) && semver.gt(latest, currentVersion)) {
const db = DatabaseService.getInstance();
const stateKey = 'last_sencho_update_notified_version';
const lastNotified = db.getSystemState(stateKey) || '';
if (lastNotified !== latest) {
const notifier = NotificationService.getInstance();
await notifier.dispatchAlert('info',
`Sencho ${latest} is available (currently running ${currentVersion}). Visit the Fleet dashboard to update.`);
db.setSystemState(stateKey, latest);
}
} else if (isDebugEnabled() && !isValidVersion(currentVersion)) {
console.debug('[Monitor:diag] Sencho version unresolvable; skipping update notification');
}
} catch (e) {
// Network errors are expected; do not spam logs
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho version check failed:', e);
await this.checkSenchoVersion();
}
/**
* 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.
*
* 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
* (30 seconds) instead of locking for 6 hours.
*/
private async checkSenchoVersion(): Promise<void> {
const sinceLast = Date.now() - this.lastVersionCheckAt;
if (sinceLast <= MonitorService.VERSION_CHECK_INTERVAL_MS) {
if (isDebugEnabled()) {
const nextInMs = MonitorService.VERSION_CHECK_INTERVAL_MS - sinceLast;
console.debug(`[Monitor:diag] Sencho version check in cooldown (next in ~${Math.round(nextInMs / 60000)}m)`);
}
return;
}
// Resolve from the packaged manifest: process.env.npm_package_version is
// only set by npm scripts, so it is undefined in Docker (node dist/index.js).
const currentVersion = getSenchoVersion();
if (!isValidVersion(currentVersion)) {
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho version unresolvable; skipping update notification');
return;
}
const latest = await getLatestVersion();
if (!isValidVersion(latest)) {
// 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;
}
this.lastVersionCheckAt = Date.now();
const db = DatabaseService.getInstance();
const stateKey = MonitorService.SENCHO_UPDATE_NOTIFIED_KEY;
const storedLastNotified = db.getSystemState(stateKey) || '';
// Self-heal: if the user has reached the previously-notified version,
// clear the dedup so future releases always trigger a fresh notification.
// This also recovers from stale state left over by the pre-586 "0.0.0" bug.
let effectiveLastNotified = storedLastNotified;
if (storedLastNotified && isValidVersion(storedLastNotified) && semver.gte(currentVersion, storedLastNotified)) {
if (isDebugEnabled()) console.debug(`[Monitor:diag] Clearing stale dedup key (running ${currentVersion} >= last notified ${storedLastNotified})`);
db.setSystemState(stateKey, '');
effectiveLastNotified = '';
}
if (!semver.gt(latest, currentVersion)) {
if (isDebugEnabled()) console.debug(`[Monitor:diag] Running ${currentVersion} is up-to-date with latest ${latest}`);
return;
}
if (effectiveLastNotified === latest) {
if (isDebugEnabled()) console.debug(`[Monitor:diag] Already notified for Sencho ${latest}`);
return;
}
try {
const notifier = NotificationService.getInstance();
await notifier.dispatchAlert('info',
`Sencho ${latest} is available (currently running ${currentVersion}). Visit the Fleet dashboard to update.`);
db.setSystemState(stateKey, latest);
if (isDebugEnabled()) console.debug(`[Monitor:diag] Dispatched version notification: ${currentVersion} -> ${latest}`);
} catch (e) {
// dispatchAlert normally catches channel errors internally, but the
// history insert or WebSocket broadcast can throw on an unhealthy DB.
console.error('[MonitorService] Failed to dispatch Sencho version notification:', e);
}
}