feat: add service-scoped Compose update and restore (#1648)

* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
Anso
2026-07-19 02:42:29 -04:00
committed by GitHub
parent 31d4e4669b
commit 63213c0960
89 changed files with 7608 additions and 331 deletions
@@ -428,6 +428,83 @@ describe('DockerController - pruneDanglingImages', () => {
});
});
// ── Prune holds (): images held for a pending service-update
// recovery must never be deleted by any image-prune path. ──────────────
describe('DockerController - prune holds (isImageHeld)', () => {
it('pruneSystem("images") falls back to enumerate-and-delete when isImageHeld is given, skipping the held image', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-held', RepoTags: ['app:1'], Containers: 0 },
{ Id: 'img-free', RepoTags: ['app:2'], Containers: 0 },
]);
mockDocker.df
.mockResolvedValueOnce({ LayersSize: 5000 })
.mockResolvedValueOnce({ LayersSize: 3000 });
const removeFn = vi.fn().mockResolvedValue(undefined);
mockDocker.getImage.mockReturnValue({ remove: removeFn });
const dc = DockerController.getInstance(1);
const result = await dc.pruneSystem('images', undefined, (id: string) => id === 'img-held');
expect(mockDocker.pruneImages).not.toHaveBeenCalled();
expect(removeFn).toHaveBeenCalledTimes(1);
expect(mockDocker.getImage).toHaveBeenCalledWith('img-free');
expect(result).toEqual({ success: true, reclaimedBytes: 2000 });
});
it('pruneDanglingImages falls back to enumerate-and-delete when isImageHeld is given, skipping the held image', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-held', RepoTags: [], Containers: 0 },
{ Id: 'img-free', RepoTags: ['<none>:<none>'], Containers: 0 },
{ Id: 'img-tagged', RepoTags: ['app:1'], Containers: 0 }, // not dangling; excluded regardless of hold
]);
mockDocker.df
.mockResolvedValueOnce({ LayersSize: 5000 })
.mockResolvedValueOnce({ LayersSize: 4000 });
const removeFn = vi.fn().mockResolvedValue(undefined);
mockDocker.getImage.mockReturnValue({ remove: removeFn });
const dc = DockerController.getInstance(1);
const result = await dc.pruneDanglingImages((id: string) => id === 'img-held');
expect(mockDocker.pruneImages).not.toHaveBeenCalled();
expect(removeFn).toHaveBeenCalledTimes(1);
expect(mockDocker.getImage).toHaveBeenCalledWith('img-free');
expect(result).toEqual({ success: true, reclaimedBytes: 1000 });
});
it('pruneManagedOnly("images") re-checks isImageHeld immediately before each delete', async () => {
mockDocker.df
.mockResolvedValueOnce({ LayersSize: 5000, Images: [] })
.mockResolvedValueOnce({ LayersSize: 4000, Images: [] });
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-held', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-free', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
const removeFn = vi.fn().mockResolvedValue(undefined);
mockDocker.getImage.mockReturnValue({ remove: removeFn });
const dc = DockerController.getInstance(1);
const result = await dc.pruneManagedOnly('images', ['any-stack'], (id: string) => id === 'img-held');
expect(removeFn).toHaveBeenCalledTimes(1);
expect(mockDocker.getImage).toHaveBeenCalledWith('img-free');
expect(result.success).toBe(true);
});
it('pruneSystem("images") keeps the bulk Docker prune API when no isImageHeld predicate is given', async () => {
mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 999 });
const dc = DockerController.getInstance(1);
const result = await dc.pruneSystem('images');
expect(mockDocker.pruneImages).toHaveBeenCalled();
expect(mockDocker.listImages).not.toHaveBeenCalled();
expect(result).toEqual({ success: true, reclaimedBytes: 999 });
});
});
// ── getClassifiedResources ─────────────────────────────────────────────
describe('DockerController - getClassifiedResources', () => {