diff --git a/backend/src/__tests__/image-update-service.test.ts b/backend/src/__tests__/image-update-service.test.ts index 19aa4826..302dcf40 100644 --- a/backend/src/__tests__/image-update-service.test.ts +++ b/backend/src/__tests__/image-update-service.test.ts @@ -110,11 +110,21 @@ vi.mock('../services/NodeRegistry', () => ({ })); // compareLocalToRemoteTag is module-scoped inside checkImage; mock it to drive the -// comparison outcome while keeping the real parseImageRef / selectLocalRepoDigest. -const { mockCompareLocalToRemoteTag } = vi.hoisted(() => ({ mockCompareLocalToRemoteTag: vi.fn() })); +// comparison outcome while keeping the real parseImageRef / selectLocalRepoDigests. +const { mockCompareLocalToRemoteTag, mockListRegistryTagsResult } = vi.hoisted(() => ({ + mockCompareLocalToRemoteTag: vi.fn(), + // Detection now checks tags alongside digests; default to an empty, successful + // list so digest-focused tests are not accidentally driven by a real network + // call finding a genuine newer tag for whatever image ref they pass. + mockListRegistryTagsResult: vi.fn().mockResolvedValue({ ok: true, tags: [] }), +})); vi.mock('../services/registry-api', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, compareLocalToRemoteTag: mockCompareLocalToRemoteTag }; + return { + ...actual, + compareLocalToRemoteTag: mockCompareLocalToRemoteTag, + listRegistryTagsResult: mockListRegistryTagsResult, + }; }); // ── Re-export internal helpers via the module ───────────────────────── @@ -207,6 +217,17 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => { expect(result.notCheckable).toBeUndefined(); expect(result.error).toContain('Could not resolve a local registry digest'); }); + + it('errors (not a comparison) when the sole valid RepoDigest belongs to an unrelated repository', async () => { + // A well-formed digest is present, but it names a different repo (e.g. + // left over from a retag): comparing it against this ref's registry state + // would risk a false update against unrelated content. + const docker = makeMockDocker([`ghcr.io/other/image@sha256:${'b'.repeat(64)}`]); + const result = await service.checkImage(docker, 'nginx:latest'); + expect(result.hasUpdate).toBe(false); + expect(result.notCheckable).toBeUndefined(); + expect(result.error).toContain('Could not resolve a local registry digest'); + }); }); // ── checkImage surfaces the comparison resolver's outcome ────────────── @@ -264,6 +285,36 @@ describe('ImageUpdateService - checkImage surfaces the comparison resolver outco null, ); }); + + it('forwards every matching RepoDigest (stale index ahead of current) to the comparison resolver', async () => { + const STALE = `sha256:${'f'.repeat(64)}`; + const CURRENT = `sha256:${'e'.repeat(64)}`; + const docker = { + getDocker: () => ({ + getImage: () => ({ + inspect: vi.fn().mockResolvedValue({ + RepoDigests: [ + `redis@${STALE}`, + `redis@${CURRENT}`, + ], + Os: 'linux', + Architecture: 'amd64', + }), + }), + }), + } as any; + mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' }); + const result = await service.checkImage(docker, 'redis:8.8.0'); + expect(result).toEqual({ hasUpdate: false, digestUpdate: false, tagUpdate: false, checkStatus: 'ok' }); + expect(mockCompareLocalToRemoteTag).toHaveBeenCalledWith( + [STALE, CURRENT], + 'registry-1.docker.io', + 'library/redis', + '8.8.0', + { os: 'linux', architecture: 'amd64' }, + null, + ); + }); }); // ── Multi-arch digest comparison persistence (end-to-end via checkNode) ─ diff --git a/backend/src/__tests__/registry-api.test.ts b/backend/src/__tests__/registry-api.test.ts index 7ac9b69d..11c5ac0e 100644 --- a/backend/src/__tests__/registry-api.test.ts +++ b/backend/src/__tests__/registry-api.test.ts @@ -58,6 +58,7 @@ import { getRemoteDigest, getRemoteDigestResult, getAuthToken, + listRegistryTags, listRegistryTagsResult, parseImageRef, selectLocalRepoDigest, @@ -359,6 +360,31 @@ describe('listRegistryTagsResult', () => { }); }); +describe('listRegistryTags (compatibility wrapper)', () => { + beforeEach(() => { + calls.length = 0; + }); + + it('lists tags for a public Docker Hub repository with no credentials configured', async () => { + route = (url) => tokenOk(url) ?? ( + url.includes('/tags/list') + ? { statusCode: 200, headers: {}, body: JSON.stringify({ tags: ['8.7.0', '8.8.0'] }) } + : { statusCode: 500, headers: {} } + ); + await expect(listRegistryTags('registry-1.docker.io', 'library/redis', null)).resolves.toEqual(['8.7.0', '8.8.0']); + }); + + it('still returns empty for a registry that actually requires credentials the caller does not have', async () => { + const CHALLENGE = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"'; + route = (url): FakeResp => { + if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': CHALLENGE } }; + if (url.startsWith('https://ghcr.io/token')) return { statusCode: 401, headers: {} }; + return { statusCode: 500, headers: {} }; + }; + await expect(listRegistryTags('ghcr.io', 'private/app', undefined)).resolves.toEqual([]); + }); +}); + // ─── selectLocalRepoDigest ─────────────────────────────────────────────── describe('selectLocalRepoDigest', () => { @@ -375,9 +401,9 @@ describe('selectLocalRepoDigest', () => { expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBe(DIGEST_A); }); - it('falls back to the sole valid entry when nothing matches the ref', () => { + it('returns null when the sole valid entry belongs to an unrelated repository, rather than guessing', () => { const repoDigests = [`ghcr.io/other/image@${DIGEST_A}`]; - expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBe(DIGEST_A); + expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBeNull(); }); it('returns null when multiple valid entries exist and none matches the ref', () => { @@ -408,6 +434,7 @@ describe('selectLocalRepoDigest', () => { }); }); +// ─── selectLocalRepoDigests ────────────────────────────────────────────── describe('selectLocalRepoDigests', () => { const parsed = (ref: string) => { @@ -417,6 +444,43 @@ describe('selectLocalRepoDigests', () => { }; const DIGEST_A = `sha256:${'a'.repeat(64)}`; const DIGEST_B = `sha256:${'b'.repeat(64)}`; + const DIGEST_C = `sha256:${'c'.repeat(64)}`; + + it('returns every matching entry in first-seen order', () => { + const repoDigests = [`redis@${DIGEST_A}`, `nginx@${DIGEST_B}`, `redis@${DIGEST_C}`]; + expect(selectLocalRepoDigests(repoDigests, parsed('redis:8.8.0'))).toEqual([DIGEST_A, DIGEST_C]); + }); + + it('deduplicates matching digests while preserving first-seen order', () => { + const repoDigests = [`redis@${DIGEST_A}`, `redis@${DIGEST_B}`, `redis@${DIGEST_A}`]; + expect(selectLocalRepoDigests(repoDigests, parsed('redis:latest'))).toEqual([DIGEST_A, DIGEST_B]); + }); + + it('deduplicates case-insensitively on hex digits', () => { + const upper = `sha256:${'A'.repeat(64)}`; + const lower = `sha256:${'a'.repeat(64)}`; + expect(selectLocalRepoDigests([`nginx@${upper}`, `nginx@${lower}`], parsed('nginx:latest'))).toEqual([upper]); + }); + + it('filters malformed entries and keeps valid matches', () => { + const repoDigests = ['redis@sha256:tooshort', `redis@${DIGEST_A}`, 'redis:latest', `redis@${DIGEST_B}`]; + expect(selectLocalRepoDigests(repoDigests, parsed('redis:latest'))).toEqual([DIGEST_A, DIGEST_B]); + }); + + it('returns empty when the sole valid entry belongs to an unrelated repository, rather than guessing', () => { + // A legitimate retag can leave a lone RepoDigest from a different repo, but + // comparing it against this ref's registry state risks a false update + // against a registry that has nothing to do with the declared image. + expect(selectLocalRepoDigests([`ghcr.io/other/image@${DIGEST_A}`], parsed('nginx:latest'))).toEqual([]); + }); + + it('returns empty when multiple valid entries exist and none matches the ref', () => { + expect(selectLocalRepoDigests([`redis@${DIGEST_A}`, `postgres@${DIGEST_B}`], parsed('nginx:latest'))).toEqual([]); + }); + + it('returns empty for an empty list', () => { + expect(selectLocalRepoDigests([], parsed('nginx:latest'))).toEqual([]); + }); it('returns every matching RepoDigest for the image ref', () => { const digests = selectLocalRepoDigests([ @@ -545,6 +609,215 @@ describe('compareLocalToRemoteTag', () => { ]); }); + // #1684: Docker can list a stale index digest ahead of the current one. + const STALE_INDEX = `sha256:${'f'.repeat(64)}`; + + it('matches when a later candidate equals the primary even if the first candidate is a stale index', async () => { + route = (url, method) => tokenOk(url) ?? ( + method === 'HEAD' + ? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } } + : { statusCode: 500, headers: {} } + ); + const result = await compareLocalToRemoteTag([STALE_INDEX, INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1); + }); + + it('matches when the current primary is first among multiple candidates', async () => { + route = (url, method) => tokenOk(url) ?? ( + method === 'HEAD' + ? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } } + : { statusCode: 500, headers: {} } + ); + const result = await compareLocalToRemoteTag([INDEX_DIGEST, STALE_INDEX], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + }); + + it('expands the index once when a later candidate matches a platform child', async () => { + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag([STALE_INDEX, CHILD_AMD64], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([ + { url: MANIFEST_URL_TAG, method: 'HEAD' }, + { url: manifestDigestUrl(INDEX_DIGEST), method: 'GET' }, + ]); + }); + + it('reports update when every candidate is stale after a complete index classification', async () => { + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'update' }); + }); + + it('errors (not update) when the remote index has no descriptor at all for the local platform', async () => { + // STANDARD_INDEX_BODY only carries linux/amd64 and linux/arm64 children. + // A windows/amd64 node cannot pull anything from this index; that is not + // the same fact as "a newer build is available". + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, { os: 'windows', architecture: 'amd64' }); + expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no windows/amd64 variant') }); + }); + + it('errors (not update) for an empty index with no manifests, rather than a speculative update', async () => { + const emptyIndexBody = indexBody([]); + const emptyIndexDigest = contentDigest(emptyIndexBody); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': emptyIndexDigest, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(emptyIndexDigest) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': emptyIndexDigest }, body: emptyIndexBody }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + }); + + it('errors (not update) for an index whose only entries are filtered out (attestation-only), not just a literally empty one', async () => { + // Exercises the filtering path (parseIndexBody's attestation-manifest and + // unknown/unknown continues), distinct from manifests:[] never entering + // the per-entry loop at all. + const filteredIndexBody = indexBody([ + { + digest: CHILD_AMD64, os: 'unknown', architecture: 'unknown', + annotations: { 'vnd.docker.reference.type': 'attestation-manifest', 'vnd.docker.reference.digest': CHILD_ARM64 }, + }, + ]); + const filteredIndexDigest = contentDigest(filteredIndexBody); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': filteredIndexDigest, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(filteredIndexDigest) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': filteredIndexDigest }, body: filteredIndexBody }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no linux/amd64 variant') }); + }); + + it('matches (not errors) a platform-less runnable descriptor even though no platform-labeled descriptor exists', async () => { + // OCI allows a runnable descriptor to omit platform; parseIndexBody routes + // it to exactDigests. The index has real, pullable content, so this must + // not be confused with the "nothing to pull" case. + const platformlessDigest = `sha256:${'7'.repeat(64)}`; + const noPlatformBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [{ digest: platformlessDigest, mediaType: 'application/vnd.oci.image.manifest.v1+json' }], + }); + const noPlatformDigest = contentDigest(noPlatformBody); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': noPlatformDigest, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(noPlatformDigest) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': noPlatformDigest }, body: noPlatformBody }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag([platformlessDigest], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + }); + + it('errors (not update) for a mixed index: a platform-less leaf cannot be attributed when another descriptor is explicitly labeled for a different platform', async () => { + // Regression: an index with one arm64-labeled descriptor and one + // unlabeled leaf must not let the unlabeled leaf stand in as amd64 + // content just because SOME descriptor in the index is unlabeled. + const unrelatedLeaf = `sha256:${'6'.repeat(64)}`; + const mixedIndexBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { digest: CHILD_ARM64, mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { os: 'linux', architecture: 'arm64' } }, + { digest: unrelatedLeaf, mediaType: 'application/vnd.oci.image.manifest.v1+json' }, + ], + }); + const mixedIndexDigest = contentDigest(mixedIndexBody); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': mixedIndexDigest, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(mixedIndexDigest) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': mixedIndexDigest }, body: mixedIndexBody }; + } + return { statusCode: 500, headers: {} }; + }; + // A local candidate that matches neither the arm64 descriptor nor the + // unlabeled leaf: with no confirmed amd64 variant, this must fail closed. + const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no confirmed linux/amd64 variant') }); + }); + + it('errors (not update) when a nested index also has no descriptor for the local platform after full expansion', async () => { + const nestedIndexBody = indexBody([{ digest: CHILD_ARM64, os: 'linux', architecture: 'arm64' }]); + const nestedIndexDigest = contentDigest(nestedIndexBody); + const outerIndexBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [{ digest: nestedIndexDigest, mediaType: INDEX_CONTENT_TYPE }], + }); + const outerIndexDigest = contentDigest(outerIndexBody); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': outerIndexDigest, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(outerIndexDigest) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': outerIndexDigest }, body: outerIndexBody }; + } + if (url === manifestDigestUrl(nestedIndexDigest) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': nestedIndexDigest }, body: nestedIndexBody }; + } + return { statusCode: 500, headers: {} }; + }; + // The outer index only nests an arm64-only child index; an amd64 node has + // no descriptor anywhere in the fully-expanded tree. + const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no linux/amd64 variant') }); + }); + it('never re-fetches the mutable tag: the expansion GET targets the primary digest from HEAD, not a second tag lookup', async () => { const DIVERGED_DIGEST = `sha256:${'e'.repeat(64)}`; let tagCallCount = 0; @@ -709,7 +982,13 @@ describe('compareLocalToRemoteTag', () => { }); it('misses the cache when the primary digest changes (new manifest, new immutable key)', async () => { - const INDEX_BODY_2 = indexBody([{ digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }]); + // Carries a genuinely different arm64 child (not CHILD_ARM64) so the second + // lookup is a real same-platform mismatch, not an absent-platform index. + const NEW_CHILD_ARM64 = `sha256:${'9'.repeat(64)}`; + const INDEX_BODY_2 = indexBody([ + { digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }, + { digest: NEW_CHILD_ARM64, os: 'linux', architecture: 'arm64' }, + ]); const INDEX_DIGEST_2 = contentDigest(INDEX_BODY_2); let headDigest = INDEX_DIGEST; let digestGetCount = 0; @@ -927,6 +1206,42 @@ describe('compareLocalToRemoteTag', () => { expect(calls).toHaveLength(0); }); + it('rejects an empty candidate list as an error without contacting the registry', async () => { + route = () => ({ statusCode: 500, headers: {} }); + const result = await compareLocalToRemoteTag([], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' }); + expect(calls).toHaveLength(0); + }); + + it('rejects an all-malformed candidate list as an error without contacting the registry', async () => { + route = () => ({ statusCode: 500, headers: {} }); + const result = await compareLocalToRemoteTag(['sha256:short', 'not-a-digest'], REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' }); + expect(calls).toHaveLength(0); + }); + + it('errors on unknown platform for a multi-candidate index mismatch (fail closed)', async () => { + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag( + [STALE_INDEX, `sha256:${'e'.repeat(64)}`], + REGISTRY, + REPO, + TAG, + { os: '', architecture: '' }, + ); + expect(result.kind).toBe('error'); + }); + it('degrades to an uncached comparison (not an error) when the classification cache is at capacity', async () => { const cache = CacheService.getInstance(); for (let i = 0; i < 1000; i++) cache.set(`filler:${i}`, { kind: 'single' as const }, 3_600_000); diff --git a/backend/src/__tests__/update-guard-readiness.test.ts b/backend/src/__tests__/update-guard-readiness.test.ts index aa3c2603..ed4e71c4 100644 --- a/backend/src/__tests__/update-guard-readiness.test.ts +++ b/backend/src/__tests__/update-guard-readiness.test.ts @@ -11,7 +11,7 @@ import { buildServicesSignal, } from '../services/updateGuard/readiness'; import type { ContainerProbe, ReadinessSignal } from '../services/updateGuard/types'; -import type { UpdatePreviewSummary } from '../services/UpdatePreviewService'; +import type { UpdatePreviewImage, UpdatePreviewSummary } from '../services/UpdatePreviewService'; const NOW = 1_750_000_000_000; @@ -38,6 +38,8 @@ const summary = (over: Partial = {}): UpdatePreviewSummary has_build_services: false, rebuild_available: false, check_status: 'ok', + verification_failed: false, + verification_error: null, ...over, }); @@ -226,3 +228,77 @@ describe('aggregateVerdict', () => { expect(aggregateVerdict([driftSignal(0), preflightSignal({ activeStatus: 'pass' }), healthchecksSignal([probe()])])).toBe('ready'); }); }); + +describe('updatePreviewSignal verification failure', () => { + it('does not claim no pending update when digest verification failed', () => { + const signal = updatePreviewSignal(summary({ + verification_failed: true, + verification_error: 'Registry unreachable', + })); + expect(signal.status).toBe('unknown'); + expect(signal.detail).toMatch(/Digest verification failed/); + expect(signal.detail).toMatch(/Registry unreachable/); + expect(signal.detail).not.toMatch(/No pending image update detected/); + }); + + it('holds a confirmed update for review, not ok, when another image failed digest verification', () => { + const signal = updatePreviewSignal(summary({ + has_update: true, + semver_bump: 'patch', + update_kind: 'digest', + verification_failed: true, + verification_error: 'Registry unreachable', + })); + expect(signal.status).toBe('attention'); + expect(signal.detail).toMatch(/Registry unreachable/); + }); + + it('holds a pending rebuild for review, not verification-only, when another image failed digest verification', () => { + const signal = updatePreviewSignal(summary({ + rebuild_available: true, + has_build_services: true, + verification_failed: true, + verification_error: 'Registry unreachable', + })); + expect(signal.status).toBe('attention'); + expect(signal.detail).toMatch(/rebuild/); + expect(signal.detail).toMatch(/Registry unreachable/); + }); + + const image = (over: Partial = {}): UpdatePreviewImage => ({ + service: 'web', + image: 'nginx:1', + current_tag: '1', + next_tag: null, + has_update: false, + digest_update: false, + tag_update: false, + semver_bump: 'none', + check_status: 'ok', + check_error: null, + digest_error: null, + ...over, + }); + + it('keeps a single image with its own confirmed update ok even though that same image also failed its own digest check', () => { + // has_update and check_error are independent per image; there is no + // "other image" here, so per-image detail must clear the review hold + // that the aggregate-only fallback would otherwise apply. + const signal = updatePreviewSignal( + summary({ has_update: true, semver_bump: 'patch', update_kind: 'tag', verification_failed: true, verification_error: 'Registry unreachable' }), + [image({ has_update: true, check_error: 'Registry unreachable' })], + ); + expect(signal.status).toBe('ok'); + }); + + it('holds a confirmed update for review when per-image detail proves a genuinely different image failed verification', () => { + const signal = updatePreviewSignal( + summary({ has_update: true, semver_bump: 'patch', update_kind: 'tag', verification_failed: true, verification_error: 'Registry unreachable' }), + [ + image({ service: 'confirmed', has_update: true, check_error: null }), + image({ service: 'failing', has_update: false, check_error: 'Registry unreachable' }), + ], + ); + expect(signal.status).toBe('attention'); + }); +}); diff --git a/backend/src/__tests__/update-guard-service.test.ts b/backend/src/__tests__/update-guard-service.test.ts index 555af5f3..3de68d04 100644 --- a/backend/src/__tests__/update-guard-service.test.ts +++ b/backend/src/__tests__/update-guard-service.test.ts @@ -159,7 +159,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => { summary: { has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1', semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null, - has_build_services: false, rebuild_available: false, check_status: 'ok', + has_build_services: false, rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null, }, rollback_target: 'nginx:1.27.0', changelog: null, @@ -190,7 +190,7 @@ describe('UpdateGuardService.computeUpdateReadiness with a serviceName', () => { summary: { has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1', semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null, - has_build_services: false, rebuild_available: false, check_status: 'ok', + has_build_services: false, rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null, }, rollback_target: 'nginx:1.27.0', changelog: null, @@ -284,7 +284,7 @@ describe('UpdateGuardService.computeRollbackReadiness moving-tag wiring', () => summary: { has_update: false, primary_image: 'app', current_tag: images[0]?.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', + has_build_services: false, rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null, }, rollback_target: 'app:1.2.3', changelog: null, diff --git a/backend/src/__tests__/update-preview-reconcile.test.ts b/backend/src/__tests__/update-preview-reconcile.test.ts index 9fa050e4..61f7050d 100644 --- a/backend/src/__tests__/update-preview-reconcile.test.ts +++ b/backend/src/__tests__/update-preview-reconcile.test.ts @@ -47,6 +47,8 @@ function negativeOkPreview(stackName = 'web') { tag_update: false, semver_bump: 'none' as const, check_status: 'ok' as const, + check_error: null, + digest_error: null, }], build_services: [] as string[], summary: { @@ -61,6 +63,8 @@ function negativeOkPreview(stackName = 'web') { has_build_services: false, rebuild_available: false, check_status: 'ok' as const, + verification_failed: false, + verification_error: null, }, rollback_target: null, changelog: null, diff --git a/backend/src/__tests__/update-preview-service.test.ts b/backend/src/__tests__/update-preview-service.test.ts index b0a8aa25..931cbacd 100644 --- a/backend/src/__tests__/update-preview-service.test.ts +++ b/backend/src/__tests__/update-preview-service.test.ts @@ -12,12 +12,21 @@ import { type ComputePreviewDeps, type LocalDigestInfo, } from '../services/UpdatePreviewService'; -import type { DigestComparisonResult, TagListResult } from '../services/registry-api'; +import { selectLocalRepoDigests, type DigestComparisonResult, type ParsedRef, type TagListResult } from '../services/registry-api'; const PLATFORM = { os: 'linux', architecture: 'amd64' }; -function localDigest(digest: string | null): LocalDigestInfo { - return { digests: digest ? [digest] : [], platform: PLATFORM }; +function localDigest( + digest: string | null | string[], + emptyReason: LocalDigestInfo['emptyReason'] = null, +): LocalDigestInfo { + if (Array.isArray(digest)) return { digests: digest, platform: PLATFORM, emptyReason }; + if (digest) return { digests: [digest], platform: PLATFORM, emptyReason: null }; + return { + digests: [], + platform: PLATFORM, + emptyReason: emptyReason ?? 'not_checkable', + }; } function tagsOk(tags: string[], nextCursor?: string): TagListResult { @@ -168,9 +177,13 @@ describe('computeImagePreview', () => { expect(result.has_update).toBe(true); expect(result.next_tag).toBe('1.2.4'); expect(result.semver_bump).toBe('patch'); + // A confirmed tag-based update overrides the digest hiccup: the overall + // check_status resolves 'ok' and check_error is not surfaced (see the + // "treats digest error + higher tag as a confirmed update" test below). + expect(result.check_error).toBeNull(); }); - it('fails soft to no-update when the comparison resolver errors and no higher tag exists', async () => { + it('surfaces verification failure when the comparison resolver errors and no higher tag exists', async () => { const deps = makeDeps({ getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }), @@ -180,10 +193,11 @@ describe('computeImagePreview', () => { expect(result.has_update).toBe(false); expect(result.next_tag).toBeNull(); expect(result.semver_bump).toBe('none'); + expect(result.check_error).toBe('Registry unreachable'); expect(result.check_status).toBe('partial'); }); - it('treats digest error + higher tag as a confirmed update (ok)', async () => { + it('treats digest error + higher tag as a confirmed update (ok), but keeps digest_error unmasked for the collateral-risk gate', async () => { const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({ getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }), @@ -192,18 +206,71 @@ describe('computeImagePreview', () => { expect(result.has_update).toBe(true); expect(result.next_tag).toBe('1.2.4'); expect(result.check_status).toBe('ok'); + // The tag compare independently confirmed the update, so check_status + // masks the digest failure. digest_error must survive that masking: a + // full-stack apply still re-pulls this image's unverified current tag. + expect(result.digest_error).toBe('Registry unreachable'); }); - it('never calls the comparison resolver when no local digest is resolvable', async () => { + it('treats empty RepoDigests as not_checkable (no verification failure)', async () => { const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' }); const deps = makeDeps({ - getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)), + getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'not_checkable')), compareDigest, listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])), }); const result = await computeImagePreview('web', 'nginx:1.2.3', deps); expect(compareDigest).not.toHaveBeenCalled(); expect(result.has_update).toBe(false); + expect(result.check_error).toBeNull(); + }); + + it('surfaces unresolved RepoDigests as verification failure without claiming an update', async () => { + const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' }); + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'unresolved')), + compareDigest, + listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])), + }); + const result = await computeImagePreview('web', 'nginx:1.2.3', deps); + expect(compareDigest).not.toHaveBeenCalled(); + expect(result.has_update).toBe(false); + expect(result.check_error).toBe('Could not resolve a local registry digest'); + }); + + it('wires a real unrelated-repository RepoDigest through selectLocalRepoDigests to an unresolved verification failure', async () => { + // getLocalDigest here is not a canned stand-in: it runs the real + // selectLocalRepoDigests against a RepoDigests array whose sole valid + // entry belongs to a different repository, proving the removed + // sole-unmatched-digest fallback stays removed through the actual + // preview-computation path, not just at the registry-api unit level. + const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' }); + const deps = makeDeps({ + getLocalDigest: async (_imageRef: string, parsed: ParsedRef) => { + const digests = selectLocalRepoDigests( + [`ghcr.io/other/image@sha256:${'b'.repeat(64)}`], + parsed, + ); + return { digests, platform: PLATFORM, emptyReason: digests.length === 0 ? 'unresolved' as const : null }; + }, + compareDigest, + listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])), + }); + const result = await computeImagePreview('web', 'nginx:1.2.3', deps); + expect(compareDigest).not.toHaveBeenCalled(); + expect(result.has_update).toBe(false); + expect(result.check_error).toBe('Could not resolve a local registry digest'); + }); + + it('surfaces inspect failure as verification failure', async () => { + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'inspect_failed')), + compareDigest: vi.fn(), + listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])), + }); + const result = await computeImagePreview('web', 'nginx:1.2.3', deps); + expect(result.has_update).toBe(false); + expect(result.check_error).toBe('Failed to inspect local image'); }); it('passes the local digest, tag, and platform through to the comparison resolver', async () => { @@ -216,10 +283,35 @@ describe('computeImagePreview', () => { await computeImagePreview('web', 'ghcr.io/linuxserver/radarr:latest', deps); expect(compareDigest).toHaveBeenCalledWith(['sha256:aaa'], 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null); }); + + it('forwards every local digest candidate and reports no same-tag rebuild on match', async () => { + const STALE = `sha256:${'f'.repeat(64)}`; + const CURRENT = `sha256:${'e'.repeat(64)}`; + const compareDigest = vi.fn().mockResolvedValue({ kind: 'match' }); + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue(localDigest([STALE, CURRENT])), + compareDigest, + listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])), + }); + const result = await computeImagePreview('broker', 'redis:8.8.0', deps); + expect(compareDigest).toHaveBeenCalledWith( + [STALE, CURRENT], + 'registry-1.docker.io', + 'library/redis', + '8.8.0', + PLATFORM, + null, + ); + expect(result).toMatchObject({ + has_update: false, + next_tag: null, + semver_bump: 'none', + }); + }); }); describe('buildSummary', () => { - const baseImage = (partial: Partial[1][number]>) => ({ + const baseImage = (partial: Partial[1][number]> = {}) => ({ service: 'svc', image: 'nginx:1.0.0', current_tag: '1.0.0', @@ -229,6 +321,8 @@ describe('buildSummary', () => { tag_update: false, semver_bump: 'none' as const, check_status: 'ok' as const, + check_error: null as string | null, + digest_error: null as string | null, ...partial, }); @@ -241,6 +335,41 @@ describe('buildSummary', () => { expect(preview.summary.blocked).toBe(true); expect(preview.summary.blocked_reason).toMatch(/major/i); expect(preview.summary.semver_bump).toBe('major'); + expect(preview.summary.verification_failed).toBe(false); + expect(preview.summary.verification_error).toBeNull(); + }); + + it('aggregates digest verification failure without claiming a digest rebuild', () => { + const images = [ + baseImage({ + service: 'broker', + image: 'redis:8.8.0', + current_tag: '8.8.0', + check_error: 'Local image platform is unknown; cannot verify multi-arch membership', + }), + ]; + const preview = buildSummary('app', images); + expect(preview.summary.has_update).toBe(false); + expect(preview.summary.update_kind).toBe('none'); + expect(preview.summary.verification_failed).toBe(true); + expect(preview.summary.verification_error).toContain('cannot verify multi-arch membership'); + }); + + it('keeps a tag update when digest verification failed on the same image', () => { + const images = [ + baseImage({ + service: 'web', + has_update: true, + semver_bump: 'patch', + next_tag: '1.0.1', + check_error: 'Registry unreachable', + }), + ]; + const preview = buildSummary('app', images); + expect(preview.summary.has_update).toBe(true); + expect(preview.summary.update_kind).toBe('tag'); + expect(preview.summary.verification_failed).toBe(true); + expect(preview.summary.verification_error).toBe('Registry unreachable'); }); it('picks first updated image as primary', () => { @@ -410,6 +539,8 @@ describe('preview authority', () => { tag_update: false, semver_bump: 'none', check_status: 'not_checkable', + check_error: null, + digest_error: null, }, ]))).toBe(false); }); diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 6a38e2cc..91c8a2ea 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -1177,6 +1177,7 @@ export class ImageUpdateService { console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`); } + // Get local digests and platform from RepoDigests / Os+Architecture let localDigests: string[]; let platform: { os: string; architecture: string }; try { diff --git a/backend/src/services/UpdateGuardService.ts b/backend/src/services/UpdateGuardService.ts index a36c5d12..2d7995d1 100644 --- a/backend/src/services/UpdateGuardService.ts +++ b/backend/src/services/UpdateGuardService.ts @@ -152,7 +152,7 @@ export class UpdateGuardService { driftSignal(drift), containersSignal(containers), healthchecksSignal(containers), - updatePreviewSignal(preview === 'error' ? 'error' : preview.summary), + updatePreviewSignal(preview === 'error' ? 'error' : preview.summary, preview === 'error' ? undefined : preview.images), buildServicesSignal(preview === 'error' ? 'error' : preview.build_services), backupSlotSignal(backup, now), diskSignal(typeof disk === 'number' ? { usePercent: disk, limitPercent } : 'error'), diff --git a/backend/src/services/UpdatePreviewService.ts b/backend/src/services/UpdatePreviewService.ts index 4c6f3d90..16e8904a 100644 --- a/backend/src/services/UpdatePreviewService.ts +++ b/backend/src/services/UpdatePreviewService.ts @@ -61,6 +61,23 @@ export interface UpdatePreviewImage { semver_bump: SemverBump; /** Authority of this image's checks; not_checkable for invalid refs. */ check_status: PreviewImageCheckStatus; + /** + * Best operator-facing reason when check_status is not 'ok'/'not_checkable'. + * Never paired with a digest-based has_update claim: callers must treat + * this as verification-failed / unknown, not as "up to date" or rebuild. + */ + check_error: string | null; + /** + * This image's own digest-comparison failure reason, independent of + * check_status. A confirmed tag-based update on the SAME image resolves + * check_status to 'ok' and nulls check_error even when the digest compare + * itself errored (the tag confirmation is authoritative for that image), + * but a full-stack apply still pulls/recreates this image's CURRENT tag + * content, which was never verified. Callers gating collateral risk to a + * full-stack apply must read this field, not check_error, so that + * masking never hides an unverified image from the mixed-state gate. + */ + digest_error: string | null; } export type UpdateKind = 'tag' | 'digest' | 'none'; @@ -90,6 +107,10 @@ export interface UpdatePreviewSummary { * Older remotes omit this field; treat absence as non-authoritative. */ check_status: PreviewCheckStatus; + /** True when any image's check_status is not 'ok' (excluding not_checkable). */ + verification_failed: boolean; + /** First image check_error when verification_failed; otherwise null. */ + verification_error: string | null; } export interface UpdatePreview { @@ -136,9 +157,18 @@ async function loadStackImages( return extractServiceImagesFromCompose(composeContent, merged); } +export type LocalDigestEmptyReason = 'not_checkable' | 'inspect_failed' | 'unresolved'; + export interface LocalDigestInfo { + /** All usable RepoDigests for the image ref; compared as a set against the remote tag. */ digests: string[]; platform: { os: string; architecture: string }; + /** + * Why digests is empty. `not_checkable` mirrors the scanner (no RepoDigests / + * locally built) and must not surface as verification-failed. Inspect failure + * and unresolved selection do. + */ + emptyReason: LocalDigestEmptyReason | null; } export interface ComputePreviewDeps { @@ -164,6 +194,8 @@ function imageFromDetect( tag_update: detected.tagUpdate, semver_bump: detected.semverBump, check_status: detected.checkStatus, + check_error: detected.reason, + digest_error: detected.digestError, }; } @@ -178,6 +210,25 @@ function notCheckableImage(service: string, imageRef: string): UpdatePreviewImag tag_update: false, semver_bump: 'none', check_status: 'not_checkable', + check_error: null, + digest_error: null, + }; +} + +/** Local digest could not be established at all; never reaches the registry. */ +function failedLocalDigestImage(service: string, imageRef: string, currentTag: string, reason: string): UpdatePreviewImage { + return { + service, + image: imageRef, + current_tag: currentTag, + next_tag: null, + has_update: false, + digest_update: false, + tag_update: false, + semver_bump: 'none', + check_status: 'failed', + check_error: reason, + digest_error: reason, }; } @@ -193,6 +244,23 @@ export async function computeImagePreview( const credentials = await deps.getCredentials(parsed.registry); const localInfo = await deps.getLocalDigest(imageRef, parsed); + // A locally-built / non-registry-backed image (no RepoDigests at all) is + // not_checkable, matching ImageUpdateService.checkImage: it must never be + // funneled into detectImageUpdate's "no local digest" error path, which + // would misreport it as a verification failure instead of not applicable. + if (localInfo.emptyReason === 'not_checkable') { + return notCheckableImage(service, imageRef); + } + // Inspect failure and unresolved RepoDigests never reach the registry, and + // detectImageUpdate's generic "no local digest" reason would blur these two + // distinct causes together; keep the specific reason, matching what + // ImageUpdateService.checkImage reports for the same two cases. + if (localInfo.emptyReason === 'inspect_failed') { + return failedLocalDigestImage(service, imageRef, parsed.tag, 'Failed to inspect local image'); + } + if (localInfo.emptyReason === 'unresolved') { + return failedLocalDigestImage(service, imageRef, parsed.tag, 'Could not resolve a local registry digest'); + } const detected = await detectImageUpdate({ localDigests: localInfo.digests, platform: localInfo.platform, @@ -253,6 +321,7 @@ export function buildSummary( : updated.some(i => i.next_tag !== null && i.next_tag !== i.current_tag) ? 'tag' : 'digest'; + const verificationError = images.find(i => i.check_error)?.check_error ?? null; return { stack_name: stackName, images, @@ -269,6 +338,8 @@ export function buildSummary( has_build_services: hasBuildServices, rebuild_available: hasBuildServices, check_status: rollupPreviewCheckStatus(images), + verification_failed: verificationError !== null, + verification_error: verificationError, }, rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null, changelog: null, @@ -319,11 +390,22 @@ export class UpdatePreviewService { try { const inspect = await docker.getDocker().getImage(imageRef).inspect(); const repoDigests: string[] = inspect.RepoDigests ?? []; + if (repoDigests.length === 0) { + return { + digests: [], + platform: { os: inspect.Os, architecture: inspect.Architecture }, + emptyReason: 'not_checkable', + }; + } const digests = selectLocalRepoDigests(repoDigests, parsed); - return { digests, platform: { os: inspect.Os, architecture: inspect.Architecture } }; + return { + digests, + platform: { os: inspect.Os, architecture: inspect.Architecture }, + emptyReason: digests.length === 0 ? 'unresolved' : null, + }; } catch (err) { console.error('[UpdatePreview] local image inspect failed for %s', imageRef, err); - return { digests: [], platform: { os: '', architecture: '' } }; + return { digests: [], platform: { os: '', architecture: '' }, emptyReason: 'inspect_failed' }; } }, }; diff --git a/backend/src/services/registry-api.ts b/backend/src/services/registry-api.ts index 0af51b26..b2c6dd24 100644 --- a/backend/src/services/registry-api.ts +++ b/backend/src/services/registry-api.ts @@ -244,11 +244,12 @@ const SHA256_DIGEST_RE = /^sha256:[0-9a-f]{64}$/i; /** * All usable local RepoDigests for a parsed image ref, shared by the scanner * and update preview. Returns every valid digest whose repository matches the - * ref (deduped, first-seen order), else the sole remaining valid entry when - * nothing matches, else []. A truncated or malformed digest is never selected, - * so a corrupted RepoDigests entry surfaces as "could not resolve" rather than - * a false match or update. Callers must compare every candidate: Docker can - * list a stale index digest ahead of the current one on the same image. + * ref (deduped, first-seen order), else []. A truncated or malformed digest is + * never selected, and a valid digest belonging to an unrelated repository + * (e.g. left over from a retag) is never selected either, so either surfaces + * as "could not resolve" rather than a guessed match or update. Callers must + * compare every candidate: Docker can list a stale index digest ahead of the + * current one on the same image. */ export function selectLocalRepoDigests(repoDigests: readonly string[], parsed: ParsedRef): string[] { const valid = repoDigests @@ -267,8 +268,11 @@ export function selectLocalRepoDigests(repoDigests: readonly string[], parsed: P seen.add(key); matched.push(e.digest); } - if (matched.length > 0) return matched; - return valid.length === 1 ? [valid[0].digest] : []; + // No digest matches the configured repository: comparing an unrelated + // repository's digest (e.g. a retag) risks a false update against a + // registry state that has nothing to do with the image actually + // declared. Report unresolved instead of guessing. + return matched; } /** @@ -771,12 +775,27 @@ async function classifyManifest( } /** - * Compare a local image digest to the registry's current manifest for a tag. - * `platform` is the local image's Os/Architecture (from `docker image inspect`), - * required to safely match against an index's platform descriptors; without it, - * an index mismatch is an error rather than a speculative match. Never retries - * against the mutable tag once a primary digest is established: classification - * always targets that digest. + * Compare local image digests to the registry's current manifest for a tag. + * Any candidate that equals the remote primary or is a member of that primary's + * index counts as current (Docker often lists a stale index digest ahead of the + * current one). `platform` is the local image's Os/Architecture (from + * `docker image inspect`), required to safely match against an index's platform + * descriptors; without it, an index mismatch is an error rather than a + * speculative match. Never retries against the mutable tag once a primary + * digest is established: classification always targets that digest. + * + * `update` is returned only after a successful, complete remote classification + * with no candidate matching the primary, an exact member, or a same-platform + * runnable descriptor that the index actually offers. When the index has no + * descriptor labeled for the local platform, a platform-less leaf (which OCI + * permits) is trusted as this platform's content only when it is the ONLY + * kind of descriptor present (nothing else in the index claims a different + * platform); an index that mixes platform-less leaves with descriptors + * labeled for other platforms cannot attribute the unlabeled ones and errors + * instead. Empty/all-malformed candidates, unknown platform when platform + * matching is required, an index with no runnable content for the local + * platform at all (including an empty or fully-filtered index), and + * classification failures also return `error`. */ export async function compareLocalToRemoteTag( localDigests: readonly string[], @@ -817,11 +836,29 @@ export async function compareLocalToRemoteTag( return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` }; } - const isMember = classification.descriptors.some( - (d) => d.os === platform.os - && d.architecture === platform.architecture - && candidateSet.has(d.digest.toLowerCase()), + const platformDescriptors = classification.descriptors.filter( + (d) => d.os === platform.os && d.architecture === platform.architecture, ); + if (platformDescriptors.length === 0) { + // No descriptor is labeled for this platform. exactDigests leaves omit + // platform entirely (OCI allows this), so they cannot be attributed to + // any specific platform; their mere presence does not prove content + // for THIS one, especially when other descriptors in the same index + // are explicitly labeled for a different platform. Only when every + // descriptor in the index is unlabeled (classification.descriptors is + // empty) does a leaf's presence plausibly represent this platform's + // content, since nothing else claims a different one; that is the one + // case where reporting `update` instead of failing closed is safe. + if (classification.exactDigests.length === 0) { + return { kind: 'error', reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` }; + } + if (classification.descriptors.length > 0) { + return { kind: 'error', reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` }; + } + return { kind: 'update' }; + } + + const isMember = platformDescriptors.some((d) => candidateSet.has(d.digest.toLowerCase())); return isMember ? { kind: 'match' } : { kind: 'update' }; } @@ -875,8 +912,12 @@ function parseNextCursor(linkHeader: string | string[] | undefined): string | un /** * Typed tag list for the Resources registry browser and update-preview authority. * Never collapses auth failures into an empty array (that would hide credential - * problems and falsely look like a successful empty listing). - * Pass null/undefined credentials to attempt anonymous pull. + * problems and falsely look like a successful empty listing). `credentials` is + * optional: `getAuthToken` already resolves an anonymous pull token for public + * repositories on registries whose `WWW-Authenticate` challenge grants one + * without credentials (Docker Hub unconditionally; others via the standard + * token-service challenge), so a public repository still returns a real tag + * list with none configured. */ export async function listRegistryTagsResult( registry: string, @@ -888,7 +929,11 @@ export async function listRegistryTagsResult( try { const token = await getAuthToken(registry, repo, credentials); if (!token) { - return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' }; + return { + ok: false, + code: 'REGISTRY_UNAUTHORIZED', + message: credentials ? 'Registry rejected credentials' : 'Registry did not issue an anonymous token for this repository', + }; } const headers: Record = { Accept: 'application/json', Authorization: `Bearer ${token}` }; const params = new URLSearchParams({ n: String(limit) }); diff --git a/backend/src/services/updateGuard/readiness.ts b/backend/src/services/updateGuard/readiness.ts index a6d824a5..d9c8950e 100644 --- a/backend/src/services/updateGuard/readiness.ts +++ b/backend/src/services/updateGuard/readiness.ts @@ -1,5 +1,5 @@ import type { PreflightStatus } from '../preflight/types'; -import type { UpdatePreviewSummary } from '../UpdatePreviewService'; +import type { UpdatePreviewImage, UpdatePreviewSummary } from '../UpdatePreviewService'; import type { ContainerProbe, ReadinessSignal, @@ -109,7 +109,27 @@ export function healthchecksSignal(input: ContainerProbe[] | Errored): Readiness return { ...base, detail: parts.join(' ') }; } -export function updatePreviewSignal(input: UpdatePreviewSummary | Errored): ReadinessSignal { +/** + * True when a full-stack apply would pull/recreate an image whose digest + * verification failed as collateral of applying a DIFFERENT image's confirmed + * update or a local rebuild. `has_update` and `check_error` are independent + * per image (a tag-based update can be confirmed via the registry's tag list + * even when that same image's own digest comparison errored), so this + * excludes the case where the only verification failure belongs to the very + * image whose update is confirmed: that image's own stale-digest check does + * not block moving it to a newer tag. Falls back to the aggregate summary + * flags when per-image detail is unavailable. + */ +function hasUnverifiedOtherImage(summary: UpdatePreviewSummary, images: UpdatePreviewImage[] | undefined): boolean { + if (!images || images.length === 0) { + return summary.verification_failed && (summary.has_update || summary.rebuild_available); + } + const hasPureFailureImage = images.some((i) => Boolean(i.check_error) && !i.has_update); + if (!hasPureFailureImage) return false; + return images.some((i) => i.has_update) || summary.rebuild_available; +} + +export function updatePreviewSignal(input: UpdatePreviewSummary | Errored, images?: UpdatePreviewImage[]): ReadinessSignal { const base = { id: 'update_preview' as const, title: 'Pending update' }; if (input === 'error') { return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The update preview is unavailable.' }; @@ -129,20 +149,50 @@ export function updatePreviewSignal(input: UpdatePreviewSummary | Errored): Read if (input.has_update && input.semver_bump === 'unknown') { return { ...base, status: 'warning', affectsVerdict: true, detail: 'An image update is pending but the version change could not be classified.' }; } + // A DIFFERENT image in the stack failing digest verification holds any + // pending action (a tag/digest update or a local-build rebuild) for review, + // since a full-stack apply would pull/recreate the unverified image as + // collateral. has_update and rebuild_available are handled symmetrically + // here to match isReviewRequiredUpdatePreview on the frontend. + const reviewRequired = hasUnverifiedOtherImage(input, images); if (input.has_update) { const kind = input.update_kind === 'digest' ? 'a same-tag image refresh' : `a ${input.semver_bump} update`; const buildNote = input.has_build_services ? ' Local build services will also be rebuilt from source.' : ''; + if (reviewRequired) { + const verifyNote = input.verification_error ? `: ${input.verification_error}` : '.'; + return { + ...base, + status: 'attention', + affectsVerdict: true, + detail: `Pending: ${kind}.${buildNote} Another image failed digest verification${verifyNote} Review before a full-stack update.`, + }; + } return { ...base, status: 'ok', affectsVerdict: true, detail: `Pending: ${kind}.${buildNote}` }; } if (input.rebuild_available) { const n = input.has_build_services ? 'Local build service(s)' : 'Build'; + const rebuildNote = `${n} require a rebuild from source; the update rebuilds images and recreates containers.`; + if (reviewRequired) { + const verifyNote = input.verification_error ? `: ${input.verification_error}` : '.'; + return { + ...base, + status: 'attention', + affectsVerdict: true, + detail: `${rebuildNote} Another image failed digest verification${verifyNote} Review before a full-stack update.`, + }; + } + return { ...base, status: 'warning', affectsVerdict: true, detail: rebuildNote }; + } + if (input.verification_failed) { return { ...base, - status: 'warning', + status: 'unknown', affectsVerdict: true, - detail: `${n} require a rebuild from source; the update rebuilds images and recreates containers.`, + detail: input.verification_error + ? `Digest verification failed: ${input.verification_error}` + : 'Digest verification failed; Sencho is not claiming a rebuild.', }; } return { ...base, status: 'ok', affectsVerdict: true, detail: 'No pending image update detected; the update re-pulls and recreates with current tags.' }; diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index e19d680d..e271723e 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -11,9 +11,13 @@ import { isAuthoritativeNegativePreview } from '@/types/imageUpdates'; import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview'; import { isActionableUpdatePreview, + isClearedUpdatePreview, + isLegacyPreview, isPreviewUncertain, + isReviewRequiredUpdatePreview, isServiceApplyActionable, isTagOnlyAdvisory, + isVerificationOnlyPreview, } from '@/lib/updatePreviewActionability'; import { useNodes } from '@/context/NodeContext'; import { useIsMobile } from '@/hooks/use-is-mobile'; @@ -37,6 +41,9 @@ interface UpdatePreviewImage { semver_bump: SemverBump; /** Absent on older remotes; backend uses !== 'not_checkable' for checkability. */ check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable'; + check_error?: string | null; + /** This image's own digest-comparison failure; not masked by a confirmed tag update. */ + digest_error?: string | null; } type UpdateKind = 'tag' | 'digest' | 'none'; @@ -57,6 +64,8 @@ interface UpdatePreview { rebuild_available?: boolean; /** Absent on older remotes; treat missing as non-authoritative. */ check_status?: 'ok' | 'partial' | 'failed'; + verification_failed?: boolean; + verification_error?: string | null; }; build_services?: string[]; rollback_target: string | null; @@ -71,6 +80,11 @@ function declaredServiceCount(preview: UpdatePreview | null | undefined): number return names.size; } +/** Append `: reason` when present, otherwise end the lead-in with a period. */ +function withErrorDetail(lead: string, error: string | null | undefined): string { + return error ? `${lead}: ${error}` : `${lead}.`; +} + export interface StackCard { stack: string; nodeId: number; @@ -165,22 +179,16 @@ function formatClock(ts: number | null): string { function RiskBadge({ bump, blocked, + reviewRequired, uncertain, tagOnly, }: { bump: SemverBump; blocked: boolean; + reviewRequired?: boolean; uncertain?: boolean; tagOnly?: boolean; }) { - if (uncertain) { - return ( - - - Check uncertain - - ); - } if (blocked || bump === 'major') { return ( @@ -189,6 +197,22 @@ function RiskBadge({ ); } + if (reviewRequired) { + return ( + + + Review · unverified + + ); + } + if (uncertain) { + return ( + + + Check uncertain + + ); + } if (tagOnly) { return ( @@ -262,6 +286,15 @@ function StackReadinessCard({ // build-only), not preview.images.length (shared tags collapse that list). const showServiceApply = canServiceUpdate && declaredServiceCount(preview) > 1 && updatingImageCount > 0; const nextRun = scheduledTask?.next_run_at ?? null; + const verificationOnly = isVerificationOnlyPreview(preview); + const reviewRequired = isReviewRequiredUpdatePreview(preview); + // Full-stack apply is held for review when another image in the same stack + // failed digest verification: applying would pull/recreate that image as + // collateral. Per-service apply targets only images with their own confirmed + // update, so it is not gated by a different image's verification failure. + const applyDisabled = !isActionableUpdatePreview(preview) + || applying + || applyingService !== null; return ( @@ -281,7 +314,15 @@ function StackReadinessCard({ Auto: Off )} - {previewLoaded && preview && } + {previewLoaded && preview && ( + + )} @@ -295,20 +336,45 @@ function StackReadinessCard({ (() => { const p = preview!; const blockedReason = p.summary.blocked_reason; + const verificationFailed = Boolean(p.summary.verification_failed); + const verificationError = p.summary.verification_error; + let applyTitle: string | undefined; + if (blocked) applyTitle = blockedReason ?? undefined; + else if (verificationOnly) applyTitle = 'Digest verification failed'; + else if (reviewRequired) applyTitle = 'Another image in this stack failed digest verification; apply the confirmed service individually or resolve verification first.'; + + let headline: ReactNode; + if (verificationFailed && !p.summary.has_update) { + headline = ( +
+ {withErrorDetail('Digest verification failed', verificationError)} +
+ ); + } else if (p.summary.update_kind === 'digest') { + headline = ( +
+ {p.summary.current_tag} + + Rebuild available + +
+ ); + } else { + headline = ( + + ); + } + return ( <> - {p.summary.update_kind === 'digest' ? ( -
- {p.summary.current_tag} - - Rebuild available - + {headline} + {verificationFailed && p.summary.has_update && ( +
+ {withErrorDetail('Digest check could not be verified', verificationError)}
- ) : ( - )}
@@ -368,8 +434,8 @@ function StackReadinessCard({
@@ -549,55 +630,78 @@ export function MobileReadinessCard({
Checking registry...
) : failed ? (
Preview failed. Registry may be unreachable.
- ) : ( - <> - {preview!.summary.update_kind === 'digest' ? ( + ) : (() => { + const p = preview!; + const verificationFailed = Boolean(p.summary.verification_failed); + const verificationError = p.summary.verification_error; + + let headline: ReactNode; + if (verificationFailed && !p.summary.has_update) { + headline = ( +
+ {withErrorDetail('Digest verification failed', verificationError)} +
+ ); + } else if (p.summary.update_kind === 'digest') { + headline = (
- {preview!.summary.current_tag} + {p.summary.current_tag} Rebuild available
- ) : ( - - )} -
{preview!.summary.primary_image ?? '-'}
-
- {lead && {lead}}{rest} -
- {showServiceApply && ( -
- {updatingImages.map(img => ( -
- {img.service} - -
- ))} + ); + } else { + headline = ; + } + + return ( + <> + {headline} + {verificationFailed && p.summary.has_update && ( +
+ {withErrorDetail('Digest check could not be verified', verificationError)} +
+ )} +
{p.summary.primary_image ?? '-'}
+
+ {lead && {lead}}{rest}
- )} -
- - {nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)} : (blocked ? 'Held for review' : 'No schedule')} - - -
- - )} + {showServiceApply && ( +
+ {updatingImages.map(img => ( +
+ {img.service} + +
+ ))} +
+ )} +
+ + {nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)} : (blocked || reviewRequired ? 'Held for review' : 'No schedule')} + + +
+ + ); + })()}
); } @@ -718,14 +822,17 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) // Local-node check failures: surfaced separately because the fleet map is // boolean and the card grid only lists stacks with a confirmed update. + // Sticky has_update during a failed check is not a verified rebuild, so those + // stacks stay in the advisory only. + let failedLocalStacks = new Set(); if (detailRes.ok) { const detail = await detailRes.json() as Record; - setCheckFailures( - Object.entries(detail) - .filter(([, info]) => info.checkStatus === 'failed') - .map(([stack, info]) => ({ stack, reason: info.lastError })) - .sort((a, b) => a.stack.localeCompare(b.stack)), - ); + const failures = Object.entries(detail) + .filter(([, info]) => info.checkStatus === 'failed') + .map(([stack, info]) => ({ stack, reason: info.lastError })) + .sort((a, b) => a.stack.localeCompare(b.stack)); + failedLocalStacks = new Set(failures.map(f => f.stack)); + setCheckFailures(failures); } else { // Clear stale failures rather than persist them across a load, but log: // an empty advisory must not silently stand in for "detail unavailable". @@ -769,7 +876,9 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) const node = currentNodes.find(n => n.id === nodeId); if (!node) continue; const stacks = Object.entries(stackMap) - .filter(([, hasUpdate]) => hasUpdate) + .filter(([stack, hasUpdate]) => + hasUpdate && !(node.type === 'local' && failedLocalStacks.has(stack)), + ) .map(([stack]) => stack) .sort(); if (stacks.length === 0) continue; @@ -835,18 +944,52 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) previewByKey.set(`${pair.nodeId}::${pair.stack}`, previews[idx]); }); - setGroups(initialGroups.map(g => ({ - ...g, - cards: g.cards - .map(c => ({ - ...c, - preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null, - previewLoaded: true, - })) - // Drop cards whose live preview authoritatively reports no update. - // Missing check_status (older remotes) or non-ok status keeps the card. - .filter(c => !isAuthoritativeNegativePreview(c.preview)), - })).filter(g => g.cards.length > 0)); + const previewAdvisory: { stack: string; reason: string | null }[] = []; + const groupsWithPreview = initialGroups + .map(g => { + const cards: StackCard[] = []; + for (const c of g.cards) { + const preview = previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null; + if (isVerificationOnlyPreview(preview)) { + previewAdvisory.push({ + stack: g.nodeType === 'remote' ? `${c.stack} (${g.nodeName})` : c.stack, + reason: preview?.summary.verification_error ?? 'Digest verification failed', + }); + continue; + } + // Sticky fleet booleans can outlive a successful no-update preview. + // Drop those cards without treating them as check failures. + if (isClearedUpdatePreview(preview)) continue; + // A legacy preview (missing verification_failed entirely) is kept + // rather than cleared, but that alone renders as a pending card + // with nothing to explain it; flag why in the advisory too. Skip + // this when the preview is already actionable on its own terms + // (the remote's own has_update/rebuild_available): the card + // already speaks for itself, and pairing it with "could not be + // checked" would contradict the Apply affordance right next to it. + if (isLegacyPreview(preview) && !isActionableUpdatePreview(preview)) { + previewAdvisory.push({ + stack: g.nodeType === 'remote' ? `${c.stack} (${g.nodeName})` : c.stack, + reason: 'This node\'s preview predates digest verification reporting', + }); + } + cards.push({ ...c, preview, previewLoaded: true }); + } + return { ...g, cards }; + }) + .filter(g => g.cards.length > 0); + + if (previewAdvisory.length > 0) { + setCheckFailures(prev => { + const byStack = new Map(prev.map(f => [f.stack, f])); + for (const entry of previewAdvisory) { + if (!byStack.has(entry.stack)) byStack.set(entry.stack, entry); + } + return [...byStack.values()].sort((a, b) => a.stack.localeCompare(b.stack)); + }); + } + + setGroups(groupsWithPreview); } catch (err) { if (token !== loadTokenRef.current) return; toast.error((err as Error)?.message || 'Failed to load readiness'); @@ -1035,9 +1178,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) const flatCards = useMemo(() => groups.flatMap(g => g.cards), [groups]); const { total, ready } = useMemo(() => { const t = flatCards.length; - // "Ready" means a schedule covers the stack, the preview loaded without - // error, and no major-bump blocked it. Without a covering schedule the - // stack cannot apply automatically regardless of preview state. + // Schedule-covered and actionable (confirmed update/rebuild, not blocked). const r = flatCards.filter(c => c.autoUpdateEnabled && c.previewLoaded @@ -1056,10 +1197,14 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
0 ? 'No verified updates' : 'Up to date') + : `${total} pending`} + stateTone={total === 0 && checkFailures.length === 0 ? 'success' : 'warning'} live={total > 0} - meta={total > 0 ? `${ready} ready · ${total - ready} in review` : 'all stacks current'} + meta={total > 0 + ? `${ready} ready · ${total - ready} in review` + : (checkFailures.length > 0 ? 'some checks unresolved' : 'all stacks current')} right={headerActions} />
@@ -1081,9 +1226,15 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
Loading readiness...
) : groups.length === 0 ? (
-
) : ( groups.map(group => ( @@ -1109,6 +1260,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) nodeCount={groups.length} refreshing={refreshing} onRefresh={handleRefresh} + unresolvedChecks={checkFailures.length > 0} /> @@ -1127,10 +1279,14 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
) : groups.length === 0 ? (
-
) : ( diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index 9aae3d18..d4690a49 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -23,8 +23,19 @@ import StackAnatomyPanel from './StackAnatomyPanel'; const COMPOSE = 'services:\n web:\n image: nginx:1.25\n'; -function previewBody(hasUpdate: boolean, buildServices: string[] = []) { +function previewBody( + hasUpdate: boolean, + buildServices: string[] = [], + over: { + verification_failed?: boolean; + verification_error?: string | null; + check_error?: string | null; + blocked?: boolean; + blocked_reason?: string | null; + } = {}, +) { const hasBuild = buildServices.length > 0; + const verificationFailed = over.verification_failed ?? false; return { build_services: buildServices, images: [ @@ -37,7 +48,8 @@ function previewBody(hasUpdate: boolean, buildServices: string[] = []) { digest_update: hasUpdate, tag_update: false, semver_bump: hasUpdate ? 'patch' : 'none', - check_status: 'ok', + check_status: verificationFailed ? 'failed' : 'ok', + check_error: over.check_error ?? null, }, ], summary: { @@ -47,11 +59,48 @@ function previewBody(hasUpdate: boolean, buildServices: string[] = []) { next_tag: '1.25', semver_bump: hasUpdate ? 'patch' : 'none', update_kind: hasUpdate ? 'digest' : 'none', - blocked: false, - blocked_reason: null, + blocked: over.blocked ?? false, + blocked_reason: over.blocked_reason ?? null, has_build_services: hasBuild, rebuild_available: hasBuild, - check_status: 'ok', + check_status: verificationFailed ? 'failed' : 'ok', + verification_failed: verificationFailed, + verification_error: over.verification_error ?? null, + }, + changelog: null, + }; +} + +/** Two-service preview: `web` confirms a digest update, `db` fails digest verification. */ +function mixedPreviewBody(over: { blocked?: boolean; blocked_reason?: string | null } = {}) { + return { + build_services: [], + images: [ + { + service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25', + has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', + check_status: 'ok', check_error: null, + }, + { + service: 'db', image: 'private.example/db:latest', current_tag: 'latest', next_tag: null, + has_update: false, digest_update: false, tag_update: false, semver_bump: 'none', + check_status: 'failed', check_error: 'Registry unreachable', digest_error: 'Registry unreachable', + }, + ], + summary: { + has_update: true, + primary_image: 'nginx', + current_tag: '1.25', + next_tag: '1.25', + semver_bump: 'patch', + update_kind: 'digest', + blocked: over.blocked ?? false, + blocked_reason: over.blocked_reason ?? null, + has_build_services: false, + rebuild_available: false, + check_status: 'partial', + verification_failed: true, + verification_error: 'Registry unreachable', }, changelog: null, }; @@ -552,3 +601,113 @@ describe('StackAnatomyPanel capability gating (capability off)', () => { expect(screen.queryByTestId('storage-tab')).not.toBeInTheDocument(); }); }); + +describe('StackAnatomyPanel digest verification failure', () => { + it('shows an update-check-status banner instead of an update claim when digest check errors', async () => { + vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/update-preview')) { + return jsonRes(previewBody(false, [], { + verification_failed: true, + verification_error: 'Registry unreachable', + check_error: 'Registry unreachable', + })); + } + if (url.includes('/scan-status')) return jsonRes({ status: 'ok' }); + return jsonRes(null, false); + }); + render(panel(false)); + expect(await screen.findByTestId('update-check-status-banner')).toBeInTheDocument(); + expect(screen.queryByTestId('update-available-banner')).toBeNull(); + expect(screen.getByText(/Registry unreachable/)).toBeInTheDocument(); + }); + + it('does not claim "safe to apply" and withholds the full-stack Apply button when a confirmed update sits alongside a DIFFERENT image failing verification', async () => { + vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/update-preview')) return jsonRes(mixedPreviewBody()); + if (url.includes('/scan-status')) return jsonRes({ status: 'ok' }); + return jsonRes(null, false); + }); + render(panel(false)); + // A confirmed update alongside a different image's failure renders only + // the update banner (mutually exclusive with the check-status banner); + // the review-required hold is inline in that banner's lead-in text. + expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument(); + expect(screen.queryByTestId('update-check-status-banner')).toBeNull(); + expect(screen.getByText(/review required/i)).toBeInTheDocument(); + expect(screen.queryByText(/safe to apply/i)).toBeNull(); + expect(screen.queryByRole('button', { name: /^apply$/i })).toBeNull(); + }); + + it('keeps a single image with a confirmed digest update fully actionable when there is no digest error anywhere in the stack', async () => { + // digest_update and digest_error are mutually exclusive for one image (both + // derive from the same comparison), so a confirmed digest update is always + // its own clean case, with nothing to hold it for review. + vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/update-preview')) return jsonRes(previewBody(true)); + if (url.includes('/scan-status')) return jsonRes({ status: 'ok' }); + return jsonRes(null, false); + }); + render(panel(false)); + expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument(); + expect(screen.getByText(/same-tag digest rebuild/i)).toBeInTheDocument(); + expect(screen.queryByText(/review required/i)).toBeNull(); + expect(screen.getByRole('button', { name: /^apply$/i })).toBeEnabled(); + }); + + it('holds a confirmed update for review even when the other image\'s own tag update masks its digest error into an overall ok check_status', async () => { + // The db image's tag compare confirmed an update, so the backend masks its + // digest failure into check_status 'ok' + check_error null. Only the + // unmasked digest_error still reports that its content went unverified. + vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/update-preview')) { + return jsonRes({ + build_services: [], + images: [ + { + service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25', + has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', + check_status: 'ok', check_error: null, digest_error: null, + }, + { + service: 'db', image: 'private.example/db:latest', current_tag: '2.0', next_tag: '2.1', + has_update: true, digest_update: false, tag_update: true, semver_bump: 'minor', + check_status: 'ok', check_error: null, digest_error: 'Registry unreachable', + }, + ], + summary: { + has_update: true, primary_image: 'nginx', current_tag: '1.25', next_tag: '1.25', + semver_bump: 'patch', update_kind: 'digest', blocked: false, blocked_reason: null, + has_build_services: false, rebuild_available: false, + check_status: 'ok', verification_failed: false, verification_error: null, + }, + changelog: null, + }); + } + if (url.includes('/scan-status')) return jsonRes({ status: 'ok' }); + return jsonRes(null, false); + }); + render(panel(false)); + expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument(); + expect(screen.getByText(/review required/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^apply$/i })).toBeNull(); + }); + + it('keeps the blocked (major-bump policy) banner precedence over the verification-failure review-required banner', async () => { + vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/update-preview')) { + return jsonRes(mixedPreviewBody({ blocked: true, blocked_reason: 'Major version bump' })); + } + if (url.includes('/scan-status')) return jsonRes({ status: 'ok' }); + return jsonRes(null, false); + }); + render(panel(false)); + expect(await screen.findByText(/review required/i)).toBeInTheDocument(); + expect(screen.getByText(/Major version bump/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^apply$/i })).toBeNull(); + }); +}); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index c738fa40..77873a88 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -8,6 +8,7 @@ import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview'; import { isActionableUpdatePreview, isPreviewUncertain, + isReviewRequiredUpdatePreview, isTagOnlyAdvisory, } from '@/lib/updatePreviewActionability'; import { cn } from '@/lib/utils'; @@ -56,6 +57,9 @@ interface UpdatePreviewImage { tag_update?: boolean; semver_bump: SemverBump; check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable'; + check_error?: string | null; + /** This image's own digest-comparison failure; not masked by a confirmed tag update. */ + digest_error?: string | null; } interface UpdatePreviewSummary { @@ -70,6 +74,8 @@ interface UpdatePreviewSummary { has_build_services: boolean; rebuild_available: boolean; check_status?: 'ok' | 'partial' | 'failed'; + verification_failed?: boolean; + verification_error?: string | null; } interface UpdatePreview { @@ -372,16 +378,26 @@ export default function StackAnatomyPanel({ const hasUpdate = Boolean(updatePreview?.summary.has_update); const hasBuildServices = Boolean(updatePreview?.summary.has_build_services); const rebuildAvailable = Boolean(updatePreview?.summary.rebuild_available); + const verificationError = updatePreview?.summary.verification_error ?? null; const previewCheckStatus = updatePreview?.summary.check_status; const previewUncertain = isPreviewUncertain(updatePreview); + // Failed digest verification is never a verified rebuild claim, but a confirmed + // tag update or intentional local rebuild affordance still shows. const showUpdateBanner = hasUpdate || rebuildAvailable; const showCheckStatusBanner = previewUncertain && !showUpdateBanner; const updateKind = updatePreview?.summary.update_kind ?? 'none'; const blocked = Boolean(updatePreview?.summary.blocked); + // Another image in the stack failed digest verification: applying the + // full-stack update would pull/recreate that image as collateral, so the + // banner must not claim "safe to apply" and the Apply button is withheld + // (per-service update actions elsewhere are unaffected). Uses the same + // per-image logic as Fleet so the two surfaces never disagree; a stack + // whose only unverified image is the one being updated is unaffected. + const reviewRequired = isReviewRequiredUpdatePreview(updatePreview); const updatedImages = (updatePreview?.images ?? []).filter((img) => img.has_update); const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked ? 'danger' - : bump === 'minor' ? 'warn' : 'ok'; + : bump === 'minor' || reviewRequired ? 'warn' : 'ok'; const bannerTone = bannerSeverity === 'danger' ? 'border-destructive/40 bg-destructive/[0.06] text-destructive' : bannerSeverity === 'warn' @@ -397,7 +413,7 @@ export default function StackAnatomyPanel({ const canApplyPreview = isActionableUpdatePreview(updatePreview); let bannerLeadIn = ''; - if (blocked) { + if (blocked || reviewRequired) { bannerLeadIn = 'review required'; } else if (tagOnlyAdvisory) { bannerLeadIn = 'newer tag · edit Compose pin'; @@ -598,14 +614,16 @@ export default function StackAnatomyPanel({
{previewCheckStatus === 'failed' ? 'Update check failed' : 'Update check incomplete'}
- {previewCheckStatus === 'failed' - ? 'Registry checks could not verify image status. Retained update indicators may be stale.' - : 'Some image checks did not complete. Status is uncertain until a full check succeeds.'} + {verificationError + ?? (previewCheckStatus === 'failed' + ? 'Registry checks could not verify image status. Retained update indicators may be stale.' + : 'Some image checks did not complete. Status is uncertain until a full check succeeds.')}
)} diff --git a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx index 4fa2efd5..b91dea56 100644 --- a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx +++ b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx @@ -1,6 +1,7 @@ /** * MobileReadinessCard is the one-up phone card for the Updates readiness board. - * Its Apply button is disabled only when the update is blocked (major bump) or + * Its Apply button is disabled when the update is blocked (major bump), + * digest verification failed without a confirmed update, or an apply is * already in flight; manual apply works regardless of schedule. The Auto: Off * pill still reflects the absence of a covering auto-update schedule. */ @@ -26,9 +27,12 @@ vi.mock('@/context/DeployFeedbackContext', () => ({ // loadReadiness useCallback identity and re-triggers its effect forever. const mockNodeMeta = new Map(); const mockRefreshNodeMeta = vi.fn(); +const mockNodes: { id: number; name: string; type: 'local' | 'remote'; status: string }[] = [ + { id: 1, name: 'Local', type: 'local', status: 'online' }, +]; vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ - nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }], + nodes: mockNodes, nodeMeta: mockNodeMeta, refreshNodeMeta: mockRefreshNodeMeta, }), @@ -36,7 +40,17 @@ vi.mock('@/context/NodeContext', () => ({ import { apiFetch, fetchForNode } from '@/lib/api'; import { requestServiceUpdate } from '@/lib/serviceUpdate'; -import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView'; +import AutoUpdateReadinessView, { + MobileReadinessCard, + CadenceStrip, + type StackCard, +} from '../AutoUpdateReadinessView'; +import { + isActionableUpdatePreview, + isClearedUpdatePreview, + isReviewRequiredUpdatePreview, + isVerificationOnlyPreview, +} from '@/lib/updatePreviewActionability'; import { isAuthoritativeNegativePreview } from '@/types/imageUpdates'; function card(over: Partial = {}): StackCard { @@ -74,6 +88,208 @@ function card(over: Partial = {}): StackCard { const apply = () => screen.getByRole('button', { name: /Apply now/i }); +function previewSummary(over: Record = {}) { + return { + stack_name: 'redis', + images: [], + rollback_target: null, + changelog: null, + summary: { + has_update: false, + primary_image: 'redis', + current_tag: '8.8.0', + next_tag: '8.8.0', + semver_bump: 'none' as const, + update_kind: 'none' as const, + blocked: false, + blocked_reason: null, + rebuild_available: false, + check_status: 'ok' as const, + verification_failed: false, + verification_error: null, + ...over, + }, + }; +} + +describe('verification preview helpers', () => { + it('treats verification failure without update/rebuild as verification-only', () => { + const preview = previewSummary({ verification_failed: true }); + expect(isVerificationOnlyPreview(preview)).toBe(true); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); + + it('holds a verified update for review, not full-stack-actionable, when another image failed verification', () => { + const preview = previewSummary({ + verification_failed: true, + has_update: true, + update_kind: 'tag', + semver_bump: 'patch', + next_tag: '8.8.1', + }); + expect(isVerificationOnlyPreview(preview)).toBe(false); + expect(isReviewRequiredUpdatePreview(preview)).toBe(true); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); + + it('holds a rebuild for review, not full-stack-actionable, when another image failed verification', () => { + const preview = previewSummary({ + verification_failed: true, + rebuild_available: true, + update_kind: 'digest', + }); + expect(isVerificationOnlyPreview(preview)).toBe(false); + expect(isReviewRequiredUpdatePreview(preview)).toBe(true); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); + + it('does not treat a review-required mixed state as cleared', () => { + const preview = previewSummary({ + verification_failed: true, + has_update: true, + update_kind: 'tag', + semver_bump: 'patch', + next_tag: '8.8.1', + }); + expect(isClearedUpdatePreview(preview)).toBe(false); + }); + + it('keeps a single image with a confirmed digest update actionable when there is no digest error anywhere in the stack', () => { + // digest_update and digest_error are mutually exclusive for one image (both + // derive from the same comparison), so a confirmed digest update is always + // its own clean case, with nothing to hold it for review. + const preview = { + ...previewSummary({ has_update: true, update_kind: 'digest', semver_bump: 'patch' }), + images: [{ has_update: true, digest_update: true, digest_error: null }], + }; + expect(isReviewRequiredUpdatePreview(preview)).toBe(false); + expect(isActionableUpdatePreview(preview)).toBe(true); + }); + + it('holds a confirmed update for review when a genuinely different image failed digest verification', () => { + const preview = { + ...previewSummary({ verification_failed: true, has_update: true, update_kind: 'digest', semver_bump: 'patch' }), + images: [ + { has_update: true, digest_update: true, digest_error: null }, + { has_update: false, digest_update: false, digest_error: 'Registry unreachable' }, + ], + }; + expect(isReviewRequiredUpdatePreview(preview)).toBe(true); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); + + it('holds a confirmed update for review even when the other image\'s own tag update masks its digest error into an overall ok check_status', () => { + // The second image's tag compare confirmed an update, so the backend masks + // its digest failure into check_status 'ok' + check_error null. Only the + // unmasked digest_error still reports that its content went unverified. + const preview = { + ...previewSummary({ has_update: true, update_kind: 'digest', semver_bump: 'patch', check_status: 'ok' }), + images: [ + { has_update: true, digest_update: true, check_status: 'ok', check_error: null, digest_error: null }, + { + has_update: true, digest_update: false, tag_update: true, + check_status: 'ok', check_error: null, digest_error: 'Registry unreachable', + }, + ], + }; + expect(isReviewRequiredUpdatePreview(preview)).toBe(true); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); + + it('rejects blocked updates as actionable', () => { + const preview = previewSummary({ + has_update: true, + blocked: true, + blocked_reason: 'Major version bump', + semver_bump: 'major', + update_kind: 'tag', + }); + expect(isVerificationOnlyPreview(preview)).toBe(false); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); + + it('keeps a legacy preview that reports its own has_update:true fully actionable (trusts the remote\'s own confirmed update)', () => { + // isLegacyPreview only guards isClearedUpdatePreview: a legacy remote that + // itself confirms an update is not additionally gated by a verification + // signal it never had. This is deliberate, not an oversight -- the + // sticky/preview agreement (both say "update") is what matters here. + const legacyPreview = { + stack_name: 'redis', + images: [], + rollback_target: null, + changelog: null, + summary: { + has_update: true, + primary_image: 'redis', + current_tag: '8.8.0', + next_tag: '8.8.1', + semver_bump: 'patch' as const, + update_kind: 'digest' as const, + blocked: false, + blocked_reason: null, + // check_status, verification_failed, and rebuild_available intentionally omitted. + }, + }; + expect(isActionableUpdatePreview(legacyPreview)).toBe(true); + expect(isClearedUpdatePreview(legacyPreview)).toBe(false); + }); + + it('does not treat a legacy remote preview (verification_failed missing entirely) as cleared', () => { + // The current backend always sends verification_failed (true or false); + // its total absence means the response came from an older remote that + // predates digest verification and cannot vouch for a clean result. + const legacyPreview = { + stack_name: 'redis', + images: [], + rollback_target: null, + changelog: null, + summary: { + has_update: false, + primary_image: 'redis', + current_tag: '8.8.0', + next_tag: '8.8.0', + semver_bump: 'none' as const, + update_kind: 'none' as const, + blocked: false, + blocked_reason: null, + // verification_failed and rebuild_available intentionally omitted. + }, + }; + expect(isClearedUpdatePreview(legacyPreview)).toBe(false); + }); + + it('returns false for null/undefined previews', () => { + expect(isVerificationOnlyPreview(null)).toBe(false); + expect(isVerificationOnlyPreview(undefined)).toBe(false); + expect(isActionableUpdatePreview(null)).toBe(false); + expect(isActionableUpdatePreview(undefined)).toBe(false); + expect(isClearedUpdatePreview(null)).toBe(false); + expect(isClearedUpdatePreview(undefined)).toBe(false); + }); + + it('treats a successful no-update preview as cleared', () => { + const preview = previewSummary({ + has_update: false, + rebuild_available: false, + verification_failed: false, + }); + expect(isClearedUpdatePreview(preview)).toBe(true); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); + + it('does not treat blocked updates as cleared', () => { + const preview = previewSummary({ + has_update: true, + blocked: true, + blocked_reason: 'Major version bump', + semver_bump: 'major', + update_kind: 'tag', + }); + expect(isClearedUpdatePreview(preview)).toBe(false); + expect(isActionableUpdatePreview(preview)).toBe(false); + }); +}); + it('enables Apply for a safe, non-blocked update', () => { render(); expect(apply()).toBeEnabled(); @@ -130,6 +346,96 @@ it('disables Apply when the update is blocked (major bump)', () => { expect(apply()).toBeDisabled(); }); +it('disables Apply for verification-only preview (no confirmed update)', () => { + render( + , + ); + expect(apply()).toBeDisabled(); + expect(screen.getByTestId('readiness-verification-failed')).toBeInTheDocument(); +}); + +it('holds full-stack Apply for review when one image confirms an update and another fails verification, but keeps per-service Apply enabled', () => { + const onApplyService = vi.fn(); + render( + , + ); + expect(apply()).toBeDisabled(); + expect(screen.getByTestId('readiness-verification-warning')).toBeInTheDocument(); + expect(screen.queryByText(/Safe · patch/i)).toBeNull(); + expect(screen.getByText(/Review · unverified/i)).toBeInTheDocument(); + const serviceApply = screen.getByRole('button', { name: /^Apply$/i }); + expect(serviceApply).toBeEnabled(); + fireEvent.click(serviceApply); + expect(onApplyService).toHaveBeenCalledWith('nextcloud', 1, 'confirmed'); +}); + +it('shows the blocked (major) badge, not the review-required badge, when both apply', () => { + render( + , + ); + expect(screen.getByText(/Blocked · major/i)).toBeInTheDocument(); + expect(screen.queryByText(/Review · unverified/i)).toBeNull(); + expect(apply()).toBeDisabled(); +}); + it('disables Apply while an update is in flight', () => { render(); // While applying the button label switches to "Applying...". @@ -326,6 +632,75 @@ describe('AutoUpdateReadinessView desktop Apply now', () => { expect(mockedFetchForNode.mock.calls.filter((c) => String(c[0]).includes('/update-preview')).length).toBeGreaterThanOrEqual(2); }); }); + + it('holds the desktop full-stack Apply for review, but keeps per-service Apply enabled, when a confirmed update sits alongside another image failing verification', async () => { + mockNodeMeta.set(1, { + version: '1.0.0', + capabilities: ['service-scoped-update'], + fetchedAt: Date.now(), + }); + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ ok: true, json: async () => ({ '1': { mixed: true } }) }); + } + if (url.startsWith('/scheduled-tasks')) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockResolvedValue({ + ok: true, + json: async () => ({ + stack_name: 'mixed', + images: [ + { service: 'confirmed', image: 'alpine:latest', current_tag: 'latest', next_tag: 'latest', has_update: true, digest_update: true, semver_bump: 'patch', digest_error: null }, + { service: 'failing', image: 'private.example/db:latest', current_tag: 'latest', next_tag: null, has_update: false, digest_update: false, semver_bump: 'none', digest_error: 'Registry unreachable' }, + ], + summary: { + has_update: true, + primary_image: 'alpine:latest', + current_tag: 'latest', + next_tag: 'latest', + semver_bump: 'patch', + update_kind: 'digest', + blocked: false, + blocked_reason: null, + rebuild_available: false, + verification_failed: true, + verification_error: 'Registry unreachable', + }, + rollback_target: null, + changelog: null, + }), + }); + vi.mocked(requestServiceUpdate).mockResolvedValue({ + ok: true, + mode: 'update', + serviceName: 'confirmed', + healthGateId: null, + observing: false, + recoveryId: null, + recoveryAvailable: false, + }); + + render(); + + const applyBtn = await screen.findByRole('button', { name: /Apply now/i }); + expect(applyBtn).toBeDisabled(); + expect(screen.queryByText(/Safe · patch/i)).toBeNull(); + expect(screen.getByText(/Review · unverified/i)).toBeInTheDocument(); + expect(screen.getByTestId('readiness-verification-warning')).toBeInTheDocument(); + + const serviceApply = screen.getByRole('button', { name: /^Apply$/i }); + expect(serviceApply).toBeEnabled(); + await act(async () => { fireEvent.click(serviceApply); }); + await waitFor(() => { + expect(requestServiceUpdate).toHaveBeenCalledWith(expect.objectContaining({ + stackName: 'mixed', + serviceName: 'confirmed', + })); + }); + }); }); /** @@ -341,6 +716,7 @@ describe('AutoUpdateReadinessView check-failed advisory', () => { afterEach(() => { mockedFetch.mockReset(); mockedFetchForNode.mockReset(); + mockNodes.splice(0, mockNodes.length, { id: 1, name: 'Local', type: 'local', status: 'online' }); }); it('lists local stacks whose check failed, with the reason', async () => { @@ -368,6 +744,434 @@ describe('AutoUpdateReadinessView check-failed advisory', () => { // An ok stack with no update must not appear in the advisory. expect(screen.queryByText('web')).toBeNull(); }); + + it('keeps sticky has_update+failed stacks in the advisory, not the update card grid', async () => { + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ + ok: true, + json: async () => ({ + '1': { grafana: true, web: true }, + }), + }); + } + if (url.startsWith('/scheduled-tasks')) return Promise.resolve({ ok: true, json: async () => [] }); + if (url === '/image-updates/detail') { + return Promise.resolve({ + ok: true, + json: async () => ({ + grafana: { hasUpdate: true, checkStatus: 'failed', lastError: 'Registry unreachable', checkedAt: 1 }, + web: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 }, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockImplementation((url: string) => { + if (String(url).includes('/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'web', + images: [{ service: 'web', image: 'nginx:1', current_tag: '1', next_tag: '2', has_update: true, semver_bump: 'patch', check_error: null }], + summary: { + has_update: true, + primary_image: 'nginx:1', + current_tag: '1', + next_tag: '2', + semver_bump: 'patch', + update_kind: 'tag', + blocked: false, + blocked_reason: null, + verification_failed: false, + verification_error: null, + }, + rollback_target: null, + changelog: null, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => null }); + }); + + render(); + + expect(await screen.findByText(/could not be checked/i)).toBeInTheDocument(); + expect(screen.getByText('grafana')).toBeInTheDocument(); + expect(screen.getByText(/Registry unreachable/)).toBeInTheDocument(); + // web remains a confirmed update card; grafana must not also render as a stack card heading. + await waitFor(() => { + const headings = screen.getAllByText('web'); + expect(headings.length).toBeGreaterThan(0); + }); + }); + + it('drops verification-only sticky stacks from the card grid into the advisory', async () => { + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ + ok: true, + json: async () => ({ + '1': { redis: true }, + }), + }); + } + if (url.startsWith('/scheduled-tasks')) { + return Promise.resolve({ + ok: true, + json: async () => ([{ + id: 1, + enabled: true, + action: 'update', + target_type: 'stack', + target_id: 'redis', + node_id: 1, + next_run_at: Date.now() + 60_000, + }]), + }); + } + if (url === '/image-updates/detail') { + // Sticky hasUpdate with a successful check still enters the fleet grid; + // only the fresh preview can prove verification-only. + return Promise.resolve({ + ok: true, + json: async () => ({ + redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 }, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockImplementation((url: string) => { + if (String(url).includes('/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'redis', + images: [{ + service: 'redis', + image: 'redis:8.8.0', + current_tag: '8.8.0', + next_tag: '8.8.0', + has_update: false, + semver_bump: 'none', + check_error: 'Could not verify digest', + }], + summary: { + has_update: false, + primary_image: 'redis:8.8.0', + current_tag: '8.8.0', + next_tag: '8.8.0', + semver_bump: 'none', + update_kind: 'none', + blocked: false, + blocked_reason: null, + rebuild_available: false, + verification_failed: true, + verification_error: 'Could not verify digest', + }, + rollback_target: null, + changelog: null, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => null }); + }); + + render(); + + expect(await screen.findByText(/could not be checked/i)).toBeInTheDocument(); + expect(screen.getByText('redis')).toBeInTheDocument(); + expect(screen.getByText(/Could not verify digest/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Apply now/i })).toBeNull(); + expect(screen.getByText('No verified updates')).toBeInTheDocument(); + expect(screen.queryByText(/Everything is up to date/)).toBeNull(); + expect(screen.getByText('No verified updates pending')).toBeInTheDocument(); + expect(screen.queryByText(/ready to apply automatically/)).toBeNull(); + }); + + it('keeps actionable cards while dropping verification-only stacks to the advisory', async () => { + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ + ok: true, + json: async () => ({ '1': { web: true, redis: true } }), + }); + } + if (url.startsWith('/scheduled-tasks')) { + return Promise.resolve({ + ok: true, + json: async () => ([ + { + id: 1, enabled: true, action: 'update', target_type: 'stack', + target_id: 'web', node_id: 1, next_run_at: Date.now() + 60_000, + }, + { + id: 2, enabled: true, action: 'update', target_type: 'stack', + target_id: 'redis', node_id: 1, next_run_at: Date.now() + 60_000, + }, + ]), + }); + } + if (url === '/image-updates/detail') { + return Promise.resolve({ + ok: true, + json: async () => ({ + web: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 }, + redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 }, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockImplementation((url: string) => { + if (String(url).includes('/stacks/web/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'web', + images: [{ + service: 'web', image: 'nginx:1', current_tag: '1', next_tag: '2', + has_update: true, digest_update: true, semver_bump: 'patch', check_status: 'ok', check_error: null, + }], + summary: { + has_update: true, primary_image: 'nginx:1', current_tag: '1', next_tag: '2', + semver_bump: 'patch', update_kind: 'digest', blocked: false, blocked_reason: null, + check_status: 'ok', verification_failed: false, verification_error: null, + }, + rollback_target: null, changelog: null, + }), + }); + } + if (String(url).includes('/stacks/redis/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'redis', + images: [{ service: 'redis', image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', has_update: false, semver_bump: 'none', check_error: 'verify failed' }], + summary: { + has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', + semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null, + rebuild_available: false, verification_failed: true, verification_error: 'verify failed', + }, + rollback_target: null, changelog: null, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => null }); + }); + + render(); + + expect(await screen.findByRole('button', { name: /Apply now/i })).toBeEnabled(); + expect(screen.getByText(/1 of 1 ready to apply automatically/)).toBeInTheDocument(); + expect(screen.getByText(/could not be checked/i)).toBeInTheDocument(); + expect(screen.getByText(/verify failed/)).toBeInTheDocument(); + }); + + it('drops a sticky cleared preview from the card grid without an advisory', async () => { + // Sticky fleet still lists the stack, but a successful fresh preview proves + // no update/rebuild. The false pending card must disappear. + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ ok: true, json: async () => ({ '1': { redis: true } }) }); + } + if (url.startsWith('/scheduled-tasks')) { + return Promise.resolve({ + ok: true, + json: async () => ([{ + id: 1, enabled: true, action: 'update', target_type: 'stack', + target_id: 'redis', node_id: 1, next_run_at: Date.now() + 60_000, + }]), + }); + } + if (url === '/image-updates/detail') { + return Promise.resolve({ + ok: true, + json: async () => ({ + redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 }, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockImplementation((url: string) => { + if (String(url).includes('/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'redis', + images: [{ + service: 'redis', image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', + has_update: false, semver_bump: 'none', check_status: 'ok', check_error: null, + }], + summary: { + has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', + semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null, + rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null, + }, + rollback_target: null, changelog: null, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => null }); + }); + + render(); + + expect(await screen.findByText(/Everything is up to date/)).toBeInTheDocument(); + expect(screen.getByText(/All stacks on current builds/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Apply now/i })).toBeNull(); + expect(screen.queryByText(/could not be checked/i)).toBeNull(); + expect(screen.queryByText(/ready to apply automatically/)).toBeNull(); + }); + + it('keeps a sticky card instead of silently clearing it when a remote sends a legacy preview missing verification_failed entirely', async () => { + // Same sticky-fleet shape as the cleared-preview test above, but the + // fresh preview response is missing verification_failed/rebuild_available + // entirely (an older remote node's shape), so it must not be trusted as + // proof the stack is clean. + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ ok: true, json: async () => ({ '1': { redis: true } }) }); + } + if (url.startsWith('/scheduled-tasks')) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url === '/image-updates/detail') { + return Promise.resolve({ + ok: true, + json: async () => ({ + redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 }, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockImplementation((url: string) => { + if (String(url).includes('/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'redis', + images: [], + summary: { + has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', + semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null, + // verification_failed and rebuild_available intentionally omitted (legacy remote shape). + }, + rollback_target: null, changelog: null, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => null }); + }); + + render(); + + // Both the retained card and the advisory line name the stack. + expect(await screen.findAllByText('redis')).toHaveLength(2); + expect(screen.queryByText(/Everything is up to date/)).toBeNull(); + // The retained card alone doesn't explain itself; the advisory must. + expect(screen.getByText(/predates digest verification/i)).toBeInTheDocument(); + }); + + it('does not pair a legacy preview\'s own confirmed update with a contradictory "could not be checked" advisory', async () => { + // The remote DID check and confirmed an update via its own (older) logic; + // flagging it as unchecked right next to an enabled Apply button would + // contradict the card sitting beside it. + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ ok: true, json: async () => ({ '1': { redis: true } }) }); + } + if (url.startsWith('/scheduled-tasks')) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url === '/image-updates/detail') { + return Promise.resolve({ + ok: true, + json: async () => ({ + redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 }, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockImplementation((url: string) => { + if (String(url).includes('/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'redis', + images: [], + summary: { + has_update: true, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.1', + semver_bump: 'patch', update_kind: 'digest', blocked: false, blocked_reason: null, + // check_status, verification_failed, and rebuild_available intentionally omitted (legacy remote shape). + }, + rollback_target: null, changelog: null, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => null }); + }); + + render(); + + expect(await screen.findByRole('button', { name: /Apply now/i })).toBeEnabled(); + expect(screen.queryByText(/predates digest verification/i)).toBeNull(); + expect(screen.queryByText(/could not be checked/i)).toBeNull(); + }); + + it('labels remote verification-only stacks with the node name in the advisory', async () => { + mockNodes.splice(0, mockNodes.length, + { id: 1, name: 'Local', type: 'local', status: 'online' }, + { id: 2, name: 'Edge', type: 'remote', status: 'online' }, + ); + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') { + return Promise.resolve({ + ok: true, + json: async () => ({ '2': { redis: true } }), + }); + } + if (url.startsWith('/scheduled-tasks')) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + // Local-only detail cannot see remote sticky failures; preview must move them. + if (url === '/image-updates/detail') { + return Promise.resolve({ ok: true, json: async () => ({}) }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + mockedFetchForNode.mockImplementation((url: string, nodeId: number) => { + if (nodeId === 2 && String(url).includes('/update-preview')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + stack_name: 'redis', + images: [{ + service: 'redis', image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', + has_update: false, semver_bump: 'none', check_error: 'Could not verify digest', + }], + summary: { + has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', + semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null, + rebuild_available: false, verification_failed: true, verification_error: 'Could not verify digest', + }, + rollback_target: null, changelog: null, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => null }); + }); + + render(); + + expect(await screen.findByText(/could not be checked/i)).toBeInTheDocument(); + expect(screen.getByText('redis (Edge)')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Apply now/i })).toBeNull(); + }); }); /** diff --git a/frontend/src/lib/updatePreviewActionability.ts b/frontend/src/lib/updatePreviewActionability.ts index 315c69b1..97ae3750 100644 --- a/frontend/src/lib/updatePreviewActionability.ts +++ b/frontend/src/lib/updatePreviewActionability.ts @@ -9,11 +9,20 @@ export interface UpdatePreviewActionImage { digest_update?: boolean; tag_update?: boolean; check_status?: string | null; + check_error?: string | null; + /** + * This image's own digest-comparison failure reason, independent of + * check_error: a confirmed tag-based update on the SAME image resolves + * check_status to 'ok' and nulls check_error, but digest_error stays set + * since the image's current tag content was never actually verified. + */ + digest_error?: string | null; } export interface UpdatePreviewActionSummary { has_update?: boolean; rebuild_available?: boolean; + verification_failed?: boolean; blocked?: boolean; check_status?: string | null; /** Present on current nodes; used for older remotes without digest/tag flags. */ @@ -21,8 +30,17 @@ export interface UpdatePreviewActionSummary { } export interface UpdatePreviewActionInput { - images?: UpdatePreviewActionImage[]; summary: UpdatePreviewActionSummary; + /** + * Per-image detail backing the summary. `has_update` and `digest_error` are + * independent per image (a tag-based update can be confirmed via the + * registry's tag list even when that same image's own digest comparison + * errored), so the stack-level `verification_failed` alone cannot say + * whether the failure belongs to the confirmed image itself or to a + * different one. Optional for callers that only have the aggregate summary + * (falls back to the older, more conservative aggregate-only judgment). + */ + images?: UpdatePreviewActionImage[]; } function summaryCheckOk(summary: UpdatePreviewActionSummary): boolean { @@ -59,6 +77,73 @@ export function isTagOnlyAdvisory(preview: UpdatePreviewActionInput | null | und return images.some((i) => i.has_update === true); } +/** + * True when `check_status` is missing entirely, not merely absent from a + * checked field. The current backend always includes this field on every + * update-preview response, so its absence after JSON parsing means the + * response came from an older remote node that predates check-status + * reporting. That remote's clean-looking flags cannot be trusted as proof + * there is nothing pending; a sticky fleet card must not be silently cleared + * on the strength of a preview that never checked. + */ +export function isLegacyPreview( + preview: UpdatePreviewActionInput | null | undefined, +): boolean { + if (!preview) return false; + return preview.summary.check_status === undefined; +} + +/** Digest verification failed with no confirmed update or rebuild. */ +export function isVerificationOnlyPreview( + preview: UpdatePreviewActionInput | null | undefined, +): boolean { + if (!preview) return false; + const s = preview.summary; + return Boolean(s.verification_failed) && !s.has_update && !s.rebuild_available; +} + +/** True when the last check did not complete authoritatively (partial or failed). */ +export function isPreviewUncertain(preview: UpdatePreviewActionInput | null | undefined): boolean { + if (!preview) return false; + const status = preview.summary.check_status; + return status === 'partial' || status === 'failed'; +} + +/** + * True when a full-stack apply would pull/recreate an image whose digest + * content was never verified, as collateral of applying a DIFFERENT image's + * confirmed update or a local rebuild. Reads digest_error, not check_error: + * a confirmed tag-based update on the SAME image resolves check_status to + * 'ok' and nulls check_error, but a full-stack apply still re-pulls that + * image's current tag, whose digest_error means its content was never + * confirmed. + */ +function hasUnverifiedOtherImage(preview: UpdatePreviewActionInput): boolean { + const s = preview.summary; + const images = preview.images; + if (!images || images.length === 0) { + // No per-image detail: fall back to the aggregate flags. + return Boolean(s.verification_failed) && Boolean(s.has_update || s.rebuild_available); + } + const hasUnverifiedImage = images.some((i) => Boolean(i.digest_error)); + if (!hasUnverifiedImage) return false; + return images.some((i) => Boolean(i.has_update)) || Boolean(s.rebuild_available); +} + +/** + * A confirmed update or rebuild exists, but another image in the same stack + * failed digest verification. A full-stack or scheduled apply would pull and + * recreate the unverified image as collateral, so it is held for review; a + * service-scoped apply targeting only the confirmed image is unaffected by + * this and uses its own per-image state instead. + */ +export function isReviewRequiredUpdatePreview( + preview: UpdatePreviewActionInput | null | undefined, +): boolean { + if (!preview) return false; + return hasUnverifiedOtherImage(preview); +} + /** Confirmed update or rebuild that may be applied from Fleet. */ export function isActionableUpdatePreview( preview: UpdatePreviewActionInput | null | undefined, @@ -66,6 +151,7 @@ export function isActionableUpdatePreview( if (!preview) return false; if (preview.summary.blocked) return false; if (!summaryCheckOk(preview.summary)) return false; + if (hasUnverifiedOtherImage(preview)) return false; return hasExecutableUpdate(preview); } @@ -84,8 +170,19 @@ export function isServiceApplyActionable( return Boolean(match.has_update && preview.summary.update_kind !== 'tag'); } -export function isPreviewUncertain(preview: UpdatePreviewActionInput | null | undefined): boolean { +/** + * Fresh preview successfully proved there is nothing to apply, and the stack + * is not held for major-bump review. Sticky fleet booleans must not keep these + * cards pending. + */ +export function isClearedUpdatePreview( + preview: UpdatePreviewActionInput | null | undefined, +): boolean { if (!preview) return false; - const status = preview.summary.check_status; - return status === 'partial' || status === 'failed'; + if (preview.summary.blocked) return false; + if (isLegacyPreview(preview)) return false; + if (isPreviewUncertain(preview)) return false; + if (isVerificationOnlyPreview(preview)) return false; + if (isReviewRequiredUpdatePreview(preview)) return false; + return !isActionableUpdatePreview(preview); }