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
@@ -298,6 +298,26 @@ describe('CacheService', () => {
it('is a no-op for missing keys', () => {
expect(() => cache.invalidate('ns:missing')).not.toThrow();
});
it('prevents an in-flight fetch started before invalidate from committing afterward', async () => {
let resolveFetch!: (value: string) => void;
const staleFetcher = vi.fn(() => new Promise<string>((resolve) => {
resolveFetch = resolve;
}));
const stalePromise = cache.getOrFetch('fleet-updates', 60_000, staleFetcher);
cache.invalidate('fleet-updates');
const freshFetcher = vi.fn().mockResolvedValue('fresh');
const fresh = await cache.getOrFetch('fleet-updates', 60_000, freshFetcher);
expect(fresh).toBe('fresh');
expect(freshFetcher).toHaveBeenCalledTimes(1);
resolveFetch('stale');
await expect(stalePromise).resolves.toBe('stale');
// Stale writer must not overwrite the post-invalidate entry.
expect(cache.get<string>('fleet-updates')).toBe('fresh');
});
});
describe('invalidateNamespace', () => {
@@ -46,10 +46,40 @@ describe('stack_update_status tri-state accessors', () => {
expect(db().getStackUpdateDetail(NODE).web.checkStatus).toBe('ok');
});
it('keeps getStackUpdateStatus a boolean map for the fleet contract', () => {
it('keeps getStackUpdateStatus a raw boolean map ignoring check_status', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'api', false, 1000, 'failed', 'boom');
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false });
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false, sticky: true });
});
it('projects confirmed updates only via getConfirmedStackUpdateStatus', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
db().upsertStackUpdateStatus(NODE, 'failed', true, 1000, 'failed', 'boom');
db().upsertStackUpdateStatus(NODE, 'clean', false, 1000, 'ok', null);
expect(db().getConfirmedStackUpdateStatus(NODE)).toEqual({
web: true,
sticky: false,
failed: false,
clean: false,
});
});
it('clearStackUpdateStatus returns deleted row count and removes services_json', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'partial', 'half', [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
]);
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(1);
expect(db().getStackUpdateDetail(NODE).web).toBeUndefined();
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0);
});
it('getNodeUpdateSummary counts only confirmed updates', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
const summary = db().getNodeUpdateSummary().find((r) => r.node_id === NODE);
expect(summary?.stacks_with_updates).toBe(1);
});
it('recordStackCheckFailure preserves a prior has_update while marking failed', () => {
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CacheService } from '../services/CacheService';
import {
FLEET_UPDATE_CACHE_KEY,
invalidateFleetUpdateCache,
isFullStackUpdatePath,
isUpdatePreviewPath,
} from '../helpers/fleetUpdateCache';
describe('isFullStackUpdatePath', () => {
it('matches full-stack update paths after the /api mount strip', () => {
expect(isFullStackUpdatePath('/stacks/paperless/update')).toBe(true);
expect(isFullStackUpdatePath('/stacks/paperless/update?nodeId=2')).toBe(true);
});
it('rejects service-scoped update and restore paths', () => {
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/update')).toBe(false);
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/restore')).toBe(false);
expect(isFullStackUpdatePath('/stacks/paperless/deploy')).toBe(false);
});
});
describe('isUpdatePreviewPath', () => {
it('matches update-preview paths after the /api mount strip', () => {
expect(isUpdatePreviewPath('/stacks/paperless/update-preview')).toBe(true);
expect(isUpdatePreviewPath('/stacks/paperless/update-preview?nodeId=2')).toBe(true);
});
it('rejects full-stack update and other stack paths', () => {
expect(isUpdatePreviewPath('/stacks/paperless/update')).toBe(false);
expect(isUpdatePreviewPath('/stacks/paperless/services/redis/update')).toBe(false);
});
});
describe('invalidateFleetUpdateCache', () => {
beforeEach(() => {
CacheService.getInstance().flush();
});
afterEach(() => {
CacheService.getInstance().flush();
});
it('drops the shared fleet-updates key', async () => {
const cache = CacheService.getInstance();
await cache.getOrFetch(FLEET_UPDATE_CACHE_KEY, 60_000, async () => ({ '1': { web: true } }));
invalidateFleetUpdateCache();
expect(cache.get(FLEET_UPDATE_CACHE_KEY)).toBeUndefined();
});
});
@@ -15,13 +15,13 @@ import {
import type { StackServiceStatus } from '../services/DatabaseService';
function ok(hasUpdate: boolean): ImageCheckResult {
return { hasUpdate };
return { hasUpdate, checkStatus: 'ok', digestUpdate: hasUpdate, tagUpdate: false };
}
function errored(message: string): ImageCheckResult {
return { hasUpdate: false, error: message };
return { hasUpdate: false, checkStatus: 'failed', error: message };
}
function notCheckable(): ImageCheckResult {
return { hasUpdate: false, notCheckable: true };
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
}
describe('reduceServiceStatus', () => {
@@ -158,7 +158,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
it('marks sha256-only refs not-checkable (no tag to track)', async () => {
const docker = makeMockDocker();
const result = await service.checkImage(docker, 'sha256:abc123');
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
});
it('returns error when local image inspect fails', async () => {
@@ -195,7 +195,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
// Empty RepoDigests means locally built / not registry-backed.
const docker = makeMockDocker([]);
const result = await service.checkImage(docker, 'nginx:latest');
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
});
it('errors when RepoDigests are present but none resolves a digest', async () => {
@@ -237,26 +237,26 @@ describe('ImageUpdateService - checkImage surfaces the comparison resolver outco
it('surfaces the specific failure reason (not a generic "unreachable") as the check error', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'error', reason: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: false, error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
expect(result).toMatchObject({ hasUpdate: false, checkStatus: 'failed', error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
});
it('reports an update when the comparison resolver classifies the remote as an update', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'update' });
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: true });
expect(result).toMatchObject({ hasUpdate: true, digestUpdate: true, checkStatus: 'ok' });
});
it('reports no update when the comparison resolver classifies the remote as a match', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: false });
expect(result).toMatchObject({ hasUpdate: false, digestUpdate: false, checkStatus: 'ok' });
});
it('passes the local digest, platform, and parsed ref through to the comparison resolver', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(mockCompareLocalToRemoteTag).toHaveBeenCalledWith(
LOCAL_DIGEST,
[LOCAL_DIGEST],
'ghcr.io',
'linuxserver/radarr',
'latest',
@@ -44,6 +44,18 @@ describe('GET /api/image-updates', () => {
expect(res.status).toBe(200);
expect(res.body).toBeInstanceOf(Object);
});
it('excludes partial and failed retained rows from the confirmed boolean map', async () => {
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'ok-stack', true, 1000, 'ok', null);
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'partial-stack', true, 1000, 'partial', 'half');
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'failed-stack', true, 1000, 'failed', 'boom');
const res = await request(app).get('/api/image-updates').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body['ok-stack']).toBe(true);
expect(res.body['partial-stack']).toBe(false);
expect(res.body['failed-stack']).toBe(false);
});
});
describe('GET /api/image-updates/detail', () => {
@@ -374,7 +386,7 @@ describe('POST /api/auto-update/execute', () => {
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
.mockResolvedValue({ hasUpdate: true } as never);
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
const gateSpy = vi.spyOn(PolicyEnforcement, 'enforcePolicyPreDeploy').mockResolvedValue({
ok: false,
@@ -420,7 +432,7 @@ describe('POST /api/auto-update/execute', () => {
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
.mockResolvedValue({ hasUpdate: true } as never);
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockReturnValue('gate-au');
try {
+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');
});
});
@@ -789,7 +789,7 @@ describe('SchedulerService - executeUpdate', () => {
mockGetContainersByStack.mockResolvedValue([
{ Id: 'c1', Image: 'nginx:latest' },
]);
mockCheckImage.mockResolvedValue({ hasUpdate: true }); // Update available
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true }); // Update available
const svc = SchedulerService.getInstance();
await svc.triggerTask(80);
@@ -814,7 +814,7 @@ describe('SchedulerService - executeUpdate', () => {
last_status: null,
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
const svc = SchedulerService.getInstance();
await svc.triggerTask(82);
@@ -842,7 +842,7 @@ describe('SchedulerService - executeUpdate', () => {
last_status: null,
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
await SchedulerService.getInstance().triggerTask(83);
@@ -891,7 +891,7 @@ describe('SchedulerService - executeUpdate', () => {
mockGetContainersByStack.mockResolvedValue([
{ Id: 'c1', Image: 'nginx:latest' },
]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
const svc = SchedulerService.getInstance();
await svc.triggerTask(82);
@@ -1035,7 +1035,7 @@ describe('SchedulerService - executeUpdate', () => {
last_status: null,
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:1.14' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
mockEnforcePolicyPreDeploy.mockResolvedValue({
ok: false,
bypassed: false,
@@ -1114,7 +1114,7 @@ describe('SchedulerService - executeUpdate', () => {
});
mockGetStacks.mockResolvedValue(['app1', 'app2', 'app3']);
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
const svc = SchedulerService.getInstance();
await svc.triggerTask(87);
@@ -37,6 +37,7 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
check_status: 'ok',
...over,
});
@@ -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,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
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,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
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,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
rollback_target: 'app:1.2.3',
changelog: null,
@@ -0,0 +1,457 @@
/**
* Authoritative-negative update-preview reconcile: commitPreviewClear generation
* safety and route side effects (broadcast / fleet cache invalidate).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ImageUpdateService: typeof import('../services/ImageUpdateService').ImageUpdateService;
let UpdatePreviewService: typeof import('../services/UpdatePreviewService').UpdatePreviewService;
let NotificationService: typeof import('../services/NotificationService').NotificationService;
let CacheService: typeof import('../services/CacheService').CacheService;
let adminCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ ImageUpdateService } = await import('../services/ImageUpdateService'));
({ UpdatePreviewService } = await import('../services/UpdatePreviewService'));
({ NotificationService } = await import('../services/NotificationService'));
({ CacheService } = await import('../services/CacheService'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
const raw = (DatabaseService.getInstance() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
raw.prepare('DELETE FROM stack_update_status').run();
});
function negativeOkPreview(stackName = 'web') {
return {
stack_name: stackName,
images: [{
service: 'web',
image: 'nginx:1.2.3',
current_tag: '1.2.3',
next_tag: null,
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none' as const,
check_status: 'ok' as const,
}],
build_services: [] as string[],
summary: {
has_update: false,
primary_image: 'nginx:1.2.3',
current_tag: '1.2.3',
next_tag: null,
semver_bump: 'none' as const,
update_kind: 'none' as const,
blocked: false,
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
check_status: 'ok' as const,
},
rollback_target: null,
changelog: null,
};
}
describe('ImageUpdateService.commitPreviewClear', () => {
it('clears sticky partial rows and returns cleared', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half', [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
]);
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
const result = await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow);
expect(result).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
});
it('clears an older confirmed ok+true row', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance();
const writeGen = (svc as unknown as {
reserveStackWriteGeneration: (n: number, s: string) => number;
}).reserveStackWriteGeneration(nodeId, 'web');
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], writeGen);
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
expect(observedRow).toBe(writeGen);
expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
});
it('clears a persisted ok+true row when in-memory generation was reset (restart)', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
// Simulate a prior process that wrote generation 7, then a restart that
// left only SQLite state (in-memory high-water is 0 for this stack key).
db.upsertStackUpdateStatus(nodeId, 'restart-web', true, 1000, 'ok', null, [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], 7);
const svc = ImageUpdateService.getInstance();
expect(svc.peekStackWriteGeneration(nodeId, 'restart-web')).toBe(0);
expect(db.getStackUpdateWriteGeneration(nodeId, 'restart-web')).toBe(7);
expect(await svc.commitPreviewClear(nodeId, 'restart-web', 0, 7)).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId)['restart-web']).toBeUndefined();
});
it('returns absent when no row exists', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance();
expect(await svc.commitPreviewClear(nodeId, 'missing', 0, 0)).toBe('absent');
});
it('retains a row written after the observation watermark', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance() as unknown as {
peekStackWriteGeneration: (n: number, s: string) => number;
reserveStackWriteGeneration: (n: number, s: string) => number;
commitPreviewClear: (
n: number,
s: string,
observedMem: number,
observedRow: number,
) => Promise<'cleared' | 'stale' | 'absent'>;
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
};
const observedMem = svc.peekStackWriteGeneration(nodeId, 'race');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'race');
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'race');
expect(scannerGen).toBeGreaterThan(observedMem);
await svc.withStackWriteLock(nodeId, 'race', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'race', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
], scannerGen);
});
expect(await svc.commitPreviewClear(nodeId, 'race', observedMem, observedRow)).toBe('stale');
expect(db.getStackUpdateDetail(nodeId).race?.hasUpdate).toBe(true);
expect(db.getStackUpdateDetail(nodeId).race?.checkStatus).toBe('ok');
});
it('retains a row whose DB generation advanced after observation', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'adv', true, 1000, 'ok', null, [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], 3);
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'adv');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'adv');
expect(observedRow).toBe(3);
db.upsertStackUpdateStatus(nodeId, 'adv', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
], 4);
expect(await svc.commitPreviewClear(nodeId, 'adv', observedMem, observedRow)).toBe('absent');
expect(db.getStackUpdateDetail(nodeId).adv?.hasUpdate).toBe(true);
});
});
describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => {
it('GET does not mutate sticky state even for authoritative-negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.get('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBeUndefined();
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
expect(invalidate).not.toHaveBeenCalled();
expect(broadcast).not.toHaveBeenCalled();
});
it('POST clears sticky state and broadcasts on authoritative-negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(true);
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
type: 'state-invalidate',
scope: 'image-updates',
action: 'update-status-reconciled',
stackName: 'web',
}));
});
it('POST clears an older confirmed ok+true row and broadcasts', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance() as unknown as {
reserveStackWriteGeneration: (n: number, s: string) => number;
};
const writeGen = svc.reserveStackWriteGeneration(nodeId, 'web');
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
{ service: 'web', image: 'nginx:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], writeGen);
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(true);
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
action: 'update-status-reconciled',
stackName: 'web',
}));
});
it('POST does not mutate on partial negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
const preview = negativeOkPreview('web');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
...preview,
images: [{ ...preview.images[0], check_status: 'partial' }],
summary: { ...preview.summary, check_status: 'partial' },
});
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
});
it('POST does not mutate when an image is not_checkable alongside ok', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
const preview = negativeOkPreview('web');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
...preview,
images: [
preview.images[0],
{
...preview.images[0],
service: 'bad',
image: 'not-a-valid-ref',
check_status: 'not_checkable' as const,
},
],
summary: { ...preview.summary, check_status: 'partial' as const },
});
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
});
it('POST does not mutate when check_status is missing from summary rollup fields still fail every-ok', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
const preview = negativeOkPreview('web');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
...preview,
images: [{ ...preview.images[0], check_status: 'partial' as const }],
});
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
});
it('POST does not broadcast when clear finds no row', async () => {
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('ghost'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/ghost/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(broadcast).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
});
it('retains a confirmed row written after the preview observation', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance() as unknown as {
peekStackWriteGeneration: (n: number, s: string) => number;
reserveStackWriteGeneration: (n: number, s: string) => number;
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
};
const observedBeforePreview = svc.peekStackWriteGeneration(nodeId, 'web');
let previewCalls = 0;
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockImplementation(async () => {
previewCalls += 1;
// Simulate a scanner reservation+commit that begins after observation.
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'web');
await svc.withStackWriteLock(nodeId, 'web', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'web', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'nginx:9', hasUpdate: true, checkStatus: 'ok', lastError: null },
], scannerGen);
});
expect(scannerGen).toBeGreaterThan(observedBeforePreview);
return negativeOkPreview('web');
});
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(previewCalls).toBe(1);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
expect(db.getStackUpdateDetail(nodeId).web?.checkStatus).toBe('ok');
expect(broadcast).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
});
});
describe('generation ordering for preview clear', () => {
it('a newer scanner reservation supersedes an in-flight observation-watermark clear', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'ord', true, 1000, 'partial', 'half');
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord');
const scannerGen = (svc as unknown as {
reserveStackWriteGeneration: (n: number, s: string) => number;
}).reserveStackWriteGeneration(nodeId, 'ord');
expect(scannerGen).toBeGreaterThan(observedMem);
expect(await svc.commitPreviewClear(nodeId, 'ord', observedMem, observedRow)).toBe('stale');
const scannerCommitted = await (svc as unknown as {
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
}).withStackWriteLock(nodeId, 'ord', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'ord', true, Date.now(), 'ok', null);
});
expect(scannerCommitted).toBe(true);
expect(db.getStackUpdateDetail(nodeId).ord?.hasUpdate).toBe(true);
expect(db.getStackUpdateDetail(nodeId).ord?.checkStatus).toBe('ok');
});
it('equal-generation scanner reserved before observation cannot rewrite after clear', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'ord2', true, 1000, 'partial', 'half', [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'partial', lastError: 'half' },
], 1);
const svc = ImageUpdateService.getInstance() as unknown as {
peekStackWriteGeneration: (n: number, s: string) => number;
reserveStackWriteGeneration: (n: number, s: string) => number;
commitPreviewClear: (
n: number,
s: string,
observedMem: number,
observedRow: number,
) => Promise<'cleared' | 'stale' | 'absent'>;
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
};
// Full scan reserved generation N before its slow registry work.
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'ord2');
// Preview observation sees that same watermark.
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord2');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord2');
expect(observedMem).toBe(scannerGen);
expect(await svc.commitPreviewClear(nodeId, 'ord2', observedMem, observedRow)).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
// Delayed scanner write using the pre-observation reservation must not commit.
const scannerCommitted = await svc.withStackWriteLock(nodeId, 'ord2', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'ord2', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
], scannerGen);
});
expect(scannerCommitted).toBe(false);
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
});
});
@@ -6,15 +6,22 @@ import {
computeImagePreview,
buildSummary,
isMovingTag,
listAllRegistryTagsBounded,
isAuthoritativeNegativePreview,
PREVIEW_TAG_LIST_MAX_PAGES,
type ComputePreviewDeps,
type LocalDigestInfo,
} from '../services/UpdatePreviewService';
import type { DigestComparisonResult } from '../services/registry-api';
import type { DigestComparisonResult, TagListResult } from '../services/registry-api';
const PLATFORM = { os: 'linux', architecture: 'amd64' };
function localDigest(digest: string | null): LocalDigestInfo {
return { digest, platform: PLATFORM };
return { digests: digest ? [digest] : [], platform: PLATFORM };
}
function tagsOk(tags: string[], nextCursor?: string): TagListResult {
return nextCursor ? { ok: true, tags, nextCursor } : { ok: true, tags };
}
describe('parseSemverTag', () => {
@@ -97,7 +104,7 @@ function makeDeps(overrides: Partial<ComputePreviewDeps> = {}): ComputePreviewDe
getCredentials: vi.fn().mockResolvedValue(null),
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'not configured' } satisfies DigestComparisonResult),
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
...overrides,
};
}
@@ -107,7 +114,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(result.has_update).toBe(false);
@@ -119,7 +126,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'update' }),
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
const result = await computeImagePreview('web', 'nginx:latest', deps);
expect(result.has_update).toBe(true);
@@ -132,7 +139,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTags: vi.fn().mockResolvedValue(['27.1.4', '27.1.5', '27.2.0']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['27.1.4', '27.1.5', '27.2.0'])),
});
const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps);
expect(result.has_update).toBe(true);
@@ -144,7 +151,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '2.0.0']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '2.0.0'])),
});
const result = await computeImagePreview('db', 'postgres:1.2.3', deps);
expect(result.next_tag).toBe('2.0.0');
@@ -155,7 +162,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '1.2.4']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(result.has_update).toBe(true);
@@ -167,12 +174,24 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(result.has_update).toBe(false);
expect(result.next_tag).toBeNull();
expect(result.semver_bump).toBe('none');
expect(result.check_status).toBe('partial');
});
it('treats digest error + higher tag as a confirmed update (ok)', 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' }),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
}));
expect(result.has_update).toBe(true);
expect(result.next_tag).toBe('1.2.4');
expect(result.check_status).toBe('ok');
});
it('never calls the comparison resolver when no local digest is resolvable', async () => {
@@ -180,7 +199,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
compareDigest,
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(compareDigest).not.toHaveBeenCalled();
@@ -192,10 +211,10 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest,
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
await computeImagePreview('web', 'ghcr.io/linuxserver/radarr:latest', deps);
expect(compareDigest).toHaveBeenCalledWith('sha256:aaa', 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
expect(compareDigest).toHaveBeenCalledWith(['sha256:aaa'], 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
});
});
@@ -206,7 +225,10 @@ describe('buildSummary', () => {
current_tag: '1.0.0',
next_tag: null,
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none' as const,
check_status: 'ok' as const,
...partial,
});
@@ -334,4 +356,143 @@ describe('buildSummary', () => {
const images = [baseImage({ service: 'clean', has_update: false })];
expect(buildSummary('stacky', images).summary.update_kind).toBe('none');
});
it('sets check_status=ok for empty and all-ok images', () => {
expect(buildSummary('empty', []).summary.check_status).toBe('ok');
expect(buildSummary('ok', [baseImage({ check_status: 'ok' })]).summary.check_status).toBe('ok');
});
it('rolls up mixed and failed check_status', () => {
expect(buildSummary('mixed', [
baseImage({ service: 'a', check_status: 'ok' }),
baseImage({ service: 'b', check_status: 'partial' }),
]).summary.check_status).toBe('partial');
expect(buildSummary('fail', [
baseImage({ service: 'a', check_status: 'failed' }),
baseImage({ service: 'b', check_status: 'failed' }),
]).summary.check_status).toBe('failed');
});
});
describe('preview authority', () => {
it('marks digest match + exhausted empty tags as authoritative ok with no update', async () => {
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
}));
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('ok');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
});
it('marks digest error + successful tag list with no next as partial (not authoritative-negative)', 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: 'boom' }),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
}));
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('partial');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
});
it('does not treat empty or not_checkable-only previews as authoritative-negative', () => {
expect(isAuthoritativeNegativePreview(buildSummary('empty', []))).toBe(false);
expect(isAuthoritativeNegativePreview(buildSummary('build', [
{
service: 'app',
image: 'sha256:dead',
current_tag: 'unknown',
next_tag: null,
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none',
check_status: 'not_checkable',
},
]))).toBe(false);
});
it('marks digest match + tag list failure as partial for semver tags', async () => {
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: vi.fn().mockResolvedValue({
ok: false,
code: 'REGISTRY_UPSTREAM',
message: 'Registry unreachable',
}),
}));
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('partial');
});
it('allows moving/non-semver tags to be authoritative-negative on digest match without tag enum', async () => {
const listFn = vi.fn();
const result = await computeImagePreview('web', 'nginx:latest', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: listFn,
}));
expect(listFn).not.toHaveBeenCalled();
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('ok');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
});
it('detects a newer tag found on a later page', async () => {
const listFn = vi.fn()
.mockResolvedValueOnce(tagsOk(['1.0.0', '1.0.1'], 'cursor-1'))
.mockResolvedValueOnce(tagsOk(['1.1.0']));
const result = await computeImagePreview('web', 'nginx:1.0.0', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: listFn,
}));
expect(listFn).toHaveBeenCalledTimes(2);
expect(result.has_update).toBe(true);
expect(result.next_tag).toBe('1.1.0');
expect(result.check_status).toBe('ok');
});
it('treats page-cap with remaining cursor as non-authoritative for semver negatives', async () => {
let page = 0;
const listFn = vi.fn().mockImplementation(async () => {
page += 1;
return tagsOk([`1.0.${page}`], `cursor-${page}`);
});
const result = await computeImagePreview('web', 'nginx:2.0.0', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: listFn,
}));
expect(listFn).toHaveBeenCalledTimes(PREVIEW_TAG_LIST_MAX_PAGES);
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('partial');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
});
it('marks invalid refs as not_checkable', async () => {
const result = await computeImagePreview('web', 'sha256:deadbeef', makeDeps());
expect(result.check_status).toBe('not_checkable');
expect(result.has_update).toBe(false);
});
});
describe('listAllRegistryTagsBounded', () => {
it('returns incomplete when nextCursor remains after the page cap', async () => {
const listFn = vi.fn().mockResolvedValue(tagsOk(['a'], 'more'));
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null, { maxPages: 2 });
expect(outcome.kind).toBe('incomplete');
expect(listFn).toHaveBeenCalledTimes(2);
});
it('returns complete when pagination exhausts', async () => {
const listFn = vi.fn()
.mockResolvedValueOnce(tagsOk(['a'], 'c1'))
.mockResolvedValueOnce(tagsOk(['b']));
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null);
expect(outcome).toEqual({ kind: 'complete', tags: ['a', 'b'] });
});
});
@@ -0,0 +1,62 @@
import type { ImageCheckResult } from '../services/ImageUpdateService';
/** Accumulator for auto-update image checks (digest vs tag-only vs error). */
export interface AutoUpdateDigestGateState {
hasDigestUpdate: boolean;
hasTagOnlyUpdate: boolean;
updatedImages: string[];
checkErrors: string[];
}
export function createAutoUpdateDigestGateState(): AutoUpdateDigestGateState {
return {
hasDigestUpdate: false,
hasTagOnlyUpdate: false,
updatedImages: [],
checkErrors: [],
};
}
/**
* Record one image check into the digest gate. Only digest drift is Compose-
* actionable for auto-update; tag bumps stay advisory.
*/
export function recordAutoUpdateImageCheck(
state: AutoUpdateDigestGateState,
imageRef: string,
result: ImageCheckResult,
): void {
if (result.digestUpdate) {
state.hasDigestUpdate = true;
state.updatedImages.push(imageRef);
return;
}
if (result.tagUpdate) {
state.hasTagOnlyUpdate = true;
return;
}
if (result.error || result.checkStatus === 'failed' || result.checkStatus === 'partial') {
state.checkErrors.push(result.error ?? 'Update check incomplete');
}
}
/** Operator message when no digest-actionable update was found. */
export function messageWhenNoDigestUpdate(
stackName: string,
state: Pick<AutoUpdateDigestGateState, 'hasTagOnlyUpdate' | 'checkErrors'>,
imageRefCount: number,
): string {
if (state.hasTagOnlyUpdate) {
const errNote = state.checkErrors.length > 0
? ` (${state.checkErrors.length} check(s) failed)`
: '';
return `Stack "${stackName}": newer tag available but Compose pin unchanged; skipped auto-update${errNote}.`;
}
if (state.checkErrors.length > 0 && state.checkErrors.length === imageRefCount) {
return `Stack "${stackName}": WARNING - all image checks failed (${state.checkErrors.join('; ')}). Unable to determine update status.`;
}
if (state.checkErrors.length > 0) {
return `Stack "${stackName}": all reachable images up to date (${state.checkErrors.length} check(s) failed).`;
}
return `Stack "${stackName}": all images up to date.`;
}
+34
View File
@@ -0,0 +1,34 @@
import { CacheService } from '../services/CacheService';
/** Hub aggregation cache for GET /api/image-updates/fleet. */
export const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
/**
* Drop the hub fleet-updates aggregation. Generation-aware via CacheService:
* an in-flight getOrFetch started before this call cannot commit afterward.
*/
export function invalidateFleetUpdateCache(): void {
CacheService.getInstance().invalidate(FLEET_UPDATE_CACHE_KEY);
}
function pathWithoutQuery(pathAfterApi: string): string {
return pathAfterApi.split('?')[0] ?? pathAfterApi;
}
/**
* True when `pathAfterApi` is a full-stack update route
* (`/stacks/:name/update`), not a service-scoped update/restore.
* `pathAfterApi` is the Express path after the `/api` mount strip
* (for example `/stacks/paperless/update`).
*/
export function isFullStackUpdatePath(pathAfterApi: string): boolean {
return /^\/stacks\/[^/]+\/update\/?$/.test(pathWithoutQuery(pathAfterApi));
}
/**
* True when `pathAfterApi` is the stack update-preview route
* (`/stacks/:name/update-preview`).
*/
export function isUpdatePreviewPath(pathAfterApi: string): boolean {
return /^\/stacks\/[^/]+\/update-preview\/?$/.test(pathWithoutQuery(pathAfterApi));
}
+13
View File
@@ -11,6 +11,7 @@ import { DatabaseService } from '../services/DatabaseService';
import { redactSensitiveText } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming';
import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache';
/**
* Per-request hop timing for the critical hydration GETs, kept off the Request
@@ -175,6 +176,18 @@ export function createRemoteProxyMiddleware(): RequestHandler {
timing.upstreamStatus = proxyRes.statusCode;
timing.ttfbMs = Date.now() - timing.startedAt;
}
// Hub fleet aggregation is local-only. A successful remote full-stack
// Apply or update-preview reconcile must drop the hub cache so the next
// fleet poll does not revive a verified-cleared card from a stale entry.
const status = proxyRes.statusCode ?? 0;
if (
req.method === 'POST'
&& status >= 200
&& status < 300
&& (isFullStackUpdatePath(req.path) || isUpdatePreviewPath(req.path))
) {
invalidateFleetUpdateCache();
}
},
error: (err, req, proxyRes) => {
// Finalize the hop timing with an error outcome before the existing
+18 -22
View File
@@ -5,6 +5,12 @@ import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import { CacheService } from '../services/CacheService';
import { FLEET_UPDATE_CACHE_KEY } from '../helpers/fleetUpdateCache';
import {
createAutoUpdateDigestGateState,
messageWhenNoDigestUpdate,
recordAutoUpdateImageCheck,
} from '../helpers/autoUpdateDigestGate';
import { ImageUpdateService } from '../services/ImageUpdateService';
import { FileSystemService } from '../services/FileSystemService';
import { StackUpdateOrchestrator } from '../services/StackUpdateOrchestrator';
@@ -22,7 +28,6 @@ import { logDebugTiming } from '../utils/requestTiming';
import { getErrorMessage } from '../utils/errors';
// Fleet aggregation cache: 2-minute TTL, shared across dashboard tabs.
const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
const FLEET_CACHE_TTL = 120_000;
const REMOTE_NODE_FETCH_TIMEOUT_MS = 5000;
@@ -30,7 +35,9 @@ export const imageUpdatesRouter = Router();
imageUpdatesRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
try {
const updates = DatabaseService.getInstance().getStackUpdateStatus(req.nodeId);
// Confirmed-only: partial/failed retained has_update rows stay out of the
// boolean map so Fleet and node cards do not treat uncertainty as pending.
const updates = DatabaseService.getInstance().getConfirmedStackUpdateStatus(req.nodeId);
res.json(updates);
} catch (error) {
console.error('Failed to fetch image update status:', error);
@@ -184,10 +191,10 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo
const nr = NodeRegistry.getInstance();
const data: Record<number, Record<string, boolean>> = {};
// Local nodes: synchronous DB reads.
// Local nodes: synchronous DB reads (confirmed-only projection).
for (const node of nodes) {
if (node.type === 'local') {
data[node.id] = db.getStackUpdateStatus(node.id);
data[node.id] = db.getConfirmedStackUpdateStatus(node.id);
}
}
@@ -365,36 +372,25 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
continue;
}
let hasUpdate = false;
const updatedImages: string[] = [];
const checkErrors: string[] = [];
const gate = createAutoUpdateDigestGateState();
for (const imageRef of imageRefs) {
try {
const result = await imageUpdateService.checkImage(docker, imageRef);
if (result.error) {
checkErrors.push(result.error);
} else if (result.hasUpdate) {
hasUpdate = true;
updatedImages.push(imageRef);
}
recordAutoUpdateImageCheck(gate, imageRef, result);
} catch (e) {
const errMsg = getErrorMessage(e, String(e));
checkErrors.push(errMsg);
gate.checkErrors.push(errMsg);
console.warn('[AutoUpdate] Failed to check image %s:', sanitizeForLog(imageRef), sanitizeForLog((e as Error)?.message ?? String(e)));
}
}
if (!hasUpdate) {
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
results.push(`Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`);
} else if (checkErrors.length > 0) {
results.push(`Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`);
} else {
results.push(`Stack "${stackName}": all images up to date.`);
}
if (!gate.hasDigestUpdate) {
results.push(messageWhenNoDigestUpdate(stackName, gate, imageRefs.length));
continue;
}
const { updatedImages } = gate;
// Auto-update runs from the scheduler: a policy bypass is never
// appropriate. If updated images fail the gate, skip the stack and
// raise a notification so an operator can review before a manual retry.
+45 -1
View File
@@ -17,7 +17,9 @@ import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '
import DockerController, { type BulkStackInfo } from '../services/DockerController';
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
import { UpdatePreviewService } from '../services/UpdatePreviewService';
import { UpdatePreviewService, isAuthoritativeNegativePreview } from '../services/UpdatePreviewService';
import { ImageUpdateService } from '../services/ImageUpdateService';
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
@@ -2243,6 +2245,8 @@ stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Reque
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
// Read-only: sticky reconciliation lives on POST so UpdateGuard and other
// GET consumers never mutate persisted scanner state.
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
res.json(preview);
} catch (error) {
@@ -2251,6 +2255,45 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
}
});
stacksRouter.post('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
// Snapshot write-generation watermarks before the read-only preview so a
// later clear can erase older confirmed/sticky rows without racing a
// scanner that reserved or rewrote the row after this observation.
const imageUpdates = ImageUpdateService.getInstance();
const db = DatabaseService.getInstance();
const observedMemoryGeneration = imageUpdates.peekStackWriteGeneration(req.nodeId, stackName);
const observedRowGeneration = db.getStackUpdateWriteGeneration(req.nodeId, stackName);
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
let reconciled = false;
if (isAuthoritativeNegativePreview(preview)) {
const clearResult = await imageUpdates.commitPreviewClear(
req.nodeId,
stackName,
observedMemoryGeneration,
observedRowGeneration,
);
if (clearResult === 'cleared') {
reconciled = true;
invalidateFleetUpdateCache();
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'image-updates',
nodeId: req.nodeId,
stackName,
action: 'update-status-reconciled',
ts: Date.now(),
});
}
}
res.json({ ...preview, reconciled });
} catch (error) {
console.error('[Stacks] Update preview reconcile failed: %s', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to compute update preview' });
}
});
stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
@@ -2271,6 +2314,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
{ atomic, terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)) },
);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
invalidateFleetUpdateCache();
invalidateNodeCaches(req.nodeId);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
+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 : [];
}