mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user