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

* fix: reconcile sticky update indicators with Anatomy preview

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

Closes #1685

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

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

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

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

* fix: tombstone equal-generation writers on preview clear

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

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

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

* test: set digestUpdate on auto-update checkImage mocks

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

* fix: clear unused lint errors on sticky update branch

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

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
Anso
2026-07-25 15:42:19 -04:00
committed by GitHub
parent 8b5407fcff
commit 0daddfde00
43 changed files with 2529 additions and 390 deletions
@@ -298,6 +298,26 @@ describe('CacheService', () => {
it('is a no-op for missing keys', () => {
expect(() => cache.invalidate('ns:missing')).not.toThrow();
});
it('prevents an in-flight fetch started before invalidate from committing afterward', async () => {
let resolveFetch!: (value: string) => void;
const staleFetcher = vi.fn(() => new Promise<string>((resolve) => {
resolveFetch = resolve;
}));
const stalePromise = cache.getOrFetch('fleet-updates', 60_000, staleFetcher);
cache.invalidate('fleet-updates');
const freshFetcher = vi.fn().mockResolvedValue('fresh');
const fresh = await cache.getOrFetch('fleet-updates', 60_000, freshFetcher);
expect(fresh).toBe('fresh');
expect(freshFetcher).toHaveBeenCalledTimes(1);
resolveFetch('stale');
await expect(stalePromise).resolves.toBe('stale');
// Stale writer must not overwrite the post-invalidate entry.
expect(cache.get<string>('fleet-updates')).toBe('fresh');
});
});
describe('invalidateNamespace', () => {
@@ -46,10 +46,40 @@ describe('stack_update_status tri-state accessors', () => {
expect(db().getStackUpdateDetail(NODE).web.checkStatus).toBe('ok');
});
it('keeps getStackUpdateStatus a boolean map for the fleet contract', () => {
it('keeps getStackUpdateStatus a raw boolean map ignoring check_status', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'api', false, 1000, 'failed', 'boom');
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false });
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false, sticky: true });
});
it('projects confirmed updates only via getConfirmedStackUpdateStatus', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
db().upsertStackUpdateStatus(NODE, 'failed', true, 1000, 'failed', 'boom');
db().upsertStackUpdateStatus(NODE, 'clean', false, 1000, 'ok', null);
expect(db().getConfirmedStackUpdateStatus(NODE)).toEqual({
web: true,
sticky: false,
failed: false,
clean: false,
});
});
it('clearStackUpdateStatus returns deleted row count and removes services_json', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'partial', 'half', [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
]);
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(1);
expect(db().getStackUpdateDetail(NODE).web).toBeUndefined();
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0);
});
it('getNodeUpdateSummary counts only confirmed updates', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
const summary = db().getNodeUpdateSummary().find((r) => r.node_id === NODE);
expect(summary?.stacks_with_updates).toBe(1);
});
it('recordStackCheckFailure preserves a prior has_update while marking failed', () => {
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CacheService } from '../services/CacheService';
import {
FLEET_UPDATE_CACHE_KEY,
invalidateFleetUpdateCache,
isFullStackUpdatePath,
isUpdatePreviewPath,
} from '../helpers/fleetUpdateCache';
describe('isFullStackUpdatePath', () => {
it('matches full-stack update paths after the /api mount strip', () => {
expect(isFullStackUpdatePath('/stacks/paperless/update')).toBe(true);
expect(isFullStackUpdatePath('/stacks/paperless/update?nodeId=2')).toBe(true);
});
it('rejects service-scoped update and restore paths', () => {
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/update')).toBe(false);
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/restore')).toBe(false);
expect(isFullStackUpdatePath('/stacks/paperless/deploy')).toBe(false);
});
});
describe('isUpdatePreviewPath', () => {
it('matches update-preview paths after the /api mount strip', () => {
expect(isUpdatePreviewPath('/stacks/paperless/update-preview')).toBe(true);
expect(isUpdatePreviewPath('/stacks/paperless/update-preview?nodeId=2')).toBe(true);
});
it('rejects full-stack update and other stack paths', () => {
expect(isUpdatePreviewPath('/stacks/paperless/update')).toBe(false);
expect(isUpdatePreviewPath('/stacks/paperless/services/redis/update')).toBe(false);
});
});
describe('invalidateFleetUpdateCache', () => {
beforeEach(() => {
CacheService.getInstance().flush();
});
afterEach(() => {
CacheService.getInstance().flush();
});
it('drops the shared fleet-updates key', async () => {
const cache = CacheService.getInstance();
await cache.getOrFetch(FLEET_UPDATE_CACHE_KEY, 60_000, async () => ({ '1': { web: true } }));
invalidateFleetUpdateCache();
expect(cache.get(FLEET_UPDATE_CACHE_KEY)).toBeUndefined();
});
});
@@ -15,13 +15,13 @@ import {
import type { StackServiceStatus } from '../services/DatabaseService';
function ok(hasUpdate: boolean): ImageCheckResult {
return { hasUpdate };
return { hasUpdate, checkStatus: 'ok', digestUpdate: hasUpdate, tagUpdate: false };
}
function errored(message: string): ImageCheckResult {
return { hasUpdate: false, error: message };
return { hasUpdate: false, checkStatus: 'failed', error: message };
}
function notCheckable(): ImageCheckResult {
return { hasUpdate: false, notCheckable: true };
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
}
describe('reduceServiceStatus', () => {
@@ -158,7 +158,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
it('marks sha256-only refs not-checkable (no tag to track)', async () => {
const docker = makeMockDocker();
const result = await service.checkImage(docker, 'sha256:abc123');
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
});
it('returns error when local image inspect fails', async () => {
@@ -195,7 +195,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
// Empty RepoDigests means locally built / not registry-backed.
const docker = makeMockDocker([]);
const result = await service.checkImage(docker, 'nginx:latest');
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
});
it('errors when RepoDigests are present but none resolves a digest', async () => {
@@ -237,26 +237,26 @@ describe('ImageUpdateService - checkImage surfaces the comparison resolver outco
it('surfaces the specific failure reason (not a generic "unreachable") as the check error', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'error', reason: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: false, error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
expect(result).toMatchObject({ hasUpdate: false, checkStatus: 'failed', error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
});
it('reports an update when the comparison resolver classifies the remote as an update', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'update' });
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: true });
expect(result).toMatchObject({ hasUpdate: true, digestUpdate: true, checkStatus: 'ok' });
});
it('reports no update when the comparison resolver classifies the remote as a match', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: false });
expect(result).toMatchObject({ hasUpdate: false, digestUpdate: false, checkStatus: 'ok' });
});
it('passes the local digest, platform, and parsed ref through to the comparison resolver', async () => {
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
expect(mockCompareLocalToRemoteTag).toHaveBeenCalledWith(
LOCAL_DIGEST,
[LOCAL_DIGEST],
'ghcr.io',
'linuxserver/radarr',
'latest',
@@ -44,6 +44,18 @@ describe('GET /api/image-updates', () => {
expect(res.status).toBe(200);
expect(res.body).toBeInstanceOf(Object);
});
it('excludes partial and failed retained rows from the confirmed boolean map', async () => {
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'ok-stack', true, 1000, 'ok', null);
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'partial-stack', true, 1000, 'partial', 'half');
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'failed-stack', true, 1000, 'failed', 'boom');
const res = await request(app).get('/api/image-updates').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body['ok-stack']).toBe(true);
expect(res.body['partial-stack']).toBe(false);
expect(res.body['failed-stack']).toBe(false);
});
});
describe('GET /api/image-updates/detail', () => {
@@ -374,7 +386,7 @@ describe('POST /api/auto-update/execute', () => {
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
.mockResolvedValue({ hasUpdate: true } as never);
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
const gateSpy = vi.spyOn(PolicyEnforcement, 'enforcePolicyPreDeploy').mockResolvedValue({
ok: false,
@@ -420,7 +432,7 @@ describe('POST /api/auto-update/execute', () => {
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
.mockResolvedValue({ hasUpdate: true } as never);
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockReturnValue('gate-au');
try {
+60 -39
View File
@@ -61,6 +61,7 @@ import {
listRegistryTagsResult,
parseImageRef,
selectLocalRepoDigest,
selectLocalRepoDigests,
compareLocalToRemoteTag,
MANIFEST_CLASSIFICATION_CACHE_TTL_MS,
MANIFEST_INDEX_DESCRIPTOR_CAP,
@@ -407,6 +408,26 @@ describe('selectLocalRepoDigest', () => {
});
});
describe('selectLocalRepoDigests', () => {
const parsed = (ref: string) => {
const p = parseImageRef(ref);
if (!p) throw new Error(`unparseable ${ref}`);
return p;
};
const DIGEST_A = `sha256:${'a'.repeat(64)}`;
const DIGEST_B = `sha256:${'b'.repeat(64)}`;
it('returns every matching RepoDigest for the image ref', () => {
const digests = selectLocalRepoDigests([
`nginx@${DIGEST_A}`,
`nginx@${DIGEST_B}`,
`redis@sha256:${'c'.repeat(64)}`,
], parsed('nginx:latest'));
expect(digests).toEqual([DIGEST_A, DIGEST_B]);
});
});
// ─── compareLocalToRemoteTag ─────────────────────────────────────────────
//
// Reproduces and fixes the false-positive multi-arch update: a local
@@ -499,7 +520,7 @@ describe('compareLocalToRemoteTag', () => {
? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }
: { statusCode: 500, headers: {} }
);
const result = await compareLocalToRemoteTag(INDEX_DIGEST, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
});
@@ -516,7 +537,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
@@ -544,7 +565,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
expect(tagCallCount).toBe(1);
});
@@ -555,7 +576,7 @@ describe('compareLocalToRemoteTag', () => {
? { statusCode: 200, headers: { 'docker-content-digest': SINGLE_DIGEST, 'content-type': 'application/vnd.docker.distribution.manifest.v2+json' } }
: { statusCode: 500, headers: {} }
);
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'update' });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
});
@@ -570,7 +591,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
const result = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(result).toEqual({ kind: 'match' });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
@@ -590,7 +611,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
expect(calls.filter((c) => c.url === MANIFEST_URL_TAG)).toHaveLength(1);
});
@@ -609,9 +630,9 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(first.kind).toBe('error');
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(second.kind).toBe('error');
expect(digestGetCount).toBe(2);
});
@@ -635,12 +656,12 @@ describe('compareLocalToRemoteTag', () => {
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(first).toEqual({ kind: 'match' });
await vi.advanceTimersByTimeAsync(MANIFEST_CLASSIFICATION_CACHE_TTL_MS + 1000);
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(second).toEqual({ kind: 'match' });
expect(digestGetCount).toBe(2);
});
@@ -659,8 +680,8 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(digestGetCount).toBe(1);
});
@@ -679,8 +700,8 @@ describe('compareLocalToRemoteTag', () => {
return { statusCode: 500, headers: {} };
};
const [a, b] = await Promise.all([
compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64),
compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64),
compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64),
compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64),
]);
expect(a).toEqual({ kind: 'match' });
expect(b).toEqual({ kind: 'match' });
@@ -708,11 +729,11 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
const first = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(first).toEqual({ kind: 'match' });
headDigest = INDEX_DIGEST_2;
const second = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
const second = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
expect(second).toEqual({ kind: 'update' });
expect(digestGetCount).toBe(2);
});
@@ -734,11 +755,11 @@ describe('compareLocalToRemoteTag', () => {
};
route = routeFor(REPO);
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(digestGetCount).toBe(1);
route = routeFor('otherorg/otherapp');
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, 'otherorg/otherapp', TAG, AMD64);
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, 'otherorg/otherapp', TAG, AMD64);
expect(digestGetCount).toBe(2);
});
@@ -761,7 +782,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(VARIANT_V7, REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
const result = await compareLocalToRemoteTag([VARIANT_V7], REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
expect(result).toEqual({ kind: 'match' });
});
@@ -797,12 +818,12 @@ describe('compareLocalToRemoteTag', () => {
// A local digest equal to the filtered-out annotated-attestation entry must
// never match, since that descriptor is dropped before the membership check.
const filtered = await compareLocalToRemoteTag(ATTESTATION_ANNOTATED, REGISTRY, REPO, TAG, AMD64);
const filtered = await compareLocalToRemoteTag([ATTESTATION_ANNOTATED], REGISTRY, REPO, TAG, AMD64);
expect(filtered).toEqual({ kind: 'update' });
// The real platform descriptor still matches normally (cache hit reuses the
// same parsed classification from the previous call).
const realMatch = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const realMatch = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(realMatch).toEqual({ kind: 'match' });
});
@@ -825,7 +846,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -843,7 +864,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -860,7 +881,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('mismatched digest') });
});
@@ -879,7 +900,7 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('does not match the requested digest') });
});
@@ -895,13 +916,13 @@ describe('compareLocalToRemoteTag', () => {
}
return { statusCode: 500, headers: {} };
};
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, { os: '', architecture: '' });
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, { os: '', architecture: '' });
expect(result.kind).toBe('error');
});
it('rejects a truncated local digest as an error, never as a speculative update', async () => {
route = () => ({ statusCode: 500, headers: {} }); // must never be reached
const result = await compareLocalToRemoteTag('sha256:tooshort', REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag(['sha256:tooshort'], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' });
expect(calls).toHaveLength(0);
});
@@ -925,8 +946,8 @@ describe('compareLocalToRemoteTag', () => {
return { statusCode: 500, headers: {} };
};
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(first).toEqual({ kind: 'match' });
expect(second).toEqual({ kind: 'match' });
@@ -964,7 +985,7 @@ describe('compareLocalToRemoteTag', () => {
},
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
});
@@ -992,7 +1013,7 @@ describe('compareLocalToRemoteTag', () => {
[nestedDigest]: nestedBody,
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
expect(calls.filter((c) => c.method === 'GET' && c.url.includes('/manifests/'))).toHaveLength(2);
});
@@ -1018,7 +1039,7 @@ describe('compareLocalToRemoteTag', () => {
[nestedDigest]: { statusCode: 404, headers: {} },
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -1038,7 +1059,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
});
@@ -1065,7 +1086,7 @@ describe('compareLocalToRemoteTag', () => {
route = routePrimaryDigest(outerDigest, digests);
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
if (result.kind === 'error') {
expect(result.reason).toMatch(/depth/i);
@@ -1087,7 +1108,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -1111,7 +1132,7 @@ describe('compareLocalToRemoteTag', () => {
[nestedDigest]: nestedBody,
});
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match' });
});
@@ -1131,7 +1152,7 @@ describe('compareLocalToRemoteTag', () => {
const outerDigest = contentDigest(outerBody);
route = routePrimaryDigest(outerDigest, { [outerDigest]: outerBody });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
expect(calls.some((c) => c.url.includes('../') || c.url.includes('/evil'))).toBe(false);
});
@@ -1147,7 +1168,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
@@ -1161,7 +1182,7 @@ describe('compareLocalToRemoteTag', () => {
const primary = contentDigest(body);
route = routePrimaryDigest(primary, { [primary]: body });
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
});
@@ -789,7 +789,7 @@ describe('SchedulerService - executeUpdate', () => {
mockGetContainersByStack.mockResolvedValue([
{ Id: 'c1', Image: 'nginx:latest' },
]);
mockCheckImage.mockResolvedValue({ hasUpdate: true }); // Update available
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true }); // Update available
const svc = SchedulerService.getInstance();
await svc.triggerTask(80);
@@ -814,7 +814,7 @@ describe('SchedulerService - executeUpdate', () => {
last_status: null,
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
const svc = SchedulerService.getInstance();
await svc.triggerTask(82);
@@ -842,7 +842,7 @@ describe('SchedulerService - executeUpdate', () => {
last_status: null,
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
await SchedulerService.getInstance().triggerTask(83);
@@ -891,7 +891,7 @@ describe('SchedulerService - executeUpdate', () => {
mockGetContainersByStack.mockResolvedValue([
{ Id: 'c1', Image: 'nginx:latest' },
]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
const svc = SchedulerService.getInstance();
await svc.triggerTask(82);
@@ -1035,7 +1035,7 @@ describe('SchedulerService - executeUpdate', () => {
last_status: null,
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:1.14' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
mockEnforcePolicyPreDeploy.mockResolvedValue({
ok: false,
bypassed: false,
@@ -1114,7 +1114,7 @@ describe('SchedulerService - executeUpdate', () => {
});
mockGetStacks.mockResolvedValue(['app1', 'app2', 'app3']);
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
mockCheckImage.mockResolvedValue({ hasUpdate: true });
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
const svc = SchedulerService.getInstance();
await svc.triggerTask(87);
@@ -37,6 +37,7 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
check_status: 'ok',
...over,
});
@@ -159,7 +159,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
summary: {
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
has_build_services: false, rebuild_available: false,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
rollback_target: 'nginx:1.27.0',
changelog: null,
@@ -190,7 +190,7 @@ describe('UpdateGuardService.computeUpdateReadiness with a serviceName', () => {
summary: {
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
has_build_services: false, rebuild_available: false,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
rollback_target: 'nginx:1.27.0',
changelog: null,
@@ -284,7 +284,7 @@ describe('UpdateGuardService.computeRollbackReadiness moving-tag wiring', () =>
summary: {
has_update: false, primary_image: 'app', current_tag: images[0]?.current_tag ?? null,
next_tag: null, semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
has_build_services: false, rebuild_available: false,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
rollback_target: 'app:1.2.3',
changelog: null,
@@ -0,0 +1,457 @@
/**
* Authoritative-negative update-preview reconcile: commitPreviewClear generation
* safety and route side effects (broadcast / fleet cache invalidate).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ImageUpdateService: typeof import('../services/ImageUpdateService').ImageUpdateService;
let UpdatePreviewService: typeof import('../services/UpdatePreviewService').UpdatePreviewService;
let NotificationService: typeof import('../services/NotificationService').NotificationService;
let CacheService: typeof import('../services/CacheService').CacheService;
let adminCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ ImageUpdateService } = await import('../services/ImageUpdateService'));
({ UpdatePreviewService } = await import('../services/UpdatePreviewService'));
({ NotificationService } = await import('../services/NotificationService'));
({ CacheService } = await import('../services/CacheService'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
const raw = (DatabaseService.getInstance() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
raw.prepare('DELETE FROM stack_update_status').run();
});
function negativeOkPreview(stackName = 'web') {
return {
stack_name: stackName,
images: [{
service: 'web',
image: 'nginx:1.2.3',
current_tag: '1.2.3',
next_tag: null,
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none' as const,
check_status: 'ok' as const,
}],
build_services: [] as string[],
summary: {
has_update: false,
primary_image: 'nginx:1.2.3',
current_tag: '1.2.3',
next_tag: null,
semver_bump: 'none' as const,
update_kind: 'none' as const,
blocked: false,
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
check_status: 'ok' as const,
},
rollback_target: null,
changelog: null,
};
}
describe('ImageUpdateService.commitPreviewClear', () => {
it('clears sticky partial rows and returns cleared', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half', [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
]);
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
const result = await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow);
expect(result).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
});
it('clears an older confirmed ok+true row', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance();
const writeGen = (svc as unknown as {
reserveStackWriteGeneration: (n: number, s: string) => number;
}).reserveStackWriteGeneration(nodeId, 'web');
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], writeGen);
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
expect(observedRow).toBe(writeGen);
expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
});
it('clears a persisted ok+true row when in-memory generation was reset (restart)', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
// Simulate a prior process that wrote generation 7, then a restart that
// left only SQLite state (in-memory high-water is 0 for this stack key).
db.upsertStackUpdateStatus(nodeId, 'restart-web', true, 1000, 'ok', null, [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], 7);
const svc = ImageUpdateService.getInstance();
expect(svc.peekStackWriteGeneration(nodeId, 'restart-web')).toBe(0);
expect(db.getStackUpdateWriteGeneration(nodeId, 'restart-web')).toBe(7);
expect(await svc.commitPreviewClear(nodeId, 'restart-web', 0, 7)).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId)['restart-web']).toBeUndefined();
});
it('returns absent when no row exists', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance();
expect(await svc.commitPreviewClear(nodeId, 'missing', 0, 0)).toBe('absent');
});
it('retains a row written after the observation watermark', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance() as unknown as {
peekStackWriteGeneration: (n: number, s: string) => number;
reserveStackWriteGeneration: (n: number, s: string) => number;
commitPreviewClear: (
n: number,
s: string,
observedMem: number,
observedRow: number,
) => Promise<'cleared' | 'stale' | 'absent'>;
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
};
const observedMem = svc.peekStackWriteGeneration(nodeId, 'race');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'race');
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'race');
expect(scannerGen).toBeGreaterThan(observedMem);
await svc.withStackWriteLock(nodeId, 'race', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'race', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
], scannerGen);
});
expect(await svc.commitPreviewClear(nodeId, 'race', observedMem, observedRow)).toBe('stale');
expect(db.getStackUpdateDetail(nodeId).race?.hasUpdate).toBe(true);
expect(db.getStackUpdateDetail(nodeId).race?.checkStatus).toBe('ok');
});
it('retains a row whose DB generation advanced after observation', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'adv', true, 1000, 'ok', null, [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], 3);
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'adv');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'adv');
expect(observedRow).toBe(3);
db.upsertStackUpdateStatus(nodeId, 'adv', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
], 4);
expect(await svc.commitPreviewClear(nodeId, 'adv', observedMem, observedRow)).toBe('absent');
expect(db.getStackUpdateDetail(nodeId).adv?.hasUpdate).toBe(true);
});
});
describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => {
it('GET does not mutate sticky state even for authoritative-negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.get('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBeUndefined();
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
expect(invalidate).not.toHaveBeenCalled();
expect(broadcast).not.toHaveBeenCalled();
});
it('POST clears sticky state and broadcasts on authoritative-negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(true);
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
type: 'state-invalidate',
scope: 'image-updates',
action: 'update-status-reconciled',
stackName: 'web',
}));
});
it('POST clears an older confirmed ok+true row and broadcasts', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance() as unknown as {
reserveStackWriteGeneration: (n: number, s: string) => number;
};
const writeGen = svc.reserveStackWriteGeneration(nodeId, 'web');
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
{ service: 'web', image: 'nginx:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
], writeGen);
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(true);
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
action: 'update-status-reconciled',
stackName: 'web',
}));
});
it('POST does not mutate on partial negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
const preview = negativeOkPreview('web');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
...preview,
images: [{ ...preview.images[0], check_status: 'partial' }],
summary: { ...preview.summary, check_status: 'partial' },
});
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
});
it('POST does not mutate when an image is not_checkable alongside ok', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
const preview = negativeOkPreview('web');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
...preview,
images: [
preview.images[0],
{
...preview.images[0],
service: 'bad',
image: 'not-a-valid-ref',
check_status: 'not_checkable' as const,
},
],
summary: { ...preview.summary, check_status: 'partial' as const },
});
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
});
it('POST does not mutate when check_status is missing from summary rollup fields still fail every-ok', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
const preview = negativeOkPreview('web');
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
...preview,
images: [{ ...preview.images[0], check_status: 'partial' as const }],
});
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
});
it('POST does not broadcast when clear finds no row', async () => {
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('ghost'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/ghost/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(broadcast).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
});
it('retains a confirmed row written after the preview observation', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
const svc = ImageUpdateService.getInstance() as unknown as {
peekStackWriteGeneration: (n: number, s: string) => number;
reserveStackWriteGeneration: (n: number, s: string) => number;
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
};
const observedBeforePreview = svc.peekStackWriteGeneration(nodeId, 'web');
let previewCalls = 0;
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockImplementation(async () => {
previewCalls += 1;
// Simulate a scanner reservation+commit that begins after observation.
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'web');
await svc.withStackWriteLock(nodeId, 'web', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'web', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'nginx:9', hasUpdate: true, checkStatus: 'ok', lastError: null },
], scannerGen);
});
expect(scannerGen).toBeGreaterThan(observedBeforePreview);
return negativeOkPreview('web');
});
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(previewCalls).toBe(1);
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
expect(db.getStackUpdateDetail(nodeId).web?.checkStatus).toBe('ok');
expect(broadcast).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
});
});
describe('generation ordering for preview clear', () => {
it('a newer scanner reservation supersedes an in-flight observation-watermark clear', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'ord', true, 1000, 'partial', 'half');
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord');
const scannerGen = (svc as unknown as {
reserveStackWriteGeneration: (n: number, s: string) => number;
}).reserveStackWriteGeneration(nodeId, 'ord');
expect(scannerGen).toBeGreaterThan(observedMem);
expect(await svc.commitPreviewClear(nodeId, 'ord', observedMem, observedRow)).toBe('stale');
const scannerCommitted = await (svc as unknown as {
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
}).withStackWriteLock(nodeId, 'ord', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'ord', true, Date.now(), 'ok', null);
});
expect(scannerCommitted).toBe(true);
expect(db.getStackUpdateDetail(nodeId).ord?.hasUpdate).toBe(true);
expect(db.getStackUpdateDetail(nodeId).ord?.checkStatus).toBe('ok');
});
it('equal-generation scanner reserved before observation cannot rewrite after clear', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'ord2', true, 1000, 'partial', 'half', [
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'partial', lastError: 'half' },
], 1);
const svc = ImageUpdateService.getInstance() as unknown as {
peekStackWriteGeneration: (n: number, s: string) => number;
reserveStackWriteGeneration: (n: number, s: string) => number;
commitPreviewClear: (
n: number,
s: string,
observedMem: number,
observedRow: number,
) => Promise<'cleared' | 'stale' | 'absent'>;
withStackWriteLock: (
n: number,
s: string,
g: number,
write: () => void | Promise<void>,
) => Promise<boolean>;
};
// Full scan reserved generation N before its slow registry work.
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'ord2');
// Preview observation sees that same watermark.
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord2');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord2');
expect(observedMem).toBe(scannerGen);
expect(await svc.commitPreviewClear(nodeId, 'ord2', observedMem, observedRow)).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
// Delayed scanner write using the pre-observation reservation must not commit.
const scannerCommitted = await svc.withStackWriteLock(nodeId, 'ord2', scannerGen, () => {
db.upsertStackUpdateStatus(nodeId, 'ord2', true, Date.now(), 'ok', null, [
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
], scannerGen);
});
expect(scannerCommitted).toBe(false);
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
});
});
@@ -6,15 +6,22 @@ import {
computeImagePreview,
buildSummary,
isMovingTag,
listAllRegistryTagsBounded,
isAuthoritativeNegativePreview,
PREVIEW_TAG_LIST_MAX_PAGES,
type ComputePreviewDeps,
type LocalDigestInfo,
} from '../services/UpdatePreviewService';
import type { DigestComparisonResult } from '../services/registry-api';
import type { DigestComparisonResult, TagListResult } from '../services/registry-api';
const PLATFORM = { os: 'linux', architecture: 'amd64' };
function localDigest(digest: string | null): LocalDigestInfo {
return { digest, platform: PLATFORM };
return { digests: digest ? [digest] : [], platform: PLATFORM };
}
function tagsOk(tags: string[], nextCursor?: string): TagListResult {
return nextCursor ? { ok: true, tags, nextCursor } : { ok: true, tags };
}
describe('parseSemverTag', () => {
@@ -97,7 +104,7 @@ function makeDeps(overrides: Partial<ComputePreviewDeps> = {}): ComputePreviewDe
getCredentials: vi.fn().mockResolvedValue(null),
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'not configured' } satisfies DigestComparisonResult),
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
...overrides,
};
}
@@ -107,7 +114,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(result.has_update).toBe(false);
@@ -119,7 +126,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'update' }),
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
const result = await computeImagePreview('web', 'nginx:latest', deps);
expect(result.has_update).toBe(true);
@@ -132,7 +139,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTags: vi.fn().mockResolvedValue(['27.1.4', '27.1.5', '27.2.0']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['27.1.4', '27.1.5', '27.2.0'])),
});
const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps);
expect(result.has_update).toBe(true);
@@ -144,7 +151,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '2.0.0']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '2.0.0'])),
});
const result = await computeImagePreview('db', 'postgres:1.2.3', deps);
expect(result.next_tag).toBe('2.0.0');
@@ -155,7 +162,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '1.2.4']),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(result.has_update).toBe(true);
@@ -167,12 +174,24 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(result.has_update).toBe(false);
expect(result.next_tag).toBeNull();
expect(result.semver_bump).toBe('none');
expect(result.check_status).toBe('partial');
});
it('treats digest error + higher tag as a confirmed update (ok)', async () => {
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
}));
expect(result.has_update).toBe(true);
expect(result.next_tag).toBe('1.2.4');
expect(result.check_status).toBe('ok');
});
it('never calls the comparison resolver when no local digest is resolvable', async () => {
@@ -180,7 +199,7 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
compareDigest,
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
expect(compareDigest).not.toHaveBeenCalled();
@@ -192,10 +211,10 @@ describe('computeImagePreview', () => {
const deps = makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest,
listRegistryTags: vi.fn().mockResolvedValue([]),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
});
await computeImagePreview('web', 'ghcr.io/linuxserver/radarr:latest', deps);
expect(compareDigest).toHaveBeenCalledWith('sha256:aaa', 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
expect(compareDigest).toHaveBeenCalledWith(['sha256:aaa'], 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
});
});
@@ -206,7 +225,10 @@ describe('buildSummary', () => {
current_tag: '1.0.0',
next_tag: null,
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none' as const,
check_status: 'ok' as const,
...partial,
});
@@ -334,4 +356,143 @@ describe('buildSummary', () => {
const images = [baseImage({ service: 'clean', has_update: false })];
expect(buildSummary('stacky', images).summary.update_kind).toBe('none');
});
it('sets check_status=ok for empty and all-ok images', () => {
expect(buildSummary('empty', []).summary.check_status).toBe('ok');
expect(buildSummary('ok', [baseImage({ check_status: 'ok' })]).summary.check_status).toBe('ok');
});
it('rolls up mixed and failed check_status', () => {
expect(buildSummary('mixed', [
baseImage({ service: 'a', check_status: 'ok' }),
baseImage({ service: 'b', check_status: 'partial' }),
]).summary.check_status).toBe('partial');
expect(buildSummary('fail', [
baseImage({ service: 'a', check_status: 'failed' }),
baseImage({ service: 'b', check_status: 'failed' }),
]).summary.check_status).toBe('failed');
});
});
describe('preview authority', () => {
it('marks digest match + exhausted empty tags as authoritative ok with no update', async () => {
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
}));
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('ok');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
});
it('marks digest error + successful tag list with no next as partial (not authoritative-negative)', async () => {
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'boom' }),
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
}));
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('partial');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
});
it('does not treat empty or not_checkable-only previews as authoritative-negative', () => {
expect(isAuthoritativeNegativePreview(buildSummary('empty', []))).toBe(false);
expect(isAuthoritativeNegativePreview(buildSummary('build', [
{
service: 'app',
image: 'sha256:dead',
current_tag: 'unknown',
next_tag: null,
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none',
check_status: 'not_checkable',
},
]))).toBe(false);
});
it('marks digest match + tag list failure as partial for semver tags', async () => {
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: vi.fn().mockResolvedValue({
ok: false,
code: 'REGISTRY_UPSTREAM',
message: 'Registry unreachable',
}),
}));
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('partial');
});
it('allows moving/non-semver tags to be authoritative-negative on digest match without tag enum', async () => {
const listFn = vi.fn();
const result = await computeImagePreview('web', 'nginx:latest', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: listFn,
}));
expect(listFn).not.toHaveBeenCalled();
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('ok');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
});
it('detects a newer tag found on a later page', async () => {
const listFn = vi.fn()
.mockResolvedValueOnce(tagsOk(['1.0.0', '1.0.1'], 'cursor-1'))
.mockResolvedValueOnce(tagsOk(['1.1.0']));
const result = await computeImagePreview('web', 'nginx:1.0.0', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: listFn,
}));
expect(listFn).toHaveBeenCalledTimes(2);
expect(result.has_update).toBe(true);
expect(result.next_tag).toBe('1.1.0');
expect(result.check_status).toBe('ok');
});
it('treats page-cap with remaining cursor as non-authoritative for semver negatives', async () => {
let page = 0;
const listFn = vi.fn().mockImplementation(async () => {
page += 1;
return tagsOk([`1.0.${page}`], `cursor-${page}`);
});
const result = await computeImagePreview('web', 'nginx:2.0.0', makeDeps({
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
listRegistryTagsResult: listFn,
}));
expect(listFn).toHaveBeenCalledTimes(PREVIEW_TAG_LIST_MAX_PAGES);
expect(result.has_update).toBe(false);
expect(result.check_status).toBe('partial');
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
});
it('marks invalid refs as not_checkable', async () => {
const result = await computeImagePreview('web', 'sha256:deadbeef', makeDeps());
expect(result.check_status).toBe('not_checkable');
expect(result.has_update).toBe(false);
});
});
describe('listAllRegistryTagsBounded', () => {
it('returns incomplete when nextCursor remains after the page cap', async () => {
const listFn = vi.fn().mockResolvedValue(tagsOk(['a'], 'more'));
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null, { maxPages: 2 });
expect(outcome.kind).toBe('incomplete');
expect(listFn).toHaveBeenCalledTimes(2);
});
it('returns complete when pagination exhausts', async () => {
const listFn = vi.fn()
.mockResolvedValueOnce(tagsOk(['a'], 'c1'))
.mockResolvedValueOnce(tagsOk(['b']));
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null);
expect(outcome).toEqual({ kind: 'complete', tags: ['a', 'b'] });
});
});