feat: add node-scoped opt-out for image update detection (#1715)

* feat: add node-scoped opt-out for image update detection

Operators who use an external update authority can disable Sencho registry
polling per node without losing explicit stack Update, pull, or redeploy.

* test: fix mocks and lint for image-update checks opt-out

Scheduler tests need isChecksEnabled on the ImageUpdateService mock, and the UpdatesSection older-node fixture must not leave an unused binding.

* fix: gate update-preview and recheck when detection is off

Anatomy was still calling stack update-preview (and contacting registries)
while checks were disabled. Short-circuit those routes and skip recheckStack
writes so disabled nodes stay quiet until detection is re-enabled.
This commit is contained in:
Anso
2026-07-28 10:10:04 -04:00
committed by GitHub
parent e175db8e62
commit fa503ddf27
23 changed files with 722 additions and 80 deletions
@@ -75,6 +75,16 @@ describe('stack_update_status tri-state accessors', () => {
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0);
});
it('clearAllStackUpdateStatus deletes only the given node rows', () => {
const other = NODE + 1;
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'api', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(other, 'web', true, 1000, 'ok', null);
expect(db().clearAllStackUpdateStatus(NODE)).toBe(2);
expect(db().getStackUpdateDetail(NODE)).toEqual({});
expect(db().getStackUpdateDetail(other).web).toBeDefined();
});
it('getNodeUpdateSummary counts only confirmed updates', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
@@ -9,9 +9,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const {
mockGetAuthForRegistry,
mockGetStackUpdateStatus, mockUpsertStackUpdateStatus, mockClearStackUpdateStatus,
mockClearAllStackUpdateStatus, mockUpdateGlobalSetting,
mockRecordStackCheckFailure, mockGetStackServicesJson,
mockGetSystemState, mockSetSystemState, mockAddNotificationHistory,
mockDispatchAlert,
mockDispatchAlert, mockBroadcastEvent,
mockGetStacks, mockGetStackContent, mockGetEnvContent, mockEnvExists,
mockGetAllContainers, mockGetGlobalSettings, mockInspect,
mockBuildEffectiveServiceModel,
@@ -20,12 +21,15 @@ const {
mockGetStackUpdateStatus: vi.fn().mockReturnValue({}),
mockUpsertStackUpdateStatus: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
mockClearAllStackUpdateStatus: vi.fn().mockReturnValue(0),
mockUpdateGlobalSetting: vi.fn(),
mockRecordStackCheckFailure: vi.fn(),
mockGetStackServicesJson: vi.fn().mockReturnValue([]),
mockGetSystemState: vi.fn().mockReturnValue('1'), // default: backfilled
mockSetSystemState: vi.fn(),
mockAddNotificationHistory: vi.fn(),
mockDispatchAlert: vi.fn().mockResolvedValue({ persisted: true }),
mockBroadcastEvent: vi.fn(),
mockGetStacks: vi.fn().mockResolvedValue([]),
mockGetStackContent: vi.fn().mockResolvedValue(''),
mockGetEnvContent: vi.fn().mockRejectedValue(new Error('no env')),
@@ -53,12 +57,14 @@ vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getGlobalSettings: mockGetGlobalSettings,
updateGlobalSetting: mockUpdateGlobalSetting,
getNodes: () => [],
getGitSource: () => undefined,
getStackProjectEnvFiles: () => [],
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
getStackUpdateStatus: mockGetStackUpdateStatus,
clearStackUpdateStatus: mockClearStackUpdateStatus,
clearAllStackUpdateStatus: mockClearAllStackUpdateStatus,
recordStackCheckFailure: mockRecordStackCheckFailure,
getStackServicesJson: mockGetStackServicesJson,
getSystemState: mockGetSystemState,
@@ -76,10 +82,15 @@ vi.mock('../services/NotificationService', () => ({
NotificationService: {
getInstance: () => ({
dispatchAlert: mockDispatchAlert,
broadcastEvent: mockBroadcastEvent,
}),
},
}));
vi.mock('../helpers/fleetUpdateCache', () => ({
invalidateFleetUpdateCache: vi.fn(),
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
@@ -1209,6 +1220,62 @@ describe('ImageUpdateService - configurable interval & status', () => {
service.stop();
checkSpy.mockRestore();
});
it('start() while checks disabled arms no timer and reports enabled false', () => {
vi.useFakeTimers();
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0', image_update_check_interval_minutes: '60' });
const service = ImageUpdateService.getInstance();
const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined);
service.start();
const status = service.getStatus();
expect(status.enabled).toBe(false);
expect(status.nextCheckAt).toBeNull();
expect(status.checking).toBe(false);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(10 * 60 * 1000);
expect(checkSpy).not.toHaveBeenCalled();
checkSpy.mockRestore();
});
it('treats a missing checks-enabled key as enabled', () => {
mockGetGlobalSettings.mockReturnValue({});
const service = ImageUpdateService.getInstance();
expect(ImageUpdateService.isChecksEnabled()).toBe(true);
expect(service.getStatus().enabled).toBe(true);
});
it('applyChecksEnabled(false) stops polling, clears local findings, and broadcasts invalidate', () => {
vi.useFakeTimers();
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '60' });
const service = ImageUpdateService.getInstance();
service.start();
expect(service.getStatus().nextCheckAt).not.toBeNull();
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0', image_update_check_interval_minutes: '60' });
mockUpdateGlobalSetting.mockImplementation((key: string, value: string) => {
if (key === 'image_update_checks_enabled') {
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: value, image_update_check_interval_minutes: '60' });
}
});
const status = service.applyChecksEnabled(false);
expect(status.enabled).toBe(false);
expect(status.nextCheckAt).toBeNull();
expect(mockUpdateGlobalSetting).toHaveBeenCalledWith('image_update_checks_enabled', '0');
expect(mockClearAllStackUpdateStatus).toHaveBeenCalledWith(1);
expect(mockBroadcastEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'state-invalidate',
scope: 'image-updates',
nodeId: 1,
}));
expect(vi.getTimerCount()).toBe(0);
});
it('triggerManualRefresh returns false when checks are disabled', () => {
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0' });
const service = ImageUpdateService.getInstance();
expect(service.triggerManualRefresh()).toBe(false);
});
});
// ── Stale stack pruning ─────────────────────────────────────────────────
@@ -1572,6 +1639,7 @@ services:
mockGetSystemState.mockReturnValue('1');
mockGetAllContainers.mockResolvedValue([]);
mockEnvExists.mockResolvedValue(false);
mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' });
});
it('reduces per-service status through the effective model and persists services_json with a generation', async () => {
@@ -1693,6 +1761,25 @@ services:
});
describe('recheckStack', () => {
it('skips registry probes and DB writes when checks are disabled', async () => {
mockGetGlobalSettings.mockReturnValueOnce({ image_update_checks_enabled: '0' });
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate: true });
const genBefore = service.peekStackWriteGeneration(1, 'stackA');
const result = await service.recheckStack(1, 'stackA');
expect(result).toEqual({ outcome: 'cleared', warning: null });
expect(service.peekStackWriteGeneration(1, 'stackA')).toBe(genBefore);
expect(mockBuildEffectiveServiceModel).not.toHaveBeenCalled();
expect(mockGetAllContainers).not.toHaveBeenCalled();
expect((service as any).checkImage).not.toHaveBeenCalled();
expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled();
expect(mockRecordStackCheckFailure).not.toHaveBeenCalled();
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
expect(mockClearAllStackUpdateStatus).not.toHaveBeenCalled();
});
it('returns still_present when a checkable service still has an update', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
@@ -115,6 +115,65 @@ describe('GET /api/image-updates/status', () => {
expect(typeof res.body.manualCooldownRemainingMs).toBe('number');
expect('lastCheckedAt' in res.body).toBe(true);
expect('nextCheckAt' in res.body).toBe(true);
expect(res.body.enabled).toBe(true);
});
});
describe('PUT /api/image-updates/enabled', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).put('/api/image-updates/enabled').send({ enabled: false });
expect(res.status).toBe(401);
});
it('rejects non-admin users with 403', async () => {
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', viewerCookie).send({ enabled: false });
expect(res.status).toBe(403);
});
it('disables checks, clears local findings, and returns enabled false', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'pending-stack', true, Date.now(), 'ok', null);
expect(Object.keys(db.getStackUpdateDetail(nodeId)).length).toBeGreaterThan(0);
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', adminCookie).send({ enabled: false });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(false);
expect(res.body.nextCheckAt).toBeNull();
expect(db.getGlobalSettings().image_update_checks_enabled).toBe('0');
expect(db.getStackUpdateDetail(nodeId)).toEqual({});
});
it('re-enables checks and returns enabled true', async () => {
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', adminCookie).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
expect(DatabaseService.getInstance().getGlobalSettings().image_update_checks_enabled).toBe('1');
});
});
describe('POST /api/image-updates/refresh when disabled', () => {
it('returns 409 with enabled false instead of rate-limit 429', async () => {
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
const res = await request(app).post('/api/image-updates/refresh').set('Cookie', adminCookie);
expect(res.status).toBe(409);
expect(res.body.enabled).toBe(false);
expect(res.body.error).toMatch(/disabled/i);
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1');
});
});
describe('POST /api/image-updates/fleet/refresh when disabled', () => {
it('lists the local node in disabled rather than triggered or rateLimited', async () => {
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
const localId = DatabaseService.getInstance().getDefaultNode()!.id!;
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.disabled).toContain(localId);
expect(res.body.triggered).not.toContain(localId);
expect(res.body.rateLimited).not.toContain(localId);
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1');
});
});
@@ -167,6 +167,8 @@ vi.mock('../services/FileSystemService', () => ({
vi.mock('../services/ImageUpdateService', () => ({
ImageUpdateService: {
// Default on so existing executeUpdate tests keep prior behavior.
isChecksEnabled: () => true,
getInstance: () => ({
checkImage: mockCheckImage,
recheckStack: mockRecheckStack,
@@ -37,6 +37,7 @@ vi.mock('../services/ImageUpdateService', async () => {
return {
...actual,
ImageUpdateService: {
isChecksEnabled: () => true,
getInstance: () => ({ recheckStack: mockRecheckStack }),
},
};
@@ -160,6 +160,13 @@ describe('updatePreviewSignal', () => {
it('degrades a preview failure to a non-verdict-affecting unknown', () => {
expect(updatePreviewSignal('error')).toMatchObject({ status: 'unknown', affectsVerdict: false });
});
it('reports detection disabled without treating it as up to date', () => {
const signal = updatePreviewSignal(summary({ detection_disabled: true, has_update: false }));
expect(signal.status).toBe('unknown');
expect(signal.affectsVerdict).toBe(false);
expect(signal.detail).toMatch(/disabled/i);
});
});
describe('buildServicesSignal', () => {
@@ -182,6 +182,53 @@ describe('ImageUpdateService.commitPreviewClear', () => {
});
describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => {
it('GET returns detection_disabled preview without calling getPreview when checks are off', async () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('image_update_checks_enabled', '0');
const getPreview = vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview');
try {
const res = await request(app)
.get('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.summary?.detection_disabled).toBe(true);
expect(res.body.summary?.has_update).toBe(false);
expect(res.body.images).toEqual([]);
expect(getPreview).not.toHaveBeenCalled();
} finally {
db.updateGlobalSetting('image_update_checks_enabled', '1');
}
});
it('POST returns detection_disabled without registry I/O or sticky reconcile when checks are off', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
db.updateGlobalSetting('image_update_checks_enabled', '0');
const getPreview = vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview');
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
try {
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.summary?.detection_disabled).toBe(true);
expect(res.body.summary?.has_update).toBe(false);
expect(res.body.reconciled).toBe(false);
expect(getPreview).not.toHaveBeenCalled();
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
expect(broadcast).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
} finally {
db.updateGlobalSetting('image_update_checks_enabled', '1');
}
});
it('GET does not mutate sticky state even for authoritative-negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
+38 -2
View File
@@ -77,6 +77,13 @@ imageUpdatesRouter.get('/detail', authMiddleware, (req: Request, res: Response):
imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
if (!ImageUpdateService.isChecksEnabled()) {
res.status(409).json({
enabled: false,
error: 'Image update detection is disabled for this node.',
});
return;
}
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
if (!triggered) {
const mins = ImageUpdateService.manualCooldownMinutes;
@@ -181,6 +188,26 @@ imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response
}
});
const EnabledPatchSchema = z.object({
enabled: z.boolean(),
});
imageUpdatesRouter.put('/enabled', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
const parsed = EnabledPatchSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
try {
const status = ImageUpdateService.getInstance().applyChecksEnabled(parsed.data.enabled);
res.json(status);
} catch (error) {
console.error('Failed to update image-update checks enabled:', error);
res.status(500).json({ error: 'Failed to update image update checks setting' });
}
});
imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
@@ -254,6 +281,7 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
const triggered: number[] = [];
const rateLimited: number[] = [];
const failed: number[] = [];
const disabled: number[] = [];
// ImageUpdateService is a per-instance singleton, so the local node's manual
// refresh fires at most once per request regardless of how many local rows
@@ -261,7 +289,9 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
const localNode = nodes.find(n => n.type === 'local');
if (localNode) {
try {
if (ImageUpdateService.getInstance().triggerManualRefresh()) {
if (!ImageUpdateService.isChecksEnabled()) {
disabled.push(localNode.id);
} else if (ImageUpdateService.getInstance().triggerManualRefresh()) {
triggered.push(localNode.id);
} else {
rateLimited.push(localNode.id);
@@ -306,6 +336,8 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
const { nodeId, status } = entry.value;
if (status >= 200 && status < 300) {
triggered.push(nodeId);
} else if (status === 409) {
disabled.push(nodeId);
} else if (status === 429) {
rateLimited.push(nodeId);
} else {
@@ -314,7 +346,7 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
}
invalidateFleetUpdateCache();
res.json({ triggered, rateLimited, failed });
res.json({ triggered, rateLimited, failed, disabled });
});
/**
@@ -327,6 +359,10 @@ export const autoUpdateRouter = Router();
autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
if (!ImageUpdateService.isChecksEnabled()) {
res.json({ result: 'Image update detection is disabled for this node; skipped.' });
return;
}
const { target } = req.body as { target?: string };
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target || '')}"`);
if (!target || typeof target !== 'string') {
+16 -1
View File
@@ -17,7 +17,11 @@ import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '
import DockerController, { type BulkStackInfo } from '../services/DockerController';
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
import { UpdatePreviewService, isAuthoritativeNegativePreview } from '../services/UpdatePreviewService';
import {
UpdatePreviewService,
isAuthoritativeNegativePreview,
buildDetectionDisabledPreview,
} from '../services/UpdatePreviewService';
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
@@ -2248,6 +2252,12 @@ stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Reque
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
// Anatomy and other GET consumers must not contact registries while
// node-scoped detection is off.
if (!ImageUpdateService.isChecksEnabled()) {
res.json(buildDetectionDisabledPreview(stackName));
return;
}
// Read-only: sticky reconciliation lives on POST so UpdateGuard and other
// GET consumers never mutate persisted scanner state.
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
@@ -2261,6 +2271,11 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
stacksRouter.post('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
if (!ImageUpdateService.isChecksEnabled()) {
// No registry I/O and no sticky reconcile on a synthetic disabled preview.
res.json({ ...buildDetectionDisabledPreview(stackName), reconciled: false });
return;
}
// Snapshot write-generation watermarks before the read-only preview so a
// later clear can erase older confirmed/sticky rows without racing a
// scanner that reserved or rewrote the row after this observation.
+9
View File
@@ -2022,6 +2022,9 @@ export class DatabaseService {
stmt.run('image_update_check_mode', 'interval');
stmt.run('image_update_check_cron', '');
stmt.run('image_update_sidebar_indicators', '1');
// Opt-out for background registry polling. Default on so upgrades keep
// current behavior; missing key is also treated as enabled at read time.
stmt.run('image_update_checks_enabled', '1');
stmt.run('notification_dispatch_retries', '0');
stmt.run('env_block_deploy_on_missing_required', '0');
stmt.run('auto_create_missing_external_networks', '0');
@@ -5005,6 +5008,12 @@ export class DatabaseService {
return result.changes;
}
/** Deletes every update row for a node. Returns deleted row count. */
public clearAllStackUpdateStatus(nodeId: number): number {
const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(nodeId);
return result.changes;
}
// --- Stack Scan Attempts ---
//
// Tracks the latest post-deploy scan attempt per (nodeId, stackName) so
+91 -8
View File
@@ -14,6 +14,7 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { buildEffectiveServiceModel } from './effectiveServiceModel';
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
const BACKFILL_KEY = 'image_update_notifications_backfilled';
@@ -75,6 +76,8 @@ export function normalizeImageCheckStatus(r: ImageCheckResult): PreviewImageChec
* `nextCheckAt` is meaningless while `checking` is true.
* `mode` is the active scheduling mode; `cronExpression` is the 5-field
* expression when mode is 'cron', null otherwise or when unconfigured.
* `enabled` is whether background image-update detection is armed; always
* present on current nodes, optional on the wire for older remotes.
*/
export interface ImageUpdateStatus {
checking: boolean;
@@ -86,6 +89,7 @@ export interface ImageUpdateStatus {
mode: 'interval' | 'cron';
cronExpression: string | null;
sidebarIndicators: boolean;
enabled: boolean;
}
// ─── Compose file helpers ────────────────────────────────────────────────────
@@ -410,6 +414,7 @@ export class ImageUpdateService {
private static readonly INTERVAL_SETTING_KEY = 'image_update_check_interval_minutes';
private static readonly MODE_SETTING_KEY = 'image_update_check_mode';
private static readonly CRON_SETTING_KEY = 'image_update_check_cron';
private static readonly ENABLED_SETTING_KEY = 'image_update_checks_enabled';
private static readonly JITTER_FRACTION = 0.1; // ±10% so a fleet does not poll in lockstep
private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot
@@ -452,8 +457,15 @@ export class ImageUpdateService {
public start() {
if (this.timer) return;
this.polling = true;
this.configureFromSettings();
if (!ImageUpdateService.isChecksEnabled()) {
// Detection opted out: stay stopped across restarts so a boot does
// not re-arm registry polling until the setting is turned back on.
this.polling = false;
this.nextCheckAt = null;
return;
}
this.polling = true;
// Interval mode keeps the 2-minute post-boot delay before the first check.
// Cron mode honors its schedule: arm at the next cron fire time so a restart
// never triggers an out-of-cadence check (e.g. a weekly cron must not run on
@@ -476,7 +488,8 @@ export class ImageUpdateService {
* cadence without restarting Sencho. Safe to call repeatedly: it always
* clears the existing timer first and only arms a new one while polling, so
* it never stacks timers and is a no-op (beyond reconfiguring intervalMs)
* when the service is stopped or was never started.
* when the service is stopped or was never started. When checks are
* disabled, clears nextCheckAt and does not arm.
*/
public restartPolling(): void {
this.scheduleGeneration++;
@@ -485,13 +498,65 @@ export class ImageUpdateService {
this.timer = null;
}
this.configureFromSettings();
if (this.polling) {
if (this.polling && ImageUpdateService.isChecksEnabled()) {
this.armNext(this.nextDelayMs());
} else {
this.nextCheckAt = null;
}
}
/**
* Whether background image-update detection is enabled. Missing or blank
* keys default to enabled so upgrades and pre-seed races keep polling.
*/
public static isChecksEnabled(): boolean {
try {
const raw = DatabaseService.getInstance().getGlobalSettings()[ImageUpdateService.ENABLED_SETTING_KEY];
if (raw == null || String(raw).trim() === '') return true;
return raw === '1';
} catch (e) {
console.warn('[ImageUpdateService] Could not read checks-enabled setting; treating as enabled:', getErrorMessage(e, String(e)));
return true;
}
}
/**
* Persist the checks-enabled setting and apply the live transition: stop
* + clear findings when turning off; start (or re-arm) when turning on.
* Safe under repeated toggles (scheduleGeneration bump via stop/start).
*/
public applyChecksEnabled(enabled: boolean): ImageUpdateStatus {
const db = DatabaseService.getInstance();
db.updateGlobalSetting(ImageUpdateService.ENABLED_SETTING_KEY, enabled ? '1' : '0');
if (!enabled) {
this.stop();
// Scanner only writes rows for local nodes. Use the local default
// node ID, not req.nodeId (which may be a remote active node).
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
db.clearAllStackUpdateStatus(localNodeId);
invalidateFleetUpdateCache();
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'image-updates',
nodeId: localNodeId,
action: 'checks-disabled',
ts: Date.now(),
});
return this.getStatus();
}
// Re-enable: arm a fresh schedule. start() is a no-op if a timer already
// exists; when we were fully stopped, start() arms. When somehow still
// marked polling without a timer, restartPolling re-arms.
if (!this.timer) {
this.start();
} else {
this.restartPolling();
}
return this.getStatus();
}
/**
* Reads image_update_check_interval_minutes into intervalMs, clamped to
* [15, 1440], falling back to the 2-hour default on a missing, blank,
@@ -600,11 +665,16 @@ export class ImageUpdateService {
}
/**
* Triggers a check immediately, unless one is already running or the
* manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
* Returns false if rate-limited, true if a check was started.
* Triggers a check immediately, unless detection is disabled, one is already
* running, or the manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
* Returns false if rate-limited or disabled, true if a check was started.
* Callers that need to distinguish disabled from rate-limited must check
* isChecksEnabled() first.
*/
public triggerManualRefresh(): boolean {
if (!ImageUpdateService.isChecksEnabled()) {
return false;
}
const now = Date.now();
if (now - this.lastManualRefreshAt < ImageUpdateService.MANUAL_COOLDOWN_MS) {
return false;
@@ -624,6 +694,7 @@ export class ImageUpdateService {
}
public getStatus(): ImageUpdateStatus {
const enabled = ImageUpdateService.isChecksEnabled();
let sidebarIndicators = false;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
@@ -632,21 +703,28 @@ export class ImageUpdateService {
console.warn('[ImageUpdateService] Failed to read sidebar indicator setting:', e);
}
return {
checking: this.isRunning,
checking: enabled ? this.isRunning : false,
intervalMinutes: Math.round(this.intervalMs / (60 * 1000)),
lastCheckedAt: this.lastCheckedAt,
nextCheckAt: this.nextCheckAt,
nextCheckAt: enabled ? this.nextCheckAt : null,
manualCooldownMinutes: ImageUpdateService.manualCooldownMinutes,
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
mode: this.mode,
cronExpression: this.cronExpression,
sidebarIndicators,
enabled,
};
}
// ─── Core check ──────────────────────────────────────────────────────────
private async check() {
if (!ImageUpdateService.isChecksEnabled()) {
if (isDebugEnabled()) {
console.log('[ImageUpdateService:debug] Checks disabled; skipping scan.');
}
return;
}
// The finally block is the sole owner of isRunning, so a scan that
// overruns can never have its lock released out from under it. A
// previous fixed timer cleared the lock after CHECK_TIMEOUT_MS, which
@@ -908,6 +986,11 @@ export class ImageUpdateService {
* left untouched and a verification_failed result is returned.
*/
public async recheckStack(nodeId: number, stackName: string): Promise<StackRecheckResult> {
// While detection is off, skip registry probes and do not write
// stack_update_status (avoids stale findings after re-enable).
if (!ImageUpdateService.isChecksEnabled()) {
return { outcome: 'cleared', warning: null };
}
const generation = this.reserveStackWriteGeneration(nodeId, stackName);
const db = DatabaseService.getInstance();
const docker = DockerController.getInstance(nodeId);
+4
View File
@@ -1038,6 +1038,10 @@ export class SchedulerService {
imageUpdateService: ImageUpdateService,
isWildcard = false
): Promise<string> {
if (!ImageUpdateService.isChecksEnabled()) {
console.log(`[SchedulerService] Stack "${stackName}": image update detection is disabled; skipped.`);
return `Stack "${stackName}": image update detection is disabled; skipped.`;
}
const containers = await docker.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
if (!isWildcard) {
+14 -3
View File
@@ -3,7 +3,8 @@ import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { ComposeDoctorService } from './ComposeDoctorService';
import { UpdatePreviewService, isMovingTag, filterPreviewForService } from './UpdatePreviewService';
import { UpdatePreviewService, isMovingTag, filterPreviewForService, buildDetectionDisabledPreview } from './UpdatePreviewService';
import { ImageUpdateService } from './ImageUpdateService';
import { buildEffectiveServiceModel, type EffectiveServiceModelResult } from './effectiveServiceModel';
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
import { withTimeout } from '../utils/withTimeout';
@@ -137,6 +138,12 @@ export class UpdateGuardService {
withTimeout(this.probeContainers(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness sibling probe'))
: Promise.resolve<ContainerProbe[] | Errored>([]),
this.collect('update preview', stackName, async () => {
// Check inside the thunk so the read stays with the getPreview call.
// Stack GET/POST update-preview use the same isChecksEnabled gate.
if (!ImageUpdateService.isChecksEnabled()) {
const disabled = buildDetectionDisabledPreview(stackName);
return serviceName ? filterPreviewForService(disabled, serviceName) : disabled;
}
const full = await withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness update preview');
return serviceName ? filterPreviewForService(full, serviceName) : full;
}),
@@ -218,8 +225,12 @@ export class UpdateGuardService {
this.collect('backup info', stackName, () => fsSvc.getBackupInfo(stackName)),
this.collect('backup env summary', stackName, () => fsSvc.getBackupEnvSummary(stackName)),
this.collect('stack env presence', stackName, () => fsSvc.envExists(stackName)),
this.collect('update preview', stackName, () =>
withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness update preview')),
this.collect('update preview', stackName, async () => {
if (!ImageUpdateService.isChecksEnabled()) {
return buildDetectionDisabledPreview(stackName);
}
return withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness update preview');
}),
this.collect('activity history', stackName, async () => {
const events = db.getStackActivity(nodeId, stackName, { limit: 50 });
// A successful update is as good a known-good marker as a deploy.
@@ -111,6 +111,11 @@ export interface UpdatePreviewSummary {
verification_failed: boolean;
/** First image check_error when verification_failed; otherwise null. */
verification_error: string | null;
/**
* When true, background detection is disabled on this node and the preview
* was not fetched from registries. Optional for older remotes.
*/
detection_disabled?: boolean;
}
export interface UpdatePreview {
@@ -362,6 +367,33 @@ export function filterPreviewForService(preview: UpdatePreview, serviceName: str
return buildSummary(preview.stack_name, images, buildServices);
}
/** Minimal preview when node-scoped image update detection is disabled. */
export function buildDetectionDisabledPreview(stackName: string): UpdatePreview {
return {
stack_name: stackName,
images: [],
build_services: [],
summary: {
has_update: false,
primary_image: null,
current_tag: null,
next_tag: null,
semver_bump: 'none',
update_kind: 'none',
blocked: false,
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
check_status: 'ok',
verification_failed: false,
verification_error: null,
detection_disabled: true,
},
rollback_target: null,
changelog: null,
};
}
export class UpdatePreviewService {
private static instance: UpdatePreviewService;
@@ -134,6 +134,14 @@ export function updatePreviewSignal(input: UpdatePreviewSummary | Errored, image
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The update preview is unavailable.' };
}
if (input.detection_disabled) {
return {
...base,
status: 'unknown',
affectsVerdict: false,
detail: 'Image update detection is disabled for this node.',
};
}
if (input.blocked) {
return {
...base,