fix: reconcile sticky update indicators with Anatomy preview (#1698)

* fix: reconcile sticky update indicators with Anatomy preview

Sidebar, Updates filter, and Fleet treated retained partial/failed
scanner has_update as confirmed. Keep raw state for retention/notifications,
project confirmed-only to APIs, show distinct incomplete indicators, and
clear sticky rows only after an authoritative-negative preview.

Closes #1685

* test: align sidebar truncate E2E with failed-over-retained precedence

Purple update indicators are confirmed-only; hasUpdate with a failed
check correctly shows the failed trailing icon.

* fix: clear confirmed update rows on authoritative-negative preview

Address audit SF-1/SF-2/SF-3: observation-watermark clears for older
ok+has_update rows (DB + memory gens), Fleet checkability parity with
backend not_checkable, and Updates chip confirmed-only regressions.

* fix: tombstone equal-generation writers on preview clear

Advance the per-stack write generation when clearing at the observation
watermark so a scanner reserved before preview cannot recreate the row
after an authoritative-negative reconcile.

* fix: clear sticky updates with digest and tag preview parity

Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear.

* test: set digestUpdate on auto-update checkImage mocks

Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path.

* fix: clear unused lint errors on sticky update branch

Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green.

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
Anso
2026-07-25 15:42:19 -04:00
committed by GitHub
parent 8b5407fcff
commit 0daddfde00
43 changed files with 2529 additions and 390 deletions
+60 -39
View File
@@ -61,6 +61,7 @@ import {
listRegistryTagsResult,
parseImageRef,
selectLocalRepoDigest,
selectLocalRepoDigests,
compareLocalToRemoteTag,
MANIFEST_CLASSIFICATION_CACHE_TTL_MS,
MANIFEST_INDEX_DESCRIPTOR_CAP,
@@ -407,6 +408,26 @@ describe('selectLocalRepoDigest', () => {
});
});
describe('selectLocalRepoDigests', () => {
const parsed = (ref: string) => {
const p = parseImageRef(ref);
if (!p) throw new Error(`unparseable ${ref}`);
return p;
};
const DIGEST_A = `sha256:${'a'.repeat(64)}`;
const DIGEST_B = `sha256:${'b'.repeat(64)}`;
it('returns every matching RepoDigest for the image ref', () => {
const digests = selectLocalRepoDigests([
`nginx@${DIGEST_A}`,
`nginx@${DIGEST_B}`,
`redis@sha256:${'c'.repeat(64)}`,
], parsed('nginx:latest'));
expect(digests).toEqual([DIGEST_A, DIGEST_B]);
});
});
// ─── compareLocalToRemoteTag ─────────────────────────────────────────────
//
// Reproduces and fixes the false-positive multi-arch update: a local
@@ -499,7 +520,7 @@ describe('compareLocalToRemoteTag', () => {
? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }
: { statusCode: 500, headers: {} }
);
const result = await compareLocalToRemoteTag(INDEX_DIGEST, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
});
@@ -516,7 +537,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([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' },
@@ -544,7 +565,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
expect(tagCallCount).toBe(1);
});
@@ -555,7 +576,7 @@ describe('compareLocalToRemoteTag', () => {
? { statusCode: 200, headers: { 'docker-content-digest': SINGLE_DIGEST, 'content-type': 'application/vnd.docker.distribution.manifest.v2+json' } }
: { statusCode: 500, headers: {} }
);
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'update' });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
});
@@ -570,7 +591,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
const result = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(result).toEqual({ kind: 'match' });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
@@ -590,7 +611,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
expect(calls.filter((c) => c.url === MANIFEST_URL_TAG)).toHaveLength(1);
});
@@ -609,9 +630,9 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(first.kind).toBe('error');
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(second.kind).toBe('error');
expect(digestGetCount).toBe(2);
});
@@ -635,12 +656,12 @@ describe('compareLocalToRemoteTag', () => {
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(first).toEqual({ kind: 'match' });
await vi.advanceTimersByTimeAsync(MANIFEST_CLASSIFICATION_CACHE_TTL_MS + 1000);
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(second).toEqual({ kind: 'match' });
expect(digestGetCount).toBe(2);
});
@@ -659,8 +680,8 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(digestGetCount).toBe(1);
});
@@ -679,8 +700,8 @@ describe('compareLocalToRemoteTag', () => {
return { statusCode: 500, headers: {} };
};
const [a, b] = await Promise.all([
compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64),
compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64),
compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64),
compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64),
]);
expect(a).toEqual({ kind: 'match' });
expect(b).toEqual({ kind: 'match' });
@@ -708,11 +729,11 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
const first = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(first).toEqual({ kind: 'match' });
headDigest = INDEX_DIGEST_2;
const second = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
const second = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(second).toEqual({ kind: 'update' });
expect(digestGetCount).toBe(2);
});
@@ -734,11 +755,11 @@ describe('compareLocalToRemoteTag', () => {
};
route = routeFor(REPO);
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(digestGetCount).toBe(1);
route = routeFor('otherorg/otherapp');
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, 'otherorg/otherapp', TAG, AMD64);
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, 'otherorg/otherapp', TAG, AMD64);
expect(digestGetCount).toBe(2);
});
@@ -761,7 +782,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(VARIANT_V7, REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
const result = await compareLocalToRemoteTag([VARIANT_V7], REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
expect(result).toEqual({ kind: 'match' });
});
@@ -797,12 +818,12 @@ describe('compareLocalToRemoteTag', () => {
// A local digest equal to the filtered-out annotated-attestation entry must
// never match, since that descriptor is dropped before the membership check.
const filtered = await compareLocalToRemoteTag(ATTESTATION_ANNOTATED, REGISTRY, REPO, TAG, AMD64);
const filtered = await compareLocalToRemoteTag([ATTESTATION_ANNOTATED], REGISTRY, REPO, TAG, AMD64);
expect(filtered).toEqual({ kind: 'update' });
// The real platform descriptor still matches normally (cache hit reuses the
// same parsed classification from the previous call).
const realMatch = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const realMatch = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(realMatch).toEqual({ kind: 'match' });
});
@@ -825,7 +846,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -843,7 +864,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -860,7 +881,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('mismatched digest') });
});
@@ -879,7 +900,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('does not match the requested digest') });
});
@@ -895,13 +916,13 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, { os: '', architecture: '' });
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, { os: '', architecture: '' });
expect(result.kind).toBe('error');
});
it('rejects a truncated local digest as an error, never as a speculative update', async () => {
route = () => ({ statusCode: 500, headers: {} }); // must never be reached
const result = await compareLocalToRemoteTag('sha256:tooshort', REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag(['sha256:tooshort'], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' });
expect(calls).toHaveLength(0);
});
@@ -925,8 +946,8 @@ describe('compareLocalToRemoteTag', () => {
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(first).toEqual({ kind: 'match' });
expect(second).toEqual({ kind: 'match' });
@@ -964,7 +985,7 @@ describe('compareLocalToRemoteTag', () => {
},
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
});
@@ -992,7 +1013,7 @@ describe('compareLocalToRemoteTag', () => {
[nestedDigest]: nestedBody,
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
expect(calls.filter((c) => c.method === 'GET' && c.url.includes('/manifests/'))).toHaveLength(2);
});
@@ -1018,7 +1039,7 @@ describe('compareLocalToRemoteTag', () => {
[nestedDigest]: { statusCode: 404, headers: {} },
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -1038,7 +1059,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
});
@@ -1065,7 +1086,7 @@ describe('compareLocalToRemoteTag', () => {
route = routePrimaryDigest(outerDigest, digests);
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
if (result.kind === 'error') {
expect(result.reason).toMatch(/depth/i);
@@ -1087,7 +1108,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -1111,7 +1132,7 @@ describe('compareLocalToRemoteTag', () => {
[nestedDigest]: nestedBody,
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
});
@@ -1131,7 +1152,7 @@ describe('compareLocalToRemoteTag', () => {
const outerDigest = contentDigest(outerBody);
route = routePrimaryDigest(outerDigest, { [outerDigest]: outerBody });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
expect(calls.some((c) => c.url.includes('../') || c.url.includes('/evil'))).toBe(false);
});
@@ -1147,7 +1168,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -1161,7 +1182,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
});