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
+36 -4
View File
@@ -64,6 +64,9 @@ export class CacheService {
private readonly store = new Map<string, CacheEntry<unknown>>();
private readonly inflight = new Map<string, Promise<unknown>>();
/** Per-key write generation. Bumped on invalidate so an older in-flight
* fetcher cannot commit after the key was intentionally cleared. */
private readonly generations = new Map<string, number>();
private readonly stats = new Map<string, NamespaceStats>();
public static getInstance(): CacheService {
@@ -121,13 +124,21 @@ export class CacheService {
return { value, outcome: 'inflight' };
}
// Capture generation before the fetch so invalidate() during the wait can
// supersede this writer's store commit (and drop the inflight slot so a
// later caller starts a fresh computation).
const generation = this.currentGeneration(key);
// This caller owns the computation; the closure records whether it ended
// as a fresh compute or a stale fallback, read after the promise settles.
let outcome: CacheFetchOutcome = 'computed';
const inflightSelf: { promise: Promise<T> | null } = { promise: null };
const promise = (async () => {
try {
const value = await fetcher();
this.set(key, value, ttlMs);
if (this.currentGeneration(key) === generation) {
this.set(key, value, ttlMs);
}
return value;
} catch (err) {
if (existing) {
@@ -137,9 +148,14 @@ export class CacheService {
}
throw err;
} finally {
this.inflight.delete(key);
// Only clear the inflight slot if we still own it. invalidate() may
// have already deleted this entry and allowed a newer owner.
if (this.inflight.get(key) === inflightSelf.promise) {
this.inflight.delete(key);
}
}
})();
inflightSelf.promise = promise;
this.inflight.set(key, promise);
const value = await promise;
@@ -177,17 +193,22 @@ export class CacheService {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
/** Invalidate a single key. */
/** Invalidate a single key and supersede any in-flight writer for it. */
public invalidate(key: string): void {
this.store.delete(key);
this.inflight.delete(key);
this.bumpGeneration(key);
}
/** Invalidate every key whose namespace matches `namespace`. */
public invalidateNamespace(namespace: string): void {
const prefix = `${namespace}:`;
for (const key of this.store.keys()) {
const keys = new Set([...this.store.keys(), ...this.inflight.keys(), ...this.generations.keys()]);
for (const key of keys) {
if (key === namespace || key.startsWith(prefix)) {
this.store.delete(key);
this.inflight.delete(key);
this.bumpGeneration(key);
}
}
}
@@ -196,6 +217,7 @@ export class CacheService {
public flush(): void {
this.store.clear();
this.inflight.clear();
this.generations.clear();
this.stats.clear();
}
@@ -259,4 +281,14 @@ export class CacheService {
if (entry.expiresAt <= now) this.store.delete(key);
}
}
private currentGeneration(key: string): number {
return this.generations.get(key) ?? 0;
}
private bumpGeneration(key: string): number {
const next = this.currentGeneration(key) + 1;
this.generations.set(key, next);
return next;
}
}
+65 -5
View File
@@ -94,6 +94,20 @@ function parseServicesJson(raw: string | null | undefined): StackServiceStatus[]
}
}
/** Write generation embedded in services_json; 0 when missing or unreadable. */
function parseServicesJsonGeneration(raw: string | null | undefined): number {
if (!raw) return 0;
try {
const parsed = JSON.parse(raw) as { version?: unknown; generation?: unknown };
if (parsed?.version !== SERVICES_JSON_VERSION) return 0;
return typeof parsed.generation === 'number' && Number.isFinite(parsed.generation)
? parsed.generation
: 0;
} catch {
return 0;
}
}
function stringifyServicesJson(services: StackServiceStatus[], generation: number): string {
return JSON.stringify({ version: SERVICES_JSON_VERSION, generation, services });
}
@@ -4628,6 +4642,13 @@ export class DatabaseService {
).run(nodeId, stackName, lastError, checkedAt, servicesJson);
}
/**
* Raw has_update map for scanner retention and notification transitions.
* Ignores check_status: a partial/failed row with has_update=1 stays true
* so ImageUpdateService can preserve sticky state across incomplete runs.
* API/Fleet consumers that need "confirmed update" must use
* getConfirmedStackUpdateStatus instead.
*/
public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
const rows = nodeId !== undefined
? this.db.prepare('SELECT stack_name, has_update FROM stack_update_status WHERE node_id = ?').all(nodeId) as Array<{ stack_name: string; has_update: number }>
@@ -4639,10 +4660,32 @@ export class DatabaseService {
return result;
}
/**
* Confirmed-update projection for GET /api/image-updates and Fleet local
* aggregation. True only when has_update=1 and the latest check completed
* successfully (check_status='ok'). Partial/failed retained rows are false.
*/
public getConfirmedStackUpdateStatus(nodeId?: number): Record<string, boolean> {
const rows = nodeId !== undefined
? this.db.prepare(
`SELECT stack_name, has_update, check_status FROM stack_update_status WHERE node_id = ?`
).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null }>
: this.db.prepare(
`SELECT stack_name, has_update, check_status FROM stack_update_status`
).all() as Array<{ stack_name: string; has_update: number; check_status: string | null }>;
const result: Record<string, boolean> = {};
for (const row of rows) {
// Match getNodeUpdateSummary / frontend isConfirmedImageUpdate:
// null check_status is treated as ok (legacy rows).
result[row.stack_name] = row.has_update === 1 && (row.check_status ?? 'ok') === 'ok';
}
return result;
}
/**
* Rich per-stack update status (hasUpdate + check outcome + reason) for the
* sidebar/readiness UI. GET /api/image-updates stays the boolean map so the
* cross-version fleet aggregation contract is unaffected.
* sidebar/readiness UI. Confirmed boolean maps use getConfirmedStackUpdateStatus;
* raw prior state for the scanner stays on getStackUpdateStatus.
*/
public getStackUpdateDetail(nodeId: number): Record<string, StackUpdateDetail> {
const rows = this.db.prepare(
@@ -4675,8 +4718,22 @@ export class DatabaseService {
return parseServicesJson(row?.services_json);
}
public clearStackUpdateStatus(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
/**
* Scanner write generation stored with services_json for this stack.
* Used by preview reconcile to skip clearing a row written after the
* preview observation watermark. Returns 0 when missing or unreadable.
*/
public getStackUpdateWriteGeneration(nodeId: number, stackName: string): number {
const row = this.db.prepare(
'SELECT services_json FROM stack_update_status WHERE node_id = ? AND stack_name = ?'
).get(nodeId, stackName) as { services_json: string | null } | undefined;
return parseServicesJsonGeneration(row?.services_json);
}
/** Deletes the full update row (aggregate + services_json). Returns deleted row count. */
public clearStackUpdateStatus(nodeId: number, stackName: string): number {
const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
return result.changes;
}
// --- Stack Scan Attempts ---
@@ -4718,7 +4775,10 @@ export class DatabaseService {
public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
return this.db.prepare(
'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id'
`SELECT node_id, SUM(has_update) as stacks_with_updates
FROM stack_update_status
WHERE has_update = 1 AND COALESCE(check_status, 'ok') = 'ok'
GROUP BY node_id`
).all() as Array<{ node_id: number; stacks_with_updates: number }>;
}
+161 -32
View File
@@ -8,7 +8,8 @@ import { RegistryService } from './RegistryService';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
import { parseImageRef, selectLocalRepoDigest, compareLocalToRemoteTag } from './registry-api';
import { parseImageRef, selectLocalRepoDigests } from './registry-api';
import { detectImageUpdate, type PreviewImageCheckStatus } from './imageUpdateDetect';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
@@ -18,6 +19,12 @@ const BACKFILL_KEY = 'image_update_notifications_backfilled';
export interface ImageCheckResult {
hasUpdate: boolean;
/** Same-tag registry digest drift; Compose pull can apply without pin change. */
digestUpdate?: boolean;
/** Higher semver tag exists; UI may show it but Compose auto-apply cannot pin it. */
tagUpdate?: boolean;
/** Detector authority; consumed by reduceServiceStatus / writeStackUpdateStatus. */
checkStatus?: PreviewImageCheckStatus;
error?: string;
/**
* The image is not registry-backed (locally built, or a bare digest ref
@@ -28,6 +35,17 @@ export interface ImageCheckResult {
notCheckable?: boolean;
}
/**
* Normalize check authority for reduction. Test stubs / older callers may omit
* checkStatus: error means failed, else ok.
*/
export function normalizeImageCheckStatus(r: ImageCheckResult): PreviewImageCheckStatus {
if (r.notCheckable) return 'not_checkable';
if (r.checkStatus) return r.checkStatus;
if (r.error) return 'failed';
return 'ok';
}
/**
* Snapshot of the scanner returned by GET /api/image-updates/status.
* Units differ by field: `intervalMinutes` / `manualCooldownMinutes` are
@@ -273,7 +291,8 @@ export function reduceServiceStatus(
const checkableResults: ImageCheckResult[] = [];
for (const ref of refs) {
const result = imageUpdateMap.get(ref);
if (!result || result.notCheckable) continue;
if (!result) continue;
if (normalizeImageCheckStatus(result) === 'not_checkable') continue;
checkableResults.push(result);
}
@@ -284,12 +303,14 @@ export function reduceServiceStatus(
};
}
const errored = checkableResults.filter((r) => r.error !== undefined);
const statuses = checkableResults.map(normalizeImageCheckStatus);
const failed = checkableResults.filter((_, i) => statuses[i] === 'failed');
const partial = checkableResults.filter((_, i) => statuses[i] === 'partial');
const confirmedUpdateThisRun = checkableResults.some(
(r) => r.error === undefined && r.hasUpdate === true,
(r, i) => statuses[i] === 'ok' && r.hasUpdate === true,
);
if (errored.length === checkableResults.length) {
if (failed.length === checkableResults.length) {
const priorHasUpdate = prior?.hasUpdate ?? false;
return {
status: {
@@ -298,14 +319,16 @@ export function reduceServiceStatus(
...(dedupedRuntime.length > 0 ? { runtimeImages: dedupedRuntime } : {}),
hasUpdate: priorHasUpdate,
checkStatus: 'failed',
lastError: errored[0].error ?? 'Update check failed',
lastError: failed[0].error ?? 'Update check failed',
},
confirmedUpdateThisRun: false,
};
}
const checkStatus: StackServiceStatus['checkStatus'] = errored.length > 0 ? 'partial' : 'ok';
const lastError = errored.length > 0 ? (errored[0].error ?? null) : null;
const checkStatus: StackServiceStatus['checkStatus'] =
failed.length > 0 || partial.length > 0 ? 'partial' : 'ok';
const uncertain = [...partial, ...failed];
const lastError = uncertain.length > 0 ? (uncertain[0].error ?? null) : null;
const hasUpdate = checkStatus === 'partial'
? (confirmedUpdateThisRun || (prior?.hasUpdate ?? false))
: confirmedUpdateThisRun;
@@ -770,7 +793,11 @@ export class ImageUpdateService {
console.error(`[ImageUpdateService] Error checking ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
// getErrorMessage (not raw String(e)) because this value can surface
// verbatim in the sidebar tooltip / readiness advisory as lastError.
imageUpdateMap.set(imageRef, { hasUpdate: false, error: getErrorMessage(e, 'Update check failed') });
imageUpdateMap.set(imageRef, {
hasUpdate: false,
checkStatus: 'failed',
error: getErrorMessage(e, 'Update check failed'),
});
}
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
}
@@ -892,7 +919,11 @@ export class ImageUpdateService {
try {
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
} catch (e) {
imageUpdateMap.set(imageRef, { hasUpdate: false, error: getErrorMessage(e, 'Update check failed') });
imageUpdateMap.set(imageRef, {
hasUpdate: false,
checkStatus: 'failed',
error: getErrorMessage(e, 'Update check failed'),
});
}
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
}
@@ -963,6 +994,70 @@ export class ImageUpdateService {
return committed;
}
/**
* Current per-stack write-generation high-water mark (0 if never reserved).
* Snapshot before a read-only update-preview so commitPreviewClear can
* compare against writes that reserved or committed after observation.
*/
public peekStackWriteGeneration(nodeId: number, stackName: string): number {
return this.stackWriteState.get(this.stackWriteKey(nodeId, stackName))?.generation ?? 0;
}
/**
* Clear persisted scanner update state after an authoritative-negative
* update preview. `observedMemoryGeneration` and `observedRowGeneration`
* are snapshotted before the preview.
*
* Ordering:
* - If memory generation advanced after observation, abort (stale).
* - If memory generation still equals the observation watermark, advance
* (tombstone) so an equal-generation writer reserved before observation
* cannot commit after the clear (SF-4).
* - If the persisted row generation advanced after observation, keep the row.
* - Otherwise delete partial, failed, and confirmed ok+true rows.
*
* Returns cleared | stale | absent.
*/
public async commitPreviewClear(
nodeId: number,
stackName: string,
observedMemoryGeneration: number,
observedRowGeneration: number,
): Promise<'cleared' | 'stale' | 'absent'> {
const key = this.stackWriteKey(nodeId, stackName);
let state = this.stackWriteState.get(key);
if (!state) {
state = { chain: Promise.resolve(), generation: observedMemoryGeneration };
this.stackWriteState.set(key, state);
}
// A reservation after observation already owns a higher generation.
if (state.generation > observedMemoryGeneration) {
return 'stale';
}
// Tombstone the equal watermark so pre-observation writers reserved at
// this generation become stale when they later try to commit.
if (state.generation === observedMemoryGeneration) {
state.generation += 1;
}
const clearGeneration = state.generation;
let deleted = 0;
const committed = await this.withStackWriteLock(nodeId, stackName, clearGeneration, () => {
const db = DatabaseService.getInstance();
const detail = db.getStackUpdateDetail(nodeId)[stackName];
if (!detail) return;
// Compare DB-embedded generations only (same dimension as the
// pre-preview snapshot). Memory peek resets on restart; SQLite does not.
const rowGeneration = db.getStackUpdateWriteGeneration(nodeId, stackName);
if (rowGeneration > observedRowGeneration) return;
deleted = db.clearStackUpdateStatus(nodeId, stackName);
});
if (!committed) return 'stale';
return deleted > 0 ? 'cleared' : 'absent';
}
private runtimeImagesByService(
stackName: string,
containers: Array<{ Image?: string; Labels?: Record<string, string> }>,
@@ -1028,14 +1123,16 @@ export class ImageUpdateService {
const checkable = Array.from(images)
.map((img) => imageUpdateMap.get(img))
.filter((r): r is ImageCheckResult => !!r && !r.notCheckable);
const errored = checkable.filter((r) => r.error !== undefined);
const confirmedHasUpdate = checkable.some((r) => r.error === undefined && r.hasUpdate === true);
.filter((r): r is ImageCheckResult => !!r && normalizeImageCheckStatus(r) !== 'not_checkable');
const statuses = checkable.map(normalizeImageCheckStatus);
const failed = checkable.filter((_, i) => statuses[i] === 'failed');
const partial = checkable.filter((_, i) => statuses[i] === 'partial');
const confirmedHasUpdate = checkable.some((r, i) => statuses[i] === 'ok' && r.hasUpdate === true);
if (checkable.length > 0 && errored.length === checkable.length) {
if (checkable.length > 0 && failed.length === checkable.length) {
const committed = await this.withStackWriteLock(nodeId, stackName, generation, async () => {
db.recordStackCheckFailure(
nodeId, stackName, errored[0].error ?? 'Update check failed', checkedAt,
nodeId, stackName, failed[0].error ?? 'Update check failed', checkedAt,
);
});
return {
@@ -1045,8 +1142,10 @@ export class ImageUpdateService {
};
}
const checkStatus: StackCheckStatus = errored.length > 0 ? 'partial' : 'ok';
const lastError = errored.length > 0 ? (errored[0].error ?? null) : null;
const checkStatus: StackCheckStatus =
failed.length > 0 || partial.length > 0 ? 'partial' : 'ok';
const uncertain = [...partial, ...failed];
const lastError = uncertain.length > 0 ? (uncertain[0].error ?? null) : null;
const hasUpdate = checkStatus === 'partial'
? (confirmedHasUpdate || previousState[stackName] === true)
: confirmedHasUpdate;
@@ -1065,20 +1164,20 @@ export class ImageUpdateService {
public async checkImage(docker: DockerController, imageRef: string): Promise<ImageCheckResult> {
const parsed = parseImageRef(imageRef);
// A bare digest ref (sha256:...) has no tag to track upstream; not applicable.
if (!parsed) return { hasUpdate: false, notCheckable: true };
if (!parsed) {
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
}
if (isDebugEnabled()) {
console.log(`[ImageUpdateService] Checking ${imageRef}: registry=${parsed.registry} repo=${parsed.repo} tag=${parsed.tag}`);
}
// Look up stored credentials for this registry
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
if (isDebugEnabled()) {
console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`);
}
// Get local digest and platform from RepoDigests / Os+Architecture
let localDigest: string | null;
let localDigests: string[];
let platform: { os: string; architecture: string };
try {
const inspect = await withTimeout(docker.getDocker().getImage(imageRef).inspect(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'inspect');
@@ -1086,28 +1185,58 @@ export class ImageUpdateService {
// No RepoDigests at all: locally built / not registry-backed, so update
// status does not apply.
if (repoDigests.length === 0) return { hasUpdate: false, notCheckable: true };
if (repoDigests.length === 0) {
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
}
localDigest = selectLocalRepoDigest(repoDigests, parsed);
localDigests = selectLocalRepoDigests(repoDigests, parsed);
platform = { os: inspect.Os, architecture: inspect.Architecture };
} catch {
return { hasUpdate: false, error: `Failed to inspect local image "${imageRef}"` };
return {
hasUpdate: false,
checkStatus: 'failed',
error: `Failed to inspect local image "${imageRef}"`,
};
}
// RepoDigests were present but none resolved a usable digest: genuinely
// ambiguous, so surface it rather than silently call the image up to date.
if (!localDigest) {
return { hasUpdate: false, error: `Could not resolve a local registry digest for "${imageRef}"` };
if (localDigests.length === 0) {
return {
hasUpdate: false,
checkStatus: 'failed',
error: `Could not resolve a local registry digest for "${imageRef}"`,
};
}
const comparison = await compareLocalToRemoteTag(localDigest, parsed.registry, parsed.repo, parsed.tag, platform, credentials);
if (comparison.kind === 'error') {
return { hasUpdate: false, error: comparison.reason };
const detection = await detectImageUpdate({
localDigests,
platform,
registry: parsed.registry,
repo: parsed.repo,
tag: parsed.tag,
credentials,
});
const digestLabel = localDigests[0] ? `${localDigests[0].slice(0, 27)}...` : 'none';
const nextSuffix = detection.nextTag ? ` next=${detection.nextTag}` : '';
console.log(
`[ImageUpdateService] ${imageRef}: local=${digestLabel} update=${detection.hasUpdate}`
+ ` digest=${detection.digestUpdate} tag=${detection.tagUpdate}`
+ ` status=${detection.checkStatus}${nextSuffix}`,
);
if (detection.checkStatus === 'not_checkable') {
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
}
const hasUpdate = comparison.kind === 'update';
console.log(`[ImageUpdateService] ${imageRef}: local=${localDigest.slice(0, 27)}... update=${hasUpdate}`);
return { hasUpdate };
return {
hasUpdate: detection.hasUpdate,
digestUpdate: detection.digestUpdate,
tagUpdate: detection.tagUpdate,
checkStatus: detection.checkStatus,
...(detection.reason ? { error: detection.reason } : {}),
};
}
}
+13 -20
View File
@@ -11,7 +11,11 @@ import { FileSystemService } from './FileSystemService';
import { HealthGateService } from './HealthGateService';
import { ServiceUpdateRecoveryService } from './ServiceUpdateRecoveryService';
import { ImageUpdateService } from './ImageUpdateService';
import type { ImageCheckResult } from './ImageUpdateService';
import {
createAutoUpdateDigestGateState,
messageWhenNoDigestUpdate,
recordAutoUpdateImageCheck,
} from '../helpers/autoUpdateDigestGate';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { formatNoTargetError } from '../utils/remoteTarget';
@@ -1056,36 +1060,25 @@ export class SchedulerService {
console.log(`[SchedulerService] Stack "${stackName}": checking ${imageRefs.length} image(s): ${imageRefs.join(', ')}`);
}
let hasUpdate = false;
const updatedImages: string[] = [];
const checkErrors: string[] = [];
const gate = createAutoUpdateDigestGateState();
for (const imageRef of imageRefs) {
try {
const result: ImageCheckResult = await imageUpdateService.checkImage(docker, imageRef);
if (result.error) {
checkErrors.push(result.error);
} else if (result.hasUpdate) {
hasUpdate = true;
updatedImages.push(imageRef);
}
const result = await imageUpdateService.checkImage(docker, imageRef);
recordAutoUpdateImageCheck(gate, imageRef, result);
} catch (e) {
const msg = getErrorMessage(e, String(e));
checkErrors.push(msg);
gate.checkErrors.push(msg);
console.warn(`[SchedulerService] Failed to check image ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
}
}
if (!hasUpdate) {
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
return `Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`;
}
if (checkErrors.length > 0) {
return `Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`;
}
return `Stack "${stackName}": all images up to date.`;
if (!gate.hasDigestUpdate) {
return messageWhenNoDigestUpdate(stackName, gate, imageRefs.length);
}
const { updatedImages } = gate;
await this.enforceSchedulerPolicyGate(
stackName,
nodeId,
+131 -122
View File
@@ -10,15 +10,43 @@ import {
} from './ImageUpdateService';
import {
parseImageRef,
selectLocalRepoDigest,
selectLocalRepoDigests,
compareLocalToRemoteTag,
listRegistryTags,
listRegistryTagsResult,
type ParsedRef,
type RegistryCredentials,
type DigestComparisonResult,
} from './registry-api';
import {
detectImageUpdate,
type ImageUpdateDetectResult,
type ListRegistryTagsResultFn,
type PreviewImageCheckStatus,
type SemverBump,
computeSemverBump,
findNextTag,
isMovingTag,
listAllRegistryTagsBounded,
parseSemverTag,
PREVIEW_TAG_LIST_MAX_PAGES,
PREVIEW_TAG_LIST_MAX_TAGS,
PREVIEW_TAG_LIST_PAGE_SIZE,
type TagEnumOutcome,
} from './imageUpdateDetect';
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
export type { SemverBump, PreviewImageCheckStatus, TagEnumOutcome, ListRegistryTagsResultFn };
export {
computeSemverBump,
findNextTag,
isMovingTag,
listAllRegistryTagsBounded,
parseSemverTag,
PREVIEW_TAG_LIST_MAX_PAGES,
PREVIEW_TAG_LIST_MAX_TAGS,
PREVIEW_TAG_LIST_PAGE_SIZE,
};
/** Stack-level preview authority; absent on older remotes. */
export type PreviewCheckStatus = 'ok' | 'partial' | 'failed';
export interface UpdatePreviewImage {
service: string;
@@ -26,7 +54,13 @@ export interface UpdatePreviewImage {
current_tag: string;
next_tag: string | null;
has_update: boolean;
/** Same-tag content drift; Compose pull can apply without pin change. */
digest_update: boolean;
/** Higher pinned semver exists; advisory until Compose is edited. */
tag_update: boolean;
semver_bump: SemverBump;
/** Authority of this image's checks; not_checkable for invalid refs. */
check_status: PreviewImageCheckStatus;
}
export type UpdateKind = 'tag' | 'digest' | 'none';
@@ -50,6 +84,12 @@ export interface UpdatePreviewSummary {
has_build_services: boolean;
/** True when a manual update can rebuild local build services (always when has_build_services). */
rebuild_available: boolean;
/**
* Whether every checkable image was verified authoritatively.
* Authoritative-negative reconcile requires check_status === 'ok' and !has_update.
* Older remotes omit this field; treat absence as non-authoritative.
*/
check_status: PreviewCheckStatus;
}
export interface UpdatePreview {
@@ -61,74 +101,6 @@ export interface UpdatePreview {
changelog: string | null;
}
interface SemverParts {
prefix: string;
major: number;
minor: number;
patch: number;
suffix: string;
raw: string;
}
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
export function parseSemverTag(tag: string): SemverParts | null {
const m = tag.match(SEMVER_RE);
if (!m) return null;
return {
prefix: m[1] ?? '',
major: Number(m[2]),
minor: Number(m[3]),
patch: Number(m[4]),
suffix: m[5] ?? '',
raw: tag,
};
}
/**
* A tag is "moving" when restoring the compose file would not revert the image
* behind it: `latest`, a branch name, or an unpinned major/minor like `1.25`.
* Only a fully-pinned semver tag (X.Y.Z, optionally `v`-prefixed and/or with a
* `-prerelease` suffix) is treated as immutable, matching how a file rollback
* restores the exact tag.
*/
export function isMovingTag(tag: string): boolean {
return parseSemverTag(tag) === null;
}
function compareSemver(a: SemverParts, b: SemverParts): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
const current = parseSemverTag(currentTag);
if (!current) return null;
let best: SemverParts | null = null;
for (const tag of availableTags) {
const parsed = parseSemverTag(tag);
if (!parsed) continue;
if (parsed.prefix !== current.prefix) continue;
if (parsed.suffix !== current.suffix) continue;
if (compareSemver(parsed, current) <= 0) continue;
if (!best || compareSemver(parsed, best) > 0) best = parsed;
}
return best ? best.raw : null;
}
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
if (!nextTag) return 'none';
if (nextTag === currentTag) return 'patch';
const current = parseSemverTag(currentTag);
const next = parseSemverTag(nextTag);
if (!current || !next) return 'unknown';
if (next.major > current.major) return 'major';
if (next.minor > current.minor) return 'minor';
if (next.patch > current.patch) return 'patch';
return 'none';
}
function maxBump(a: SemverBump, b: SemverBump): SemverBump {
// Ranking: none < unknown < patch < minor < major.
// unknown ranks below real semver bumps so a single unparseable tag never masks
@@ -165,17 +137,50 @@ async function loadStackImages(
}
export interface LocalDigestInfo {
digest: string | null;
digests: string[];
platform: { os: string; architecture: string };
}
export interface ComputePreviewDeps {
getLocalDigest: (imageRef: string, parsed: ParsedRef) => Promise<LocalDigestInfo>;
compareDigest: typeof compareLocalToRemoteTag;
listRegistryTags: typeof listRegistryTags;
listRegistryTagsResult: ListRegistryTagsResultFn;
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
}
function imageFromDetect(
service: string,
imageRef: string,
currentTag: string,
detected: ImageUpdateDetectResult,
): UpdatePreviewImage {
return {
service,
image: imageRef,
current_tag: currentTag,
next_tag: detected.nextTag,
has_update: detected.hasUpdate,
digest_update: detected.digestUpdate,
tag_update: detected.tagUpdate,
semver_bump: detected.semverBump,
check_status: detected.checkStatus,
};
}
function notCheckableImage(service: string, imageRef: string): UpdatePreviewImage {
return {
service,
image: imageRef,
current_tag: 'unknown',
next_tag: null,
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none',
check_status: 'not_checkable',
};
}
export async function computeImagePreview(
service: string,
imageRef: string,
@@ -183,52 +188,24 @@ export async function computeImagePreview(
): Promise<UpdatePreviewImage> {
const parsed = parseImageRef(imageRef);
if (!parsed) {
return {
service,
image: imageRef,
current_tag: 'unknown',
next_tag: null,
has_update: false,
semver_bump: 'none',
};
return notCheckableImage(service, imageRef);
}
const credentials = await deps.getCredentials(parsed.registry);
// Digest-based: is a new build of the SAME tag available? A comparison error
// (network failure, malformed manifest) fails soft: it never claims a
// digest-based update, it only skips it.
const localInfo = await deps.getLocalDigest(imageRef, parsed);
const [comparison, tags] = await Promise.all([
localInfo.digest
? deps.compareDigest(localInfo.digest, parsed.registry, parsed.repo, parsed.tag, localInfo.platform, credentials)
: Promise.resolve<DigestComparisonResult>({ kind: 'error', reason: 'No local registry digest available' }),
deps.listRegistryTags(parsed.registry, parsed.repo, credentials),
]);
const digestUpdate = comparison.kind === 'update';
// Tag-based: is a higher semver tag available?
const nextTag = findNextTag(parsed.tag, tags);
const hasUpdate = digestUpdate || nextTag !== null;
let semverBump: SemverBump = 'none';
let resolvedNext: string | null = null;
if (nextTag) {
resolvedNext = nextTag;
semverBump = computeSemverBump(parsed.tag, nextTag);
} else if (digestUpdate) {
resolvedNext = parsed.tag;
semverBump = 'patch';
}
return {
service,
image: imageRef,
current_tag: parsed.tag,
next_tag: resolvedNext,
has_update: hasUpdate,
semver_bump: semverBump,
};
const detected = await detectImageUpdate({
localDigests: localInfo.digests,
platform: localInfo.platform,
registry: parsed.registry,
repo: parsed.repo,
tag: parsed.tag,
credentials,
deps: {
compareDigest: deps.compareDigest,
listRegistryTagsResult: deps.listRegistryTagsResult,
},
});
return imageFromDetect(service, imageRef, parsed.tag, detected);
}
function buildRollbackTarget(image: string, currentTag: string): string | null {
@@ -244,6 +221,16 @@ function buildRollbackTarget(image: string, currentTag: string): string | null {
return `${base}:${currentTag}`;
}
export function rollupPreviewCheckStatus(images: UpdatePreviewImage[]): PreviewCheckStatus {
const checkable = images.filter((i) => i.check_status !== 'not_checkable');
if (checkable.length === 0) return 'ok';
const allFailed = checkable.every((i) => i.check_status === 'failed');
if (allFailed) return 'failed';
const allOk = checkable.every((i) => i.check_status === 'ok');
if (allOk) return 'ok';
return 'partial';
}
export function buildSummary(
stackName: string,
images: UpdatePreviewImage[],
@@ -281,12 +268,22 @@ export function buildSummary(
blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null,
has_build_services: hasBuildServices,
rebuild_available: hasBuildServices,
check_status: rollupPreviewCheckStatus(images),
},
rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null,
changelog: null,
};
}
/** True when a preview is safe to clear sticky scanner state. */
export function isAuthoritativeNegativePreview(preview: UpdatePreview): boolean {
// Every declared image must be explicitly ok. Mixed ok + not_checkable must
// not clear sticky rows that may still track unresolved services.
return preview.images.length > 0
&& preview.images.every((i) => i.check_status === 'ok')
&& preview.summary.has_update === false;
}
/** Filter a full-stack preview down to one service's images and recompute the summary from that subset. */
export function filterPreviewForService(preview: UpdatePreview, serviceName: string): UpdatePreview {
const images = preview.images.filter(i => i.service === serviceName);
@@ -317,21 +314,33 @@ export class UpdatePreviewService {
const deps: ComputePreviewDeps = {
getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry),
compareDigest: compareLocalToRemoteTag,
listRegistryTags,
listRegistryTagsResult,
getLocalDigest: async (imageRef: string, parsed: ParsedRef): Promise<LocalDigestInfo> => {
try {
const inspect = await docker.getDocker().getImage(imageRef).inspect();
const repoDigests: string[] = inspect.RepoDigests ?? [];
const digest = selectLocalRepoDigest(repoDigests, parsed);
return { digest, platform: { os: inspect.Os, architecture: inspect.Architecture } };
} catch {
return { digest: null, platform: { os: '', architecture: '' } };
const digests = selectLocalRepoDigests(repoDigests, parsed);
return { digests, platform: { os: inspect.Os, architecture: inspect.Architecture } };
} catch (err) {
console.error('[UpdatePreview] local image inspect failed for %s', imageRef, err);
return { digests: [], platform: { os: '', architecture: '' } };
}
},
};
// Memoize service-independent detection by image ref so shared images
// hit the registry once, then attach each service name separately.
const detectByRef = new Map<string, Promise<UpdatePreviewImage>>();
const results = await Promise.all(
stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)),
stackImages.map(async ({ service, image }) => {
let shared = detectByRef.get(image);
if (!shared) {
shared = computeImagePreview('_shared_', image, deps);
detectByRef.set(image, shared);
}
const base = await shared;
return { ...base, service };
}),
);
return buildSummary(stackName, results, buildServices);
}
+293
View File
@@ -0,0 +1,293 @@
/**
* Shared image-update detection used by both persisted sidebar status
* (ImageUpdateService.checkImage) and Fleet/Anatomy preview
* (UpdatePreviewService.computeImagePreview).
*
* An update is available when either:
* 1. the local digest no longer matches the registry manifest for the
* currently declared tag (digestUpdate; Compose-actionable), or
* 2. a higher pinned semver tag exists in a complete bounded tag list
* (tagUpdate; advisory only until Compose is edited).
*/
import {
compareLocalToRemoteTag,
listRegistryTagsResult,
type DigestComparisonResult,
type RegistryCredentials,
type TagListResult,
} from './registry-api';
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
/** Per-image check confidence for preview authority and scanner persistence. */
export type PreviewImageCheckStatus = 'ok' | 'partial' | 'failed' | 'not_checkable';
interface SemverParts {
prefix: string;
major: number;
minor: number;
patch: number;
suffix: string;
raw: string;
}
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
/** Max pages when enumerating tags for a pinned-semver authoritative-negative. */
export const PREVIEW_TAG_LIST_MAX_PAGES = 20;
/** Max tags accumulated across pages for the same purpose. */
export const PREVIEW_TAG_LIST_MAX_TAGS = 2000;
/** Per-page size passed to listRegistryTagsResult. */
export const PREVIEW_TAG_LIST_PAGE_SIZE = 100;
export function parseSemverTag(tag: string): SemverParts | null {
const m = tag.match(SEMVER_RE);
if (!m) return null;
return {
prefix: m[1] ?? '',
major: Number(m[2]),
minor: Number(m[3]),
patch: Number(m[4]),
suffix: m[5] ?? '',
raw: tag,
};
}
/**
* A tag is "moving" when restoring the compose file would not revert the image
* behind it: `latest`, a branch name, or an unpinned major/minor like `1.25`.
* Only a fully-pinned semver tag (X.Y.Z, optionally `v`-prefixed and/or with a
* `-prerelease` suffix) is treated as immutable, matching how a file rollback
* restores the exact tag.
*/
export function isMovingTag(tag: string): boolean {
return parseSemverTag(tag) === null;
}
function compareSemver(a: SemverParts, b: SemverParts): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
const current = parseSemverTag(currentTag);
if (!current) return null;
let best: SemverParts | null = null;
for (const tag of availableTags) {
const parsed = parseSemverTag(tag);
if (!parsed) continue;
if (parsed.prefix !== current.prefix) continue;
if (parsed.suffix !== current.suffix) continue;
if (compareSemver(parsed, current) <= 0) continue;
if (!best || compareSemver(parsed, best) > 0) best = parsed;
}
return best ? best.raw : null;
}
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
if (!nextTag) return 'none';
if (nextTag === currentTag) return 'patch';
const current = parseSemverTag(currentTag);
const next = parseSemverTag(nextTag);
if (!current || !next) return 'unknown';
if (next.major > current.major) return 'major';
if (next.minor > current.minor) return 'minor';
if (next.patch > current.patch) return 'patch';
return 'none';
}
export type ListRegistryTagsResultFn = (
registry: string,
repo: string,
credentials?: RegistryCredentials | null,
opts?: { limit?: number; cursor?: string },
) => Promise<TagListResult>;
export type TagEnumOutcome =
| { kind: 'complete'; tags: string[] }
| { kind: 'incomplete'; tags: string[]; reason: string }
| { kind: 'error'; reason: string }
| { kind: 'skipped' };
/**
* Enumerate tags with bounded pagination. Hitting the page/tag cap while a
* nextCursor remains is incomplete (non-authoritative), not a successful empty
* or "no newer tag" result.
*/
export async function listAllRegistryTagsBounded(
listFn: ListRegistryTagsResultFn,
registry: string,
repo: string,
credentials: RegistryCredentials | null,
opts: { maxPages?: number; maxTags?: number; pageSize?: number } = {},
): Promise<TagEnumOutcome> {
const maxPages = opts.maxPages ?? PREVIEW_TAG_LIST_MAX_PAGES;
const maxTags = opts.maxTags ?? PREVIEW_TAG_LIST_MAX_TAGS;
const pageSize = opts.pageSize ?? PREVIEW_TAG_LIST_PAGE_SIZE;
const tags: string[] = [];
let cursor: string | undefined;
for (let page = 0; page < maxPages; page++) {
const result = await listFn(registry, repo, credentials, { limit: pageSize, cursor });
if (!result.ok) {
return { kind: 'error', reason: result.message };
}
tags.push(...result.tags);
if (tags.length > maxTags) {
return {
kind: 'incomplete',
tags: tags.slice(0, maxTags),
reason: `Tag list exceeded ${maxTags} tags before pagination completed`,
};
}
if (!result.nextCursor) {
return { kind: 'complete', tags };
}
cursor = result.nextCursor;
}
return {
kind: 'incomplete',
tags,
reason: `Tag list exceeded ${maxPages} pages before pagination completed`,
};
}
export interface ImageUpdateDetectInput {
localDigests: readonly string[];
platform: { os: string; architecture: string };
registry: string;
repo: string;
tag: string;
credentials: RegistryCredentials | null;
}
export interface ImageUpdateDetectResult {
hasUpdate: boolean;
digestUpdate: boolean;
tagUpdate: boolean;
nextTag: string | null;
digestError: string | null;
tagEnumKind: 'complete' | 'incomplete' | 'error' | 'skipped';
tagEnumReason: string | null;
checkStatus: PreviewImageCheckStatus;
/** Best operator-facing uncertainty reason for lastError / tooltips. */
reason: string | null;
semverBump: SemverBump;
}
export interface DetectImageUpdateDeps {
compareDigest?: typeof compareLocalToRemoteTag;
listRegistryTagsResult?: ListRegistryTagsResultFn;
}
export function resolveImageCheckStatus(args: {
digest: DigestComparisonResult['kind'];
tagApplicable: boolean;
tagOutcome: TagEnumOutcome;
hasUpdate: boolean;
}): PreviewImageCheckStatus {
const { digest, tagApplicable, tagOutcome, hasUpdate } = args;
if (digest === 'update') return 'ok';
if (!tagApplicable) {
return digest === 'match' ? 'ok' : 'failed';
}
if (tagOutcome.kind === 'complete') {
if (hasUpdate) return 'ok';
if (digest === 'match') return 'ok';
return 'partial';
}
if (tagOutcome.kind === 'incomplete') {
if (hasUpdate) return 'ok';
return 'partial';
}
if (hasUpdate) return 'partial';
if (digest === 'match') return 'partial';
return 'failed';
}
function uncertaintyReason(
checkStatus: PreviewImageCheckStatus,
digestError: string | null,
tagEnumReason: string | null,
): string | null {
if (checkStatus === 'ok' || checkStatus === 'not_checkable') return null;
return tagEnumReason ?? digestError;
}
/**
* Core availability check shared by preview and persistence.
*/
export async function detectImageUpdate(args: ImageUpdateDetectInput & {
deps?: DetectImageUpdateDeps;
}): Promise<ImageUpdateDetectResult> {
const compareDigest = args.deps?.compareDigest ?? compareLocalToRemoteTag;
const listFn = args.deps?.listRegistryTagsResult ?? listRegistryTagsResult;
const tagApplicable = !isMovingTag(args.tag);
const comparisonPromise: Promise<DigestComparisonResult> = args.localDigests.length > 0
? compareDigest(
args.localDigests,
args.registry,
args.repo,
args.tag,
args.platform,
args.credentials,
)
: Promise.resolve({ kind: 'error', reason: 'No local registry digest available' });
const tagPromise: Promise<TagEnumOutcome> = tagApplicable
? listAllRegistryTagsBounded(listFn, args.registry, args.repo, args.credentials)
: Promise.resolve({ kind: 'skipped' });
const [comparison, tagOutcome] = await Promise.all([comparisonPromise, tagPromise]);
let nextTag: string | null = null;
if (tagOutcome.kind === 'complete' || tagOutcome.kind === 'incomplete') {
nextTag = findNextTag(args.tag, tagOutcome.tags);
}
const digestUpdate = comparison.kind === 'update';
const tagUpdate = nextTag !== null;
const hasUpdate = digestUpdate || tagUpdate;
let resolvedNext: string | null = null;
let semverBump: SemverBump = 'none';
if (nextTag) {
resolvedNext = nextTag;
semverBump = computeSemverBump(args.tag, nextTag);
} else if (digestUpdate) {
resolvedNext = args.tag;
semverBump = 'patch';
}
const digestError = comparison.kind === 'error' ? comparison.reason : null;
const tagEnumKind = tagOutcome.kind;
const tagEnumReason = tagOutcome.kind === 'incomplete' || tagOutcome.kind === 'error'
? tagOutcome.reason
: null;
const checkStatus = resolveImageCheckStatus({
digest: comparison.kind,
tagApplicable,
tagOutcome,
hasUpdate,
});
return {
hasUpdate,
digestUpdate,
tagUpdate,
nextTag: resolvedNext,
digestError,
tagEnumKind,
tagEnumReason,
checkStatus,
reason: uncertaintyReason(checkStatus, digestError, tagEnumReason),
semverBump,
};
}
+42 -20
View File
@@ -242,14 +242,15 @@ export function repoDigestMatchesRef(repoDigest: string, parsed: ParsedRef): boo
const SHA256_DIGEST_RE = /^sha256:[0-9a-f]{64}$/i;
/**
* Deterministic local RepoDigest selection shared by the scanner and the
* update preview: the first entry whose repository matches the parsed
* image ref, else the sole remaining valid entry, else null. A truncated
* or malformed digest (not a complete "sha256:" + 64 hex chars) is never
* selected, so a corrupted RepoDigests entry surfaces as "could not
* resolve" rather than a false match or update.
* 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.
*/
export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef): string | null {
export function selectLocalRepoDigests(repoDigests: readonly string[], parsed: ParsedRef): string[] {
const valid = repoDigests
.map((entry) => {
const at = entry.indexOf('@');
@@ -257,9 +258,25 @@ export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef):
})
.filter((e): e is { entry: string; digest: string } => e !== null && SHA256_DIGEST_RE.test(e.digest));
const matched = valid.find((e) => repoDigestMatchesRef(e.entry, parsed));
if (matched) return matched.digest;
return valid.length === 1 ? valid[0].digest : null;
const matched: string[] = [];
const seen = new Set<string>();
for (const e of valid) {
if (!repoDigestMatchesRef(e.entry, parsed)) continue;
const key = e.digest.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
matched.push(e.digest);
}
if (matched.length > 0) return matched;
return valid.length === 1 ? [valid[0].digest] : [];
}
/**
* Scalar view of {@link selectLocalRepoDigests}: the first candidate, or null.
* Prefer the plural form when comparing against a remote tag.
*/
export function selectLocalRepoDigest(repoDigests: readonly string[], parsed: ParsedRef): string | null {
return selectLocalRepoDigests(repoDigests, parsed)[0] ?? null;
}
/** Outcome of a remote-digest lookup: the digest, or a human-readable reason it failed. */
@@ -762,16 +779,18 @@ async function classifyManifest(
* always targets that digest.
*/
export async function compareLocalToRemoteTag(
localDigest: string,
localDigests: readonly string[],
registry: string,
repo: string,
tag: string,
platform: { os: string; architecture: string },
credentials?: RegistryCredentials | null,
): Promise<DigestComparisonResult> {
if (!SHA256_DIGEST_RE.test(localDigest)) {
const candidates = localDigests.filter((d) => SHA256_DIGEST_RE.test(d));
if (candidates.length === 0) {
return { kind: 'error', reason: 'Local digest is malformed or truncated' };
}
const candidateSet = new Set(candidates.map((d) => d.toLowerCase()));
const ref = `${registry}/${repo}:${tag}`;
const probe = await probeManifestForRef(registry, repo, tag, credentials, ref);
@@ -781,7 +800,7 @@ export async function compareLocalToRemoteTag(
if (!SHA256_DIGEST_RE.test(primaryDigest)) {
return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` };
}
if (localDigest === primaryDigest) return { kind: 'match' };
if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match' };
let classification: ManifestClassification;
try {
@@ -792,14 +811,16 @@ export async function compareLocalToRemoteTag(
if (classification.kind === 'single') return { kind: 'update' };
if (classification.exactDigests.includes(localDigest)) return { kind: 'match' };
if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match' };
if (!platform.os || !platform.architecture) {
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 && d.digest === localDigest,
(d) => d.os === platform.os
&& d.architecture === platform.architecture
&& candidateSet.has(d.digest.toLowerCase()),
);
return isMember ? { kind: 'match' } : { kind: 'update' };
}
@@ -852,13 +873,15 @@ function parseNextCursor(linkHeader: string | string[] | undefined): string | un
}
/**
* Typed tag list for the Resources registry browser. Never collapses auth
* failures into an empty array (that would hide credential problems).
* 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.
*/
export async function listRegistryTagsResult(
registry: string,
repo: string,
credentials: RegistryCredentials,
credentials?: RegistryCredentials | null,
opts: { limit?: number; cursor?: string } = {},
): Promise<TagListResult> {
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
@@ -896,13 +919,12 @@ export async function listRegistryTagsResult(
}
}
/** Compatibility wrapper for update-preview: empty list on any failure. */
/** Compatibility wrapper for callers that only need tags: empty list on any failure. */
export async function listRegistryTags(
registry: string,
repo: string,
credentials?: RegistryCredentials | null,
): Promise<string[]> {
if (!credentials) return [];
const result = await listRegistryTagsResult(registry, repo, credentials);
return result.ok ? result.tags : [];
}