fix(image-updates): explain persistent digest rebuilds after update (#1784)

* fix(image-updates): explain persistent digest rebuilds after update

When an update completes but a same-tag digest rebuild is still detected,
the generic "update still detected" warning told operators nothing about
why. The digest comparison already knows the remaining updates are
digest-only (no higher tag), so recheckStack now returns a targeted
warning naming the two daemon-side causes: a registry mirror or cache
serving stale content, or a container still pinned to the previous image.

The digest-rebuild badge surfaces (Anatomy banner, Fleet cards, mobile)
now carry a tooltip with the same explanation, and the post-update
warning is added to the pre-update refresh sanitization set.

* fix(image-updates): surface digest warnings on editor and mobile paths

Editor Update discarded recheckWarning, digest hints were hover-only, and
service-scoped rechecks blamed the daemon when only sibling services remained stale.
This commit is contained in:
Anso
2026-08-06 09:22:53 -04:00
committed by GitHub
parent 575848e017
commit 4fa532530e
13 changed files with 346 additions and 34 deletions
@@ -153,7 +153,12 @@ vi.mock('../services/registry-api', async (importOriginal) => {
// For this test we re-implement the function signatures to test via the
// public checkImage method (which calls parseImageRef internally).
import { ImageUpdateService } from '../services/ImageUpdateService';
import {
ImageUpdateService,
UPDATE_DIGEST_UNCHANGED_WARNING,
UPDATE_STILL_PRESENT_WARNING,
otherServicesStillPresentWarning,
} from '../services/ImageUpdateService';
import YAML from 'yaml';
// ── parseImageRef (tested indirectly via checkImage) ──────────────────
@@ -1807,6 +1812,136 @@ services:
);
});
it('returns the digest-unchanged warning when every still-present update is a same-tag digest rebuild', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
services: [specFor('web', 'web:latest')],
});
mockGetAllContainers.mockResolvedValue([
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
]);
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockResolvedValue({
hasUpdate: true,
digestUpdate: true,
tagUpdate: false,
checkStatus: 'ok',
});
const result = await service.recheckStack(1, 'stackA');
expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_DIGEST_UNCHANGED_WARNING });
});
it('names sibling services when a service-scoped recheck cleared the target but siblings remain', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
services: [specFor('web', 'web:latest'), specFor('worker', 'worker:latest')],
});
mockGetAllContainers.mockResolvedValue([
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
{ Id: 'c2', Image: 'worker:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'worker' } },
]);
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockImplementation(async (_docker: unknown, ref: string) => (
ref === 'worker:latest'
? { hasUpdate: true, digestUpdate: true, tagUpdate: false, checkStatus: 'ok' }
: { hasUpdate: false, checkStatus: 'ok' }
));
const result = await service.recheckStack(1, 'stackA', { updatedService: 'web' });
expect(result).toEqual({
outcome: 'still_present',
warning: otherServicesStillPresentWarning('web', ['worker']),
});
expect(result.warning).not.toBe(UPDATE_DIGEST_UNCHANGED_WARNING);
});
it('keeps the digest-unchanged warning when the updated service itself is still digest-stale', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
services: [specFor('web', 'web:latest'), specFor('worker', 'worker:latest')],
});
mockGetAllContainers.mockResolvedValue([
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
{ Id: 'c2', Image: 'worker:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'worker' } },
]);
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockImplementation(async (_docker: unknown, ref: string) => (
ref === 'web:latest'
? { hasUpdate: true, digestUpdate: true, tagUpdate: false, checkStatus: 'ok' }
: { hasUpdate: false, checkStatus: 'ok' }
));
const result = await service.recheckStack(1, 'stackA', { updatedService: 'web' });
expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_DIGEST_UNCHANGED_WARNING });
});
it('returns the generic warning when one still-present update is a digest rebuild and another is a tag bump', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
services: [specFor('web', 'web:latest'), specFor('worker', 'worker:latest')],
});
mockGetAllContainers.mockResolvedValue([
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
{ Id: 'c2', Image: 'worker:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'worker' } },
]);
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockImplementation(async (_docker: unknown, ref: string) => (
ref === 'web:latest'
? { hasUpdate: true, digestUpdate: true, tagUpdate: false, checkStatus: 'ok' }
: { hasUpdate: true, digestUpdate: false, tagUpdate: true, checkStatus: 'ok' }
));
const result = await service.recheckStack(1, 'stackA');
expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_STILL_PRESENT_WARNING });
});
it('returns the generic warning when a single image has both a digest drift and a newer tag', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
services: [specFor('web', 'web:latest')],
});
mockGetAllContainers.mockResolvedValue([
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
]);
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockResolvedValue({
hasUpdate: true,
digestUpdate: true,
tagUpdate: true,
checkStatus: 'ok',
});
const result = await service.recheckStack(1, 'stackA');
expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_STILL_PRESENT_WARNING });
});
it('returns the generic warning when the still-present update is a tag bump, not a digest-only rebuild', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
services: [specFor('web', 'web:latest')],
});
mockGetAllContainers.mockResolvedValue([
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
]);
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockResolvedValue({
hasUpdate: true,
digestUpdate: false,
tagUpdate: true,
checkStatus: 'ok',
});
const result = await service.recheckStack(1, 'stackA');
expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_STILL_PRESENT_WARNING });
});
it('returns cleared when every checkable service is up to date', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
@@ -6,6 +6,7 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { UPDATE_DIGEST_UNCHANGED_WARNING } from '../services/ImageUpdateService';
const {
mockExecute,
@@ -166,6 +167,24 @@ describe('POST /api/stacks/:name/update post-compose verification', () => {
);
});
it('surfaces the digest-unchanged warning when the image digest did not move after update', async () => {
mockRecheckStack.mockImplementation(async () => {
callOrder.push('recheckStack');
return {
outcome: 'still_present',
warning: UPDATE_DIGEST_UNCHANGED_WARNING,
};
});
const res = await request(app)
.post('/api/stacks/web/update')
.set('Cookie', authCookie)
.send({ skip_scan: true });
expect(res.status).toBe(200);
expect(res.body.recheckWarning).toBe(UPDATE_DIGEST_UNCHANGED_WARNING);
});
it('keeps HTTP 200 and success notification when recheck throws after Compose', async () => {
mockRecheckStack.mockImplementation(async () => {
callOrder.push('recheckStack');