mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +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!;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user