fix: distinguish failed image-update checks from "up to date" (#1470)

* fix: distinguish failed image-update checks from "up to date"

The image-update detector collapsed every failure (registry unreachable,
missing auth, rate limit, unresolved local digest) into hasUpdate:false and
dropped the captured reason, so a failed check was indistinguishable from a
current image and never raised a notification, even while a manual stack
update still pulled a newer image.

Detection now records a tri-state per stack (ok / partial / failed) with the
failure reason, exposed via a new GET /api/image-updates/detail (the boolean
GET / is unchanged so fleet aggregation is unaffected). A fully-failed check
preserves the last known has_update, so a transient outage neither erases a
real update nor flaps the notification state. The sidebar shows a muted
"couldn't check" indicator with the reason on hover, and the Update board
lists stacks whose check failed in a "could not be checked" advisory.

Detector hardening: the manifest digest lookup issues HEAD first (falling back
to GET) so it no longer draws down Docker Hub's anonymous pull-rate budget, and
local RepoDigest matching is normalized so official library/* images resolve
their digest instead of falling through to a silent "no update".

* fix: preserve confirmed updates through partial checks; tighten failure surfacing

Address review findings on the tri-state image-update detection:

- A partial check (some images errored) no longer erases a previously
  confirmed update; only a fully-ok check can lower has_update, so a single
  image's registry blip cannot drop the stack's update and re-fire the
  notification on recovery. Adds a regression test.
- The image-level catch stores getErrorMessage(e) rather than raw String(e),
  since that value surfaces verbatim in the sidebar tooltip and readiness
  advisory.
- useImageUpdates and the readiness detail fetch now log unexpected non-ok
  responses instead of silently leaving stale state.
- Remove an unused checkFailedCount derivation (the row indicator is driven by
  the checkStatus prop).
- Reword the recordStackCheckFailure docstring and the HEAD-first comment.
This commit is contained in:
Anso
2026-06-26 16:16:34 -04:00
committed by GitHub
parent e3b3c3b857
commit d9b7911f12
19 changed files with 820 additions and 38 deletions
+57 -4
View File
@@ -50,8 +50,9 @@ export function parseImageRef(imageRef: string): ParsedRef | null {
return { registry, repo: rest, tag };
}
export function httpGet(
export function httpRequest(
url: string,
method: 'GET' | 'HEAD',
headers: Record<string, string> = {},
timeoutMs = 10000,
): Promise<HttpResult> {
@@ -63,7 +64,7 @@ export function httpGet(
settled = true;
fn();
};
const req = lib.get(url, { headers }, (res) => {
const req = lib.request(url, { method, headers }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => finish(() => resolve({
@@ -79,9 +80,18 @@ export function httpGet(
req.destroy(err);
finish(() => reject(err));
});
req.end();
});
}
export function httpGet(
url: string,
headers: Record<string, string> = {},
timeoutMs = 10000,
): Promise<HttpResult> {
return httpRequest(url, 'GET', headers, timeoutMs);
}
export async function getAuthToken(
registry: string,
repo: string,
@@ -129,6 +139,31 @@ const MANIFEST_ACCEPT = [
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
/** docker.io has three hostnames that all address the same registry. */
function canonicalRegistry(host: string): string {
if (host === 'docker.io' || host === 'index.docker.io' || host === 'registry-1.docker.io') {
return 'docker.io';
}
return host;
}
/**
* True when a local RepoDigest entry ("name@sha256:...") refers to the same
* registry + repository as the parsed image ref. Parses the name side through
* the same normalization as the image ref (Docker Hub's implicit `library/`
* namespace and default registry), replacing a fragile substring check that
* missed `library/*` official images: their RepoDigests read `nginx@sha256:...`,
* never `library/nginx@...`, so `name.includes('library/nginx')` was false.
*/
export function repoDigestMatchesRef(repoDigest: string, parsed: ParsedRef): boolean {
const at = repoDigest.indexOf('@');
if (at === -1) return false;
const parsedName = parseImageRef(repoDigest.slice(0, at));
if (!parsedName) return false;
return canonicalRegistry(parsedName.registry) === canonicalRegistry(parsed.registry)
&& parsedName.repo === parsed.repo;
}
export async function getRemoteDigest(
registry: string,
repo: string,
@@ -139,10 +174,28 @@ export async function getRemoteDigest(
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = `https://${registry}/v2/${repo}/manifests/${tag}`;
const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers);
// HEAD first: the registry returns docker-content-digest without
// transferring the manifest body, so it does not draw down Docker Hub's
// anonymous pull-rate budget the way a GET does (a GET can self-inflict a
// 429). Fall back to GET only when the registry rejects HEAD (405/501) or
// omits the digest header on a 200. A 401/403/404/429/5xx HEAD returns
// null without a GET retry: the bearer token is fetched up-front, so a
// 401 here is a real auth failure, not a token-scope challenge to retry.
const head = await httpRequest(url, 'HEAD', headers);
if (head.statusCode === 200) {
const digest = head.headers['docker-content-digest'];
if (typeof digest === 'string') return digest;
} else if (head.statusCode !== 405 && head.statusCode !== 501) {
// 401/403/404/429/5xx: a GET would fail the same way. Report as unreachable.
return null;
}
const res = await httpRequest(url, 'GET', headers);
if (res.statusCode !== 200) return null;
return (res.headers['docker-content-digest'] as string) ?? null;
const digest = res.headers['docker-content-digest'];
return typeof digest === 'string' ? digest : null;
} catch {
return null;
}