mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
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:
@@ -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');
|
||||
|
||||
@@ -37,6 +37,27 @@ export const UPDATE_STILL_PRESENT_WARNING =
|
||||
export const UPDATE_VERIFICATION_INCOMPLETE_WARNING =
|
||||
'The update command completed, but Sencho could not fully verify whether an image update remains.';
|
||||
|
||||
// Mirrored verbatim in GENERIC_POST_UPDATE_WARNINGS in
|
||||
// frontend/src/components/EditorLayout/hooks/useStackActions.ts; keep the copy in sync.
|
||||
export const UPDATE_DIGEST_UNCHANGED_WARNING =
|
||||
'The update command completed, but the image digest was not updated. Your Docker daemon may cache older content through a registry mirror, or the container may still be pinned to the previous image. Check your daemon configuration or recreate the container with --force-recreate.';
|
||||
|
||||
/** Warning when a service-scoped update cleared the target but siblings still need updates. */
|
||||
export function otherServicesStillPresentWarning(updatedService: string, otherServices: string[]): string {
|
||||
return `The update for "${updatedService}" completed, but Sencho still detects an available image update on ${formatServiceList(otherServices)}.`;
|
||||
}
|
||||
|
||||
function formatServiceList(names: string[]): string {
|
||||
if (names.length <= 1) return names[0] ?? '';
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
export interface RecheckStackOptions {
|
||||
/** When set after a service-scoped update, attribution prefers siblings over daemon blame. */
|
||||
updatedService?: string;
|
||||
}
|
||||
|
||||
export interface ImageCheckResult {
|
||||
hasUpdate: boolean;
|
||||
/** Same-tag registry digest drift; Compose pull can apply without pin change. */
|
||||
@@ -1026,7 +1047,11 @@ export class ImageUpdateService {
|
||||
* after a manual full-stack update. On a render failure the prior row is
|
||||
* left untouched and a verification_failed result is returned.
|
||||
*/
|
||||
public async recheckStack(nodeId: number, stackName: string): Promise<StackRecheckResult> {
|
||||
public async recheckStack(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
options?: RecheckStackOptions,
|
||||
): 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()) {
|
||||
@@ -1092,6 +1117,16 @@ export class ImageUpdateService {
|
||||
const services = reductions.map((r) => r.status);
|
||||
const checkStatus = aggregateServiceCheckStatus(services);
|
||||
const hasUpdate = services.some((s) => s.hasUpdate);
|
||||
// Every still-present update being a same-tag digest rebuild (no higher
|
||||
// semver tag) signals the local content for that image did not move: the
|
||||
// daemon may serve a cached/mirrored manifest, or the container may still
|
||||
// run the previous image. After a service-scoped update, prefer naming
|
||||
// sibling services that still need work over blaming the daemon for the
|
||||
// service that was just updated.
|
||||
const updatingEntries = [...imageUpdateMap.values()]
|
||||
.filter((r) => r.hasUpdate && normalizeImageCheckStatus(r) !== 'not_checkable');
|
||||
const allDigestOnly = hasUpdate && updatingEntries.length > 0
|
||||
&& updatingEntries.every((r) => r.digestUpdate === true && r.tagUpdate !== true);
|
||||
const lastError = stackStatusLastError(services);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1117,9 +1152,20 @@ export class ImageUpdateService {
|
||||
};
|
||||
}
|
||||
if (hasUpdate) {
|
||||
const updatedService = options?.updatedService;
|
||||
if (updatedService) {
|
||||
const staleNames = services.filter((s) => s.hasUpdate).map((s) => s.service);
|
||||
const siblings = staleNames.filter((name) => name !== updatedService).sort();
|
||||
if (!staleNames.includes(updatedService) && siblings.length > 0) {
|
||||
return {
|
||||
outcome: 'still_present',
|
||||
warning: otherServicesStillPresentWarning(updatedService, siblings),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: 'still_present',
|
||||
warning: UPDATE_STILL_PRESENT_WARNING,
|
||||
warning: allDigestOnly ? UPDATE_DIGEST_UNCHANGED_WARNING : UPDATE_STILL_PRESENT_WARNING,
|
||||
};
|
||||
}
|
||||
return { outcome: 'cleared', warning: null };
|
||||
|
||||
@@ -288,7 +288,9 @@ export class StackUpdateOrchestrator {
|
||||
);
|
||||
}
|
||||
}
|
||||
const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName);
|
||||
const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName, {
|
||||
updatedService: serviceName,
|
||||
});
|
||||
await DriftLedgerService.getInstance().reconcileServiceForStack(nodeId, stackName, serviceName);
|
||||
await this.refreshMeshIfEnabled(nodeId, stackName);
|
||||
|
||||
@@ -434,7 +436,9 @@ export class StackUpdateOrchestrator {
|
||||
consumed = true;
|
||||
}
|
||||
|
||||
const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName);
|
||||
const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName, {
|
||||
updatedService: serviceName,
|
||||
});
|
||||
await this.refreshMeshIfEnabled(nodeId, stackName);
|
||||
|
||||
const warnings = [observed.gateWarning, recheck.warning].filter((w): w is string => !!w);
|
||||
|
||||
Reference in New Issue
Block a user