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!;