mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +00:00
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:
@@ -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!;
|
||||
|
||||
Reference in New Issue
Block a user