feat: make image-update check cadence configurable and visible (#1377)

* feat: make image-update check cadence configurable and visible

The background image-update scanner polled registries on a hardcoded
6-hour interval, with no way to see when it last ran or when the next
run was due. Operators testing updates read this as auto-update being
unreliable: a manual update checks the registry immediately and applies,
so the slow background scan rarely raised the "update available"
notification before the stack was already current.

Backend:
- ImageUpdateService reads image_update_check_interval_minutes (15-1440,
  default 120) and drives a single generation-guarded self-rescheduling
  timer with 10% per-run jitter so fleet nodes do not poll in lockstep.
  restartPolling() applies a new interval live, with no restart, and
  cannot leave a duplicate timer when a save lands mid-scan.
- GET /api/image-updates/status now returns checking, intervalMinutes,
  lastCheckedAt, nextCheckAt, and the manual-cooldown fields. New
  admin-only PUT /api/image-updates/interval persists the setting and
  reschedules.

Frontend:
- New Settings > Automation > Image update checks section to choose the
  interval (read-only for non-admins; admin enforced on the backend).
- The Auto-Update readiness view shows last-checked, next-check, and a
  ticking manual-recheck cooldown, and the copy distinguishes registry
  detection from scheduled auto-update execution.

Adds backend unit and route tests and frontend component tests, and
updates the auto-update documentation.

* fix: drop stale image-update status response in the readiness strip

loadCadence() ran on mount and again after a Recheck with no request
token, so a slow initial /image-updates/status response could resolve
after the recheck-triggered one and overwrite the fresh cooldown with
stale data, or set state after the view unmounted. Guard setCadence with
a monotonic token mirroring loadReadiness, and bump it on unmount. Adds a
regression test for the out-of-order resolution.
This commit is contained in:
Anso
2026-06-15 20:06:13 -04:00
committed by GitHub
parent 02c3b006eb
commit 058cf8f2c7
16 changed files with 890 additions and 29 deletions
@@ -2,7 +2,7 @@
* Unit tests for ImageUpdateService: image ref parsing, compose extraction,
* env file loading, checkImage digest comparison, and rate limiting.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Hoisted mocks ──────────────────────────────────────────────────────
@@ -12,7 +12,7 @@ const {
mockGetSystemState, mockSetSystemState, mockAddNotificationHistory,
mockDispatchAlert,
mockGetStacks, mockGetStackContent, mockGetEnvContent, mockEnvExists,
mockGetAllContainers,
mockGetAllContainers, mockGetGlobalSettings,
} = vi.hoisted(() => ({
mockGetAuthForRegistry: vi.fn().mockResolvedValue(null),
mockGetStackUpdateStatus: vi.fn().mockReturnValue({}),
@@ -27,6 +27,7 @@ const {
mockGetEnvContent: vi.fn().mockRejectedValue(new Error('no env')),
mockEnvExists: vi.fn().mockResolvedValue(false),
mockGetAllContainers: vi.fn().mockResolvedValue([]),
mockGetGlobalSettings: vi.fn().mockReturnValue({ developer_mode: '0' }),
}));
vi.mock('../services/RegistryService', () => ({
@@ -40,7 +41,7 @@ vi.mock('../services/RegistryService', () => ({
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getGlobalSettings: () => ({ developer_mode: '0' }),
getGlobalSettings: mockGetGlobalSettings,
getNodes: () => [],
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
getStackUpdateStatus: mockGetStackUpdateStatus,
@@ -696,6 +697,177 @@ describe('ImageUpdateService - stop() cancels startup timeout', () => {
});
});
// ── Configurable interval, status, and reschedule ───────────────────────
describe('ImageUpdateService - configurable interval & status', () => {
beforeEach(() => {
vi.clearAllMocks();
(ImageUpdateService as any).instance = undefined;
mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' });
});
afterEach(() => {
vi.useRealTimers();
});
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => { resolve = r; });
return { promise, resolve };
}
it('reports the default 120-minute interval before start() runs', () => {
const service = ImageUpdateService.getInstance();
const status = service.getStatus();
expect(status.intervalMinutes).toBe(120);
expect(status.checking).toBe(false);
expect(status.lastCheckedAt).toBeNull();
expect(status.nextCheckAt).toBeNull();
expect(status.manualCooldownMinutes).toBe(2);
expect(status.manualCooldownRemainingMs).toBe(0);
});
it('reads the configured interval from settings', () => {
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '30' });
const service = ImageUpdateService.getInstance();
service.configureFromSettings();
expect(service.getStatus().intervalMinutes).toBe(30);
});
it('clamps an interval below the minimum to 15', () => {
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '5' });
const service = ImageUpdateService.getInstance();
service.configureFromSettings();
expect(service.getStatus().intervalMinutes).toBe(15);
});
it('clamps an interval above the maximum to 1440', () => {
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '5000' });
const service = ImageUpdateService.getInstance();
service.configureFromSettings();
expect(service.getStatus().intervalMinutes).toBe(1440);
});
it('falls back to the default for a malformed or non-integer value', () => {
const service = ImageUpdateService.getInstance();
const badValues: (string | undefined)[] = ['15abc', '30.5', '', undefined];
for (const bad of badValues) {
mockGetGlobalSettings.mockReturnValue(bad === undefined ? {} : { image_update_check_interval_minutes: bad });
service.configureFromSettings();
expect(service.getStatus().intervalMinutes).toBe(120);
}
});
it('stamps lastCheckedAt when a manual refresh runs', async () => {
const service = ImageUpdateService.getInstance();
// getNodes() returns [] in the shared mock, so check() completes immediately.
expect(service.getStatus().lastCheckedAt).toBeNull();
const triggered = service.triggerManualRefresh();
expect(triggered).toBe(true);
await Promise.resolve();
await Promise.resolve();
expect(service.getStatus().lastCheckedAt).not.toBeNull();
});
it('applies ±10% jitter that actually reaches both endpoints', () => {
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '60' });
const service = ImageUpdateService.getInstance();
service.configureFromSettings();
const interval = 60 * 60 * 1000;
// random=0 must reach the low edge (90%), proving jitter is applied and not
// collapsed to the bare interval.
const low = vi.spyOn(Math, 'random').mockReturnValue(0);
expect((service as any).nextDelayMs()).toBe(Math.round(interval * 0.9));
low.mockRestore();
const mid = vi.spyOn(Math, 'random').mockReturnValue(0.5);
expect((service as any).nextDelayMs()).toBe(interval);
mid.mockRestore();
// random→1 must reach the high edge (≈110%).
const high = vi.spyOn(Math, 'random').mockReturnValue(0.999);
const hi = (service as any).nextDelayMs() as number;
expect(hi).toBeGreaterThan(interval);
expect(hi).toBeGreaterThanOrEqual(Math.round(interval * 1.09));
expect(hi).toBeLessThanOrEqual(Math.round(interval * 1.1));
high.mockRestore();
});
it('reports the manual-refresh cooldown remaining and clears it after the window', () => {
vi.useFakeTimers();
const service = ImageUpdateService.getInstance();
expect(service.getManualCooldownRemainingMs()).toBe(0);
service.triggerManualRefresh();
const remaining = service.getManualCooldownRemainingMs();
expect(remaining).toBeGreaterThan(0);
expect(remaining).toBeLessThanOrEqual(2 * 60 * 1000);
vi.advanceTimersByTime(2 * 60 * 1000);
expect(service.getManualCooldownRemainingMs()).toBe(0);
});
it('stop() after start() clears the timer and nulls nextCheckAt without firing a check', () => {
vi.useFakeTimers();
const service = ImageUpdateService.getInstance();
const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined);
service.start();
expect(service.getStatus().nextCheckAt).not.toBeNull();
expect(vi.getTimerCount()).toBe(1);
service.stop();
expect(vi.getTimerCount()).toBe(0);
expect(service.getStatus().nextCheckAt).toBeNull();
// Past the old startup delay: the cleared timer + bumped generation mean no
// check fires on a stopped service.
vi.advanceTimersByTime(5 * 60 * 1000);
expect(checkSpy).not.toHaveBeenCalled();
checkSpy.mockRestore();
});
it('restartPolling() while stopped reconfigures the interval but arms no timer', () => {
vi.useFakeTimers();
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '45' });
const service = ImageUpdateService.getInstance();
// Never started: polling is false, so it reconfigures without arming.
service.restartPolling();
expect(service.getStatus().intervalMinutes).toBe(45);
expect(service.getStatus().nextCheckAt).toBeNull();
expect((service as any).timer).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('restartPolling() during an in-flight tick leaves exactly one timer', async () => {
vi.useFakeTimers();
const service = ImageUpdateService.getInstance();
const d = deferred();
const checkSpy = vi.spyOn(service as any, 'check').mockReturnValue(d.promise);
service.start();
expect(vi.getTimerCount()).toBe(1);
// Fire the startup tick: it invokes check() (our pending deferred) and does
// not re-arm until check resolves.
vi.advanceTimersByTime(2 * 60 * 1000);
expect(checkSpy).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
// A settings save lands mid-scan: it arms a fresh timer.
service.restartPolling();
expect(vi.getTimerCount()).toBe(1);
// The original tick resolves; its generation is now stale, so it must not
// re-arm a second timer.
d.resolve();
await d.promise;
await Promise.resolve();
expect(vi.getTimerCount()).toBe(1);
service.stop();
checkSpy.mockRestore();
});
});
// ── Stale stack pruning ─────────────────────────────────────────────────
describe('ImageUpdateService - stale stack pruning', () => {
@@ -72,10 +72,55 @@ describe('GET /api/image-updates/status', () => {
expect(res.status).toBe(401);
});
it('returns a checking flag', async () => {
it('returns the enriched status payload', async () => {
const res = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(typeof res.body.checking).toBe('boolean');
// start() never runs in route tests, so the interval reflects the seeded
// default (120) via the field initializer rather than NaN.
expect(res.body.intervalMinutes).toBe(120);
expect(res.body.manualCooldownMinutes).toBe(2);
expect(typeof res.body.manualCooldownRemainingMs).toBe('number');
expect('lastCheckedAt' in res.body).toBe(true);
expect('nextCheckAt' in res.body).toBe(true);
});
});
describe('PUT /api/image-updates/interval', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).put('/api/image-updates/interval').send({ minutes: 30 });
expect(res.status).toBe(401);
});
it('rejects non-admin users with 403', async () => {
const res = await request(app).put('/api/image-updates/interval').set('Cookie', viewerCookie).send({ minutes: 30 });
expect(res.status).toBe(403);
});
it('rejects an interval below the minimum', async () => {
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 5 });
expect(res.status).toBe(400);
});
it('rejects an interval above the maximum', async () => {
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 5000 });
expect(res.status).toBe(400);
});
it('rejects a non-integer interval', async () => {
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 'soon' });
expect(res.status).toBe(400);
});
it('persists a valid interval and returns the enriched status', async () => {
const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 30 });
expect(res.status).toBe(200);
expect(res.body.intervalMinutes).toBe(30);
// The value is persisted to global_settings...
expect(DatabaseService.getInstance().getGlobalSettings().image_update_check_interval_minutes).toBe('30');
// ...and a follow-up status read reflects the rescheduled cadence.
const statusRes = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie);
expect(statusRes.body.intervalMinutes).toBe(30);
});
});