mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +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'] });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user