mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: reconcile sticky update indicators with Anatomy preview (#1698)
* fix: reconcile sticky update indicators with Anatomy preview Sidebar, Updates filter, and Fleet treated retained partial/failed scanner has_update as confirmed. Keep raw state for retention/notifications, project confirmed-only to APIs, show distinct incomplete indicators, and clear sticky rows only after an authoritative-negative preview. Closes #1685 * test: align sidebar truncate E2E with failed-over-retained precedence Purple update indicators are confirmed-only; hasUpdate with a failed check correctly shows the failed trailing icon. * fix: clear confirmed update rows on authoritative-negative preview Address audit SF-1/SF-2/SF-3: observation-watermark clears for older ok+has_update rows (DB + memory gens), Fleet checkability parity with backend not_checkable, and Updates chip confirmed-only regressions. * fix: tombstone equal-generation writers on preview clear Advance the per-stack write generation when clearing at the observation watermark so a scanner reserved before preview cannot recreate the row after an authoritative-negative reconcile. * fix: clear sticky updates with digest and tag preview parity Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear. * test: set digestUpdate on auto-update checkImage mocks Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path. * fix: clear unused lint errors on sticky update branch Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green. * fix: use inflight holder for CacheService prefer-const Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
@@ -298,6 +298,26 @@ describe('CacheService', () => {
|
||||
it('is a no-op for missing keys', () => {
|
||||
expect(() => cache.invalidate('ns:missing')).not.toThrow();
|
||||
});
|
||||
|
||||
it('prevents an in-flight fetch started before invalidate from committing afterward', async () => {
|
||||
let resolveFetch!: (value: string) => void;
|
||||
const staleFetcher = vi.fn(() => new Promise<string>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}));
|
||||
|
||||
const stalePromise = cache.getOrFetch('fleet-updates', 60_000, staleFetcher);
|
||||
cache.invalidate('fleet-updates');
|
||||
|
||||
const freshFetcher = vi.fn().mockResolvedValue('fresh');
|
||||
const fresh = await cache.getOrFetch('fleet-updates', 60_000, freshFetcher);
|
||||
expect(fresh).toBe('fresh');
|
||||
expect(freshFetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFetch('stale');
|
||||
await expect(stalePromise).resolves.toBe('stale');
|
||||
// Stale writer must not overwrite the post-invalidate entry.
|
||||
expect(cache.get<string>('fleet-updates')).toBe('fresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateNamespace', () => {
|
||||
|
||||
@@ -46,10 +46,40 @@ describe('stack_update_status tri-state accessors', () => {
|
||||
expect(db().getStackUpdateDetail(NODE).web.checkStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('keeps getStackUpdateStatus a boolean map for the fleet contract', () => {
|
||||
it('keeps getStackUpdateStatus a raw boolean map ignoring check_status', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'api', false, 1000, 'failed', 'boom');
|
||||
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false });
|
||||
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
||||
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false, sticky: true });
|
||||
});
|
||||
|
||||
it('projects confirmed updates only via getConfirmedStackUpdateStatus', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
||||
db().upsertStackUpdateStatus(NODE, 'failed', true, 1000, 'failed', 'boom');
|
||||
db().upsertStackUpdateStatus(NODE, 'clean', false, 1000, 'ok', null);
|
||||
expect(db().getConfirmedStackUpdateStatus(NODE)).toEqual({
|
||||
web: true,
|
||||
sticky: false,
|
||||
failed: false,
|
||||
clean: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('clearStackUpdateStatus returns deleted row count and removes services_json', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'partial', 'half', [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
]);
|
||||
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(1);
|
||||
expect(db().getStackUpdateDetail(NODE).web).toBeUndefined();
|
||||
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0);
|
||||
});
|
||||
|
||||
it('getNodeUpdateSummary counts only confirmed updates', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
||||
const summary = db().getNodeUpdateSummary().find((r) => r.node_id === NODE);
|
||||
expect(summary?.stacks_with_updates).toBe(1);
|
||||
});
|
||||
|
||||
it('recordStackCheckFailure preserves a prior has_update while marking failed', () => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import {
|
||||
FLEET_UPDATE_CACHE_KEY,
|
||||
invalidateFleetUpdateCache,
|
||||
isFullStackUpdatePath,
|
||||
isUpdatePreviewPath,
|
||||
} from '../helpers/fleetUpdateCache';
|
||||
|
||||
describe('isFullStackUpdatePath', () => {
|
||||
it('matches full-stack update paths after the /api mount strip', () => {
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/update')).toBe(true);
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/update?nodeId=2')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects service-scoped update and restore paths', () => {
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/update')).toBe(false);
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/restore')).toBe(false);
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/deploy')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUpdatePreviewPath', () => {
|
||||
it('matches update-preview paths after the /api mount strip', () => {
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/update-preview')).toBe(true);
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/update-preview?nodeId=2')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects full-stack update and other stack paths', () => {
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/update')).toBe(false);
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/services/redis/update')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateFleetUpdateCache', () => {
|
||||
beforeEach(() => {
|
||||
CacheService.getInstance().flush();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
CacheService.getInstance().flush();
|
||||
});
|
||||
|
||||
it('drops the shared fleet-updates key', async () => {
|
||||
const cache = CacheService.getInstance();
|
||||
await cache.getOrFetch(FLEET_UPDATE_CACHE_KEY, 60_000, async () => ({ '1': { web: true } }));
|
||||
invalidateFleetUpdateCache();
|
||||
expect(cache.get(FLEET_UPDATE_CACHE_KEY)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
import type { StackServiceStatus } from '../services/DatabaseService';
|
||||
|
||||
function ok(hasUpdate: boolean): ImageCheckResult {
|
||||
return { hasUpdate };
|
||||
return { hasUpdate, checkStatus: 'ok', digestUpdate: hasUpdate, tagUpdate: false };
|
||||
}
|
||||
function errored(message: string): ImageCheckResult {
|
||||
return { hasUpdate: false, error: message };
|
||||
return { hasUpdate: false, checkStatus: 'failed', error: message };
|
||||
}
|
||||
function notCheckable(): ImageCheckResult {
|
||||
return { hasUpdate: false, notCheckable: true };
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
describe('reduceServiceStatus', () => {
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
|
||||
it('marks sha256-only refs not-checkable (no tag to track)', async () => {
|
||||
const docker = makeMockDocker();
|
||||
const result = await service.checkImage(docker, 'sha256:abc123');
|
||||
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
|
||||
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
|
||||
});
|
||||
|
||||
it('returns error when local image inspect fails', async () => {
|
||||
@@ -195,7 +195,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
|
||||
// Empty RepoDigests means locally built / not registry-backed.
|
||||
const docker = makeMockDocker([]);
|
||||
const result = await service.checkImage(docker, 'nginx:latest');
|
||||
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
|
||||
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
|
||||
});
|
||||
|
||||
it('errors when RepoDigests are present but none resolves a digest', async () => {
|
||||
@@ -237,26 +237,26 @@ describe('ImageUpdateService - checkImage surfaces the comparison resolver outco
|
||||
it('surfaces the specific failure reason (not a generic "unreachable") as the check error', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'error', reason: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
|
||||
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(result).toEqual({ hasUpdate: false, error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
|
||||
expect(result).toMatchObject({ hasUpdate: false, checkStatus: 'failed', error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
|
||||
});
|
||||
|
||||
it('reports an update when the comparison resolver classifies the remote as an update', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'update' });
|
||||
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(result).toEqual({ hasUpdate: true });
|
||||
expect(result).toMatchObject({ hasUpdate: true, digestUpdate: true, checkStatus: 'ok' });
|
||||
});
|
||||
|
||||
it('reports no update when the comparison resolver classifies the remote as a match', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
|
||||
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(result).toEqual({ hasUpdate: false });
|
||||
expect(result).toMatchObject({ hasUpdate: false, digestUpdate: false, checkStatus: 'ok' });
|
||||
});
|
||||
|
||||
it('passes the local digest, platform, and parsed ref through to the comparison resolver', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
|
||||
await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(mockCompareLocalToRemoteTag).toHaveBeenCalledWith(
|
||||
LOCAL_DIGEST,
|
||||
[LOCAL_DIGEST],
|
||||
'ghcr.io',
|
||||
'linuxserver/radarr',
|
||||
'latest',
|
||||
|
||||
@@ -44,6 +44,18 @@ describe('GET /api/image-updates', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
it('excludes partial and failed retained rows from the confirmed boolean map', async () => {
|
||||
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
||||
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'ok-stack', true, 1000, 'ok', null);
|
||||
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'partial-stack', true, 1000, 'partial', 'half');
|
||||
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'failed-stack', true, 1000, 'failed', 'boom');
|
||||
const res = await request(app).get('/api/image-updates').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body['ok-stack']).toBe(true);
|
||||
expect(res.body['partial-stack']).toBe(false);
|
||||
expect(res.body['failed-stack']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/image-updates/detail', () => {
|
||||
@@ -374,7 +386,7 @@ describe('POST /api/auto-update/execute', () => {
|
||||
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
||||
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
|
||||
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
||||
.mockResolvedValue({ hasUpdate: true } as never);
|
||||
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
|
||||
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
|
||||
const gateSpy = vi.spyOn(PolicyEnforcement, 'enforcePolicyPreDeploy').mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -420,7 +432,7 @@ describe('POST /api/auto-update/execute', () => {
|
||||
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
||||
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
|
||||
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
||||
.mockResolvedValue({ hasUpdate: true } as never);
|
||||
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
|
||||
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
|
||||
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockReturnValue('gate-au');
|
||||
try {
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
listRegistryTagsResult,
|
||||
parseImageRef,
|
||||
selectLocalRepoDigest,
|
||||
selectLocalRepoDigests,
|
||||
compareLocalToRemoteTag,
|
||||
MANIFEST_CLASSIFICATION_CACHE_TTL_MS,
|
||||
MANIFEST_INDEX_DESCRIPTOR_CAP,
|
||||
@@ -407,6 +408,26 @@ describe('selectLocalRepoDigest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('selectLocalRepoDigests', () => {
|
||||
const parsed = (ref: string) => {
|
||||
const p = parseImageRef(ref);
|
||||
if (!p) throw new Error(`unparseable ${ref}`);
|
||||
return p;
|
||||
};
|
||||
const DIGEST_A = `sha256:${'a'.repeat(64)}`;
|
||||
const DIGEST_B = `sha256:${'b'.repeat(64)}`;
|
||||
|
||||
it('returns every matching RepoDigest for the image ref', () => {
|
||||
const digests = selectLocalRepoDigests([
|
||||
`nginx@${DIGEST_A}`,
|
||||
`nginx@${DIGEST_B}`,
|
||||
`redis@sha256:${'c'.repeat(64)}`,
|
||||
], parsed('nginx:latest'));
|
||||
expect(digests).toEqual([DIGEST_A, DIGEST_B]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── compareLocalToRemoteTag ─────────────────────────────────────────────
|
||||
//
|
||||
// Reproduces and fixes the false-positive multi-arch update: a local
|
||||
@@ -499,7 +520,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }
|
||||
: { statusCode: 500, headers: {} }
|
||||
);
|
||||
const result = await compareLocalToRemoteTag(INDEX_DIGEST, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
|
||||
});
|
||||
@@ -516,7 +537,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
|
||||
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
|
||||
@@ -544,7 +565,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(tagCallCount).toBe(1);
|
||||
});
|
||||
@@ -555,7 +576,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
? { statusCode: 200, headers: { 'docker-content-digest': SINGLE_DIGEST, 'content-type': 'application/vnd.docker.distribution.manifest.v2+json' } }
|
||||
: { statusCode: 500, headers: {} }
|
||||
);
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'update' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
|
||||
});
|
||||
@@ -570,7 +591,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
|
||||
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
|
||||
@@ -590,7 +611,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
expect(calls.filter((c) => c.url === MANIFEST_URL_TAG)).toHaveLength(1);
|
||||
});
|
||||
@@ -609,9 +630,9 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(first.kind).toBe('error');
|
||||
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(second.kind).toBe('error');
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
@@ -635,12 +656,12 @@ describe('compareLocalToRemoteTag', () => {
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
|
||||
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(first).toEqual({ kind: 'match' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MANIFEST_CLASSIFICATION_CACHE_TTL_MS + 1000);
|
||||
|
||||
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(second).toEqual({ kind: 'match' });
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
@@ -659,8 +680,8 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(digestGetCount).toBe(1);
|
||||
});
|
||||
|
||||
@@ -679,8 +700,8 @@ describe('compareLocalToRemoteTag', () => {
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const [a, b] = await Promise.all([
|
||||
compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64),
|
||||
compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64),
|
||||
compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64),
|
||||
compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64),
|
||||
]);
|
||||
expect(a).toEqual({ kind: 'match' });
|
||||
expect(b).toEqual({ kind: 'match' });
|
||||
@@ -708,11 +729,11 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const first = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(first).toEqual({ kind: 'match' });
|
||||
|
||||
headDigest = INDEX_DIGEST_2;
|
||||
const second = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(second).toEqual({ kind: 'update' });
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
@@ -734,11 +755,11 @@ describe('compareLocalToRemoteTag', () => {
|
||||
};
|
||||
|
||||
route = routeFor(REPO);
|
||||
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(digestGetCount).toBe(1);
|
||||
|
||||
route = routeFor('otherorg/otherapp');
|
||||
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, 'otherorg/otherapp', TAG, AMD64);
|
||||
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, 'otherorg/otherapp', TAG, AMD64);
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
|
||||
@@ -761,7 +782,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(VARIANT_V7, REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
|
||||
const result = await compareLocalToRemoteTag([VARIANT_V7], REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -797,12 +818,12 @@ describe('compareLocalToRemoteTag', () => {
|
||||
|
||||
// A local digest equal to the filtered-out annotated-attestation entry must
|
||||
// never match, since that descriptor is dropped before the membership check.
|
||||
const filtered = await compareLocalToRemoteTag(ATTESTATION_ANNOTATED, REGISTRY, REPO, TAG, AMD64);
|
||||
const filtered = await compareLocalToRemoteTag([ATTESTATION_ANNOTATED], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(filtered).toEqual({ kind: 'update' });
|
||||
|
||||
// The real platform descriptor still matches normally (cache hit reuses the
|
||||
// same parsed classification from the previous call).
|
||||
const realMatch = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const realMatch = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(realMatch).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -825,7 +846,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -843,7 +864,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -860,7 +881,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('mismatched digest') });
|
||||
});
|
||||
|
||||
@@ -879,7 +900,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('does not match the requested digest') });
|
||||
});
|
||||
|
||||
@@ -895,13 +916,13 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, { os: '', architecture: '' });
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, { os: '', architecture: '' });
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
it('rejects a truncated local digest as an error, never as a speculative update', async () => {
|
||||
route = () => ({ statusCode: 500, headers: {} }); // must never be reached
|
||||
const result = await compareLocalToRemoteTag('sha256:tooshort', REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag(['sha256:tooshort'], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
@@ -925,8 +946,8 @@ describe('compareLocalToRemoteTag', () => {
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
|
||||
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
|
||||
expect(first).toEqual({ kind: 'match' });
|
||||
expect(second).toEqual({ kind: 'match' });
|
||||
@@ -964,7 +985,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -992,7 +1013,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
[nestedDigest]: nestedBody,
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.method === 'GET' && c.url.includes('/manifests/'))).toHaveLength(2);
|
||||
});
|
||||
@@ -1018,7 +1039,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
[nestedDigest]: { statusCode: 404, headers: {} },
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -1038,7 +1059,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -1065,7 +1086,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
|
||||
route = routePrimaryDigest(outerDigest, digests);
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind === 'error') {
|
||||
expect(result.reason).toMatch(/depth/i);
|
||||
@@ -1087,7 +1108,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -1111,7 +1132,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
[nestedDigest]: nestedBody,
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -1131,7 +1152,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const outerDigest = contentDigest(outerBody);
|
||||
route = routePrimaryDigest(outerDigest, { [outerDigest]: outerBody });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
expect(calls.some((c) => c.url.includes('../') || c.url.includes('/evil'))).toBe(false);
|
||||
});
|
||||
@@ -1147,7 +1168,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -1161,7 +1182,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -789,7 +789,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
mockGetContainersByStack.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'nginx:latest' },
|
||||
]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true }); // Update available
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true }); // Update available
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(80);
|
||||
@@ -814,7 +814,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(82);
|
||||
@@ -842,7 +842,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
await SchedulerService.getInstance().triggerTask(83);
|
||||
|
||||
@@ -891,7 +891,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
mockGetContainersByStack.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'nginx:latest' },
|
||||
]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(82);
|
||||
@@ -1035,7 +1035,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:1.14' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
mockEnforcePolicyPreDeploy.mockResolvedValue({
|
||||
ok: false,
|
||||
bypassed: false,
|
||||
@@ -1114,7 +1114,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
});
|
||||
mockGetStacks.mockResolvedValue(['app1', 'app2', 'app3']);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(87);
|
||||
|
||||
@@ -37,6 +37,7 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
|
||||
blocked_reason: null,
|
||||
has_build_services: false,
|
||||
rebuild_available: false,
|
||||
check_status: 'ok',
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
|
||||
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
has_build_services: false, rebuild_available: false, check_status: 'ok',
|
||||
},
|
||||
rollback_target: 'nginx:1.27.0',
|
||||
changelog: null,
|
||||
@@ -190,7 +190,7 @@ describe('UpdateGuardService.computeUpdateReadiness with a serviceName', () => {
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
|
||||
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
has_build_services: false, rebuild_available: false, check_status: 'ok',
|
||||
},
|
||||
rollback_target: 'nginx:1.27.0',
|
||||
changelog: null,
|
||||
@@ -284,7 +284,7 @@ describe('UpdateGuardService.computeRollbackReadiness moving-tag wiring', () =>
|
||||
summary: {
|
||||
has_update: false, primary_image: 'app', current_tag: images[0]?.current_tag ?? null,
|
||||
next_tag: null, semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
has_build_services: false, rebuild_available: false, check_status: 'ok',
|
||||
},
|
||||
rollback_target: 'app:1.2.3',
|
||||
changelog: null,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Authoritative-negative update-preview reconcile: commitPreviewClear generation
|
||||
* safety and route side effects (broadcast / fleet cache invalidate).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let ImageUpdateService: typeof import('../services/ImageUpdateService').ImageUpdateService;
|
||||
let UpdatePreviewService: typeof import('../services/UpdatePreviewService').UpdatePreviewService;
|
||||
let NotificationService: typeof import('../services/NotificationService').NotificationService;
|
||||
let CacheService: typeof import('../services/CacheService').CacheService;
|
||||
let adminCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ ImageUpdateService } = await import('../services/ImageUpdateService'));
|
||||
({ UpdatePreviewService } = await import('../services/UpdatePreviewService'));
|
||||
({ NotificationService } = await import('../services/NotificationService'));
|
||||
({ CacheService } = await import('../services/CacheService'));
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
const raw = (DatabaseService.getInstance() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
|
||||
raw.prepare('DELETE FROM stack_update_status').run();
|
||||
});
|
||||
|
||||
function negativeOkPreview(stackName = 'web') {
|
||||
return {
|
||||
stack_name: stackName,
|
||||
images: [{
|
||||
service: 'web',
|
||||
image: 'nginx:1.2.3',
|
||||
current_tag: '1.2.3',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
check_status: 'ok' as const,
|
||||
}],
|
||||
build_services: [] as string[],
|
||||
summary: {
|
||||
has_update: false,
|
||||
primary_image: 'nginx:1.2.3',
|
||||
current_tag: '1.2.3',
|
||||
next_tag: null,
|
||||
semver_bump: 'none' as const,
|
||||
update_kind: 'none' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: false,
|
||||
rebuild_available: false,
|
||||
check_status: 'ok' as const,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageUpdateService.commitPreviewClear', () => {
|
||||
it('clears sticky partial rows and returns cleared', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half', [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
]);
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
|
||||
const result = await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow);
|
||||
expect(result).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears an older confirmed ok+true row', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const writeGen = (svc as unknown as {
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
}).reserveStackWriteGeneration(nodeId, 'web');
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], writeGen);
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
|
||||
expect(observedRow).toBe(writeGen);
|
||||
expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a persisted ok+true row when in-memory generation was reset (restart)', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
// Simulate a prior process that wrote generation 7, then a restart that
|
||||
// left only SQLite state (in-memory high-water is 0 for this stack key).
|
||||
db.upsertStackUpdateStatus(nodeId, 'restart-web', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], 7);
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
expect(svc.peekStackWriteGeneration(nodeId, 'restart-web')).toBe(0);
|
||||
expect(db.getStackUpdateWriteGeneration(nodeId, 'restart-web')).toBe(7);
|
||||
expect(await svc.commitPreviewClear(nodeId, 'restart-web', 0, 7)).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId)['restart-web']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns absent when no row exists', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
expect(await svc.commitPreviewClear(nodeId, 'missing', 0, 0)).toBe('absent');
|
||||
});
|
||||
|
||||
it('retains a row written after the observation watermark', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
peekStackWriteGeneration: (n: number, s: string) => number;
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
commitPreviewClear: (
|
||||
n: number,
|
||||
s: string,
|
||||
observedMem: number,
|
||||
observedRow: number,
|
||||
) => Promise<'cleared' | 'stale' | 'absent'>;
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'race');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'race');
|
||||
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'race');
|
||||
expect(scannerGen).toBeGreaterThan(observedMem);
|
||||
|
||||
await svc.withStackWriteLock(nodeId, 'race', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'race', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], scannerGen);
|
||||
});
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'race', observedMem, observedRow)).toBe('stale');
|
||||
expect(db.getStackUpdateDetail(nodeId).race?.hasUpdate).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).race?.checkStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('retains a row whose DB generation advanced after observation', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'adv', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], 3);
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'adv');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'adv');
|
||||
expect(observedRow).toBe(3);
|
||||
|
||||
db.upsertStackUpdateStatus(nodeId, 'adv', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], 4);
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'adv', observedMem, observedRow)).toBe('absent');
|
||||
expect(db.getStackUpdateDetail(nodeId).adv?.hasUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => {
|
||||
it('GET does not mutate sticky state even for authoritative-negative preview', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBeUndefined();
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST clears sticky state and broadcasts on authoritative-negative preview', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
|
||||
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
action: 'update-status-reconciled',
|
||||
stackName: 'web',
|
||||
}));
|
||||
});
|
||||
|
||||
it('POST clears an older confirmed ok+true row and broadcasts', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
};
|
||||
const writeGen = svc.reserveStackWriteGeneration(nodeId, 'web');
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'nginx:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], writeGen);
|
||||
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
|
||||
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'update-status-reconciled',
|
||||
stackName: 'web',
|
||||
}));
|
||||
});
|
||||
|
||||
it('POST does not mutate on partial negative preview', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
const preview = negativeOkPreview('web');
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
|
||||
...preview,
|
||||
images: [{ ...preview.images[0], check_status: 'partial' }],
|
||||
summary: { ...preview.summary, check_status: 'partial' },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('POST does not mutate when an image is not_checkable alongside ok', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
const preview = negativeOkPreview('web');
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
|
||||
...preview,
|
||||
images: [
|
||||
preview.images[0],
|
||||
{
|
||||
...preview.images[0],
|
||||
service: 'bad',
|
||||
image: 'not-a-valid-ref',
|
||||
check_status: 'not_checkable' as const,
|
||||
},
|
||||
],
|
||||
summary: { ...preview.summary, check_status: 'partial' as const },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('POST does not mutate when check_status is missing from summary rollup fields still fail every-ok', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
const preview = negativeOkPreview('web');
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
|
||||
...preview,
|
||||
images: [{ ...preview.images[0], check_status: 'partial' as const }],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('POST does not broadcast when clear finds no row', async () => {
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('ghost'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/ghost/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains a confirmed row written after the preview observation', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
peekStackWriteGeneration: (n: number, s: string) => number;
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const observedBeforePreview = svc.peekStackWriteGeneration(nodeId, 'web');
|
||||
let previewCalls = 0;
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockImplementation(async () => {
|
||||
previewCalls += 1;
|
||||
// Simulate a scanner reservation+commit that begins after observation.
|
||||
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'web');
|
||||
await svc.withStackWriteLock(nodeId, 'web', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'nginx:9', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], scannerGen);
|
||||
});
|
||||
expect(scannerGen).toBeGreaterThan(observedBeforePreview);
|
||||
return negativeOkPreview('web');
|
||||
});
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(previewCalls).toBe(1);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.checkStatus).toBe('ok');
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generation ordering for preview clear', () => {
|
||||
it('a newer scanner reservation supersedes an in-flight observation-watermark clear', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord', true, 1000, 'partial', 'half');
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord');
|
||||
const scannerGen = (svc as unknown as {
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
}).reserveStackWriteGeneration(nodeId, 'ord');
|
||||
expect(scannerGen).toBeGreaterThan(observedMem);
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'ord', observedMem, observedRow)).toBe('stale');
|
||||
|
||||
const scannerCommitted = await (svc as unknown as {
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
}).withStackWriteLock(nodeId, 'ord', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord', true, Date.now(), 'ok', null);
|
||||
});
|
||||
expect(scannerCommitted).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).ord?.hasUpdate).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).ord?.checkStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('equal-generation scanner reserved before observation cannot rewrite after clear', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord2', true, 1000, 'partial', 'half', [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'partial', lastError: 'half' },
|
||||
], 1);
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
peekStackWriteGeneration: (n: number, s: string) => number;
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
commitPreviewClear: (
|
||||
n: number,
|
||||
s: string,
|
||||
observedMem: number,
|
||||
observedRow: number,
|
||||
) => Promise<'cleared' | 'stale' | 'absent'>;
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
// Full scan reserved generation N before its slow registry work.
|
||||
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'ord2');
|
||||
// Preview observation sees that same watermark.
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord2');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord2');
|
||||
expect(observedMem).toBe(scannerGen);
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'ord2', observedMem, observedRow)).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
|
||||
|
||||
// Delayed scanner write using the pre-observation reservation must not commit.
|
||||
const scannerCommitted = await svc.withStackWriteLock(nodeId, 'ord2', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord2', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], scannerGen);
|
||||
});
|
||||
expect(scannerCommitted).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,22 @@ import {
|
||||
computeImagePreview,
|
||||
buildSummary,
|
||||
isMovingTag,
|
||||
listAllRegistryTagsBounded,
|
||||
isAuthoritativeNegativePreview,
|
||||
PREVIEW_TAG_LIST_MAX_PAGES,
|
||||
type ComputePreviewDeps,
|
||||
type LocalDigestInfo,
|
||||
} from '../services/UpdatePreviewService';
|
||||
import type { DigestComparisonResult } from '../services/registry-api';
|
||||
import type { DigestComparisonResult, TagListResult } from '../services/registry-api';
|
||||
|
||||
const PLATFORM = { os: 'linux', architecture: 'amd64' };
|
||||
|
||||
function localDigest(digest: string | null): LocalDigestInfo {
|
||||
return { digest, platform: PLATFORM };
|
||||
return { digests: digest ? [digest] : [], platform: PLATFORM };
|
||||
}
|
||||
|
||||
function tagsOk(tags: string[], nextCursor?: string): TagListResult {
|
||||
return nextCursor ? { ok: true, tags, nextCursor } : { ok: true, tags };
|
||||
}
|
||||
|
||||
describe('parseSemverTag', () => {
|
||||
@@ -97,7 +104,7 @@ function makeDeps(overrides: Partial<ComputePreviewDeps> = {}): ComputePreviewDe
|
||||
getCredentials: vi.fn().mockResolvedValue(null),
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'not configured' } satisfies DigestComparisonResult),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -107,7 +114,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(false);
|
||||
@@ -119,7 +126,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'update' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:latest', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
@@ -132,7 +139,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['27.1.4', '27.1.5', '27.2.0']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['27.1.4', '27.1.5', '27.2.0'])),
|
||||
});
|
||||
const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
@@ -144,7 +151,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '2.0.0']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '2.0.0'])),
|
||||
});
|
||||
const result = await computeImagePreview('db', 'postgres:1.2.3', deps);
|
||||
expect(result.next_tag).toBe('2.0.0');
|
||||
@@ -155,7 +162,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '1.2.4']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
@@ -167,12 +174,24 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.next_tag).toBeNull();
|
||||
expect(result.semver_bump).toBe('none');
|
||||
expect(result.check_status).toBe('partial');
|
||||
});
|
||||
|
||||
it('treats digest error + higher tag as a confirmed update (ok)', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
|
||||
}));
|
||||
expect(result.has_update).toBe(true);
|
||||
expect(result.next_tag).toBe('1.2.4');
|
||||
expect(result.check_status).toBe('ok');
|
||||
});
|
||||
|
||||
it('never calls the comparison resolver when no local digest is resolvable', async () => {
|
||||
@@ -180,7 +199,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
|
||||
compareDigest,
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(compareDigest).not.toHaveBeenCalled();
|
||||
@@ -192,10 +211,10 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest,
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
await computeImagePreview('web', 'ghcr.io/linuxserver/radarr:latest', deps);
|
||||
expect(compareDigest).toHaveBeenCalledWith('sha256:aaa', 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
|
||||
expect(compareDigest).toHaveBeenCalledWith(['sha256:aaa'], 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +225,10 @@ describe('buildSummary', () => {
|
||||
current_tag: '1.0.0',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
check_status: 'ok' as const,
|
||||
...partial,
|
||||
});
|
||||
|
||||
@@ -334,4 +356,143 @@ describe('buildSummary', () => {
|
||||
const images = [baseImage({ service: 'clean', has_update: false })];
|
||||
expect(buildSummary('stacky', images).summary.update_kind).toBe('none');
|
||||
});
|
||||
|
||||
it('sets check_status=ok for empty and all-ok images', () => {
|
||||
expect(buildSummary('empty', []).summary.check_status).toBe('ok');
|
||||
expect(buildSummary('ok', [baseImage({ check_status: 'ok' })]).summary.check_status).toBe('ok');
|
||||
});
|
||||
|
||||
it('rolls up mixed and failed check_status', () => {
|
||||
expect(buildSummary('mixed', [
|
||||
baseImage({ service: 'a', check_status: 'ok' }),
|
||||
baseImage({ service: 'b', check_status: 'partial' }),
|
||||
]).summary.check_status).toBe('partial');
|
||||
expect(buildSummary('fail', [
|
||||
baseImage({ service: 'a', check_status: 'failed' }),
|
||||
baseImage({ service: 'b', check_status: 'failed' }),
|
||||
]).summary.check_status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview authority', () => {
|
||||
it('marks digest match + exhausted empty tags as authoritative ok with no update', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
|
||||
}));
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('ok');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks digest error + successful tag list with no next as partial (not authoritative-negative)', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'boom' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
|
||||
}));
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('partial');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat empty or not_checkable-only previews as authoritative-negative', () => {
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('empty', []))).toBe(false);
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('build', [
|
||||
{
|
||||
service: 'app',
|
||||
image: 'sha256:dead',
|
||||
current_tag: 'unknown',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none',
|
||||
check_status: 'not_checkable',
|
||||
},
|
||||
]))).toBe(false);
|
||||
});
|
||||
|
||||
it('marks digest match + tag list failure as partial for semver tags', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'REGISTRY_UPSTREAM',
|
||||
message: 'Registry unreachable',
|
||||
}),
|
||||
}));
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('partial');
|
||||
});
|
||||
|
||||
it('allows moving/non-semver tags to be authoritative-negative on digest match without tag enum', async () => {
|
||||
const listFn = vi.fn();
|
||||
const result = await computeImagePreview('web', 'nginx:latest', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: listFn,
|
||||
}));
|
||||
expect(listFn).not.toHaveBeenCalled();
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('ok');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a newer tag found on a later page', async () => {
|
||||
const listFn = vi.fn()
|
||||
.mockResolvedValueOnce(tagsOk(['1.0.0', '1.0.1'], 'cursor-1'))
|
||||
.mockResolvedValueOnce(tagsOk(['1.1.0']));
|
||||
const result = await computeImagePreview('web', 'nginx:1.0.0', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: listFn,
|
||||
}));
|
||||
expect(listFn).toHaveBeenCalledTimes(2);
|
||||
expect(result.has_update).toBe(true);
|
||||
expect(result.next_tag).toBe('1.1.0');
|
||||
expect(result.check_status).toBe('ok');
|
||||
});
|
||||
|
||||
it('treats page-cap with remaining cursor as non-authoritative for semver negatives', async () => {
|
||||
let page = 0;
|
||||
const listFn = vi.fn().mockImplementation(async () => {
|
||||
page += 1;
|
||||
return tagsOk([`1.0.${page}`], `cursor-${page}`);
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:2.0.0', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: listFn,
|
||||
}));
|
||||
expect(listFn).toHaveBeenCalledTimes(PREVIEW_TAG_LIST_MAX_PAGES);
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('partial');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
|
||||
});
|
||||
|
||||
it('marks invalid refs as not_checkable', async () => {
|
||||
const result = await computeImagePreview('web', 'sha256:deadbeef', makeDeps());
|
||||
expect(result.check_status).toBe('not_checkable');
|
||||
expect(result.has_update).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAllRegistryTagsBounded', () => {
|
||||
it('returns incomplete when nextCursor remains after the page cap', async () => {
|
||||
const listFn = vi.fn().mockResolvedValue(tagsOk(['a'], 'more'));
|
||||
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null, { maxPages: 2 });
|
||||
expect(outcome.kind).toBe('incomplete');
|
||||
expect(listFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns complete when pagination exhausts', async () => {
|
||||
const listFn = vi.fn()
|
||||
.mockResolvedValueOnce(tagsOk(['a'], 'c1'))
|
||||
.mockResolvedValueOnce(tagsOk(['b']));
|
||||
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null);
|
||||
expect(outcome).toEqual({ kind: 'complete', tags: ['a', 'b'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ImageCheckResult } from '../services/ImageUpdateService';
|
||||
|
||||
/** Accumulator for auto-update image checks (digest vs tag-only vs error). */
|
||||
export interface AutoUpdateDigestGateState {
|
||||
hasDigestUpdate: boolean;
|
||||
hasTagOnlyUpdate: boolean;
|
||||
updatedImages: string[];
|
||||
checkErrors: string[];
|
||||
}
|
||||
|
||||
export function createAutoUpdateDigestGateState(): AutoUpdateDigestGateState {
|
||||
return {
|
||||
hasDigestUpdate: false,
|
||||
hasTagOnlyUpdate: false,
|
||||
updatedImages: [],
|
||||
checkErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one image check into the digest gate. Only digest drift is Compose-
|
||||
* actionable for auto-update; tag bumps stay advisory.
|
||||
*/
|
||||
export function recordAutoUpdateImageCheck(
|
||||
state: AutoUpdateDigestGateState,
|
||||
imageRef: string,
|
||||
result: ImageCheckResult,
|
||||
): void {
|
||||
if (result.digestUpdate) {
|
||||
state.hasDigestUpdate = true;
|
||||
state.updatedImages.push(imageRef);
|
||||
return;
|
||||
}
|
||||
if (result.tagUpdate) {
|
||||
state.hasTagOnlyUpdate = true;
|
||||
return;
|
||||
}
|
||||
if (result.error || result.checkStatus === 'failed' || result.checkStatus === 'partial') {
|
||||
state.checkErrors.push(result.error ?? 'Update check incomplete');
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator message when no digest-actionable update was found. */
|
||||
export function messageWhenNoDigestUpdate(
|
||||
stackName: string,
|
||||
state: Pick<AutoUpdateDigestGateState, 'hasTagOnlyUpdate' | 'checkErrors'>,
|
||||
imageRefCount: number,
|
||||
): string {
|
||||
if (state.hasTagOnlyUpdate) {
|
||||
const errNote = state.checkErrors.length > 0
|
||||
? ` (${state.checkErrors.length} check(s) failed)`
|
||||
: '';
|
||||
return `Stack "${stackName}": newer tag available but Compose pin unchanged; skipped auto-update${errNote}.`;
|
||||
}
|
||||
if (state.checkErrors.length > 0 && state.checkErrors.length === imageRefCount) {
|
||||
return `Stack "${stackName}": WARNING - all image checks failed (${state.checkErrors.join('; ')}). Unable to determine update status.`;
|
||||
}
|
||||
if (state.checkErrors.length > 0) {
|
||||
return `Stack "${stackName}": all reachable images up to date (${state.checkErrors.length} check(s) failed).`;
|
||||
}
|
||||
return `Stack "${stackName}": all images up to date.`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
/** Hub aggregation cache for GET /api/image-updates/fleet. */
|
||||
export const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
|
||||
|
||||
/**
|
||||
* Drop the hub fleet-updates aggregation. Generation-aware via CacheService:
|
||||
* an in-flight getOrFetch started before this call cannot commit afterward.
|
||||
*/
|
||||
export function invalidateFleetUpdateCache(): void {
|
||||
CacheService.getInstance().invalidate(FLEET_UPDATE_CACHE_KEY);
|
||||
}
|
||||
|
||||
function pathWithoutQuery(pathAfterApi: string): string {
|
||||
return pathAfterApi.split('?')[0] ?? pathAfterApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pathAfterApi` is a full-stack update route
|
||||
* (`/stacks/:name/update`), not a service-scoped update/restore.
|
||||
* `pathAfterApi` is the Express path after the `/api` mount strip
|
||||
* (for example `/stacks/paperless/update`).
|
||||
*/
|
||||
export function isFullStackUpdatePath(pathAfterApi: string): boolean {
|
||||
return /^\/stacks\/[^/]+\/update\/?$/.test(pathWithoutQuery(pathAfterApi));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pathAfterApi` is the stack update-preview route
|
||||
* (`/stacks/:name/update-preview`).
|
||||
*/
|
||||
export function isUpdatePreviewPath(pathAfterApi: string): boolean {
|
||||
return /^\/stacks\/[^/]+\/update-preview\/?$/.test(pathWithoutQuery(pathAfterApi));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { DatabaseService } from '../services/DatabaseService';
|
||||
import { redactSensitiveText } from '../utils/safeLog';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming';
|
||||
import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache';
|
||||
|
||||
/**
|
||||
* Per-request hop timing for the critical hydration GETs, kept off the Request
|
||||
@@ -175,6 +176,18 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
timing.upstreamStatus = proxyRes.statusCode;
|
||||
timing.ttfbMs = Date.now() - timing.startedAt;
|
||||
}
|
||||
// Hub fleet aggregation is local-only. A successful remote full-stack
|
||||
// Apply or update-preview reconcile must drop the hub cache so the next
|
||||
// fleet poll does not revive a verified-cleared card from a stale entry.
|
||||
const status = proxyRes.statusCode ?? 0;
|
||||
if (
|
||||
req.method === 'POST'
|
||||
&& status >= 200
|
||||
&& status < 300
|
||||
&& (isFullStackUpdatePath(req.path) || isUpdatePreviewPath(req.path))
|
||||
) {
|
||||
invalidateFleetUpdateCache();
|
||||
}
|
||||
},
|
||||
error: (err, req, proxyRes) => {
|
||||
// Finalize the hop timing with an error outcome before the existing
|
||||
|
||||
@@ -5,6 +5,12 @@ import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import { FLEET_UPDATE_CACHE_KEY } from '../helpers/fleetUpdateCache';
|
||||
import {
|
||||
createAutoUpdateDigestGateState,
|
||||
messageWhenNoDigestUpdate,
|
||||
recordAutoUpdateImageCheck,
|
||||
} from '../helpers/autoUpdateDigestGate';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { StackUpdateOrchestrator } from '../services/StackUpdateOrchestrator';
|
||||
@@ -22,7 +28,6 @@ import { logDebugTiming } from '../utils/requestTiming';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// Fleet aggregation cache: 2-minute TTL, shared across dashboard tabs.
|
||||
const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
|
||||
const FLEET_CACHE_TTL = 120_000;
|
||||
const REMOTE_NODE_FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -30,7 +35,9 @@ export const imageUpdatesRouter = Router();
|
||||
|
||||
imageUpdatesRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const updates = DatabaseService.getInstance().getStackUpdateStatus(req.nodeId);
|
||||
// Confirmed-only: partial/failed retained has_update rows stay out of the
|
||||
// boolean map so Fleet and node cards do not treat uncertainty as pending.
|
||||
const updates = DatabaseService.getInstance().getConfirmedStackUpdateStatus(req.nodeId);
|
||||
res.json(updates);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch image update status:', error);
|
||||
@@ -184,10 +191,10 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo
|
||||
const nr = NodeRegistry.getInstance();
|
||||
const data: Record<number, Record<string, boolean>> = {};
|
||||
|
||||
// Local nodes: synchronous DB reads.
|
||||
// Local nodes: synchronous DB reads (confirmed-only projection).
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'local') {
|
||||
data[node.id] = db.getStackUpdateStatus(node.id);
|
||||
data[node.id] = db.getConfirmedStackUpdateStatus(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,36 +372,25 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasUpdate = false;
|
||||
const updatedImages: string[] = [];
|
||||
const checkErrors: string[] = [];
|
||||
const gate = createAutoUpdateDigestGateState();
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const result = await imageUpdateService.checkImage(docker, imageRef);
|
||||
if (result.error) {
|
||||
checkErrors.push(result.error);
|
||||
} else if (result.hasUpdate) {
|
||||
hasUpdate = true;
|
||||
updatedImages.push(imageRef);
|
||||
}
|
||||
recordAutoUpdateImageCheck(gate, imageRef, result);
|
||||
} catch (e) {
|
||||
const errMsg = getErrorMessage(e, String(e));
|
||||
checkErrors.push(errMsg);
|
||||
gate.checkErrors.push(errMsg);
|
||||
console.warn('[AutoUpdate] Failed to check image %s:', sanitizeForLog(imageRef), sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdate) {
|
||||
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
|
||||
results.push(`Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`);
|
||||
} else if (checkErrors.length > 0) {
|
||||
results.push(`Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`);
|
||||
} else {
|
||||
results.push(`Stack "${stackName}": all images up to date.`);
|
||||
}
|
||||
if (!gate.hasDigestUpdate) {
|
||||
results.push(messageWhenNoDigestUpdate(stackName, gate, imageRefs.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
const { updatedImages } = gate;
|
||||
|
||||
// Auto-update runs from the scheduler: a policy bypass is never
|
||||
// appropriate. If updated images fail the gate, skip the stack and
|
||||
// raise a notification so an operator can review before a manual retry.
|
||||
|
||||
@@ -17,7 +17,9 @@ import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '
|
||||
import DockerController, { type BulkStackInfo } from '../services/DockerController';
|
||||
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
|
||||
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
|
||||
import { UpdatePreviewService } from '../services/UpdatePreviewService';
|
||||
import { UpdatePreviewService, isAuthoritativeNegativePreview } from '../services/UpdatePreviewService';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
||||
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
|
||||
@@ -2243,6 +2245,8 @@ stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Reque
|
||||
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
try {
|
||||
// Read-only: sticky reconciliation lives on POST so UpdateGuard and other
|
||||
// GET consumers never mutate persisted scanner state.
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
res.json(preview);
|
||||
} catch (error) {
|
||||
@@ -2251,6 +2255,45 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
try {
|
||||
// Snapshot write-generation watermarks before the read-only preview so a
|
||||
// later clear can erase older confirmed/sticky rows without racing a
|
||||
// scanner that reserved or rewrote the row after this observation.
|
||||
const imageUpdates = ImageUpdateService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const observedMemoryGeneration = imageUpdates.peekStackWriteGeneration(req.nodeId, stackName);
|
||||
const observedRowGeneration = db.getStackUpdateWriteGeneration(req.nodeId, stackName);
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
let reconciled = false;
|
||||
if (isAuthoritativeNegativePreview(preview)) {
|
||||
const clearResult = await imageUpdates.commitPreviewClear(
|
||||
req.nodeId,
|
||||
stackName,
|
||||
observedMemoryGeneration,
|
||||
observedRowGeneration,
|
||||
);
|
||||
if (clearResult === 'cleared') {
|
||||
reconciled = true;
|
||||
invalidateFleetUpdateCache();
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
nodeId: req.nodeId,
|
||||
stackName,
|
||||
action: 'update-status-reconciled',
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ ...preview, reconciled });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Update preview reconcile failed: %s', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to compute update preview' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
@@ -2271,6 +2314,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
{ atomic, terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)) },
|
||||
);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
invalidateFleetUpdateCache();
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
|
||||
@@ -64,6 +64,9 @@ export class CacheService {
|
||||
|
||||
private readonly store = new Map<string, CacheEntry<unknown>>();
|
||||
private readonly inflight = new Map<string, Promise<unknown>>();
|
||||
/** Per-key write generation. Bumped on invalidate so an older in-flight
|
||||
* fetcher cannot commit after the key was intentionally cleared. */
|
||||
private readonly generations = new Map<string, number>();
|
||||
private readonly stats = new Map<string, NamespaceStats>();
|
||||
|
||||
public static getInstance(): CacheService {
|
||||
@@ -121,13 +124,21 @@ export class CacheService {
|
||||
return { value, outcome: 'inflight' };
|
||||
}
|
||||
|
||||
// Capture generation before the fetch so invalidate() during the wait can
|
||||
// supersede this writer's store commit (and drop the inflight slot so a
|
||||
// later caller starts a fresh computation).
|
||||
const generation = this.currentGeneration(key);
|
||||
|
||||
// This caller owns the computation; the closure records whether it ended
|
||||
// as a fresh compute or a stale fallback, read after the promise settles.
|
||||
let outcome: CacheFetchOutcome = 'computed';
|
||||
const inflightSelf: { promise: Promise<T> | null } = { promise: null };
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const value = await fetcher();
|
||||
if (this.currentGeneration(key) === generation) {
|
||||
this.set(key, value, ttlMs);
|
||||
}
|
||||
return value;
|
||||
} catch (err) {
|
||||
if (existing) {
|
||||
@@ -137,9 +148,14 @@ export class CacheService {
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
// Only clear the inflight slot if we still own it. invalidate() may
|
||||
// have already deleted this entry and allowed a newer owner.
|
||||
if (this.inflight.get(key) === inflightSelf.promise) {
|
||||
this.inflight.delete(key);
|
||||
}
|
||||
}
|
||||
})();
|
||||
inflightSelf.promise = promise;
|
||||
|
||||
this.inflight.set(key, promise);
|
||||
const value = await promise;
|
||||
@@ -177,17 +193,22 @@ export class CacheService {
|
||||
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
/** Invalidate a single key. */
|
||||
/** Invalidate a single key and supersede any in-flight writer for it. */
|
||||
public invalidate(key: string): void {
|
||||
this.store.delete(key);
|
||||
this.inflight.delete(key);
|
||||
this.bumpGeneration(key);
|
||||
}
|
||||
|
||||
/** Invalidate every key whose namespace matches `namespace`. */
|
||||
public invalidateNamespace(namespace: string): void {
|
||||
const prefix = `${namespace}:`;
|
||||
for (const key of this.store.keys()) {
|
||||
const keys = new Set([...this.store.keys(), ...this.inflight.keys(), ...this.generations.keys()]);
|
||||
for (const key of keys) {
|
||||
if (key === namespace || key.startsWith(prefix)) {
|
||||
this.store.delete(key);
|
||||
this.inflight.delete(key);
|
||||
this.bumpGeneration(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +217,7 @@ export class CacheService {
|
||||
public flush(): void {
|
||||
this.store.clear();
|
||||
this.inflight.clear();
|
||||
this.generations.clear();
|
||||
this.stats.clear();
|
||||
}
|
||||
|
||||
@@ -259,4 +281,14 @@ export class CacheService {
|
||||
if (entry.expiresAt <= now) this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private currentGeneration(key: string): number {
|
||||
return this.generations.get(key) ?? 0;
|
||||
}
|
||||
|
||||
private bumpGeneration(key: string): number {
|
||||
const next = this.currentGeneration(key) + 1;
|
||||
this.generations.set(key, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,20 @@ function parseServicesJson(raw: string | null | undefined): StackServiceStatus[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Write generation embedded in services_json; 0 when missing or unreadable. */
|
||||
function parseServicesJsonGeneration(raw: string | null | undefined): number {
|
||||
if (!raw) return 0;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { version?: unknown; generation?: unknown };
|
||||
if (parsed?.version !== SERVICES_JSON_VERSION) return 0;
|
||||
return typeof parsed.generation === 'number' && Number.isFinite(parsed.generation)
|
||||
? parsed.generation
|
||||
: 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyServicesJson(services: StackServiceStatus[], generation: number): string {
|
||||
return JSON.stringify({ version: SERVICES_JSON_VERSION, generation, services });
|
||||
}
|
||||
@@ -4628,6 +4642,13 @@ export class DatabaseService {
|
||||
).run(nodeId, stackName, lastError, checkedAt, servicesJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw has_update map for scanner retention and notification transitions.
|
||||
* Ignores check_status: a partial/failed row with has_update=1 stays true
|
||||
* so ImageUpdateService can preserve sticky state across incomplete runs.
|
||||
* API/Fleet consumers that need "confirmed update" must use
|
||||
* getConfirmedStackUpdateStatus instead.
|
||||
*/
|
||||
public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
|
||||
const rows = nodeId !== undefined
|
||||
? this.db.prepare('SELECT stack_name, has_update FROM stack_update_status WHERE node_id = ?').all(nodeId) as Array<{ stack_name: string; has_update: number }>
|
||||
@@ -4639,10 +4660,32 @@ export class DatabaseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed-update projection for GET /api/image-updates and Fleet local
|
||||
* aggregation. True only when has_update=1 and the latest check completed
|
||||
* successfully (check_status='ok'). Partial/failed retained rows are false.
|
||||
*/
|
||||
public getConfirmedStackUpdateStatus(nodeId?: number): Record<string, boolean> {
|
||||
const rows = nodeId !== undefined
|
||||
? this.db.prepare(
|
||||
`SELECT stack_name, has_update, check_status FROM stack_update_status WHERE node_id = ?`
|
||||
).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null }>
|
||||
: this.db.prepare(
|
||||
`SELECT stack_name, has_update, check_status FROM stack_update_status`
|
||||
).all() as Array<{ stack_name: string; has_update: number; check_status: string | null }>;
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const row of rows) {
|
||||
// Match getNodeUpdateSummary / frontend isConfirmedImageUpdate:
|
||||
// null check_status is treated as ok (legacy rows).
|
||||
result[row.stack_name] = row.has_update === 1 && (row.check_status ?? 'ok') === 'ok';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich per-stack update status (hasUpdate + check outcome + reason) for the
|
||||
* sidebar/readiness UI. GET /api/image-updates stays the boolean map so the
|
||||
* cross-version fleet aggregation contract is unaffected.
|
||||
* sidebar/readiness UI. Confirmed boolean maps use getConfirmedStackUpdateStatus;
|
||||
* raw prior state for the scanner stays on getStackUpdateStatus.
|
||||
*/
|
||||
public getStackUpdateDetail(nodeId: number): Record<string, StackUpdateDetail> {
|
||||
const rows = this.db.prepare(
|
||||
@@ -4675,8 +4718,22 @@ export class DatabaseService {
|
||||
return parseServicesJson(row?.services_json);
|
||||
}
|
||||
|
||||
public clearStackUpdateStatus(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
/**
|
||||
* Scanner write generation stored with services_json for this stack.
|
||||
* Used by preview reconcile to skip clearing a row written after the
|
||||
* preview observation watermark. Returns 0 when missing or unreadable.
|
||||
*/
|
||||
public getStackUpdateWriteGeneration(nodeId: number, stackName: string): number {
|
||||
const row = this.db.prepare(
|
||||
'SELECT services_json FROM stack_update_status WHERE node_id = ? AND stack_name = ?'
|
||||
).get(nodeId, stackName) as { services_json: string | null } | undefined;
|
||||
return parseServicesJsonGeneration(row?.services_json);
|
||||
}
|
||||
|
||||
/** Deletes the full update row (aggregate + services_json). Returns deleted row count. */
|
||||
public clearStackUpdateStatus(nodeId: number, stackName: string): number {
|
||||
const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// --- Stack Scan Attempts ---
|
||||
@@ -4718,7 +4775,10 @@ export class DatabaseService {
|
||||
|
||||
public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
|
||||
return this.db.prepare(
|
||||
'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id'
|
||||
`SELECT node_id, SUM(has_update) as stacks_with_updates
|
||||
FROM stack_update_status
|
||||
WHERE has_update = 1 AND COALESCE(check_status, 'ok') = 'ok'
|
||||
GROUP BY node_id`
|
||||
).all() as Array<{ node_id: number; stacks_with_updates: number }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import { RegistryService } from './RegistryService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
|
||||
import { parseImageRef, selectLocalRepoDigest, compareLocalToRemoteTag } from './registry-api';
|
||||
import { parseImageRef, selectLocalRepoDigests } from './registry-api';
|
||||
import { detectImageUpdate, type PreviewImageCheckStatus } from './imageUpdateDetect';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -18,6 +19,12 @@ const BACKFILL_KEY = 'image_update_notifications_backfilled';
|
||||
|
||||
export interface ImageCheckResult {
|
||||
hasUpdate: boolean;
|
||||
/** Same-tag registry digest drift; Compose pull can apply without pin change. */
|
||||
digestUpdate?: boolean;
|
||||
/** Higher semver tag exists; UI may show it but Compose auto-apply cannot pin it. */
|
||||
tagUpdate?: boolean;
|
||||
/** Detector authority; consumed by reduceServiceStatus / writeStackUpdateStatus. */
|
||||
checkStatus?: PreviewImageCheckStatus;
|
||||
error?: string;
|
||||
/**
|
||||
* The image is not registry-backed (locally built, or a bare digest ref
|
||||
@@ -28,6 +35,17 @@ export interface ImageCheckResult {
|
||||
notCheckable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize check authority for reduction. Test stubs / older callers may omit
|
||||
* checkStatus: error means failed, else ok.
|
||||
*/
|
||||
export function normalizeImageCheckStatus(r: ImageCheckResult): PreviewImageCheckStatus {
|
||||
if (r.notCheckable) return 'not_checkable';
|
||||
if (r.checkStatus) return r.checkStatus;
|
||||
if (r.error) return 'failed';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the scanner returned by GET /api/image-updates/status.
|
||||
* Units differ by field: `intervalMinutes` / `manualCooldownMinutes` are
|
||||
@@ -273,7 +291,8 @@ export function reduceServiceStatus(
|
||||
const checkableResults: ImageCheckResult[] = [];
|
||||
for (const ref of refs) {
|
||||
const result = imageUpdateMap.get(ref);
|
||||
if (!result || result.notCheckable) continue;
|
||||
if (!result) continue;
|
||||
if (normalizeImageCheckStatus(result) === 'not_checkable') continue;
|
||||
checkableResults.push(result);
|
||||
}
|
||||
|
||||
@@ -284,12 +303,14 @@ export function reduceServiceStatus(
|
||||
};
|
||||
}
|
||||
|
||||
const errored = checkableResults.filter((r) => r.error !== undefined);
|
||||
const statuses = checkableResults.map(normalizeImageCheckStatus);
|
||||
const failed = checkableResults.filter((_, i) => statuses[i] === 'failed');
|
||||
const partial = checkableResults.filter((_, i) => statuses[i] === 'partial');
|
||||
const confirmedUpdateThisRun = checkableResults.some(
|
||||
(r) => r.error === undefined && r.hasUpdate === true,
|
||||
(r, i) => statuses[i] === 'ok' && r.hasUpdate === true,
|
||||
);
|
||||
|
||||
if (errored.length === checkableResults.length) {
|
||||
if (failed.length === checkableResults.length) {
|
||||
const priorHasUpdate = prior?.hasUpdate ?? false;
|
||||
return {
|
||||
status: {
|
||||
@@ -298,14 +319,16 @@ export function reduceServiceStatus(
|
||||
...(dedupedRuntime.length > 0 ? { runtimeImages: dedupedRuntime } : {}),
|
||||
hasUpdate: priorHasUpdate,
|
||||
checkStatus: 'failed',
|
||||
lastError: errored[0].error ?? 'Update check failed',
|
||||
lastError: failed[0].error ?? 'Update check failed',
|
||||
},
|
||||
confirmedUpdateThisRun: false,
|
||||
};
|
||||
}
|
||||
|
||||
const checkStatus: StackServiceStatus['checkStatus'] = errored.length > 0 ? 'partial' : 'ok';
|
||||
const lastError = errored.length > 0 ? (errored[0].error ?? null) : null;
|
||||
const checkStatus: StackServiceStatus['checkStatus'] =
|
||||
failed.length > 0 || partial.length > 0 ? 'partial' : 'ok';
|
||||
const uncertain = [...partial, ...failed];
|
||||
const lastError = uncertain.length > 0 ? (uncertain[0].error ?? null) : null;
|
||||
const hasUpdate = checkStatus === 'partial'
|
||||
? (confirmedUpdateThisRun || (prior?.hasUpdate ?? false))
|
||||
: confirmedUpdateThisRun;
|
||||
@@ -770,7 +793,11 @@ export class ImageUpdateService {
|
||||
console.error(`[ImageUpdateService] Error checking ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
// getErrorMessage (not raw String(e)) because this value can surface
|
||||
// verbatim in the sidebar tooltip / readiness advisory as lastError.
|
||||
imageUpdateMap.set(imageRef, { hasUpdate: false, error: getErrorMessage(e, 'Update check failed') });
|
||||
imageUpdateMap.set(imageRef, {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: getErrorMessage(e, 'Update check failed'),
|
||||
});
|
||||
}
|
||||
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
|
||||
}
|
||||
@@ -892,7 +919,11 @@ export class ImageUpdateService {
|
||||
try {
|
||||
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
|
||||
} catch (e) {
|
||||
imageUpdateMap.set(imageRef, { hasUpdate: false, error: getErrorMessage(e, 'Update check failed') });
|
||||
imageUpdateMap.set(imageRef, {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: getErrorMessage(e, 'Update check failed'),
|
||||
});
|
||||
}
|
||||
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
|
||||
}
|
||||
@@ -963,6 +994,70 @@ export class ImageUpdateService {
|
||||
return committed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current per-stack write-generation high-water mark (0 if never reserved).
|
||||
* Snapshot before a read-only update-preview so commitPreviewClear can
|
||||
* compare against writes that reserved or committed after observation.
|
||||
*/
|
||||
public peekStackWriteGeneration(nodeId: number, stackName: string): number {
|
||||
return this.stackWriteState.get(this.stackWriteKey(nodeId, stackName))?.generation ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted scanner update state after an authoritative-negative
|
||||
* update preview. `observedMemoryGeneration` and `observedRowGeneration`
|
||||
* are snapshotted before the preview.
|
||||
*
|
||||
* Ordering:
|
||||
* - If memory generation advanced after observation, abort (stale).
|
||||
* - If memory generation still equals the observation watermark, advance
|
||||
* (tombstone) so an equal-generation writer reserved before observation
|
||||
* cannot commit after the clear (SF-4).
|
||||
* - If the persisted row generation advanced after observation, keep the row.
|
||||
* - Otherwise delete partial, failed, and confirmed ok+true rows.
|
||||
*
|
||||
* Returns cleared | stale | absent.
|
||||
*/
|
||||
public async commitPreviewClear(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
observedMemoryGeneration: number,
|
||||
observedRowGeneration: number,
|
||||
): Promise<'cleared' | 'stale' | 'absent'> {
|
||||
const key = this.stackWriteKey(nodeId, stackName);
|
||||
let state = this.stackWriteState.get(key);
|
||||
if (!state) {
|
||||
state = { chain: Promise.resolve(), generation: observedMemoryGeneration };
|
||||
this.stackWriteState.set(key, state);
|
||||
}
|
||||
|
||||
// A reservation after observation already owns a higher generation.
|
||||
if (state.generation > observedMemoryGeneration) {
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
// Tombstone the equal watermark so pre-observation writers reserved at
|
||||
// this generation become stale when they later try to commit.
|
||||
if (state.generation === observedMemoryGeneration) {
|
||||
state.generation += 1;
|
||||
}
|
||||
const clearGeneration = state.generation;
|
||||
|
||||
let deleted = 0;
|
||||
const committed = await this.withStackWriteLock(nodeId, stackName, clearGeneration, () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const detail = db.getStackUpdateDetail(nodeId)[stackName];
|
||||
if (!detail) return;
|
||||
// Compare DB-embedded generations only (same dimension as the
|
||||
// pre-preview snapshot). Memory peek resets on restart; SQLite does not.
|
||||
const rowGeneration = db.getStackUpdateWriteGeneration(nodeId, stackName);
|
||||
if (rowGeneration > observedRowGeneration) return;
|
||||
deleted = db.clearStackUpdateStatus(nodeId, stackName);
|
||||
});
|
||||
if (!committed) return 'stale';
|
||||
return deleted > 0 ? 'cleared' : 'absent';
|
||||
}
|
||||
|
||||
private runtimeImagesByService(
|
||||
stackName: string,
|
||||
containers: Array<{ Image?: string; Labels?: Record<string, string> }>,
|
||||
@@ -1028,14 +1123,16 @@ export class ImageUpdateService {
|
||||
|
||||
const checkable = Array.from(images)
|
||||
.map((img) => imageUpdateMap.get(img))
|
||||
.filter((r): r is ImageCheckResult => !!r && !r.notCheckable);
|
||||
const errored = checkable.filter((r) => r.error !== undefined);
|
||||
const confirmedHasUpdate = checkable.some((r) => r.error === undefined && r.hasUpdate === true);
|
||||
.filter((r): r is ImageCheckResult => !!r && normalizeImageCheckStatus(r) !== 'not_checkable');
|
||||
const statuses = checkable.map(normalizeImageCheckStatus);
|
||||
const failed = checkable.filter((_, i) => statuses[i] === 'failed');
|
||||
const partial = checkable.filter((_, i) => statuses[i] === 'partial');
|
||||
const confirmedHasUpdate = checkable.some((r, i) => statuses[i] === 'ok' && r.hasUpdate === true);
|
||||
|
||||
if (checkable.length > 0 && errored.length === checkable.length) {
|
||||
if (checkable.length > 0 && failed.length === checkable.length) {
|
||||
const committed = await this.withStackWriteLock(nodeId, stackName, generation, async () => {
|
||||
db.recordStackCheckFailure(
|
||||
nodeId, stackName, errored[0].error ?? 'Update check failed', checkedAt,
|
||||
nodeId, stackName, failed[0].error ?? 'Update check failed', checkedAt,
|
||||
);
|
||||
});
|
||||
return {
|
||||
@@ -1045,8 +1142,10 @@ export class ImageUpdateService {
|
||||
};
|
||||
}
|
||||
|
||||
const checkStatus: StackCheckStatus = errored.length > 0 ? 'partial' : 'ok';
|
||||
const lastError = errored.length > 0 ? (errored[0].error ?? null) : null;
|
||||
const checkStatus: StackCheckStatus =
|
||||
failed.length > 0 || partial.length > 0 ? 'partial' : 'ok';
|
||||
const uncertain = [...partial, ...failed];
|
||||
const lastError = uncertain.length > 0 ? (uncertain[0].error ?? null) : null;
|
||||
const hasUpdate = checkStatus === 'partial'
|
||||
? (confirmedHasUpdate || previousState[stackName] === true)
|
||||
: confirmedHasUpdate;
|
||||
@@ -1065,20 +1164,20 @@ export class ImageUpdateService {
|
||||
public async checkImage(docker: DockerController, imageRef: string): Promise<ImageCheckResult> {
|
||||
const parsed = parseImageRef(imageRef);
|
||||
// A bare digest ref (sha256:...) has no tag to track upstream; not applicable.
|
||||
if (!parsed) return { hasUpdate: false, notCheckable: true };
|
||||
if (!parsed) {
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[ImageUpdateService] Checking ${imageRef}: registry=${parsed.registry} repo=${parsed.repo} tag=${parsed.tag}`);
|
||||
}
|
||||
|
||||
// Look up stored credentials for this registry
|
||||
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`);
|
||||
}
|
||||
|
||||
// Get local digest and platform from RepoDigests / Os+Architecture
|
||||
let localDigest: string | null;
|
||||
let localDigests: string[];
|
||||
let platform: { os: string; architecture: string };
|
||||
try {
|
||||
const inspect = await withTimeout(docker.getDocker().getImage(imageRef).inspect(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'inspect');
|
||||
@@ -1086,28 +1185,58 @@ export class ImageUpdateService {
|
||||
|
||||
// No RepoDigests at all: locally built / not registry-backed, so update
|
||||
// status does not apply.
|
||||
if (repoDigests.length === 0) return { hasUpdate: false, notCheckable: true };
|
||||
if (repoDigests.length === 0) {
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
localDigest = selectLocalRepoDigest(repoDigests, parsed);
|
||||
localDigests = selectLocalRepoDigests(repoDigests, parsed);
|
||||
platform = { os: inspect.Os, architecture: inspect.Architecture };
|
||||
} catch {
|
||||
return { hasUpdate: false, error: `Failed to inspect local image "${imageRef}"` };
|
||||
return {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: `Failed to inspect local image "${imageRef}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// RepoDigests were present but none resolved a usable digest: genuinely
|
||||
// ambiguous, so surface it rather than silently call the image up to date.
|
||||
if (!localDigest) {
|
||||
return { hasUpdate: false, error: `Could not resolve a local registry digest for "${imageRef}"` };
|
||||
if (localDigests.length === 0) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: `Could not resolve a local registry digest for "${imageRef}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const comparison = await compareLocalToRemoteTag(localDigest, parsed.registry, parsed.repo, parsed.tag, platform, credentials);
|
||||
if (comparison.kind === 'error') {
|
||||
return { hasUpdate: false, error: comparison.reason };
|
||||
const detection = await detectImageUpdate({
|
||||
localDigests,
|
||||
platform,
|
||||
registry: parsed.registry,
|
||||
repo: parsed.repo,
|
||||
tag: parsed.tag,
|
||||
credentials,
|
||||
});
|
||||
|
||||
const digestLabel = localDigests[0] ? `${localDigests[0].slice(0, 27)}...` : 'none';
|
||||
const nextSuffix = detection.nextTag ? ` next=${detection.nextTag}` : '';
|
||||
console.log(
|
||||
`[ImageUpdateService] ${imageRef}: local=${digestLabel} update=${detection.hasUpdate}`
|
||||
+ ` digest=${detection.digestUpdate} tag=${detection.tagUpdate}`
|
||||
+ ` status=${detection.checkStatus}${nextSuffix}`,
|
||||
);
|
||||
|
||||
if (detection.checkStatus === 'not_checkable') {
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
const hasUpdate = comparison.kind === 'update';
|
||||
console.log(`[ImageUpdateService] ${imageRef}: local=${localDigest.slice(0, 27)}... update=${hasUpdate}`);
|
||||
return { hasUpdate };
|
||||
return {
|
||||
hasUpdate: detection.hasUpdate,
|
||||
digestUpdate: detection.digestUpdate,
|
||||
tagUpdate: detection.tagUpdate,
|
||||
checkStatus: detection.checkStatus,
|
||||
...(detection.reason ? { error: detection.reason } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ import { FileSystemService } from './FileSystemService';
|
||||
import { HealthGateService } from './HealthGateService';
|
||||
import { ServiceUpdateRecoveryService } from './ServiceUpdateRecoveryService';
|
||||
import { ImageUpdateService } from './ImageUpdateService';
|
||||
import type { ImageCheckResult } from './ImageUpdateService';
|
||||
import {
|
||||
createAutoUpdateDigestGateState,
|
||||
messageWhenNoDigestUpdate,
|
||||
recordAutoUpdateImageCheck,
|
||||
} from '../helpers/autoUpdateDigestGate';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { formatNoTargetError } from '../utils/remoteTarget';
|
||||
@@ -1056,36 +1060,25 @@ export class SchedulerService {
|
||||
console.log(`[SchedulerService] Stack "${stackName}": checking ${imageRefs.length} image(s): ${imageRefs.join(', ')}`);
|
||||
}
|
||||
|
||||
let hasUpdate = false;
|
||||
const updatedImages: string[] = [];
|
||||
const checkErrors: string[] = [];
|
||||
const gate = createAutoUpdateDigestGateState();
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const result: ImageCheckResult = await imageUpdateService.checkImage(docker, imageRef);
|
||||
if (result.error) {
|
||||
checkErrors.push(result.error);
|
||||
} else if (result.hasUpdate) {
|
||||
hasUpdate = true;
|
||||
updatedImages.push(imageRef);
|
||||
}
|
||||
const result = await imageUpdateService.checkImage(docker, imageRef);
|
||||
recordAutoUpdateImageCheck(gate, imageRef, result);
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
checkErrors.push(msg);
|
||||
gate.checkErrors.push(msg);
|
||||
console.warn(`[SchedulerService] Failed to check image ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdate) {
|
||||
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
|
||||
return `Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`;
|
||||
}
|
||||
if (checkErrors.length > 0) {
|
||||
return `Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`;
|
||||
}
|
||||
return `Stack "${stackName}": all images up to date.`;
|
||||
if (!gate.hasDigestUpdate) {
|
||||
return messageWhenNoDigestUpdate(stackName, gate, imageRefs.length);
|
||||
}
|
||||
|
||||
const { updatedImages } = gate;
|
||||
|
||||
await this.enforceSchedulerPolicyGate(
|
||||
stackName,
|
||||
nodeId,
|
||||
|
||||
@@ -10,15 +10,43 @@ import {
|
||||
} from './ImageUpdateService';
|
||||
import {
|
||||
parseImageRef,
|
||||
selectLocalRepoDigest,
|
||||
selectLocalRepoDigests,
|
||||
compareLocalToRemoteTag,
|
||||
listRegistryTags,
|
||||
listRegistryTagsResult,
|
||||
type ParsedRef,
|
||||
type RegistryCredentials,
|
||||
type DigestComparisonResult,
|
||||
} from './registry-api';
|
||||
import {
|
||||
detectImageUpdate,
|
||||
type ImageUpdateDetectResult,
|
||||
type ListRegistryTagsResultFn,
|
||||
type PreviewImageCheckStatus,
|
||||
type SemverBump,
|
||||
computeSemverBump,
|
||||
findNextTag,
|
||||
isMovingTag,
|
||||
listAllRegistryTagsBounded,
|
||||
parseSemverTag,
|
||||
PREVIEW_TAG_LIST_MAX_PAGES,
|
||||
PREVIEW_TAG_LIST_MAX_TAGS,
|
||||
PREVIEW_TAG_LIST_PAGE_SIZE,
|
||||
type TagEnumOutcome,
|
||||
} from './imageUpdateDetect';
|
||||
|
||||
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
export type { SemverBump, PreviewImageCheckStatus, TagEnumOutcome, ListRegistryTagsResultFn };
|
||||
export {
|
||||
computeSemverBump,
|
||||
findNextTag,
|
||||
isMovingTag,
|
||||
listAllRegistryTagsBounded,
|
||||
parseSemverTag,
|
||||
PREVIEW_TAG_LIST_MAX_PAGES,
|
||||
PREVIEW_TAG_LIST_MAX_TAGS,
|
||||
PREVIEW_TAG_LIST_PAGE_SIZE,
|
||||
};
|
||||
|
||||
/** Stack-level preview authority; absent on older remotes. */
|
||||
export type PreviewCheckStatus = 'ok' | 'partial' | 'failed';
|
||||
|
||||
export interface UpdatePreviewImage {
|
||||
service: string;
|
||||
@@ -26,7 +54,13 @@ export interface UpdatePreviewImage {
|
||||
current_tag: string;
|
||||
next_tag: string | null;
|
||||
has_update: boolean;
|
||||
/** Same-tag content drift; Compose pull can apply without pin change. */
|
||||
digest_update: boolean;
|
||||
/** Higher pinned semver exists; advisory until Compose is edited. */
|
||||
tag_update: boolean;
|
||||
semver_bump: SemverBump;
|
||||
/** Authority of this image's checks; not_checkable for invalid refs. */
|
||||
check_status: PreviewImageCheckStatus;
|
||||
}
|
||||
|
||||
export type UpdateKind = 'tag' | 'digest' | 'none';
|
||||
@@ -50,6 +84,12 @@ export interface UpdatePreviewSummary {
|
||||
has_build_services: boolean;
|
||||
/** True when a manual update can rebuild local build services (always when has_build_services). */
|
||||
rebuild_available: boolean;
|
||||
/**
|
||||
* Whether every checkable image was verified authoritatively.
|
||||
* Authoritative-negative reconcile requires check_status === 'ok' and !has_update.
|
||||
* Older remotes omit this field; treat absence as non-authoritative.
|
||||
*/
|
||||
check_status: PreviewCheckStatus;
|
||||
}
|
||||
|
||||
export interface UpdatePreview {
|
||||
@@ -61,74 +101,6 @@ export interface UpdatePreview {
|
||||
changelog: string | null;
|
||||
}
|
||||
|
||||
interface SemverParts {
|
||||
prefix: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
suffix: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
|
||||
|
||||
export function parseSemverTag(tag: string): SemverParts | null {
|
||||
const m = tag.match(SEMVER_RE);
|
||||
if (!m) return null;
|
||||
return {
|
||||
prefix: m[1] ?? '',
|
||||
major: Number(m[2]),
|
||||
minor: Number(m[3]),
|
||||
patch: Number(m[4]),
|
||||
suffix: m[5] ?? '',
|
||||
raw: tag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tag is "moving" when restoring the compose file would not revert the image
|
||||
* behind it: `latest`, a branch name, or an unpinned major/minor like `1.25`.
|
||||
* Only a fully-pinned semver tag (X.Y.Z, optionally `v`-prefixed and/or with a
|
||||
* `-prerelease` suffix) is treated as immutable, matching how a file rollback
|
||||
* restores the exact tag.
|
||||
*/
|
||||
export function isMovingTag(tag: string): boolean {
|
||||
return parseSemverTag(tag) === null;
|
||||
}
|
||||
|
||||
function compareSemver(a: SemverParts, b: SemverParts): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
|
||||
const current = parseSemverTag(currentTag);
|
||||
if (!current) return null;
|
||||
let best: SemverParts | null = null;
|
||||
for (const tag of availableTags) {
|
||||
const parsed = parseSemverTag(tag);
|
||||
if (!parsed) continue;
|
||||
if (parsed.prefix !== current.prefix) continue;
|
||||
if (parsed.suffix !== current.suffix) continue;
|
||||
if (compareSemver(parsed, current) <= 0) continue;
|
||||
if (!best || compareSemver(parsed, best) > 0) best = parsed;
|
||||
}
|
||||
return best ? best.raw : null;
|
||||
}
|
||||
|
||||
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
|
||||
if (!nextTag) return 'none';
|
||||
if (nextTag === currentTag) return 'patch';
|
||||
const current = parseSemverTag(currentTag);
|
||||
const next = parseSemverTag(nextTag);
|
||||
if (!current || !next) return 'unknown';
|
||||
if (next.major > current.major) return 'major';
|
||||
if (next.minor > current.minor) return 'minor';
|
||||
if (next.patch > current.patch) return 'patch';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function maxBump(a: SemverBump, b: SemverBump): SemverBump {
|
||||
// Ranking: none < unknown < patch < minor < major.
|
||||
// unknown ranks below real semver bumps so a single unparseable tag never masks
|
||||
@@ -165,17 +137,50 @@ async function loadStackImages(
|
||||
}
|
||||
|
||||
export interface LocalDigestInfo {
|
||||
digest: string | null;
|
||||
digests: string[];
|
||||
platform: { os: string; architecture: string };
|
||||
}
|
||||
|
||||
export interface ComputePreviewDeps {
|
||||
getLocalDigest: (imageRef: string, parsed: ParsedRef) => Promise<LocalDigestInfo>;
|
||||
compareDigest: typeof compareLocalToRemoteTag;
|
||||
listRegistryTags: typeof listRegistryTags;
|
||||
listRegistryTagsResult: ListRegistryTagsResultFn;
|
||||
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
|
||||
}
|
||||
|
||||
function imageFromDetect(
|
||||
service: string,
|
||||
imageRef: string,
|
||||
currentTag: string,
|
||||
detected: ImageUpdateDetectResult,
|
||||
): UpdatePreviewImage {
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: currentTag,
|
||||
next_tag: detected.nextTag,
|
||||
has_update: detected.hasUpdate,
|
||||
digest_update: detected.digestUpdate,
|
||||
tag_update: detected.tagUpdate,
|
||||
semver_bump: detected.semverBump,
|
||||
check_status: detected.checkStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function notCheckableImage(service: string, imageRef: string): UpdatePreviewImage {
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: 'unknown',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none',
|
||||
check_status: 'not_checkable',
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeImagePreview(
|
||||
service: string,
|
||||
imageRef: string,
|
||||
@@ -183,52 +188,24 @@ export async function computeImagePreview(
|
||||
): Promise<UpdatePreviewImage> {
|
||||
const parsed = parseImageRef(imageRef);
|
||||
if (!parsed) {
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: 'unknown',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
semver_bump: 'none',
|
||||
};
|
||||
return notCheckableImage(service, imageRef);
|
||||
}
|
||||
|
||||
const credentials = await deps.getCredentials(parsed.registry);
|
||||
|
||||
// Digest-based: is a new build of the SAME tag available? A comparison error
|
||||
// (network failure, malformed manifest) fails soft: it never claims a
|
||||
// digest-based update, it only skips it.
|
||||
const localInfo = await deps.getLocalDigest(imageRef, parsed);
|
||||
const [comparison, tags] = await Promise.all([
|
||||
localInfo.digest
|
||||
? deps.compareDigest(localInfo.digest, parsed.registry, parsed.repo, parsed.tag, localInfo.platform, credentials)
|
||||
: Promise.resolve<DigestComparisonResult>({ kind: 'error', reason: 'No local registry digest available' }),
|
||||
deps.listRegistryTags(parsed.registry, parsed.repo, credentials),
|
||||
]);
|
||||
const digestUpdate = comparison.kind === 'update';
|
||||
|
||||
// Tag-based: is a higher semver tag available?
|
||||
const nextTag = findNextTag(parsed.tag, tags);
|
||||
|
||||
const hasUpdate = digestUpdate || nextTag !== null;
|
||||
let semverBump: SemverBump = 'none';
|
||||
let resolvedNext: string | null = null;
|
||||
if (nextTag) {
|
||||
resolvedNext = nextTag;
|
||||
semverBump = computeSemverBump(parsed.tag, nextTag);
|
||||
} else if (digestUpdate) {
|
||||
resolvedNext = parsed.tag;
|
||||
semverBump = 'patch';
|
||||
}
|
||||
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: parsed.tag,
|
||||
next_tag: resolvedNext,
|
||||
has_update: hasUpdate,
|
||||
semver_bump: semverBump,
|
||||
};
|
||||
const detected = await detectImageUpdate({
|
||||
localDigests: localInfo.digests,
|
||||
platform: localInfo.platform,
|
||||
registry: parsed.registry,
|
||||
repo: parsed.repo,
|
||||
tag: parsed.tag,
|
||||
credentials,
|
||||
deps: {
|
||||
compareDigest: deps.compareDigest,
|
||||
listRegistryTagsResult: deps.listRegistryTagsResult,
|
||||
},
|
||||
});
|
||||
return imageFromDetect(service, imageRef, parsed.tag, detected);
|
||||
}
|
||||
|
||||
function buildRollbackTarget(image: string, currentTag: string): string | null {
|
||||
@@ -244,6 +221,16 @@ function buildRollbackTarget(image: string, currentTag: string): string | null {
|
||||
return `${base}:${currentTag}`;
|
||||
}
|
||||
|
||||
export function rollupPreviewCheckStatus(images: UpdatePreviewImage[]): PreviewCheckStatus {
|
||||
const checkable = images.filter((i) => i.check_status !== 'not_checkable');
|
||||
if (checkable.length === 0) return 'ok';
|
||||
const allFailed = checkable.every((i) => i.check_status === 'failed');
|
||||
if (allFailed) return 'failed';
|
||||
const allOk = checkable.every((i) => i.check_status === 'ok');
|
||||
if (allOk) return 'ok';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
export function buildSummary(
|
||||
stackName: string,
|
||||
images: UpdatePreviewImage[],
|
||||
@@ -281,12 +268,22 @@ export function buildSummary(
|
||||
blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null,
|
||||
has_build_services: hasBuildServices,
|
||||
rebuild_available: hasBuildServices,
|
||||
check_status: rollupPreviewCheckStatus(images),
|
||||
},
|
||||
rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null,
|
||||
changelog: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a preview is safe to clear sticky scanner state. */
|
||||
export function isAuthoritativeNegativePreview(preview: UpdatePreview): boolean {
|
||||
// Every declared image must be explicitly ok. Mixed ok + not_checkable must
|
||||
// not clear sticky rows that may still track unresolved services.
|
||||
return preview.images.length > 0
|
||||
&& preview.images.every((i) => i.check_status === 'ok')
|
||||
&& preview.summary.has_update === false;
|
||||
}
|
||||
|
||||
/** Filter a full-stack preview down to one service's images and recompute the summary from that subset. */
|
||||
export function filterPreviewForService(preview: UpdatePreview, serviceName: string): UpdatePreview {
|
||||
const images = preview.images.filter(i => i.service === serviceName);
|
||||
@@ -317,21 +314,33 @@ export class UpdatePreviewService {
|
||||
const deps: ComputePreviewDeps = {
|
||||
getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry),
|
||||
compareDigest: compareLocalToRemoteTag,
|
||||
listRegistryTags,
|
||||
listRegistryTagsResult,
|
||||
getLocalDigest: async (imageRef: string, parsed: ParsedRef): Promise<LocalDigestInfo> => {
|
||||
try {
|
||||
const inspect = await docker.getDocker().getImage(imageRef).inspect();
|
||||
const repoDigests: string[] = inspect.RepoDigests ?? [];
|
||||
const digest = selectLocalRepoDigest(repoDigests, parsed);
|
||||
return { digest, platform: { os: inspect.Os, architecture: inspect.Architecture } };
|
||||
} catch {
|
||||
return { digest: null, platform: { os: '', architecture: '' } };
|
||||
const digests = selectLocalRepoDigests(repoDigests, parsed);
|
||||
return { digests, platform: { os: inspect.Os, architecture: inspect.Architecture } };
|
||||
} catch (err) {
|
||||
console.error('[UpdatePreview] local image inspect failed for %s', imageRef, err);
|
||||
return { digests: [], platform: { os: '', architecture: '' } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Memoize service-independent detection by image ref so shared images
|
||||
// hit the registry once, then attach each service name separately.
|
||||
const detectByRef = new Map<string, Promise<UpdatePreviewImage>>();
|
||||
const results = await Promise.all(
|
||||
stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)),
|
||||
stackImages.map(async ({ service, image }) => {
|
||||
let shared = detectByRef.get(image);
|
||||
if (!shared) {
|
||||
shared = computeImagePreview('_shared_', image, deps);
|
||||
detectByRef.set(image, shared);
|
||||
}
|
||||
const base = await shared;
|
||||
return { ...base, service };
|
||||
}),
|
||||
);
|
||||
return buildSummary(stackName, results, buildServices);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Shared image-update detection used by both persisted sidebar status
|
||||
* (ImageUpdateService.checkImage) and Fleet/Anatomy preview
|
||||
* (UpdatePreviewService.computeImagePreview).
|
||||
*
|
||||
* An update is available when either:
|
||||
* 1. the local digest no longer matches the registry manifest for the
|
||||
* currently declared tag (digestUpdate; Compose-actionable), or
|
||||
* 2. a higher pinned semver tag exists in a complete bounded tag list
|
||||
* (tagUpdate; advisory only until Compose is edited).
|
||||
*/
|
||||
import {
|
||||
compareLocalToRemoteTag,
|
||||
listRegistryTagsResult,
|
||||
type DigestComparisonResult,
|
||||
type RegistryCredentials,
|
||||
type TagListResult,
|
||||
} from './registry-api';
|
||||
|
||||
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
|
||||
/** Per-image check confidence for preview authority and scanner persistence. */
|
||||
export type PreviewImageCheckStatus = 'ok' | 'partial' | 'failed' | 'not_checkable';
|
||||
|
||||
interface SemverParts {
|
||||
prefix: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
suffix: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
|
||||
|
||||
/** Max pages when enumerating tags for a pinned-semver authoritative-negative. */
|
||||
export const PREVIEW_TAG_LIST_MAX_PAGES = 20;
|
||||
/** Max tags accumulated across pages for the same purpose. */
|
||||
export const PREVIEW_TAG_LIST_MAX_TAGS = 2000;
|
||||
/** Per-page size passed to listRegistryTagsResult. */
|
||||
export const PREVIEW_TAG_LIST_PAGE_SIZE = 100;
|
||||
|
||||
export function parseSemverTag(tag: string): SemverParts | null {
|
||||
const m = tag.match(SEMVER_RE);
|
||||
if (!m) return null;
|
||||
return {
|
||||
prefix: m[1] ?? '',
|
||||
major: Number(m[2]),
|
||||
minor: Number(m[3]),
|
||||
patch: Number(m[4]),
|
||||
suffix: m[5] ?? '',
|
||||
raw: tag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tag is "moving" when restoring the compose file would not revert the image
|
||||
* behind it: `latest`, a branch name, or an unpinned major/minor like `1.25`.
|
||||
* Only a fully-pinned semver tag (X.Y.Z, optionally `v`-prefixed and/or with a
|
||||
* `-prerelease` suffix) is treated as immutable, matching how a file rollback
|
||||
* restores the exact tag.
|
||||
*/
|
||||
export function isMovingTag(tag: string): boolean {
|
||||
return parseSemverTag(tag) === null;
|
||||
}
|
||||
|
||||
function compareSemver(a: SemverParts, b: SemverParts): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
|
||||
const current = parseSemverTag(currentTag);
|
||||
if (!current) return null;
|
||||
let best: SemverParts | null = null;
|
||||
for (const tag of availableTags) {
|
||||
const parsed = parseSemverTag(tag);
|
||||
if (!parsed) continue;
|
||||
if (parsed.prefix !== current.prefix) continue;
|
||||
if (parsed.suffix !== current.suffix) continue;
|
||||
if (compareSemver(parsed, current) <= 0) continue;
|
||||
if (!best || compareSemver(parsed, best) > 0) best = parsed;
|
||||
}
|
||||
return best ? best.raw : null;
|
||||
}
|
||||
|
||||
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
|
||||
if (!nextTag) return 'none';
|
||||
if (nextTag === currentTag) return 'patch';
|
||||
const current = parseSemverTag(currentTag);
|
||||
const next = parseSemverTag(nextTag);
|
||||
if (!current || !next) return 'unknown';
|
||||
if (next.major > current.major) return 'major';
|
||||
if (next.minor > current.minor) return 'minor';
|
||||
if (next.patch > current.patch) return 'patch';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export type ListRegistryTagsResultFn = (
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
opts?: { limit?: number; cursor?: string },
|
||||
) => Promise<TagListResult>;
|
||||
|
||||
export type TagEnumOutcome =
|
||||
| { kind: 'complete'; tags: string[] }
|
||||
| { kind: 'incomplete'; tags: string[]; reason: string }
|
||||
| { kind: 'error'; reason: string }
|
||||
| { kind: 'skipped' };
|
||||
|
||||
/**
|
||||
* Enumerate tags with bounded pagination. Hitting the page/tag cap while a
|
||||
* nextCursor remains is incomplete (non-authoritative), not a successful empty
|
||||
* or "no newer tag" result.
|
||||
*/
|
||||
export async function listAllRegistryTagsBounded(
|
||||
listFn: ListRegistryTagsResultFn,
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials: RegistryCredentials | null,
|
||||
opts: { maxPages?: number; maxTags?: number; pageSize?: number } = {},
|
||||
): Promise<TagEnumOutcome> {
|
||||
const maxPages = opts.maxPages ?? PREVIEW_TAG_LIST_MAX_PAGES;
|
||||
const maxTags = opts.maxTags ?? PREVIEW_TAG_LIST_MAX_TAGS;
|
||||
const pageSize = opts.pageSize ?? PREVIEW_TAG_LIST_PAGE_SIZE;
|
||||
const tags: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const result = await listFn(registry, repo, credentials, { limit: pageSize, cursor });
|
||||
if (!result.ok) {
|
||||
return { kind: 'error', reason: result.message };
|
||||
}
|
||||
tags.push(...result.tags);
|
||||
if (tags.length > maxTags) {
|
||||
return {
|
||||
kind: 'incomplete',
|
||||
tags: tags.slice(0, maxTags),
|
||||
reason: `Tag list exceeded ${maxTags} tags before pagination completed`,
|
||||
};
|
||||
}
|
||||
if (!result.nextCursor) {
|
||||
return { kind: 'complete', tags };
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
return {
|
||||
kind: 'incomplete',
|
||||
tags,
|
||||
reason: `Tag list exceeded ${maxPages} pages before pagination completed`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageUpdateDetectInput {
|
||||
localDigests: readonly string[];
|
||||
platform: { os: string; architecture: string };
|
||||
registry: string;
|
||||
repo: string;
|
||||
tag: string;
|
||||
credentials: RegistryCredentials | null;
|
||||
}
|
||||
|
||||
export interface ImageUpdateDetectResult {
|
||||
hasUpdate: boolean;
|
||||
digestUpdate: boolean;
|
||||
tagUpdate: boolean;
|
||||
nextTag: string | null;
|
||||
digestError: string | null;
|
||||
tagEnumKind: 'complete' | 'incomplete' | 'error' | 'skipped';
|
||||
tagEnumReason: string | null;
|
||||
checkStatus: PreviewImageCheckStatus;
|
||||
/** Best operator-facing uncertainty reason for lastError / tooltips. */
|
||||
reason: string | null;
|
||||
semverBump: SemverBump;
|
||||
}
|
||||
|
||||
export interface DetectImageUpdateDeps {
|
||||
compareDigest?: typeof compareLocalToRemoteTag;
|
||||
listRegistryTagsResult?: ListRegistryTagsResultFn;
|
||||
}
|
||||
|
||||
export function resolveImageCheckStatus(args: {
|
||||
digest: DigestComparisonResult['kind'];
|
||||
tagApplicable: boolean;
|
||||
tagOutcome: TagEnumOutcome;
|
||||
hasUpdate: boolean;
|
||||
}): PreviewImageCheckStatus {
|
||||
const { digest, tagApplicable, tagOutcome, hasUpdate } = args;
|
||||
|
||||
if (digest === 'update') return 'ok';
|
||||
|
||||
if (!tagApplicable) {
|
||||
return digest === 'match' ? 'ok' : 'failed';
|
||||
}
|
||||
|
||||
if (tagOutcome.kind === 'complete') {
|
||||
if (hasUpdate) return 'ok';
|
||||
if (digest === 'match') return 'ok';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
if (tagOutcome.kind === 'incomplete') {
|
||||
if (hasUpdate) return 'ok';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
if (hasUpdate) return 'partial';
|
||||
if (digest === 'match') return 'partial';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function uncertaintyReason(
|
||||
checkStatus: PreviewImageCheckStatus,
|
||||
digestError: string | null,
|
||||
tagEnumReason: string | null,
|
||||
): string | null {
|
||||
if (checkStatus === 'ok' || checkStatus === 'not_checkable') return null;
|
||||
return tagEnumReason ?? digestError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core availability check shared by preview and persistence.
|
||||
*/
|
||||
export async function detectImageUpdate(args: ImageUpdateDetectInput & {
|
||||
deps?: DetectImageUpdateDeps;
|
||||
}): Promise<ImageUpdateDetectResult> {
|
||||
const compareDigest = args.deps?.compareDigest ?? compareLocalToRemoteTag;
|
||||
const listFn = args.deps?.listRegistryTagsResult ?? listRegistryTagsResult;
|
||||
const tagApplicable = !isMovingTag(args.tag);
|
||||
|
||||
const comparisonPromise: Promise<DigestComparisonResult> = args.localDigests.length > 0
|
||||
? compareDigest(
|
||||
args.localDigests,
|
||||
args.registry,
|
||||
args.repo,
|
||||
args.tag,
|
||||
args.platform,
|
||||
args.credentials,
|
||||
)
|
||||
: Promise.resolve({ kind: 'error', reason: 'No local registry digest available' });
|
||||
|
||||
const tagPromise: Promise<TagEnumOutcome> = tagApplicable
|
||||
? listAllRegistryTagsBounded(listFn, args.registry, args.repo, args.credentials)
|
||||
: Promise.resolve({ kind: 'skipped' });
|
||||
|
||||
const [comparison, tagOutcome] = await Promise.all([comparisonPromise, tagPromise]);
|
||||
|
||||
let nextTag: string | null = null;
|
||||
if (tagOutcome.kind === 'complete' || tagOutcome.kind === 'incomplete') {
|
||||
nextTag = findNextTag(args.tag, tagOutcome.tags);
|
||||
}
|
||||
|
||||
const digestUpdate = comparison.kind === 'update';
|
||||
const tagUpdate = nextTag !== null;
|
||||
const hasUpdate = digestUpdate || tagUpdate;
|
||||
|
||||
let resolvedNext: string | null = null;
|
||||
let semverBump: SemverBump = 'none';
|
||||
if (nextTag) {
|
||||
resolvedNext = nextTag;
|
||||
semverBump = computeSemverBump(args.tag, nextTag);
|
||||
} else if (digestUpdate) {
|
||||
resolvedNext = args.tag;
|
||||
semverBump = 'patch';
|
||||
}
|
||||
|
||||
const digestError = comparison.kind === 'error' ? comparison.reason : null;
|
||||
const tagEnumKind = tagOutcome.kind;
|
||||
const tagEnumReason = tagOutcome.kind === 'incomplete' || tagOutcome.kind === 'error'
|
||||
? tagOutcome.reason
|
||||
: null;
|
||||
|
||||
const checkStatus = resolveImageCheckStatus({
|
||||
digest: comparison.kind,
|
||||
tagApplicable,
|
||||
tagOutcome,
|
||||
hasUpdate,
|
||||
});
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
digestUpdate,
|
||||
tagUpdate,
|
||||
nextTag: resolvedNext,
|
||||
digestError,
|
||||
tagEnumKind,
|
||||
tagEnumReason,
|
||||
checkStatus,
|
||||
reason: uncertaintyReason(checkStatus, digestError, tagEnumReason),
|
||||
semverBump,
|
||||
};
|
||||
}
|
||||
@@ -242,14 +242,15 @@ export function repoDigestMatchesRef(repoDigest: string, parsed: ParsedRef): boo
|
||||
const SHA256_DIGEST_RE = /^sha256:[0-9a-f]{64}$/i;
|
||||
|
||||
/**
|
||||
* Deterministic local RepoDigest selection shared by the scanner and the
|
||||
* update preview: the first entry whose repository matches the parsed
|
||||
* image ref, else the sole remaining valid entry, else null. A truncated
|
||||
* or malformed digest (not a complete "sha256:" + 64 hex chars) is never
|
||||
* selected, so a corrupted RepoDigests entry surfaces as "could not
|
||||
* resolve" rather than a false match or update.
|
||||
* All usable local RepoDigests for a parsed image ref, shared by the scanner
|
||||
* and update preview. Returns every valid digest whose repository matches the
|
||||
* ref (deduped, first-seen order), else the sole remaining valid entry when
|
||||
* nothing matches, else []. A truncated or malformed digest is never selected,
|
||||
* so a corrupted RepoDigests entry surfaces as "could not resolve" rather than
|
||||
* a false match or update. Callers must compare every candidate: Docker can
|
||||
* list a stale index digest ahead of the current one on the same image.
|
||||
*/
|
||||
export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef): string | null {
|
||||
export function selectLocalRepoDigests(repoDigests: readonly string[], parsed: ParsedRef): string[] {
|
||||
const valid = repoDigests
|
||||
.map((entry) => {
|
||||
const at = entry.indexOf('@');
|
||||
@@ -257,9 +258,25 @@ export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef):
|
||||
})
|
||||
.filter((e): e is { entry: string; digest: string } => e !== null && SHA256_DIGEST_RE.test(e.digest));
|
||||
|
||||
const matched = valid.find((e) => repoDigestMatchesRef(e.entry, parsed));
|
||||
if (matched) return matched.digest;
|
||||
return valid.length === 1 ? valid[0].digest : null;
|
||||
const matched: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of valid) {
|
||||
if (!repoDigestMatchesRef(e.entry, parsed)) continue;
|
||||
const key = e.digest.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
matched.push(e.digest);
|
||||
}
|
||||
if (matched.length > 0) return matched;
|
||||
return valid.length === 1 ? [valid[0].digest] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scalar view of {@link selectLocalRepoDigests}: the first candidate, or null.
|
||||
* Prefer the plural form when comparing against a remote tag.
|
||||
*/
|
||||
export function selectLocalRepoDigest(repoDigests: readonly string[], parsed: ParsedRef): string | null {
|
||||
return selectLocalRepoDigests(repoDigests, parsed)[0] ?? null;
|
||||
}
|
||||
|
||||
/** Outcome of a remote-digest lookup: the digest, or a human-readable reason it failed. */
|
||||
@@ -762,16 +779,18 @@ async function classifyManifest(
|
||||
* always targets that digest.
|
||||
*/
|
||||
export async function compareLocalToRemoteTag(
|
||||
localDigest: string,
|
||||
localDigests: readonly string[],
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
platform: { os: string; architecture: string },
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<DigestComparisonResult> {
|
||||
if (!SHA256_DIGEST_RE.test(localDigest)) {
|
||||
const candidates = localDigests.filter((d) => SHA256_DIGEST_RE.test(d));
|
||||
if (candidates.length === 0) {
|
||||
return { kind: 'error', reason: 'Local digest is malformed or truncated' };
|
||||
}
|
||||
const candidateSet = new Set(candidates.map((d) => d.toLowerCase()));
|
||||
|
||||
const ref = `${registry}/${repo}:${tag}`;
|
||||
const probe = await probeManifestForRef(registry, repo, tag, credentials, ref);
|
||||
@@ -781,7 +800,7 @@ export async function compareLocalToRemoteTag(
|
||||
if (!SHA256_DIGEST_RE.test(primaryDigest)) {
|
||||
return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` };
|
||||
}
|
||||
if (localDigest === primaryDigest) return { kind: 'match' };
|
||||
if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match' };
|
||||
|
||||
let classification: ManifestClassification;
|
||||
try {
|
||||
@@ -792,14 +811,16 @@ export async function compareLocalToRemoteTag(
|
||||
|
||||
if (classification.kind === 'single') return { kind: 'update' };
|
||||
|
||||
if (classification.exactDigests.includes(localDigest)) return { kind: 'match' };
|
||||
if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match' };
|
||||
|
||||
if (!platform.os || !platform.architecture) {
|
||||
return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
|
||||
}
|
||||
|
||||
const isMember = classification.descriptors.some(
|
||||
(d) => d.os === platform.os && d.architecture === platform.architecture && d.digest === localDigest,
|
||||
(d) => d.os === platform.os
|
||||
&& d.architecture === platform.architecture
|
||||
&& candidateSet.has(d.digest.toLowerCase()),
|
||||
);
|
||||
return isMember ? { kind: 'match' } : { kind: 'update' };
|
||||
}
|
||||
@@ -852,13 +873,15 @@ function parseNextCursor(linkHeader: string | string[] | undefined): string | un
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed tag list for the Resources registry browser. Never collapses auth
|
||||
* failures into an empty array (that would hide credential problems).
|
||||
* Typed tag list for the Resources registry browser and update-preview authority.
|
||||
* Never collapses auth failures into an empty array (that would hide credential
|
||||
* problems and falsely look like a successful empty listing).
|
||||
* Pass null/undefined credentials to attempt anonymous pull.
|
||||
*/
|
||||
export async function listRegistryTagsResult(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials: RegistryCredentials,
|
||||
credentials?: RegistryCredentials | null,
|
||||
opts: { limit?: number; cursor?: string } = {},
|
||||
): Promise<TagListResult> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||
@@ -896,13 +919,12 @@ export async function listRegistryTagsResult(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility wrapper for update-preview: empty list on any failure. */
|
||||
/** Compatibility wrapper for callers that only need tags: empty list on any failure. */
|
||||
export async function listRegistryTags(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<string[]> {
|
||||
if (!credentials) return [];
|
||||
const result = await listRegistryTagsResult(registry, repo, credentials);
|
||||
return result.ok ? result.tags : [];
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ Once the cause is resolved, the next check (on the interval, or via **Recheck**)
|
||||
## Workflow
|
||||
|
||||
1. Open **Update** from the top nav strip.
|
||||
2. Skim the card grid. The badge tells you the risk at a glance: `Safe · patch` is green, `Review · minor` is amber, `Blocked · major` is red, and a digest-only rebuild on a non-semver tag shows the gray `Digest rebuild` badge.
|
||||
3. For a safe update, click **Apply now** on the card to pull and recreate the stack immediately.
|
||||
2. Skim the card grid. The badge tells you the risk at a glance: `Safe · patch` is green, `Review · minor` is amber, `Blocked · major` is red, a digest-only rebuild on a non-semver tag shows the gray `Digest rebuild` badge, `Check uncertain` appears when the last preview check was incomplete or failed, and `Newer tag · edit Compose` marks a higher tag that requires editing the Compose pin (Compose pull cannot rewrite pins).
|
||||
3. For a safe, Compose-actionable update (digest drift or local rebuild), click **Apply now** on the card to pull and recreate the stack immediately. **Apply now** stays disabled for tag-only advisories and for uncertain checks.
|
||||
4. For a major bump, review the changelog preview and the upstream release notes. **Apply now** is disabled on the readiness board for blocked cards; to apply a major bump after review, use the stack's lifecycle **Update** action (right-click the stack in the sidebar, or open the kebab menu and choose **Update**, or click **Deploy** in the stack editor).
|
||||
|
||||
On multi-service stacks, the Updates view can also apply a single service when that service has a confirmed image update. Scheduled auto-update, webhook pull, and bulk update always refresh the full stack.
|
||||
@@ -85,6 +85,8 @@ On multi-service stacks, the Updates view can also apply a single service when t
|
||||
| `Review · minor` | Amber (AlertTriangle icon) | Minor semver bump (e.g. `1.2.3` to `1.3.0`) |
|
||||
| `Blocked · major` | Red (ShieldAlert icon) | Major semver bump (e.g. `1.2.3` to `2.0.0`). **Apply now** is disabled; the card surfaces the reason "Major version jumps require human review before applying." |
|
||||
| `Digest rebuild` | Gray | Non-semver tag (e.g. `main`, `stable`) with an updated digest (tag name unchanged; content changed; see [Tags vs digests](/features/vulnerability-scanning#tags-vs-digests)) |
|
||||
| `Check uncertain` | Amber | Preview check incomplete or failed; **Apply now** stays disabled until a full successful check |
|
||||
| `Newer tag · edit Compose` | Amber | A higher tag exists than the Compose pin; edit Compose (do not use **Apply now**) |
|
||||
|
||||
A separate inline `Rebuild available` label replaces the version diff when only the digest changed (same tag, new image). The risk badge on those cards still reflects the underlying semver classification reported by the registry.
|
||||
|
||||
@@ -133,7 +135,7 @@ For each stack with a pending image update, Sencho computes a preview by:
|
||||
|
||||
1. Parsing the compose file to enumerate every service that pulls a registry image (a service declaring only `build:`, with no `image:` key, has no registry reference to check and is excluded from this preview).
|
||||
2. Calling the registry with your configured credentials to fetch the current tag list and remote digest.
|
||||
3. Picking the highest semver tag greater than the current tag (keeping the same prefix and suffix). If the highest available tag matches the current one but the remote digest has changed, the card surfaces as a `Rebuild available` update. Sencho checks both a newer semver tag and a digest change behind the same tag; see [Tags vs digests](/features/vulnerability-scanning#tags-vs-digests).
|
||||
3. Picking the highest semver tag greater than the current tag (keeping the same prefix and suffix). If a higher tag exists, the card surfaces as a tag-only advisory (`Newer tag · edit Compose`); scheduled auto-update and **Apply now** do not rewrite Compose pins. If the highest available tag matches the current one but the remote digest has changed, the card surfaces as a Compose-actionable `Rebuild available` / digest update. Sencho checks both a newer semver tag and a digest change behind the same tag; see [Tags vs digests](/features/vulnerability-scanning#tags-vs-digests).
|
||||
4. Scoring the overall stack by the most severe image bump. Any major bump marks the stack as blocked.
|
||||
5. Normalizing Docker Hub library paths so credentials and changelog lookups resolve correctly.
|
||||
|
||||
|
||||
@@ -144,8 +144,9 @@ Each row leads with a two-character status indicator that summarizes the stack's
|
||||
|
||||
Additional indicators appear to the right of the stack name:
|
||||
|
||||
- A pulsing fuchsia dot flags that an image update is available.
|
||||
- A muted alert icon replaces the dot when the update check ran but failed to reach the registry or errored.
|
||||
- A pulsing fuchsia dot flags that an image update is available and the latest check completed successfully. That signal covers Compose-actionable digest drift (same tag, new registry content) as well as a newer tag that requires editing the Compose pin.
|
||||
- A muted warning icon appears when the last check was incomplete. When an update was also detected or previously recorded, the tooltip notes that the full stack could not be verified. When no update is recorded, the tooltip notes that update status could not be fully verified. Incomplete results are not counted under **Updates**.
|
||||
- A muted alert icon appears when the update check failed (and, when an earlier update was recorded, notes that the previous status was retained).
|
||||
- A branch icon signals that the Git source's upstream branch has moved ahead of the working copy.
|
||||
|
||||
### Search and filter chips
|
||||
@@ -159,7 +160,7 @@ The search box above the list filters stacks by name. The chip row below it filt
|
||||
- **All**: every stack discovered in `COMPOSE_DIR`.
|
||||
- **Up**: stacks where every container is running.
|
||||
- **Down**: stacks with at least one stopped or exited container.
|
||||
- **Updates**: stacks with a pending image update.
|
||||
- **Updates**: stacks with a confirmed pending image update (latest check completed successfully). Incomplete or failed checks are excluded from this chip.
|
||||
|
||||
Each chip carries a live count. Click **Hide filters** in the top-right of the row to collapse the chips when you want a denser list. Search and chips combine: filtering by **Updates** and typing a few characters returns the intersection.
|
||||
|
||||
@@ -281,7 +282,13 @@ Each row maps one compose concept to the value it resolves to right now:
|
||||
|
||||
A footer card under the rows surfaces the first published port as a clickable **EXPOSED** link, so you can jump straight to the running app.
|
||||
|
||||
If an image update is available, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows. Registry updates name each image with a pending update and show its version transition (for example `27.1.4 -> 27.1.5`); the detail line below reads `patch · safe to apply` (green), `minor · review recommended` (amber), or `major · breaking changes possible` (rose), followed by an **apply** button. Build-only stacks show **Rebuild available** instead of a version bump, with a **Rebuild & Update** button. A stack that mixes registry images and local builds still shows a single banner, with the rebuild note folded into the same detail line. Major bumps use the rose styling and are worth reviewing before applying.
|
||||
If an image update is available, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows.
|
||||
|
||||
- **Digest rebuild / rebuild**: same-tag registry content changed, or a local `build:` service needs rebuilding. The banner shows the version context and an **apply** (or **Rebuild & Update**) button when the check completed successfully.
|
||||
- **Newer tag**: a higher semver tag exists than the pin in Compose. The detail line reads `newer tag · edit Compose pin`. There is no **apply** button; Compose pull does not rewrite image pins. Edit the Compose file (or use your usual pin-change workflow), then deploy.
|
||||
- **Incomplete or failed check** with no confirmed update: a separate amber banner explains that status is uncertain until a full check succeeds.
|
||||
|
||||
For actionable registry updates, the detail line also reads `patch · safe to apply` (green), `minor · review recommended` (amber), or `major · breaking changes possible` (rose). Build-only stacks show **Rebuild available** instead of a version bump, with a **Rebuild & Update** button. A stack that mixes registry images and local builds still shows a single banner, with the rebuild note folded into the same detail line. Major bumps use the rose styling and are worth reviewing before applying.
|
||||
|
||||
Rebuilds can take longer than a registry pull and depend on the local Dockerfile context, network access, and base-image availability. Atomic rollback restores compose and env files only; previously built image layers are not rolled back automatically.
|
||||
|
||||
|
||||
@@ -149,6 +149,69 @@ components:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
UpdatePreviewImage:
|
||||
type: object
|
||||
required:
|
||||
[service, image, current_tag, next_tag, has_update, digest_update, tag_update, semver_bump, check_status]
|
||||
properties:
|
||||
service: { type: string }
|
||||
image: { type: string }
|
||||
current_tag: { type: string }
|
||||
next_tag: { type: ["string", "null"] }
|
||||
has_update: { type: boolean }
|
||||
digest_update:
|
||||
type: boolean
|
||||
description: Same-tag registry content drift; Compose pull can apply without changing the pin.
|
||||
tag_update:
|
||||
type: boolean
|
||||
description: A higher pinned semver exists; advisory until Compose is edited.
|
||||
semver_bump:
|
||||
type: string
|
||||
enum: [none, unknown, patch, minor, major]
|
||||
check_status:
|
||||
type: string
|
||||
enum: [ok, partial, failed, not_checkable]
|
||||
|
||||
UpdatePreviewSummary:
|
||||
type: object
|
||||
required:
|
||||
[has_update, primary_image, current_tag, next_tag, semver_bump, update_kind, blocked, blocked_reason, has_build_services, rebuild_available, check_status]
|
||||
properties:
|
||||
has_update: { type: boolean }
|
||||
primary_image: { type: ["string", "null"] }
|
||||
current_tag: { type: ["string", "null"] }
|
||||
next_tag: { type: ["string", "null"] }
|
||||
semver_bump:
|
||||
type: string
|
||||
enum: [none, unknown, patch, minor, major]
|
||||
update_kind:
|
||||
type: string
|
||||
enum: [tag, digest, none]
|
||||
blocked: { type: boolean }
|
||||
blocked_reason: { type: ["string", "null"] }
|
||||
has_build_services: { type: boolean }
|
||||
rebuild_available: { type: boolean }
|
||||
check_status:
|
||||
type: string
|
||||
enum: [ok, partial, failed]
|
||||
|
||||
UpdatePreview:
|
||||
type: object
|
||||
required: [stack_name, images, build_services, summary, rollback_target, changelog]
|
||||
properties:
|
||||
stack_name: { type: string }
|
||||
images:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UpdatePreviewImage"
|
||||
build_services:
|
||||
type: array
|
||||
items: { type: string }
|
||||
summary:
|
||||
$ref: "#/components/schemas/UpdatePreviewSummary"
|
||||
rollback_target: { type: ["string", "null"] }
|
||||
changelog: { type: ["string", "null"] }
|
||||
|
||||
LabelSource:
|
||||
type: string
|
||||
description: Provenance of a label. `unknown` when a container or image could not be inspected.
|
||||
@@ -1788,6 +1851,68 @@ paths:
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/stacks/{stackName}/update-preview:
|
||||
get:
|
||||
operationId: getStackUpdatePreview
|
||||
tags: [Stacks]
|
||||
summary: Compute image update preview (read-only)
|
||||
description: |
|
||||
Computes the current registry update preview for the stack without
|
||||
mutating persisted scanner state. Use POST when the client should
|
||||
reconcile sticky update indicators after an authoritative-negative
|
||||
result. Requires `stack:read` permission.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stackName"
|
||||
- $ref: "#/components/parameters/nodeId"
|
||||
responses:
|
||||
"200":
|
||||
description: Update preview.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdatePreview"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
post:
|
||||
operationId: reconcileStackUpdatePreview
|
||||
tags: [Stacks]
|
||||
summary: Compute update preview and reconcile sticky state
|
||||
description: |
|
||||
Computes the same preview as GET. When every image reports
|
||||
`check_status: ok` and `has_update` is false (mixed `ok` +
|
||||
`not_checkable` does not clear), clears sticky confirmed update rows
|
||||
for the stack and sets `reconciled: true`. Requires `stack:read`
|
||||
permission.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stackName"
|
||||
- $ref: "#/components/parameters/nodeId"
|
||||
responses:
|
||||
"200":
|
||||
description: Update preview, with reconcile outcome.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/UpdatePreview"
|
||||
- type: object
|
||||
required: [reconciled]
|
||||
properties:
|
||||
reconciled:
|
||||
type: boolean
|
||||
description: |
|
||||
True when sticky update rows were cleared after an
|
||||
authoritative-negative preview.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/stacks/{stackName}/update-readiness:
|
||||
get:
|
||||
operationId: getStackUpdateReadiness
|
||||
|
||||
@@ -209,7 +209,7 @@ test.describe('Sidebar stack name truncation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('update dot wins over check-failed on a long stack name', async ({ page }) => {
|
||||
test('failed icon wins over retained hasUpdate on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/image-updates/detail', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -230,7 +230,7 @@ test.describe('Sidebar stack name truncation', () => {
|
||||
|
||||
await assertRowLayout(page, UPDATE_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'update',
|
||||
trailingKind: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { isAuthoritativeNegativePreview } from '@/types/imageUpdates';
|
||||
import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview';
|
||||
import {
|
||||
isActionableUpdatePreview,
|
||||
isPreviewUncertain,
|
||||
isServiceApplyActionable,
|
||||
isTagOnlyAdvisory,
|
||||
} from '@/lib/updatePreviewActionability';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { Masthead, Kicker } from '@/components/mobile/mobile-ui';
|
||||
@@ -24,7 +32,11 @@ interface UpdatePreviewImage {
|
||||
current_tag: string;
|
||||
next_tag: string | null;
|
||||
has_update: boolean;
|
||||
digest_update?: boolean;
|
||||
tag_update?: boolean;
|
||||
semver_bump: SemverBump;
|
||||
/** Absent on older remotes; backend uses !== 'not_checkable' for checkability. */
|
||||
check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable';
|
||||
}
|
||||
|
||||
type UpdateKind = 'tag' | 'digest' | 'none';
|
||||
@@ -43,6 +55,8 @@ interface UpdatePreview {
|
||||
blocked_reason: string | null;
|
||||
has_build_services?: boolean;
|
||||
rebuild_available?: boolean;
|
||||
/** Absent on older remotes; treat missing as non-authoritative. */
|
||||
check_status?: 'ok' | 'partial' | 'failed';
|
||||
};
|
||||
build_services?: string[];
|
||||
rollback_target: string | null;
|
||||
@@ -148,7 +162,25 @@ function formatClock(ts: number | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) {
|
||||
function RiskBadge({
|
||||
bump,
|
||||
blocked,
|
||||
uncertain,
|
||||
tagOnly,
|
||||
}: {
|
||||
bump: SemverBump;
|
||||
blocked: boolean;
|
||||
uncertain?: boolean;
|
||||
tagOnly?: boolean;
|
||||
}) {
|
||||
if (uncertain) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
|
||||
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
|
||||
Check uncertain
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (blocked || bump === 'major') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-destructive/40 bg-destructive/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-destructive">
|
||||
@@ -157,6 +189,13 @@ function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (tagOnly) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-card-border bg-muted/30 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Newer tag · edit Compose
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (bump === 'minor') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
|
||||
@@ -242,7 +281,7 @@ function StackReadinessCard({
|
||||
Auto: Off
|
||||
</span>
|
||||
)}
|
||||
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
|
||||
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,7 +341,7 @@ function StackReadinessCard({
|
||||
variant="outline"
|
||||
className="h-6 gap-1 rounded-md px-2 text-[11px]"
|
||||
onClick={() => onApplyService?.(stack, nodeId, img.service)}
|
||||
disabled={blocked || applying || applyingService !== null}
|
||||
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
|
||||
>
|
||||
{applyingService === img.service ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
@@ -329,7 +368,7 @@ function StackReadinessCard({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApply(stack, nodeId)}
|
||||
disabled={blocked || applying || applyingService !== null}
|
||||
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
|
||||
title={blocked ? (blockedReason ?? undefined) : undefined}
|
||||
className="gap-1.5"
|
||||
>
|
||||
@@ -502,7 +541,7 @@ export function MobileReadinessCard({
|
||||
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />Auto: Off
|
||||
</span>
|
||||
)}
|
||||
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
|
||||
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,7 +573,7 @@ export function MobileReadinessCard({
|
||||
variant="outline"
|
||||
className="h-7 gap-1 rounded-md px-2 text-[11px]"
|
||||
onClick={() => onApplyService?.(stack, nodeId, img.service)}
|
||||
disabled={blocked || applying || applyingService !== null}
|
||||
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
|
||||
>
|
||||
{applyingService === img.service ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
@@ -550,7 +589,7 @@ export function MobileReadinessCard({
|
||||
size="sm"
|
||||
variant={blocked ? 'outline' : 'default'}
|
||||
onClick={() => onApply(stack, nodeId)}
|
||||
disabled={blocked || applying || applyingService !== null}
|
||||
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
@@ -779,9 +818,11 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
const previews = await Promise.all(
|
||||
flatPairs.map(async ({ nodeId, stack }) => {
|
||||
try {
|
||||
const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as UpdatePreview;
|
||||
const result = await fetchUpdatePreview(stack, {
|
||||
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
|
||||
});
|
||||
if (!result.ok || !result.preview) return null;
|
||||
return result.preview as UpdatePreview;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -796,12 +837,16 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
|
||||
setGroups(initialGroups.map(g => ({
|
||||
...g,
|
||||
cards: g.cards.map(c => ({
|
||||
cards: g.cards
|
||||
.map(c => ({
|
||||
...c,
|
||||
preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null,
|
||||
previewLoaded: true,
|
||||
})),
|
||||
})));
|
||||
}))
|
||||
// Drop cards whose live preview authoritatively reports no update.
|
||||
// Missing check_status (older remotes) or non-ok status keeps the card.
|
||||
.filter(c => !isAuthoritativeNegativePreview(c.preview)),
|
||||
})).filter(g => g.cards.length > 0));
|
||||
} catch (err) {
|
||||
if (token !== loadTokenRef.current) return;
|
||||
toast.error((err as Error)?.message || 'Failed to load readiness');
|
||||
@@ -920,13 +965,25 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
}
|
||||
// Reload authoritative preview so summary / Apply affordances stay accurate.
|
||||
try {
|
||||
const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
|
||||
if (res.ok) {
|
||||
const next = await res.json() as UpdatePreview;
|
||||
const result = await fetchUpdatePreview(stack, {
|
||||
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
|
||||
});
|
||||
if (result.ok && result.preview) {
|
||||
const next = result.preview as UpdatePreview;
|
||||
if (isAuthoritativeNegativePreview(next)) {
|
||||
setGroups(prev => prev
|
||||
.map(g => g.nodeId === nodeId
|
||||
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
|
||||
: g)
|
||||
.filter(g => g.cards.length > 0));
|
||||
} else {
|
||||
setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true });
|
||||
}
|
||||
} catch {
|
||||
// Preview refresh is best-effort; the update itself already succeeded.
|
||||
} else {
|
||||
console.error(`[AutoUpdateReadinessView] post-apply update-preview failed (${result.status})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AutoUpdateReadinessView] post-apply update-preview refresh failed', err);
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
@@ -985,7 +1042,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
c.autoUpdateEnabled
|
||||
&& c.previewLoaded
|
||||
&& c.preview !== null
|
||||
&& !c.preview.summary.blocked,
|
||||
&& isActionableUpdatePreview(c.preview),
|
||||
).length;
|
||||
return { total: t, ready: r };
|
||||
}, [flatCards]);
|
||||
|
||||
@@ -52,6 +52,7 @@ import type { useAuth } from '@/context/AuthContext';
|
||||
import type { ContainerInfo, ContainerStatsEntry, StackAction } from './EditorView';
|
||||
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
|
||||
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
|
||||
import { isConfirmedServiceUpdate } from '@/types/imageUpdates';
|
||||
|
||||
const extractUptime = (status: string | undefined): string | null => {
|
||||
if (!status) return null;
|
||||
@@ -733,7 +734,7 @@ export function ContainersHealth({
|
||||
const group = safeContainers.filter(c => c.Service === spec.name);
|
||||
const status = serviceUpdateStatuses.find(s => s.service === spec.name);
|
||||
const busy = serviceUpdateInProgress?.service === spec.name;
|
||||
const hasUpdate = status?.hasUpdate === true;
|
||||
const hasUpdate = status ? isConfirmedServiceUpdate(status) : false;
|
||||
const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update';
|
||||
const showUpdateAction = spec.declaredImage !== null || spec.hasBuild;
|
||||
const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused');
|
||||
|
||||
@@ -142,6 +142,40 @@ describe('useNotifications', () => {
|
||||
expect(onStateInvalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires onImageUpdatesChange on update-status-reconciled', () => {
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate', scope: 'image-updates', nodeId: 1,
|
||||
stackName: 'foo', action: 'update-status-reconciled', ts: 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(onImageUpdatesChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores unrelated image-updates actions for the refresh callback', () => {
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate', scope: 'image-updates', nodeId: 1,
|
||||
stackName: 'foo', action: 'other', ts: 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(onImageUpdatesChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire onImageUpdatesChange on a generic state-invalidate', () => {
|
||||
const onStateInvalidate = vi.fn();
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
|
||||
@@ -11,6 +11,11 @@ interface UseNotificationsOptions {
|
||||
onImageUpdatesChange: () => void;
|
||||
}
|
||||
|
||||
/** Local stack-updated and preview-reconcile clears both refresh the update map. */
|
||||
function isImageUpdatesRefreshAction(action: unknown): boolean {
|
||||
return action === 'stack-updated' || action === 'update-status-reconciled';
|
||||
}
|
||||
|
||||
export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }: UseNotificationsOptions) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [tickerConnected, setTickerConnected] = useState(false);
|
||||
@@ -189,7 +194,7 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
onStateInvalidateRef.current();
|
||||
if (msg.scope === 'notifications') {
|
||||
reconcileNotificationsInvalidateRef.current(msg);
|
||||
} else if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
|
||||
} else if (msg.scope === 'image-updates' && isImageUpdatesRefreshAction(msg.action)) {
|
||||
onImageUpdatesChangeRef.current();
|
||||
}
|
||||
}
|
||||
@@ -271,7 +276,7 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
// Remote payloads use the remote's local DB node ID. Hub UI state
|
||||
// is keyed by rn.id, so always reconcile with the hub node ID.
|
||||
reconcileNotificationsInvalidateRef.current({ ...msg, nodeId: rn.id });
|
||||
} else if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
|
||||
} else if (msg.scope === 'image-updates' && isImageUpdatesRefreshAction(msg.action)) {
|
||||
onImageUpdatesChangeRef.current();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => useNodesMock(),
|
||||
}));
|
||||
|
||||
const useImageUpdatesMock = vi.fn();
|
||||
vi.mock('@/hooks/useImageUpdates', () => ({
|
||||
useImageUpdates: (...args: unknown[]) => useImageUpdatesMock(...args),
|
||||
}));
|
||||
|
||||
import { useStackListState } from './useStackListState';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
@@ -32,10 +37,16 @@ function notFound(): Response {
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
useNodesMock.mockReset();
|
||||
useImageUpdatesMock.mockReset();
|
||||
useNodesMock.mockReturnValue({
|
||||
activeNode: { id: 1, name: 'Local', type: 'local' },
|
||||
nodes: [{ id: 1, name: 'Local', type: 'local' }],
|
||||
});
|
||||
useImageUpdatesMock.mockReturnValue({
|
||||
stackUpdates: {},
|
||||
refresh: vi.fn(),
|
||||
sidebarIndicators: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackListState.refreshStacks failure classification', () => {
|
||||
@@ -138,3 +149,50 @@ describe('useStackListState.refreshStacks failure classification', () => {
|
||||
expect(result.current.files).toEqual(['web.yml']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackListState Updates chip confirmed-only', () => {
|
||||
async function loadStacks() {
|
||||
apiFetchMock.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === '/stacks') {
|
||||
return Promise.resolve(okJson(['ok.yml', 'partial.yml', 'failed.yml']));
|
||||
}
|
||||
if (endpoint === '/stacks/statuses') {
|
||||
return Promise.resolve(okJson({
|
||||
'ok.yml': { status: 'running' },
|
||||
'partial.yml': { status: 'running' },
|
||||
'failed.yml': { status: 'running' },
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(notFound());
|
||||
});
|
||||
|
||||
useImageUpdatesMock.mockReturnValue({
|
||||
stackUpdates: {
|
||||
'ok.yml': { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
'partial.yml': { hasUpdate: true, checkStatus: 'partial', lastError: 'timeout', checkedAt: 1 },
|
||||
'failed.yml': { hasUpdate: true, checkStatus: 'failed', lastError: 'unreachable', checkedAt: 1 },
|
||||
},
|
||||
refresh: vi.fn(),
|
||||
sidebarIndicators: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStackListState());
|
||||
await act(async () => {
|
||||
await result.current.refreshStacks();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
it('counts only ok+true stacks under Updates', async () => {
|
||||
const result = await loadStacks();
|
||||
expect(result.current.filterCounts.updates).toBe(1);
|
||||
});
|
||||
|
||||
it('filters the Updates chip to confirmed stacks only', async () => {
|
||||
const result = await loadStacks();
|
||||
await act(async () => {
|
||||
result.current.setFilterChip('updates');
|
||||
});
|
||||
expect(result.current.chipFilteredFiles).toEqual(['ok.yml']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackAction
|
||||
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
|
||||
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { isConfirmedImageUpdate } from '@/types/imageUpdates';
|
||||
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
||||
import type { StackAction, StackActionResult } from '../EditorView';
|
||||
import type { Label as StackLabel } from '../../label-types';
|
||||
@@ -431,18 +432,23 @@ export function useStackListState() {
|
||||
[files, searchQuery],
|
||||
);
|
||||
|
||||
const hasConfirmedSidebarUpdate = (file: string): boolean => {
|
||||
const info = sidebarStackUpdates[file];
|
||||
return info != null && isConfirmedImageUpdate(info);
|
||||
};
|
||||
|
||||
const filterCounts = useMemo(() => ({
|
||||
all: filteredFiles.length,
|
||||
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
|
||||
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
|
||||
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
|
||||
updates: filteredFiles.filter(hasConfirmedSidebarUpdate).length,
|
||||
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
|
||||
|
||||
const chipFilteredFiles = useMemo(() => {
|
||||
if (filterChip === 'all') return filteredFiles;
|
||||
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
|
||||
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
|
||||
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
|
||||
if (filterChip === 'updates') return filteredFiles.filter(hasConfirmedSidebarUpdate);
|
||||
return filteredFiles;
|
||||
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
|
||||
|
||||
|
||||
@@ -32,22 +32,26 @@ function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
|
||||
service: 'web',
|
||||
image: 'nginx:1.25',
|
||||
current_tag: '1.25',
|
||||
next_tag: hasUpdate ? '1.26' : null,
|
||||
next_tag: '1.25',
|
||||
has_update: hasUpdate,
|
||||
semver_bump: hasUpdate ? 'minor' : 'none',
|
||||
digest_update: hasUpdate,
|
||||
tag_update: false,
|
||||
semver_bump: hasUpdate ? 'patch' : 'none',
|
||||
check_status: 'ok',
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
has_update: hasUpdate,
|
||||
primary_image: 'nginx',
|
||||
current_tag: '1.25',
|
||||
next_tag: '1.26',
|
||||
semver_bump: 'minor',
|
||||
update_kind: hasUpdate ? 'tag' : 'none',
|
||||
next_tag: '1.25',
|
||||
semver_bump: hasUpdate ? 'patch' : 'none',
|
||||
update_kind: hasUpdate ? 'digest' : 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: hasBuild,
|
||||
rebuild_available: hasBuild,
|
||||
check_status: 'ok',
|
||||
},
|
||||
changelog: null,
|
||||
};
|
||||
@@ -118,6 +122,33 @@ describe('StackAnatomyPanel edit affordance', () => {
|
||||
});
|
||||
|
||||
describe('StackAnatomyPanel update banner', () => {
|
||||
it('hides apply when only a newer tag is available', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/update-preview')) {
|
||||
return jsonRes({
|
||||
build_services: [],
|
||||
images: [{
|
||||
service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26',
|
||||
has_update: true, digest_update: false, tag_update: true, semver_bump: 'minor', check_status: 'ok',
|
||||
}],
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx', current_tag: '1.25', next_tag: '1.26',
|
||||
semver_bump: 'minor', update_kind: 'tag', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false, check_status: 'ok',
|
||||
},
|
||||
changelog: null,
|
||||
});
|
||||
}
|
||||
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
|
||||
return jsonRes(null, false);
|
||||
});
|
||||
render(panel(false));
|
||||
await waitFor(() => expect(screen.getByTestId('update-available-banner')).toBeInTheDocument());
|
||||
expect(screen.getByText((t) => typeof t === 'string' && t.includes('newer tag') && t.includes('edit Compose pin'))).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'apply' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the apply button and fires onApplyUpdate when clicked', async () => {
|
||||
const onApply = vi.fn();
|
||||
render(panel(false, onApply));
|
||||
@@ -135,9 +166,9 @@ describe('StackAnatomyPanel update banner', () => {
|
||||
return jsonRes({
|
||||
build_services: [],
|
||||
images: [
|
||||
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26', has_update: true, semver_bump: 'minor' },
|
||||
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.4', has_update: true, semver_bump: 'minor' },
|
||||
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, semver_bump: 'none' },
|
||||
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
|
||||
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.2', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
|
||||
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, digest_update: false, tag_update: false, semver_bump: 'none', check_status: 'ok' },
|
||||
],
|
||||
summary: {
|
||||
has_update: true,
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { ScrollableTabRow } from './ui/ScrollableTabRow';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview';
|
||||
import {
|
||||
isActionableUpdatePreview,
|
||||
isPreviewUncertain,
|
||||
isTagOnlyAdvisory,
|
||||
} from '@/lib/updatePreviewActionability';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
||||
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
||||
@@ -46,7 +52,10 @@ interface UpdatePreviewImage {
|
||||
current_tag: string;
|
||||
next_tag: string | null;
|
||||
has_update: boolean;
|
||||
digest_update?: boolean;
|
||||
tag_update?: boolean;
|
||||
semver_bump: SemverBump;
|
||||
check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable';
|
||||
}
|
||||
|
||||
interface UpdatePreviewSummary {
|
||||
@@ -60,6 +69,7 @@ interface UpdatePreviewSummary {
|
||||
blocked_reason: string | null;
|
||||
has_build_services: boolean;
|
||||
rebuild_available: boolean;
|
||||
check_status?: 'ok' | 'partial' | 'failed';
|
||||
}
|
||||
|
||||
interface UpdatePreview {
|
||||
@@ -249,15 +259,15 @@ export default function StackAnatomyPanel({
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
|
||||
const result = await fetchUpdatePreview(stackName);
|
||||
if (cancelled) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUpdatePreview(data);
|
||||
if (result.ok && result.preview) {
|
||||
setUpdatePreview(result.preview as UpdatePreview);
|
||||
} else {
|
||||
setUpdatePreview(null);
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[StackAnatomyPanel] update-preview load failed', err);
|
||||
if (!cancelled) setUpdatePreview(null);
|
||||
}
|
||||
};
|
||||
@@ -279,16 +289,15 @@ export default function StackAnatomyPanel({
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
|
||||
const result = await fetchUpdatePreview(stackName);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
if (!result.ok) {
|
||||
// Re-check failed: keep the banner already shown rather than hiding a
|
||||
// possibly-still-pending update. The apply action reports its own outcome.
|
||||
console.error(`[StackAnatomyPanel] update-preview re-check returned ${res.status}; keeping the existing banner`);
|
||||
console.error(`[StackAnatomyPanel] update-preview re-check returned ${result.status}; keeping the existing banner`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!cancelled) setUpdatePreview(data);
|
||||
if (!cancelled && result.preview) setUpdatePreview(result.preview as UpdatePreview);
|
||||
} catch (err) {
|
||||
console.error('[StackAnatomyPanel] update-preview re-check failed:', err);
|
||||
}
|
||||
@@ -363,7 +372,10 @@ export default function StackAnatomyPanel({
|
||||
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
||||
const hasBuildServices = Boolean(updatePreview?.summary.has_build_services);
|
||||
const rebuildAvailable = Boolean(updatePreview?.summary.rebuild_available);
|
||||
const previewCheckStatus = updatePreview?.summary.check_status;
|
||||
const previewUncertain = isPreviewUncertain(updatePreview);
|
||||
const showUpdateBanner = hasUpdate || rebuildAvailable;
|
||||
const showCheckStatusBanner = previewUncertain && !showUpdateBanner;
|
||||
const updateKind = updatePreview?.summary.update_kind ?? 'none';
|
||||
const blocked = Boolean(updatePreview?.summary.blocked);
|
||||
const updatedImages = (updatePreview?.images ?? []).filter((img) => img.has_update);
|
||||
@@ -381,21 +393,27 @@ export default function StackAnatomyPanel({
|
||||
? 'border-warning/40 text-warning hover:bg-warning/10'
|
||||
: 'border-success/40 text-success hover:bg-success/10';
|
||||
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
|
||||
const bannerLeadIn = blocked
|
||||
? 'review required'
|
||||
: hasUpdate && updateKind === 'digest'
|
||||
? 'same-tag digest rebuild'
|
||||
: hasUpdate && hasBuildServices
|
||||
? 'registry update + local rebuild'
|
||||
: rebuildAvailable && !hasUpdate
|
||||
? 'local build / rebuild required'
|
||||
: bump === 'patch'
|
||||
? 'safe to apply'
|
||||
: bump === 'minor'
|
||||
? 'review recommended'
|
||||
: bump === 'major'
|
||||
? 'breaking changes possible'
|
||||
: '';
|
||||
const tagOnlyAdvisory = isTagOnlyAdvisory(updatePreview);
|
||||
const canApplyPreview = isActionableUpdatePreview(updatePreview);
|
||||
|
||||
let bannerLeadIn = '';
|
||||
if (blocked) {
|
||||
bannerLeadIn = 'review required';
|
||||
} else if (tagOnlyAdvisory) {
|
||||
bannerLeadIn = 'newer tag · edit Compose pin';
|
||||
} else if (hasUpdate && updateKind === 'digest') {
|
||||
bannerLeadIn = 'same-tag digest rebuild';
|
||||
} else if (hasUpdate && hasBuildServices) {
|
||||
bannerLeadIn = 'registry update + local rebuild';
|
||||
} else if (rebuildAvailable && !hasUpdate) {
|
||||
bannerLeadIn = 'local build / rebuild required';
|
||||
} else if (bump === 'patch') {
|
||||
bannerLeadIn = 'safe to apply';
|
||||
} else if (bump === 'minor') {
|
||||
bannerLeadIn = 'review recommended';
|
||||
} else if (bump === 'major') {
|
||||
bannerLeadIn = 'breaking changes possible';
|
||||
}
|
||||
const buildServiceNames = updatePreview?.build_services ?? [];
|
||||
const buildHint = hasBuildServices
|
||||
? `Rebuilds ${buildServiceNames.length} local build service${buildServiceNames.length === 1 ? '' : 's'} from Dockerfile context; may take longer and needs network access for base images.`
|
||||
@@ -576,6 +594,21 @@ export default function StackAnatomyPanel({
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
{showCheckStatusBanner && updatePreview && (
|
||||
<div
|
||||
data-testid="update-check-status-banner"
|
||||
className="mt-3 mb-3 rounded-lg border border-warning/40 bg-warning/[0.06] p-3 text-warning"
|
||||
>
|
||||
<div className="font-mono text-xs uppercase tracking-wide">
|
||||
{previewCheckStatus === 'failed' ? 'Update check failed' : 'Update check incomplete'}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-xs text-foreground/80 leading-relaxed">
|
||||
{previewCheckStatus === 'failed'
|
||||
? 'Registry checks could not verify image status. Retained update indicators may be stale.'
|
||||
: 'Some image checks did not complete. Status is uncertain until a full check succeeds.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showUpdateBanner && updatePreview && (
|
||||
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -616,7 +649,7 @@ export default function StackAnatomyPanel({
|
||||
<div className="mt-1 font-mono text-[10px] text-destructive">{updatePreview.summary.blocked_reason}</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && !blocked && (
|
||||
{canEdit && !blocked && canApplyPreview && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('@/context/NodeContext', () => ({
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { requestServiceUpdate } from '@/lib/serviceUpdate';
|
||||
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
|
||||
import { isAuthoritativeNegativePreview } from '@/types/imageUpdates';
|
||||
|
||||
function card(over: Partial<StackCard> = {}): StackCard {
|
||||
return {
|
||||
@@ -49,16 +50,20 @@ function card(over: Partial<StackCard> = {}): StackCard {
|
||||
scheduledTask: null,
|
||||
preview: {
|
||||
stack_name: 'nextcloud',
|
||||
images: [],
|
||||
images: [{
|
||||
service: 'app', image: 'nextcloud:27.1.4', current_tag: '27.1.4', next_tag: '27.1.4',
|
||||
has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok',
|
||||
}],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nextcloud',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
next_tag: '27.1.4',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
update_kind: 'digest',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
check_status: 'ok',
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes. Security patch.',
|
||||
@@ -74,6 +79,39 @@ it('enables Apply for a safe, non-blocked update', () => {
|
||||
expect(apply()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('disables Apply for tag-only advisory updates', () => {
|
||||
render(
|
||||
<MobileReadinessCard
|
||||
card={card({
|
||||
preview: {
|
||||
stack_name: 'nextcloud',
|
||||
images: [{
|
||||
service: 'app', image: 'nextcloud:27.1.4', current_tag: '27.1.4', next_tag: '27.1.5',
|
||||
has_update: true, digest_update: false, tag_update: true, semver_bump: 'patch', check_status: 'ok',
|
||||
}],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nextcloud',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
check_status: 'ok',
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes.',
|
||||
},
|
||||
})}
|
||||
onApply={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Newer tag/i)).toBeInTheDocument();
|
||||
expect(apply()).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
it('disables Apply when the update is blocked (major bump)', () => {
|
||||
render(
|
||||
<MobileReadinessCard
|
||||
@@ -117,9 +155,12 @@ it('offers per-service Apply when build-only companions make the stack multi-ser
|
||||
service: 'app',
|
||||
image: 'nextcloud:27',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
next_tag: '27.1.4',
|
||||
has_update: true,
|
||||
digest_update: true,
|
||||
tag_update: false,
|
||||
semver_bump: 'patch',
|
||||
check_status: 'ok',
|
||||
}],
|
||||
build_services: ['cron'],
|
||||
summary: {
|
||||
@@ -128,10 +169,11 @@ it('offers per-service Apply when build-only companions make the stack multi-ser
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
update_kind: 'digest',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: true,
|
||||
check_status: 'ok',
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes.',
|
||||
@@ -200,9 +242,12 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
service: 'app',
|
||||
image: 'nextcloud:27',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
next_tag: '27.1.4',
|
||||
has_update: true,
|
||||
digest_update: true,
|
||||
tag_update: false,
|
||||
semver_bump: 'patch' as const,
|
||||
check_status: 'ok' as const,
|
||||
},
|
||||
{
|
||||
service: 'redis',
|
||||
@@ -210,18 +255,22 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
current_tag: '7.2',
|
||||
next_tag: '7.2',
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
check_status: 'ok' as const,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nextcloud',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
next_tag: '27.1.4',
|
||||
semver_bump: 'patch' as const,
|
||||
update_kind: 'tag' as const,
|
||||
update_kind: 'digest' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
check_status: 'ok' as const,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes.',
|
||||
@@ -229,7 +278,7 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
const refreshedPreview = {
|
||||
...multiPreview,
|
||||
images: multiPreview.images.map((img) => (
|
||||
img.service === 'app' ? { ...img, has_update: false, current_tag: '27.1.5', next_tag: '27.1.5' } : img
|
||||
img.service === 'app' ? { ...img, has_update: false, digest_update: false, current_tag: '27.1.4', next_tag: '27.1.4' } : img
|
||||
)),
|
||||
summary: { ...multiPreview.summary, has_update: false, current_tag: '27.1.5' },
|
||||
};
|
||||
@@ -455,3 +504,40 @@ describe('AutoUpdateReadinessView cadence fetch race', () => {
|
||||
expect(screen.getByText(/Recheck available in/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthoritativeNegativePreview (Fleet card drop parity)', () => {
|
||||
it('drops when a checkable image has ok + no update', () => {
|
||||
expect(isAuthoritativeNegativePreview({
|
||||
images: [{ check_status: 'ok' }],
|
||||
summary: { has_update: false, check_status: 'ok' },
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('retains not_checkable-only negative previews', () => {
|
||||
expect(isAuthoritativeNegativePreview({
|
||||
images: [{ check_status: 'not_checkable' }],
|
||||
summary: { has_update: false, check_status: 'ok' },
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('clears when every image is ok even if summary check_status is omitted', () => {
|
||||
expect(isAuthoritativeNegativePreview({
|
||||
images: [{ check_status: 'ok' }],
|
||||
summary: { has_update: false },
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('retains when image check_status is missing', () => {
|
||||
expect(isAuthoritativeNegativePreview({
|
||||
images: [{}],
|
||||
summary: { has_update: false, check_status: 'ok' },
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('retains empty image lists even with ok summary', () => {
|
||||
expect(isAuthoritativeNegativePreview({
|
||||
images: [],
|
||||
summary: { has_update: false, check_status: 'ok' },
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { StackStatusEntry, MetricPoint, StackCpuSeries, StackStatusesLoadStatus } from './types';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { isConfirmedImageUpdate, isConfirmedServiceUpdate } from '@/types/imageUpdates';
|
||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||
import { classifyRow, type RowState } from './classifyRow';
|
||||
import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
@@ -119,6 +120,7 @@ export function StackHealthTable({
|
||||
const series = stackCpuSeries[name];
|
||||
const peakCpu = series?.peakValue ?? agg?.cpu ?? 0;
|
||||
const state = classifyRow(entry.status, peakCpu);
|
||||
const updateInfo = stackUpdates[file];
|
||||
return {
|
||||
file,
|
||||
name,
|
||||
@@ -132,9 +134,9 @@ export function StackHealthTable({
|
||||
runningSince: entry.runningSince ?? null,
|
||||
source: entry.source ?? 'local',
|
||||
mainPort: entry.mainPort ?? null,
|
||||
hasUpdate: stackUpdates[file]?.hasUpdate ?? false,
|
||||
outdatedServices: (stackUpdates[file]?.services ?? [])
|
||||
.filter((s) => s.hasUpdate)
|
||||
hasUpdate: updateInfo != null && isConfirmedImageUpdate(updateInfo),
|
||||
outdatedServices: (updateInfo?.services ?? [])
|
||||
.filter((s) => isConfirmedServiceUpdate(s))
|
||||
.map((s) => s.service),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import type { CheckStatus } from '@/types/imageUpdates';
|
||||
import { isConfirmedImageUpdate } from '@/types/imageUpdates';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -23,8 +24,8 @@ interface StackRowProps {
|
||||
hasUpdate: boolean;
|
||||
/** Outdated service names for the update tooltip; empty keeps the generic label. */
|
||||
outdatedServices?: string[];
|
||||
// Last image-update check outcome. 'failed' surfaces a muted "couldn't check"
|
||||
// indicator so an undeterminable check is not mistaken for "up to date".
|
||||
// Last image-update check outcome. Incomplete/failed checks with hasUpdate
|
||||
// use a distinct indicator so they are not mistaken for a confirmed update.
|
||||
checkStatus?: CheckStatus;
|
||||
lastError?: string;
|
||||
hasGitPending: boolean;
|
||||
@@ -48,6 +49,36 @@ function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function appendErrorDetail(base: string, lastError?: string): string {
|
||||
if (!lastError) return base;
|
||||
return `${base} ${lastError}`;
|
||||
}
|
||||
|
||||
function partialUpdateTooltip(hasUpdate: boolean, lastError?: string): string {
|
||||
if (hasUpdate) {
|
||||
// Neutral copy: partial + hasUpdate can mean newly detected OR retained;
|
||||
// provenance is not persisted on the wire.
|
||||
return appendErrorDetail(
|
||||
'The last check was incomplete; an update was detected or retained, but the full stack could not be verified.',
|
||||
lastError,
|
||||
);
|
||||
}
|
||||
return appendErrorDetail(
|
||||
'The last image-update check was incomplete; update status could not be fully verified.',
|
||||
lastError,
|
||||
);
|
||||
}
|
||||
|
||||
function failedCheckTooltip(hasUpdate: boolean, lastError?: string): string {
|
||||
if (hasUpdate) {
|
||||
return appendErrorDetail(
|
||||
'Previous update status retained; the last check failed.',
|
||||
lastError,
|
||||
);
|
||||
}
|
||||
return lastError ? `Update check failed: ${lastError}` : 'Update check failed';
|
||||
}
|
||||
|
||||
export function StackRow(props: StackRowProps) {
|
||||
const {
|
||||
file, displayName, status, running, total, isBusy, isActive,
|
||||
@@ -55,6 +86,10 @@ export function StackRow(props: StackRowProps) {
|
||||
bulkMode = false, isSelected = false, onToggleSelect,
|
||||
} = props;
|
||||
|
||||
const confirmedUpdate = isConfirmedImageUpdate({ hasUpdate, checkStatus });
|
||||
const partialIncomplete = checkStatus === 'partial';
|
||||
const failedCheck = checkStatus === 'failed';
|
||||
|
||||
const handleClick = () => {
|
||||
if (bulkMode) onToggleSelect?.(file);
|
||||
else onSelect(file);
|
||||
@@ -106,9 +141,9 @@ export function StackRow(props: StackRowProps) {
|
||||
{/* Stack name */}
|
||||
<span className="flex-1 truncate font-mono text-sm min-w-0">{displayName}</span>
|
||||
|
||||
{/* Fixed trailing icon slot: update dot > check-failed > git pending */}
|
||||
{/* Trailing: confirmed update > partial incomplete > failed > git pending */}
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0" data-testid="stack-row-trailing">
|
||||
{hasUpdate ? (
|
||||
{confirmedUpdate ? (
|
||||
<RowTooltip
|
||||
trigger={(
|
||||
<span className="relative inline-flex w-2 h-2" data-testid="stack-trailing-update">
|
||||
@@ -118,10 +153,15 @@ export function StackRow(props: StackRowProps) {
|
||||
)}
|
||||
label={updateAvailableLabel(outdatedServices)}
|
||||
/>
|
||||
) : checkStatus === 'failed' ? (
|
||||
) : partialIncomplete ? (
|
||||
<RowTooltip
|
||||
trigger={<span data-testid="stack-trailing-check-partial"><AlertCircle className="w-3 h-3 text-warning-foreground/80" strokeWidth={1.5} /></span>}
|
||||
label={partialUpdateTooltip(hasUpdate, lastError)}
|
||||
/>
|
||||
) : failedCheck ? (
|
||||
<RowTooltip
|
||||
trigger={<span data-testid="stack-trailing-check-failed"><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
|
||||
label={lastError ? `Update check failed: ${lastError}` : 'Update check failed'}
|
||||
label={failedCheckTooltip(hasUpdate, lastError)}
|
||||
/>
|
||||
) : hasGitPending ? (
|
||||
<RowTooltip
|
||||
|
||||
@@ -111,13 +111,34 @@ describe('StackRow', () => {
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the update dot over the check-failed indicator', () => {
|
||||
it('prefers the failed indicator over the update dot when checkStatus is failed', () => {
|
||||
const { container } = render(<StackRow {...base({ status: 'running', hasUpdate: true, checkStatus: 'failed' })} />);
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
expect(screen.getByTestId('stack-trailing-check-failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a partial indicator (not purple) for incomplete checks with hasUpdate', async () => {
|
||||
const { container } = render(<StackRow {...base({
|
||||
hasUpdate: true,
|
||||
checkStatus: 'partial',
|
||||
lastError: 'ghcr.io unreachable',
|
||||
})} />);
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
expect(screen.getByTestId('stack-trailing-check-partial')).toBeInTheDocument();
|
||||
fireEvent.pointerMove(screen.getByTestId('stack-trailing-check-partial'));
|
||||
const tips = await screen.findAllByText(/last check was incomplete/i);
|
||||
expect(tips.length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/previous result was retained/i)).toBeNull();
|
||||
expect((await screen.findAllByText(/ghcr.io unreachable/i)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the purple update dot only for confirmed ok+hasUpdate', () => {
|
||||
const { container } = render(<StackRow {...base({ hasUpdate: true, checkStatus: 'ok' })} />);
|
||||
expect(container.querySelector('.bg-update')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('names outdated services in the update tooltip', async () => {
|
||||
render(<StackRow {...base({ hasUpdate: true, outdatedServices: ['api', 'worker'] })} />);
|
||||
render(<StackRow {...base({ hasUpdate: true, checkStatus: 'ok', outdatedServices: ['api', 'worker'] })} />);
|
||||
fireEvent.pointerMove(screen.getByTestId('stack-trailing-update'));
|
||||
expect((await screen.findAllByText('Update available: api, worker')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { apiFetch } from './api';
|
||||
|
||||
export interface FetchUpdatePreviewOptions {
|
||||
/** When true (default), POST to reconcile sticky state. Falls back to GET on 404/405. */
|
||||
reconcile?: boolean;
|
||||
/** Optional node-scoped fetch (Fleet). When omitted, uses hub apiFetch. */
|
||||
fetchImpl?: (path: string, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
export interface FetchUpdatePreviewResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
preview: unknown | null;
|
||||
/** True only when POST succeeded and the body reported reconciled. */
|
||||
reconciled: boolean;
|
||||
/** True when POST was unsupported and GET was used instead. */
|
||||
usedGetFallback: boolean;
|
||||
}
|
||||
|
||||
async function asPreviewResult(
|
||||
res: Response,
|
||||
opts: { usedGetFallback: boolean; readReconciledFlag: boolean },
|
||||
): Promise<FetchUpdatePreviewResult> {
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
preview: null,
|
||||
reconciled: false,
|
||||
usedGetFallback: opts.usedGetFallback,
|
||||
};
|
||||
}
|
||||
const body = await res.json() as { reconciled?: unknown };
|
||||
return {
|
||||
ok: true,
|
||||
status: res.status,
|
||||
preview: body,
|
||||
reconciled: opts.readReconciledFlag && body.reconciled === true,
|
||||
usedGetFallback: opts.usedGetFallback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer POST /stacks/:name/update-preview (reconcile). On 404/405 only, fall
|
||||
* back to GET and treat as not reconciled. Ordinary 5xx / network errors do
|
||||
* not fall back.
|
||||
*/
|
||||
export async function fetchUpdatePreview(
|
||||
stackName: string,
|
||||
options: FetchUpdatePreviewOptions = {},
|
||||
): Promise<FetchUpdatePreviewResult> {
|
||||
const reconcile = options.reconcile !== false;
|
||||
const fetchImpl = options.fetchImpl ?? ((path: string, init?: RequestInit) => apiFetch(path, init));
|
||||
const path = `/stacks/${encodeURIComponent(stackName)}/update-preview`;
|
||||
|
||||
if (!reconcile) {
|
||||
const res = await fetchImpl(path, { method: 'GET' });
|
||||
return asPreviewResult(res, { usedGetFallback: false, readReconciledFlag: false });
|
||||
}
|
||||
|
||||
const postRes = await fetchImpl(path, { method: 'POST' });
|
||||
if (postRes.status === 404 || postRes.status === 405) {
|
||||
const getRes = await fetchImpl(path, { method: 'GET' });
|
||||
return asPreviewResult(getRes, { usedGetFallback: true, readReconciledFlag: false });
|
||||
}
|
||||
|
||||
return asPreviewResult(postRes, { usedGetFallback: false, readReconciledFlag: true });
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Shared Fleet/Anatomy gates for update-preview summaries.
|
||||
* Tag-only availability is advisory: Compose pull does not rewrite pins.
|
||||
*/
|
||||
|
||||
export interface UpdatePreviewActionImage {
|
||||
service?: string;
|
||||
has_update?: boolean;
|
||||
digest_update?: boolean;
|
||||
tag_update?: boolean;
|
||||
check_status?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdatePreviewActionSummary {
|
||||
has_update?: boolean;
|
||||
rebuild_available?: boolean;
|
||||
blocked?: boolean;
|
||||
check_status?: string | null;
|
||||
/** Present on current nodes; used for older remotes without digest/tag flags. */
|
||||
update_kind?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdatePreviewActionInput {
|
||||
images?: UpdatePreviewActionImage[];
|
||||
summary: UpdatePreviewActionSummary;
|
||||
}
|
||||
|
||||
function summaryCheckOk(summary: UpdatePreviewActionSummary): boolean {
|
||||
return (summary.check_status ?? 'ok') === 'ok';
|
||||
}
|
||||
|
||||
function imageParityFlagsPresent(images: UpdatePreviewActionImage[]): boolean {
|
||||
return images.some((i) => i.digest_update !== undefined || i.tag_update !== undefined);
|
||||
}
|
||||
|
||||
/** True when the stack has a Compose-executable update (digest drift or rebuild). */
|
||||
export function hasExecutableUpdate(preview: UpdatePreviewActionInput | null | undefined): boolean {
|
||||
if (!preview) return false;
|
||||
if (preview.summary.rebuild_available) return true;
|
||||
const images = preview.images ?? [];
|
||||
if (images.some((i) => i.digest_update === true)) return true;
|
||||
// Older remotes omit digest_update/tag_update; fall back to update_kind.
|
||||
if (!imageParityFlagsPresent(images)) {
|
||||
return Boolean(preview.summary.has_update && preview.summary.update_kind !== 'tag');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when only newer tags exist (no digest/rebuild action Compose can apply). */
|
||||
export function isTagOnlyAdvisory(preview: UpdatePreviewActionInput | null | undefined): boolean {
|
||||
if (!preview?.summary.has_update) return false;
|
||||
if (preview.summary.rebuild_available) return false;
|
||||
const images = preview.images ?? [];
|
||||
if (images.some((i) => i.digest_update === true)) return false;
|
||||
if (images.some((i) => i.tag_update === true)) return true;
|
||||
if (!imageParityFlagsPresent(images)) {
|
||||
return preview.summary.update_kind === 'tag';
|
||||
}
|
||||
return images.some((i) => i.has_update === true);
|
||||
}
|
||||
|
||||
/** Confirmed update or rebuild that may be applied from Fleet. */
|
||||
export function isActionableUpdatePreview(
|
||||
preview: UpdatePreviewActionInput | null | undefined,
|
||||
): boolean {
|
||||
if (!preview) return false;
|
||||
if (preview.summary.blocked) return false;
|
||||
if (!summaryCheckOk(preview.summary)) return false;
|
||||
return hasExecutableUpdate(preview);
|
||||
}
|
||||
|
||||
/** Per-service Apply: digest update for that service. */
|
||||
export function isServiceApplyActionable(
|
||||
preview: UpdatePreviewActionInput | null | undefined,
|
||||
serviceName: string,
|
||||
): boolean {
|
||||
if (!preview) return false;
|
||||
if (preview.summary.blocked) return false;
|
||||
if (!summaryCheckOk(preview.summary)) return false;
|
||||
const match = (preview.images ?? []).find((i) => i.service === serviceName);
|
||||
if (!match) return false;
|
||||
if (match.digest_update === true) return true;
|
||||
if (match.digest_update !== undefined || match.tag_update !== undefined) return false;
|
||||
return Boolean(match.has_update && preview.summary.update_kind !== 'tag');
|
||||
}
|
||||
|
||||
export function isPreviewUncertain(preview: UpdatePreviewActionInput | null | undefined): boolean {
|
||||
if (!preview) return false;
|
||||
const status = preview.summary.check_status;
|
||||
return status === 'partial' || status === 'failed';
|
||||
}
|
||||
@@ -63,3 +63,36 @@ export interface StackUpdateInfo {
|
||||
/** Per-service breakdown; absent when the stack has no persisted per-service data yet. */
|
||||
services?: StackServiceUpdateStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed update for sidebar / Updates filter / dashboard / Fleet.
|
||||
* Missing checkStatus is treated as 'ok' for older-node /detail fallbacks.
|
||||
* Partial or failed rows with hasUpdate=true are NOT confirmed.
|
||||
*/
|
||||
export function isConfirmedImageUpdate(info: { hasUpdate: boolean; checkStatus?: CheckStatus }): boolean {
|
||||
return info.hasUpdate && (info.checkStatus ?? 'ok') === 'ok';
|
||||
}
|
||||
|
||||
/** Confirmed per-service update for editor Update badges. */
|
||||
export function isConfirmedServiceUpdate(info: {
|
||||
hasUpdate: boolean;
|
||||
checkStatus?: ServiceCheckStatus;
|
||||
}): boolean {
|
||||
return info.hasUpdate && (info.checkStatus ?? 'ok') === 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a live update-preview is safe to treat as "no update" for Fleet
|
||||
* card drops and similar UI reconcile. Mirrors backend
|
||||
* UpdatePreviewService.isAuthoritativeNegativePreview: every image must be
|
||||
* explicitly ok, and !has_update. Empty / mixed not_checkable previews never clear.
|
||||
*/
|
||||
export function isAuthoritativeNegativePreview(preview: {
|
||||
images: Array<{ check_status?: string | null }>;
|
||||
summary: { has_update: boolean; check_status?: string | null };
|
||||
} | null | undefined): boolean {
|
||||
if (!preview) return false;
|
||||
return preview.images.length > 0
|
||||
&& preview.images.every((i) => i.check_status === 'ok')
|
||||
&& preview.summary.has_update === false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user