fix(image-updates): match any local RepoDigest against the remote tag (#1695)

* fix(image-updates): match any local RepoDigest against the remote tag

Docker can list a stale multi-arch index digest ahead of the current one
on the same image. Selecting only the first RepoDigest caused false
same-tag rebuilds (for example redis:8.8.0) even when another digest
equaled the registry primary. Compare every matching candidate and keep
fail-closed behavior for empty, unknown-platform, and classification errors.

Fixes #1684

* fix(image-updates): surface digest verification failures to operators

Carry comparator errors into update-preview as check_error / verification_failed,
prefer failed checks over sticky has_update in Fleet and the sidebar, and keep
Update Guard from claiming no pending update when verification failed.

* fix(e2e): align sidebar truncation spec with check-failed precedence

StackRow now shows the check-failed icon over a stale update dot, but
this spec still asserted the old precedence and failed deterministically
in CI on every attempt.

* fix(fleet): treat verification-only previews as non-actionable

Fresh update-preview wins over sticky fleet booleans: disable Apply, exclude
from ready counts, and move verification-only stacks into the check-failures
advisory (including remote-labeled names).

* fix(fleet): move preview actionability helpers out of the view

Exporting non-components from AutoUpdateReadinessView tripped react-refresh
lint in CI. Keep the helpers in a shared lib module and drop an unused mock arg.

* fix(fleet): drop sticky cards when fresh preview clears the update

A successful no-update preview now removes the pending Fleet card instead of
leaving Apply enabled. Verification-only stacks still go to the advisory, and
empty-state copy no longer claims all-clear while checks remain unresolved.

* fix(image-updates): hold full-stack apply for review when another image fails verification

A confirmed update or rebuild on one image previously left the whole stack
fully actionable even when a different image in the same stack failed digest
verification: Anatomy claimed "safe to apply", Update Guard reported ready,
and Fleet's full-stack Apply stayed enabled, all while showing the
verification-failure text right next to those claims.

isActionableUpdatePreview now requires no verification failure anywhere in
the stack; a new isReviewRequiredUpdatePreview flags the mixed state so Fleet
still surfaces the card (not silently cleared) with Apply now disabled and a
"Review · unverified" badge. Anatomy's banner says "review required" instead
of a bump-based safety claim and withholds its Apply button. Update Guard's
pending-update signal downgrades from ok to attention. Per-service apply
(Fleet's per-image row) is deliberately left enabled since a service-scoped
update to the confirmed image does not touch the unverified one.

* fix(image-updates): treat rebuild_available symmetrically with has_update in Update Guard

updatePreviewSignal only downgraded to 'attention' inside the has_update
branch, so a rebuild-only stack (has_update false, rebuild_available true)
with a sibling verification failure fell through to the plain
verification-only 'unknown' branch and never mentioned the pending rebuild,
inconsistent with isReviewRequiredUpdatePreview on the frontend which treats
has_update and rebuild_available the same way.

Also adds desktop-card coverage for the mixed state (previously only the
mobile card was exercised) and locks in blocked/major-bump precedence over
the new review-required badge/banner in both Fleet and Anatomy.

* fix(image-updates): derive the mixed-verification review-hold from per-image detail, not the stack aggregate

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 against the current tag errored (already covered by an
existing update-preview-service test). The stack-level verification_failed
and has_update flags can therefore both be true for the SAME single image,
which the previous review-hold treated identically to a genuinely different
image failing verification: Update Guard said "another image failed digest
verification" and Fleet told the user to "apply the confirmed service
individually" on a single-service stack where no such affordance exists.

isReviewRequiredUpdatePreview (and isActionableUpdatePreview) now walk the
preview's images to require a pure failure image (check_error, no has_update
of its own) alongside a genuinely different confirmed image or rebuild,
falling back to the old aggregate-only judgment when per-image detail is
unavailable. StackAnatomyPanel now imports the shared helper instead of
hand-rolling the same predicate, so Fleet and Anatomy cannot drift apart.
Backend updatePreviewSignal gets the same per-image treatment via a new
optional images parameter, threaded through from UpdateGuardService.

* fix(image-updates): fail closed on platform-unavailable indexes and legacy previews, allow anonymous tag listing

Four independent gaps from the same QA pass, all in the digest/tag
verification path this PR introduced or touches:

- compareLocalToRemoteTag now distinguishes a remote index with no descriptor
  at all for the local platform (including an empty or fully-filtered index)
  from a genuine mismatch: the former returns an error instead of reporting a
  speculative update. A node cannot pull a platform the index does not offer.
- selectLocalRepoDigests no longer falls back to a sole unrelated-repository
  RepoDigest when nothing matches the configured repo; comparing against a
  registry state that has nothing to do with the declared image risks a
  false update. Returns unresolved instead of guessing.
- isClearedUpdatePreview no longer treats a preview with verification_failed
  missing entirely (not merely false) as proof the stack is clean. The
  current backend always includes this field, so its absence identifies an
  older remote node's response, which cannot vouch for a clean result the
  way an explicit false can.
- listRegistryTags (and the underlying listRegistryTagsResult) no longer
  short-circuits to an empty list whenever no registry credentials are
  configured. getAuthToken already resolves anonymous tokens for public
  repositories; skipping it meant tag-based update detection silently never
  fired for any public image without a stored credential.

* fix(image-updates): correct platform-check overreach, add cache and advisory for prior fixes

Addresses code-review findings on the previous commit:

- The platform-unavailable check fired too eagerly: an index whose runnable
  descriptors legally omit platform (OCI-permitted, routed to exactDigests)
  has real pullable content, so it must not be confused with a genuinely
  empty or fully-filtered index. Now only errors when both platform-labeled
  descriptors and exactDigests are empty.
- listRegistryTags is now cached (15 min TTL): anonymous listing has no other
  rate limiting, and Fleet fans this out across every image on every reload.
- A legacy preview (kept rather than cleared) now also pushes a check-failure
  advisory entry explaining why, instead of rendering as an unexplained
  pending card.
- Corrected docstrings that described the old sole-unmatched-digest fallback
  and inverted how anonymous registry auth actually resolves.

Adds coverage for: nested-index and attestation-only-filtered platform
unavailability, a platform-less-but-populated index staying a match, the
unrelated-repo digest rejection wired through the real preview-computation
path (not just the registry-api unit), and legacy-preview interaction with
an actionable has_update:true.

* fix(image-updates): fail closed on mixed platform indexes, stop caching tag-list failures

Addresses a second review round on the previous commit, including an
empirically-verified regression:

- The exactDigests fallback was unconditional: an index mixing a
  platform-labeled descriptor for a DIFFERENT platform with an unlabeled leaf
  let that leaf stand in as this platform's content, reporting a speculative
  update for a genuinely incompatible platform. Now an unlabeled leaf is only
  trusted when it is the ONLY kind of descriptor in the index (nothing else
  claims a different platform); a mixed index errors instead.
- listRegistryTags was caching failed lookups for the full 15-minute TTL
  (a 429, an unreachable registry, or credentials not yet configured all
  looked identical to a real empty tag list). The fetcher now throws on
  failure so only a success is ever cached; CacheService's existing
  stale-on-error fallback still serves the last good list when one exists.
- The manual "Recheck" action now also drops the tag-list cache, so a newly
  published tag is visible immediately instead of waiting out the TTL.
- The legacy-preview advisory no longer fires when the same preview is
  already actionable on its own terms (a remote's own confirmed
  has_update/rebuild_available): pairing a "could not be checked" banner
  with an enabled Apply button next to it contradicted itself.
- Corrected docstrings and a self-contradictory inline comment left over
  from the prior fix.

New coverage: the exact mixed-index regression this round found and fixed,
cache hit/no-repeat-fetch and failure-not-cached behavior, and the
legacy-preview-plus-already-actionable non-contradiction.

* fix(image-updates): add digest_error unmasked field, reorder RiskBadge, fix actionability gates

Add digest_error to UpdatePreviewImage as an always-populated field that
is independent of check_status masking: 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
verified. Switch hasUnverifiedOtherImage to read digest_error.

Reorder RiskBadge precedence so reviewRequired is checked before
uncertain (both derive from check_error, so uncertain was unreachable).

Fix self-contradiction in test fixtures (digest_update:true +
check_error). Add masked-tag regression test with two-image fixture.
Simplify hasUnverifiedOtherImage per code review feedback.
This commit is contained in:
Anso
2026-07-25 21:57:30 -04:00
committed by GitHub
parent 0daddfde00
commit 6688da97b1
16 changed files with 2151 additions and 162 deletions
@@ -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<typeof import('../services/registry-api')>();
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) ─
+318 -3
View File
@@ -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);
@@ -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> = {}): 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> = {}): 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');
});
});
@@ -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,
@@ -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,
@@ -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<Parameters<typeof buildSummary>[1][number]>) => ({
const baseImage = (partial: Partial<Parameters<typeof buildSummary>[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);
});
@@ -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 {
+1 -1
View File
@@ -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'),
+84 -2
View File
@@ -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' };
}
},
};
+65 -20
View File
@@ -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<string, string> = { Accept: 'application/json', Authorization: `Bearer ${token}` };
const params = new URLSearchParams({ n: String(limit) });
+54 -4
View File
@@ -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: 'warning',
status: 'attention',
affectsVerdict: true,
detail: `${n} require a rebuild from source; the update rebuilds images and recreates containers.`,
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: 'unknown',
affectsVerdict: true,
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.' };
@@ -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 (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
Check uncertain
</span>
);
}
if (blocked || bump === 'major') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-destructive/40 bg-destructive/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-destructive">
@@ -189,6 +197,22 @@ function RiskBadge({
</span>
);
}
if (reviewRequired) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
Review · unverified
</span>
);
}
if (uncertain) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
Check uncertain
</span>
);
}
if (tagOnly) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-card-border bg-muted/30 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
@@ -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 (
<Card className="flex flex-col gap-4 p-5">
@@ -281,7 +314,15 @@ function StackReadinessCard({
Auto: Off
</span>
)}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
{previewLoaded && preview && (
<RiskBadge
bump={bump}
blocked={blocked}
reviewRequired={reviewRequired}
uncertain={isPreviewUncertain(preview)}
tagOnly={isTagOnlyAdvisory(preview)}
/>
)}
</div>
</div>
@@ -295,20 +336,45 @@ function StackReadinessCard({
(() => {
const p = preview!;
const blockedReason = p.summary.blocked_reason;
return (
<>
{p.summary.update_kind === 'digest' ? (
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 = (
<div className="font-mono text-xs text-warning" data-testid="readiness-verification-failed">
{withErrorDetail('Digest verification failed', verificationError)}
</div>
);
} else if (p.summary.update_kind === 'digest') {
headline = (
<div className="flex items-baseline gap-2 font-mono text-sm">
<span className="text-stat-subtitle">{p.summary.current_tag}</span>
<span className="text-brand text-[10px] leading-3 uppercase tracking-[0.18em]">
Rebuild available
</span>
</div>
) : (
);
} else {
headline = (
<VersionDiff
current={p.summary.current_tag}
next={p.summary.next_tag}
/>
);
}
return (
<>
{headline}
{verificationFailed && p.summary.has_update && (
<div className="font-mono text-[11px] text-warning" data-testid="readiness-verification-warning">
{withErrorDetail('Digest check could not be verified', verificationError)}
</div>
)}
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle/80">
@@ -368,8 +434,8 @@ function StackReadinessCard({
<Button
size="sm"
onClick={() => onApply(stack, nodeId)}
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
title={blocked ? (blockedReason ?? undefined) : undefined}
disabled={applyDisabled}
title={applyTitle}
className="gap-1.5"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
@@ -390,15 +456,17 @@ function ReadinessHero({
nodeCount,
refreshing,
onRefresh,
unresolvedChecks = false,
}: {
total: number;
ready: number;
nodeCount: number;
refreshing: boolean;
onRefresh: () => void;
unresolvedChecks?: boolean;
}) {
const headline = total === 0
? 'Everything is up to date'
? (unresolvedChecks ? 'No verified updates' : 'Everything is up to date')
: total === 1
? '1 update pending'
: `${total} updates pending`;
@@ -523,6 +591,11 @@ export function MobileReadinessCard({
const updatingImages = preview?.images.filter(i => i.has_update) ?? [];
const showServiceApply = canServiceUpdate && declaredServiceCount(preview) > 1 && updatingImages.length > 0;
const nextRun = scheduledTask?.next_run_at ?? null;
const verificationOnly = isVerificationOnlyPreview(preview);
const reviewRequired = isReviewRequiredUpdatePreview(preview);
const applyDisabled = !isActionableUpdatePreview(preview)
|| applying
|| applyingService !== null;
const changelog = preview?.changelog ?? 'No changelog available from the registry yet.';
const dot = changelog.indexOf('.');
const lead = dot > 0 ? changelog.slice(0, dot + 1) : '';
@@ -541,7 +614,15 @@ export function MobileReadinessCard({
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />Auto: Off
</span>
)}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
{previewLoaded && preview && (
<RiskBadge
bump={bump}
blocked={blocked}
reviewRequired={reviewRequired}
uncertain={isPreviewUncertain(preview)}
tagOnly={isTagOnlyAdvisory(preview)}
/>
)}
</div>
</div>
@@ -549,17 +630,38 @@ export function MobileReadinessCard({
<div className="font-mono text-xs text-stat-subtitle/80">Checking registry...</div>
) : failed ? (
<div className="font-mono text-xs text-destructive/80">Preview failed. Registry may be unreachable.</div>
) : (
<>
{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 = (
<div className="font-mono text-xs text-warning" data-testid="readiness-verification-failed">
{withErrorDetail('Digest verification failed', verificationError)}
</div>
);
} else if (p.summary.update_kind === 'digest') {
headline = (
<div className="flex items-baseline gap-2 font-mono text-[13px]">
<span className="text-stat-subtitle">{preview!.summary.current_tag}</span>
<span className="text-stat-subtitle">{p.summary.current_tag}</span>
<span className="text-[10px] uppercase tracking-[0.12em] text-brand">Rebuild available</span>
</div>
) : (
<VersionDiff current={preview!.summary.current_tag} next={preview!.summary.next_tag} />
);
} else {
headline = <VersionDiff current={p.summary.current_tag} next={p.summary.next_tag} />;
}
return (
<>
{headline}
{verificationFailed && p.summary.has_update && (
<div className="font-mono text-[11px] text-warning" data-testid="readiness-verification-warning">
{withErrorDetail('Digest check could not be verified', verificationError)}
</div>
)}
<div className="truncate font-mono text-[11px] text-stat-subtitle">{preview!.summary.primary_image ?? '-'}</div>
<div className="truncate font-mono text-[11px] text-stat-subtitle">{p.summary.primary_image ?? '-'}</div>
<div className="border-t border-dashed border-card-border pt-[9px] text-[12.5px] leading-[18px] text-stat-subtitle">
{lead && <b className="text-stat-title">{lead}</b>}{rest}
</div>
@@ -582,14 +684,15 @@ export function MobileReadinessCard({
</div>
)}
<div className="flex items-center justify-between gap-[10px] pt-0.5">
<span className={`font-mono text-[11px] ${blocked ? 'text-destructive' : 'text-stat-subtitle'}`}>
{nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)}</> : (blocked ? 'Held for review' : 'No schedule')}
<span className={`font-mono text-[11px] ${blocked || reviewRequired ? 'text-destructive' : 'text-stat-subtitle'}`}>
{nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)}</> : (blocked || reviewRequired ? 'Held for review' : 'No schedule')}
</span>
<Button
size="sm"
variant={blocked ? 'outline' : 'default'}
variant={blocked || verificationOnly || reviewRequired ? 'outline' : 'default'}
onClick={() => onApply(stack, nodeId)}
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
disabled={applyDisabled}
title={reviewRequired ? 'Another image in this stack failed digest verification; apply the confirmed service individually or resolve verification first.' : undefined}
className="gap-1.5"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
@@ -597,7 +700,8 @@ export function MobileReadinessCard({
</Button>
</div>
</>
)}
);
})()}
</div>
);
}
@@ -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<string>();
if (detailRes.ok) {
const detail = await detailRes.json() as Record<string, StackUpdateInfo>;
setCheckFailures(
Object.entries(detail)
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)),
);
.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)
<div className="flex h-full min-h-0 flex-col">
<Masthead
kicker="fleet · updates"
state={total === 0 ? 'Up to date' : `${total} pending`}
stateTone={total === 0 ? 'success' : 'warning'}
state={total === 0
? (checkFailures.length > 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}
/>
<div className="flex-1 min-h-0 overflow-y-auto p-4 [&>*+*]:mt-4">
@@ -1081,9 +1226,15 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
<div className="flex items-center justify-center py-16 font-mono text-xs text-stat-subtitle">Loading readiness...</div>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-card-border bg-card/40 py-16 text-center">
<Shield className="h-8 w-8 text-success/70" strokeWidth={1.5} aria-hidden="true" />
<div className="font-display italic text-xl text-stat-value">All stacks on current builds</div>
<div className="font-mono text-[11px] text-stat-subtitle">Sencho rechecks registries on the configured interval.</div>
<Shield className={`h-8 w-8 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
<div className="font-display italic text-xl text-stat-value">
{checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
</div>
<div className="font-mono text-[11px] text-stat-subtitle">
{checkFailures.length > 0
? 'Review the unresolved checks above, then recheck.'
: 'Sencho rechecks registries on the configured interval.'}
</div>
</div>
) : (
groups.map(group => (
@@ -1109,6 +1260,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
nodeCount={groups.length}
refreshing={refreshing}
onRefresh={handleRefresh}
unresolvedChecks={checkFailures.length > 0}
/>
<CadenceStrip cadence={cadence} className="-mt-3 pl-7" />
@@ -1127,10 +1279,14 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
</div>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-card-border bg-card/40 py-16">
<Shield className="h-8 w-8 text-success/70" strokeWidth={1.5} aria-hidden="true" />
<div className="font-display italic text-xl text-stat-value">All stacks on current builds</div>
<Shield className={`h-8 w-8 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
<div className="font-display italic text-xl text-stat-value">
{checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
</div>
<div className="font-mono text-[11px] text-stat-subtitle">
Sencho rechecks registries on the configured interval.
{checkFailures.length > 0
? 'Review the unresolved checks above, then recheck.'
: 'Sencho rechecks registries on the configured interval.'}
</div>
</div>
) : (
@@ -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();
});
});
+22 -4
View File
@@ -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({
<div
data-testid="update-check-status-banner"
className="mt-3 mb-3 rounded-lg border border-warning/40 bg-warning/[0.06] p-3 text-warning"
role="status"
>
<div className="font-mono text-xs uppercase tracking-wide">
{previewCheckStatus === 'failed' ? 'Update check failed' : 'Update check incomplete'}
</div>
<div className="mt-1 font-mono text-xs text-foreground/80 leading-relaxed">
{previewCheckStatus === 'failed'
{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.'}
: 'Some image checks did not complete. Status is uncertain until a full check succeeds.')}
</div>
</div>
)}
@@ -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> = {}): StackCard {
@@ -74,6 +88,208 @@ function card(over: Partial<StackCard> = {}): StackCard {
const apply = () => screen.getByRole('button', { name: /Apply now/i });
function previewSummary(over: Record<string, unknown> = {}) {
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(<MobileReadinessCard card={card()} onApply={vi.fn()} />);
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(
<MobileReadinessCard
card={card({
preview: {
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',
update_kind: 'none',
blocked: false,
blocked_reason: null,
rebuild_available: false,
verification_failed: true,
verification_error: 'Could not verify digest',
},
},
})}
onApply={vi.fn()}
/>,
);
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(
<MobileReadinessCard
card={card({
preview: {
stack_name: 'mixed', rollback_target: null, changelog: null,
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',
},
},
})}
canServiceUpdate
onApply={vi.fn()}
onApplyService={onApplyService}
/>,
);
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(
<MobileReadinessCard
card={card({
preview: {
stack_name: 'gitea', images: [], rollback_target: null, changelog: null,
summary: {
has_update: true, primary_image: 'gitea', current_tag: '1.21', next_tag: '2.0',
semver_bump: 'major', update_kind: 'tag', blocked: true, blocked_reason: 'Major version bump',
rebuild_available: false, verification_failed: true, verification_error: 'Registry unreachable',
},
},
})}
onApply={vi.fn()}
/>,
);
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(<MobileReadinessCard card={card({ applying: true })} onApply={vi.fn()} />);
// 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(<AutoUpdateReadinessView />);
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(<AutoUpdateReadinessView />);
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(<AutoUpdateReadinessView />);
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(<AutoUpdateReadinessView />);
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(<AutoUpdateReadinessView />);
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(<AutoUpdateReadinessView />);
// 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(<AutoUpdateReadinessView />);
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(<AutoUpdateReadinessView />);
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();
});
});
/**
+101 -4
View File
@@ -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);
}