diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 28572a4..bf9406d 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -21,7 +21,7 @@ import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from './mount-dedu import { resolveNanoCpusConflict } from './hostconfig-recreate'; import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge'; import { encodeRegistryAuth } from './registry-auth'; -import { isSystemContainer, classifyEmptyDigestImage } from './scheduler/tasks/update-utils'; +import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils'; import { deepDiff } from '../utils/diff.js'; import { getInstanceId } from './backups/identity'; import { isOwnedBackupHelper } from './backups/reap-core'; @@ -3704,6 +3704,52 @@ export async function getRegistryManifestDigest(imageName: string): Promise phantom "update available" (#1367). + * + * Fetches the index body and returns true if any local digest is one of its per-arch + * child digests (same image, just recorded per-arch). Best-effort: ANY failure + * (network, timeout, non-index body, unauthenticated) returns false so the caller + * keeps its existing "update available" verdict. NEVER throws, own short timeout, and + * runs ONLY on the already-flagged-as-update path - it can't slow or break the common + * case. A false "update available" is acceptable; breaking the check is not. + */ +async function localDigestMatchesRegistryChild( + imageName: string, + localDigests: string[] +): Promise { + try { + const { registry, repo, tag } = parseImageReference(imageName); + const token = await getRegistryBearerToken(registry, repo); + const headers: Record = { + 'User-Agent': 'Dockhand/1.0', + 'Accept': [ + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.index.v1+json' + ].join(', ') + }; + if (token) headers['Authorization'] = token; + + const response = await fetch(`https://${registry}/v2/${repo}/manifests/${tag}`, { + method: 'GET', + headers, + signal: AbortSignal.timeout(8000) + }); + if (!response.ok) { + await drainResponse(response); + return false; + } + const body: unknown = await response.json(); + return localDigestIsIndexChild(localDigests, body); + } catch { + return false; + } +} + export interface ImageUpdateCheckResult { hasUpdate: boolean; currentDigest?: string; @@ -3802,13 +3848,22 @@ export async function checkImageUpdateAvailable( } // Check if registry digest matches ANY of the local digests - const matchesLocal = localDigests.includes(registryDigest); - const hasUpdate = !matchesLocal; + if (localDigests.includes(registryDigest)) { + return { hasUpdate: false, currentDigest: currentRepoDigests[0] }; + } + + // The HEAD digest (always the manifest-list/index digest) didn't match. Before + // declaring an update, rule out the multi-arch false positive: a local per-arch + // child digest won't equal the index digest even when the image is current + // (#1367). Best-effort GET of the index; on any failure hasUpdate stays true. + if (await localDigestMatchesRegistryChild(imageName, localDigests)) { + return { hasUpdate: false, currentDigest: currentRepoDigests[0] }; + } return { - hasUpdate, + hasUpdate: true, currentDigest: currentRepoDigests[0], - registryDigest: hasUpdate ? registryDigest : undefined + registryDigest }; } catch (e: any) { return { hasUpdate: false, error: e.message }; diff --git a/src/lib/server/scheduler/tasks/update-utils.ts b/src/lib/server/scheduler/tasks/update-utils.ts index 1504b33..b15311e 100644 --- a/src/lib/server/scheduler/tasks/update-utils.ts +++ b/src/lib/server/scheduler/tasks/update-utils.ts @@ -65,6 +65,30 @@ export function classifyEmptyDigestImage( return { hasUpdate: true, isLocalImage: false, registryDigest }; } +/** + * Extract the per-architecture child manifest digests from a fetched manifest-list / + * OCI image-index body. Returns [] for anything that isn't an index (single-arch + * manifest, error body, garbage) - the caller treats "no children" as "no match". + * Pure + defensive so it can be unit-tested without touching the network (#1367). + */ +export function indexChildDigests(indexBody: unknown): string[] { + const manifests = (indexBody as { manifests?: unknown })?.manifests; + if (!Array.isArray(manifests)) return []; + return manifests + .map((m) => (m as { digest?: unknown })?.digest) + .filter((d): d is string => typeof d === 'string' && d.length > 0); +} + +/** + * True if any of the running image's local digests is one of the index's per-arch + * child digests - i.e. the local image IS the current multi-arch tag, just recorded + * by its per-arch digest instead of the index digest (#1367). + */ +export function localDigestIsIndexChild(localDigests: string[], indexBody: unknown): boolean { + const children = indexChildDigests(indexBody); + return children.some((d) => localDigests.includes(d)); +} + /** * Determine if an update should be blocked based on vulnerability criteria. */