Files
sencho/backend/src/__tests__/docker-controller.test.ts
T
Anso 41bf075eb0 feat(recovery): make rollback-recovery image lifecycle visible and controllable (#1753)
* feat(recovery): make rollback-recovery image lifecycle visible and controllable

GitHub discussion #1751 asked why Sencho creates sencho-rb/<id>/<service>:hold
images during automatic updates and how to clean them up. That surfaced a real
safety bug alongside the missing visibility: the manual single-image delete
route did not consult the held-image predicate every other deletion path
already honors, so a user could delete a rollback-protected image straight
through the Images tab and silently break automatic recovery for that update.
A short/truncated id also bypassed the predicate's full-id lookup.

Fixes:
- POST /images/delete now resolves the submitted id to its canonical form and
  checks the unified held-image predicate before deleting, returning 409
  IMAGE_HELD_FOR_ROLLBACK for a protected image.
- The Images tab no longer mislabels a protected image as plain "Unused"; a
  fully-synthetic hold image is kept out of the generic inventory entirely and
  surfaced instead in a new Resources -> Rollback tab, with an additive
  "Rollback protected" badge for images that still carry a normal tag too.

New capability:
- Two settings (Deploy Guardrails): superseded-generation retention (days,
  replaces a hardcoded 7) and a cap on retained generations per stack.
- A new Resources -> Rollback tab lists every generation (stack, short id,
  state, retention) with an admin-gated manual release action, including
  releasing the current generation with an explicit warning that automatic
  rollback becomes unavailable until the next successful update. Release is
  a single atomic, server-revalidated transition so a stale UI read can never
  release a row that has since become ineligible.

Also consolidated three near-duplicate implementations of the held-image
predicate (two of which relied on a require() of a sibling .ts file that
silently failed to resolve under the test runner and was never actually
exercised by a real test before this change) into one shared module.

Known follow-up, not fixed here: an orphaned sencho-rb tag whose recovery row
no longer exists (DB restore, node re-add) is invisible in both the Images
and Rollback tabs with no UI path to reclaim it.

* fix(audit): add summary mapping for rollback generation release

* fix(security): sanitize prune target in log sinks and cover release RBAC

Closes two open js/log-injection findings on the system prune route by
applying the same inline sanitizeForLog barrier the rest of the file
already uses. The prune target is validated against an enum by
parsePruneTargets before reaching these sinks, so the findings were false
positives, but the barrier is cheap and removes the standing alerts on a
file this change already touches. Also wraps the generation id in the
release log line for consistency with the stack name beside it.

Adds coverage for gaps a QA pass identified:
- Release endpoint refuses a viewer and a deployer (Admin-only), leaving
  the generation and its artifacts untouched.
- Viewer can still read the generations list, matching the sibling
  Resources routes.
- The predicate the prune routes build reports full-stack rollback holds,
  not just service-scoped ones, and re-reads per call so a hold taken
  between plan and delete still gates the delete.
- After releasing the current generation, no rollback point is claimed
  for the stack through any consumer of the current-generation lookup.
2026-08-02 21:55:22 -04:00

2039 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for DockerController — validateApiData, state-safe container ops,
* disk usage, classified resources, orphan detection, and error paths.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockDocker, composeDirRef, mockExecFileAsync } = vi.hoisted(() => {
const mockDocker = {
df: vi.fn(),
listImages: vi.fn().mockResolvedValue([]),
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
listNetworks: vi.fn().mockResolvedValue([]),
listContainers: vi.fn().mockResolvedValue([]),
getContainer: vi.fn(),
getImage: vi.fn(),
getVolume: vi.fn(),
getNetwork: vi.fn(),
pruneContainers: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
pruneImages: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
pruneNetworks: vi.fn().mockResolvedValue({}),
pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
createNetwork: vi.fn(),
};
return {
mockDocker,
composeDirRef: { current: '/test/compose' },
mockExecFileAsync: vi.fn(),
};
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDocker: () => mockDocker,
getDefaultNodeId: () => 1,
getComposeDir: () => composeDirRef.current,
}),
},
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getGitSource: () => undefined,
getStackProjectEnvFiles: () => [],
}),
},
}));
// Prevent COMPOSE_DIR related issues
vi.mock('child_process', () => ({
exec: vi.fn(),
execFile: vi.fn(),
}));
vi.mock('util', () => ({
promisify: () => mockExecFileAsync,
}));
import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController';
import { CacheService } from '../services/CacheService';
import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService';
import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
beforeEach(() => {
vi.clearAllMocks();
composeDirRef.current = '/test/compose';
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
});
// ── validateApiData ────────────────────────────────────────────────────
describe('DockerController - validateApiData', () => {
it('throws when response is a string (HTML from wrong port)', async () => {
mockDocker.listImages.mockResolvedValue('<html>Not Docker</html>');
const dc = DockerController.getInstance(1);
await expect(dc.getImages()).rejects.toThrow('Invalid response from Docker API');
});
it('passes through valid object data', async () => {
const imageData = [{ Id: 'sha256:abc', RepoTags: ['nginx:latest'], Size: 100 }];
mockDocker.listImages.mockResolvedValue(imageData);
const dc = DockerController.getInstance(1);
const result = await dc.getImages();
expect(result).toEqual(imageData);
});
});
// ── State-safe container operations ────────────────────────────────────
describe('DockerController - startContainer', () => {
it('starts a container successfully', async () => {
const mockStart = vi.fn().mockResolvedValue(undefined);
mockDocker.getContainer.mockReturnValue({ start: mockStart });
const dc = DockerController.getInstance(1);
await dc.startContainer('abc123');
expect(mockStart).toHaveBeenCalled();
});
it('silently ignores 304 already-started error', async () => {
const mockStart = vi.fn().mockRejectedValue({ statusCode: 304 });
mockDocker.getContainer.mockReturnValue({ start: mockStart });
const dc = DockerController.getInstance(1);
await expect(dc.startContainer('abc123')).resolves.toBeUndefined();
});
it('rethrows other errors', async () => {
const mockStart = vi.fn().mockRejectedValue(new Error('container not found'));
mockDocker.getContainer.mockReturnValue({ start: mockStart });
const dc = DockerController.getInstance(1);
await expect(dc.startContainer('abc123')).rejects.toThrow('container not found');
});
});
describe('DockerController - stopContainer', () => {
it('stops a container successfully', async () => {
const mockStop = vi.fn().mockResolvedValue(undefined);
mockDocker.getContainer.mockReturnValue({ stop: mockStop });
const dc = DockerController.getInstance(1);
await dc.stopContainer('abc123');
expect(mockStop).toHaveBeenCalled();
});
it('silently ignores 304 already-stopped error', async () => {
const mockStop = vi.fn().mockRejectedValue({ statusCode: 304 });
mockDocker.getContainer.mockReturnValue({ stop: mockStop });
const dc = DockerController.getInstance(1);
await expect(dc.stopContainer('abc123')).resolves.toBeUndefined();
});
it('rethrows other errors', async () => {
const err = new Error('permission denied');
const mockStop = vi.fn().mockRejectedValue(err);
mockDocker.getContainer.mockReturnValue({ stop: mockStop });
const dc = DockerController.getInstance(1);
await expect(dc.stopContainer('abc123')).rejects.toThrow('permission denied');
});
});
// ── removeContainers ───────────────────────────────────────────────────
describe('DockerController - removeContainers', () => {
it('removes multiple containers and returns results', async () => {
const mockRemove = vi.fn().mockResolvedValue(undefined);
mockDocker.getContainer.mockReturnValue({ remove: mockRemove });
const dc = DockerController.getInstance(1);
const results = await dc.removeContainers(['c1', 'c2']);
expect(results).toEqual([
{ id: 'c1', success: true },
{ id: 'c2', success: true },
]);
});
it('returns failure result for containers that cannot be removed', async () => {
let callCount = 0;
mockDocker.getContainer.mockImplementation(() => ({
remove: vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 2) throw new Error('in use');
return Promise.resolve();
}),
}));
const dc = DockerController.getInstance(1);
const results = await dc.removeContainers(['c1', 'c2']);
expect(results[0]).toEqual({ id: 'c1', success: true });
expect(results[1]).toMatchObject({ id: 'c2', success: false, error: 'in use' });
});
});
// ── getDiskUsage ───────────────────────────────────────────────────────
describe('DockerController - getDiskUsage', () => {
it('calculates reclaimable space correctly', async () => {
mockDocker.df.mockResolvedValue({
LayersSize: 800, // total image-layer bytes on disk
Images: [
{ Id: 'img1', Containers: 0, Size: 500, SharedSize: 0 }, // reclaimable (unused)
{ Id: 'img2', Containers: 1, Size: 300, SharedSize: 0 }, // not reclaimable (in use); used = 300
],
Containers: [
{ State: 'running', SizeRw: 100 }, // not reclaimable (running)
{ State: 'exited', SizeRw: 200 }, // reclaimable (stopped)
],
Volumes: [
{ UsageData: { RefCount: 0, Size: 400 } }, // reclaimable (unused)
{ UsageData: { RefCount: 1, Size: 300 } }, // not reclaimable (in use)
],
BuildCache: [
{ ID: 'bc1', InUse: false, Size: 600 }, // reclaimable
{ ID: 'bc2', InUse: true, Size: 250 }, // not reclaimable
],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
// 800 LayersSize - 300 used-by-in-use-image = 500 reclaimable
expect(usage.reclaimableImages).toBe(500);
expect(usage.reclaimableImageCount).toBe(1);
expect(usage.reclaimableContainers).toBe(200);
expect(usage.reclaimableVolumes).toBe(400);
expect(usage.reclaimableBuildCache).toBe(600);
expect(usage.reclaimableBuildCacheCount).toBe(1);
});
it('uses daemon-reported ImageUsage.Reclaimable when present (API v1.44+)', async () => {
// Modern Docker daemons compute Reclaimable server-side and ship the exact
// value `docker system df` displays. Trust it over any client-side fallback.
mockDocker.df.mockResolvedValue({
LayersSize: 10_000_000_000,
ImageUsage: { Reclaimable: 7_837_305_085, TotalSize: 10_000_000_000, ActiveCount: 11, TotalCount: 33 },
Images: [
{ Id: 'a', Containers: 0, Size: 5_000_000_000, SharedSize: 0 }, // a fallback formula would say 5G
{ Id: 'b', Containers: 1, Size: 3_000_000_000, SharedSize: 0 },
],
Containers: [],
Volumes: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
expect(usage.reclaimableImages).toBe(7_837_305_085);
expect(usage.reclaimableImageCount).toBe(1);
});
it('does not double-count shared layers across unused images', async () => {
// Three images sharing a 400MB base layer: two unused (800MB virtual each),
// one in-use (600MB virtual). Total on-disk: 1GB. The in-use image holds
// its unique 200MB; pruning the two unused frees the remaining 800MB.
// The previous formula summed VirtualSize and would have returned 1.6GB
// (impossibly larger than LayersSize).
mockDocker.df.mockResolvedValue({
LayersSize: 1_000_000_000,
Images: [
{ Id: 'a', Containers: 0, VirtualSize: 800_000_000, SharedSize: 400_000_000 },
{ Id: 'b', Containers: 0, VirtualSize: 800_000_000, SharedSize: 400_000_000 },
{ Id: 'c', Containers: 1, VirtualSize: 600_000_000, SharedSize: 400_000_000 },
],
Containers: [],
Volumes: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
expect(usage.reclaimableImages).toBe(800_000_000);
expect(usage.reclaimableImageCount).toBe(2);
});
it('skips active images only when no usable size is available', async () => {
// Truly unaccountable image: both VirtualSize and Size are -1 / missing.
// Skipping leaks at most one image's worth of bytes into the reclaim
// total, but modern daemons never return this shape; the prior "always
// skip on -1" path moved any image with SharedSize=-1 into the leak set.
mockDocker.df.mockResolvedValue({
LayersSize: 1000,
Images: [
{ Id: 'known', Containers: 1, VirtualSize: 400, SharedSize: 100 },
{ Id: 'truly-lost', Containers: 1, VirtualSize: -1, Size: -1 },
],
Containers: [],
Volumes: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
// truly-lost is unaccountable and skipped; used = 400 - 100 = 300
expect(usage.reclaimableImages).toBe(700);
expect(usage.reclaimableImageCount).toBe(0);
});
it('treats SharedSize=-1 conservatively (full Size counts as used)', async () => {
// Older daemons may report SharedSize as -1 (unknown) while Size is
// accurate. Treating SharedSize as 0 in that case under-reports
// reclaimable, which is the safe direction; the prior skip-on-(-1)
// path made the image's full Size look reclaimable.
mockDocker.df.mockResolvedValue({
LayersSize: 1000,
Images: [
{ Id: 'modern', Containers: 1, Size: 400, SharedSize: 100 },
{ Id: 'no-shared-info', Containers: 1, Size: 300, SharedSize: -1 },
],
Containers: [],
Volumes: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
// modern: used += 400 - 100 = 300
// no-shared-info: shared treated as 0, used += 300 - 0 = 300; total = 600
// (the old buggy formula skipped no-shared-info, leaving used=300 and
// reclaimable=700 — i.e. the in-use 300 bytes looked reclaimable)
expect(usage.reclaimableImages).toBe(400);
});
it('handles empty arrays gracefully', async () => {
mockDocker.df.mockResolvedValue({
Images: [],
Containers: [],
Volumes: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
expect(usage.reclaimableImages).toBe(0);
expect(usage.reclaimableContainers).toBe(0);
expect(usage.reclaimableVolumes).toBe(0);
});
it('handles missing fields gracefully', async () => {
mockDocker.df.mockResolvedValue({});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
expect(usage.reclaimableImages).toBe(0);
expect(usage.reclaimableContainers).toBe(0);
expect(usage.reclaimableVolumes).toBe(0);
});
it('counts only prune-eligible container states (created/exited/dead)', async () => {
// `docker container prune` removes stopped containers (created/exited/dead).
// Paused and restarting containers survive it, so counting them would leave
// a residue the banner can never clear no matter which prune runs.
mockDocker.df.mockResolvedValue({
Images: [],
Volumes: [],
Containers: [
{ State: 'exited', SizeRw: 200 }, // prunable
{ State: 'created', SizeRw: 50 }, // prunable
{ State: 'dead', SizeRw: 25 }, // prunable
{ State: 'paused', SizeRw: 1000 }, // survives prune
{ State: 'restarting', SizeRw: 1000 }, // survives prune
{ State: 'running', SizeRw: 1000 }, // in use
],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
expect(usage.reclaimableContainers).toBe(275);
expect(usage.reclaimableContainerCount).toBe(3);
});
it('sizes stopped containers by the writable layer (SizeRw), not SizeRootFs', async () => {
// SizeRootFs includes the read-only image layers, which removing a
// container never frees. A stopped container that wrote nothing reclaims 0.
mockDocker.df.mockResolvedValue({
Images: [],
Volumes: [],
Containers: [
{ State: 'exited', SizeRw: 0, SizeRootFs: 500_000_000 }, // wrote nothing
{ State: 'exited', SizeRw: 1_500, SizeRootFs: 900_000 }, // writable layer only
],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
expect(usage.reclaimableContainers).toBe(1_500);
expect(usage.reclaimableContainerCount).toBe(2);
});
});
// ── pruneSystem ────────────────────────────────────────────────────────
describe('DockerController - pruneSystem', () => {
it('prunes containers and returns reclaimed bytes', async () => {
mockDocker.pruneContainers.mockResolvedValue({ SpaceReclaimed: 1024 });
const dc = DockerController.getInstance(1);
const result = await dc.pruneSystem('containers');
expect(result).toEqual({ success: true, reclaimedBytes: 1024 });
});
it('prunes images with dangling false filter', async () => {
mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 2048 });
const dc = DockerController.getInstance(1);
await dc.pruneSystem('images');
expect(mockDocker.pruneImages).toHaveBeenCalledWith({
filters: expect.objectContaining({ dangling: { 'false': true } }),
});
});
it('includes label filter when provided', async () => {
mockDocker.pruneContainers.mockResolvedValue({ SpaceReclaimed: 0 });
const dc = DockerController.getInstance(1);
await dc.pruneSystem('containers', 'com.example=true');
expect(mockDocker.pruneContainers).toHaveBeenCalledWith({
filters: { label: ['com.example=true'] },
});
});
it('prunes volumes with all true filter', async () => {
mockDocker.pruneVolumes.mockResolvedValue({ SpaceReclaimed: 4096 });
const dc = DockerController.getInstance(1);
await dc.pruneSystem('volumes');
expect(mockDocker.pruneVolumes).toHaveBeenCalledWith({
filters: { all: ['true'] },
});
});
});
// ── pruneDanglingImages ────────────────────────────────────────────────
describe('DockerController - pruneDanglingImages', () => {
it('prunes only dangling images and returns reclaimed bytes', async () => {
mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 5000 });
const dc = DockerController.getInstance(1);
const result = await dc.pruneDanglingImages();
// dangling:true keeps the prune to untagged layers, unlike pruneSystem('images')
// which uses dangling:false to remove every unused image.
expect(mockDocker.pruneImages).toHaveBeenCalledWith({
filters: { dangling: { 'true': true } },
});
expect(result).toEqual({ success: true, reclaimedBytes: 5000 });
});
});
// ── 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', () => {
beforeEach(() => {
CacheService.getInstance().invalidate('project-name-map');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('classifies managed and unmanaged images', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img1', RepoTags: ['nginx:latest'], Size: 100, Containers: 1 },
{ Id: 'img2', RepoTags: ['redis:latest'], Size: 200, Containers: 1 },
{ Id: 'img3', RepoTags: ['old:v1'], Size: 50, Containers: 0 },
]);
mockDocker.listContainers.mockResolvedValue([
{ ImageID: 'img1', Labels: { 'com.docker.compose.project': 'my-stack' } },
{ ImageID: 'img2', Labels: { 'com.docker.compose.project': 'unknown-stack' } },
]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
const managed = result.images.find(i => i.Id === 'img1');
expect(managed!.managedStatus).toBe('managed');
expect(managed!.managedBy).toBe('my-stack');
expect(managed!.usedByStacks).toEqual(['my-stack']);
const unmanaged = result.images.find(i => i.Id === 'img2');
expect(unmanaged!.managedStatus).toBe('unmanaged');
expect(unmanaged!.usedByStacks).toEqual([]);
const unused = result.images.find(i => i.Id === 'img3');
expect(unused!.managedStatus).toBe('unused');
expect(unused!.usedByStacks).toEqual([]);
});
it('aggregates multi-stack usedByStacks with stable sort and managedBy first entry', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-shared', RepoTags: ['postgres:16'], Size: 100, Containers: 2 },
]);
mockDocker.listContainers.mockResolvedValue([
{ ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'zeta' } },
{ ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } },
{ ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } },
]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['alpha', 'zeta']);
const img = result.images.find(i => i.Id === 'img-shared');
expect(img!.usedByStacks).toEqual(['alpha', 'zeta']);
expect(img!.managedBy).toBe('alpha');
expect(img!.managedStatus).toBe('managed');
});
it('classifies system networks', async () => {
mockDocker.listImages.mockResolvedValue([]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'bridge', Driver: 'bridge', Scope: 'local' },
{ Id: 'n2', Name: 'host', Driver: 'host', Scope: 'local' },
{ Id: 'n3', Name: 'none', Driver: 'null', Scope: 'local' },
{ Id: 'n4', Name: 'my-stack_default', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'my-stack' } },
]);
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
expect(result.networks.filter(n => n.managedStatus === 'system')).toHaveLength(3);
expect(result.networks.find(n => n.Name === 'my-stack_default')!.managedStatus).toBe('managed');
});
it('classifies managed and unmanaged volumes', async () => {
mockDocker.listImages.mockResolvedValue([]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({
Volumes: [
{ Name: 'my-stack_data', Driver: 'local', Mountpoint: '/var/lib/docker/volumes/my-stack_data', Labels: { 'com.docker.compose.project': 'my-stack' } },
{ Name: 'random_vol', Driver: 'local', Mountpoint: '/var/lib/docker/volumes/random', Labels: {} },
],
});
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
expect(result.volumes.find(v => v.Name === 'my-stack_data')!.managedStatus).toBe('managed');
expect(result.volumes.find(v => v.Name === 'random_vol')!.managedStatus).toBe('unmanaged');
});
it('excludes an image whose only tag is a synthetic sencho-rb rollback hold', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-hold-only', RepoTags: ['sencho-rb/abc123456789/web:hold'], Size: 50, Containers: 0 },
{ Id: 'img-normal', RepoTags: ['nginx:latest'], Size: 100, Containers: 0 },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
expect(result.images.find(i => i.Id === 'img-hold-only')).toBeUndefined();
expect(result.images.find(i => i.Id === 'img-normal')).toBeDefined();
});
it('keeps an image visible when it carries both a normal tag and a sencho-rb hold tag', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-multi-tag', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
const img = result.images.find(i => i.Id === 'img-multi-tag');
expect(img).toBeDefined();
expect(img!.RepoTags).toEqual(['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold']);
});
it('marks an image rollbackProtected with kind "stack" when StackUpdateRecoveryService holds it', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-stack-held', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-stack-held']));
vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
const img = result.images.find(i => i.Id === 'img-stack-held');
expect(img?.rollbackProtected).toBe(true);
expect(img?.rollbackProtectionKind).toBe('stack');
});
it('marks an image rollbackProtected with kind "service" when only ServiceUpdateRecoveryService holds it', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-service-held', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-service-held']));
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
const img = result.images.find(i => i.Id === 'img-service-held');
expect(img?.rollbackProtected).toBe(true);
expect(img?.rollbackProtectionKind).toBe('service');
});
it('fails closed (marks every image rollbackProtected) when a held-image lookup fails', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-unrelated', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
// getHeldImageIds returns null when its own DB lookup fails (already logs
// internally); the badge must fail the same direction as the delete guard
// (recoveryHeldImages.ts), not the opposite.
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
const dc = DockerController.getInstance(1);
const result = await dc.getClassifiedResources(['my-stack']);
const img = result.images.find(i => i.Id === 'img-unrelated');
expect(img?.rollbackProtected).toBe(true);
});
});
// ── pruneManagedOnly / estimateManagedReclaim (images) ─────────────────
describe('DockerController - managed image prune accounting', () => {
// Two unused images each report a 1000-byte rolled-up Size, but 400 of those
// bytes live in a layer shared with a third (in-use) image, so removing
// the unused pair only frees the 600 unique bytes from each. The buggy
// sum-of-Size accounting would have reported 2000; the fix should report
// 1200 (600 × 2).
const sharedLayerDfMock = {
Images: [
{ Id: 'img-a', SharedSize: 400 },
{ Id: 'img-b', SharedSize: 400 },
{ Id: 'img-c', SharedSize: 400 },
],
};
const unusedManagedListMock = [
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-c', Containers: 1, Size: 800 }, // in-use; filtered by Containers===0
];
it('estimateManagedReclaim subtracts SharedSize per prunable image', async () => {
mockDocker.df.mockResolvedValue(sharedLayerDfMock);
mockDocker.listImages.mockResolvedValue(unusedManagedListMock);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.estimateManagedReclaim('images', ['any-stack']);
expect(result.reclaimableBytes).toBe(1200);
});
it('pruneManagedOnly reports the LayersSize delta as the reclaimed total', async () => {
// df-before reports 5000 bytes on disk; df-after reports 3400. The
// honest reclaim is 1600, independent of per-image Size/SharedSize.
// Two prunable images that share a layer exclusively would be
// undercounted by the per-image formula (1200) but Docker actually
// frees the shared layer too.
mockDocker.df
.mockResolvedValueOnce({
LayersSize: 5000,
Images: [
{ Id: 'img-a', SharedSize: 400 },
{ Id: 'img-b', SharedSize: 400 },
],
})
.mockResolvedValueOnce({ LayersSize: 3400, Images: [] });
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', 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']);
expect(result).toEqual({ success: true, reclaimedBytes: 1600 });
expect(removeFn).toHaveBeenCalledTimes(2);
});
it('pruneManagedOnly falls back to per-image lower bound using before-snapshot when after-df fails', async () => {
// Before-snapshot succeeded (SharedSize known); after-snapshot failed.
// Fall back to Σ(Size - SharedSize) over the prunable set: a
// conservative lower bound, but defensible because each pruned image's
// SharedSize was known at the start.
mockDocker.df
.mockResolvedValueOnce({
Images: [
{ Id: 'img-a', SharedSize: 400 },
{ Id: 'img-b', SharedSize: 400 },
],
})
.mockRejectedValueOnce(new Error('df unavailable after prune'));
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) });
const dc = DockerController.getInstance(1);
const result = await dc.pruneManagedOnly('images', ['any-stack']);
// (1000 - 400) + (1000 - 400) = 1200
expect(result).toEqual({ success: true, reclaimedBytes: 1200 });
});
it('pruneManagedOnly reports 0 reclaimed when both df snapshots fail', async () => {
// Without the before-snapshot we cannot build a meaningful per-image
// bound (after-only has stale image IDs missing from its view), so the
// destructive path completes the removes and reports 0 rather than a
// misleading approximation.
mockDocker.df
.mockRejectedValueOnce(new Error('df unavailable before'))
.mockRejectedValueOnce(new Error('df unavailable after'));
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', 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']);
expect(result).toEqual({ success: true, reclaimedBytes: 0 });
expect(removeFn).toHaveBeenCalledTimes(1);
});
it('estimateManagedReclaim under-reports when layers are shared exclusively between prunable images', async () => {
// Two prunable images that share a 400-byte layer with no retained
// image. Docker would free that layer once on prune (1600 bytes total:
// each image's unique 600 plus the shared 400). The estimate formula
// subtracts 400 from each image and reports 1200, a conservative
// lower bound documented in the JSDoc.
mockDocker.df.mockResolvedValue({
Images: [
{ Id: 'img-a', SharedSize: 400 },
{ Id: 'img-b', SharedSize: 400 },
],
});
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.estimateManagedReclaim('images', ['any-stack']);
expect(result.reclaimableBytes).toBe(1200);
});
it('invariant: pruneManagedOnly reports >= estimateManagedReclaim on the same inputs', async () => {
// The estimate is a conservative lower bound; the destructive path
// reports the truth (df-delta). On any input, destructive >= estimate.
// This locks the contract so future changes to either formula cannot
// accidentally flip the direction.
const dfBefore = {
LayersSize: 5000,
Images: [
{ Id: 'img-a', SharedSize: 400 },
{ Id: 'img-b', SharedSize: 400 },
],
};
const dfAfter = { LayersSize: 3400, Images: [] };
const listImages = [
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
];
// Estimate path: df called once.
mockDocker.df.mockResolvedValueOnce(dfBefore);
mockDocker.listImages.mockResolvedValue(listImages);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const estimate = await dc.estimateManagedReclaim('images', ['any-stack']);
// Reset the df mock for the destructive path: before then after.
mockDocker.df.mockReset();
mockDocker.df.mockResolvedValueOnce(dfBefore).mockResolvedValueOnce(dfAfter);
mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) });
const prune = await dc.pruneManagedOnly('images', ['any-stack']);
expect(prune.reclaimedBytes).toBeGreaterThanOrEqual(estimate.reclaimableBytes);
});
it('treats images missing from the df map as having no shared layers', async () => {
// df reports only img-a; img-b is omitted (stale / race / older daemon).
// The accounting should still process img-b, defaulting its SharedSize to 0.
mockDocker.df.mockResolvedValue({ Images: [{ Id: 'img-a', SharedSize: 400 }] });
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.estimateManagedReclaim('images', ['any-stack']);
// img-a: 1000-400=600; img-b: 1000-0=1000; total 1600
expect(result.reclaimableBytes).toBe(1600);
});
it('degrades to no-sharing assumption when df fails', async () => {
// If df throws, the helper returns an empty map and the accounting falls
// back to full per-image Size. This preserves prior behavior (over-report)
// rather than failing the prune.
mockDocker.df.mockRejectedValue(new Error('daemon df failed'));
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.estimateManagedReclaim('images', ['any-stack']);
expect(result.reclaimableBytes).toBe(2000);
});
});
// ── getOrphanContainers ────────────────────────────────────────────────
describe('DockerController - getOrphanContainers', () => {
it('returns containers whose project label is not in known stacks', async () => {
mockDocker.listContainers.mockResolvedValue([
{ Id: 'c1', Names: ['/c1'], State: 'running', Status: 'Up', Image: 'nginx', Labels: { 'com.docker.compose.project': 'orphan-stack' } },
{ Id: 'c2', Names: ['/c2'], State: 'running', Status: 'Up', Image: 'redis', Labels: { 'com.docker.compose.project': 'known-stack' } },
]);
const dc = DockerController.getInstance(1);
const result = await dc.getOrphanContainers(['known-stack']);
expect(result['orphan-stack']).toHaveLength(1);
expect(result['orphan-stack'][0].Id).toBe('c1');
expect(result['known-stack']).toBeUndefined();
});
it('returns empty when all containers belong to known stacks', async () => {
mockDocker.listContainers.mockResolvedValue([
{ Id: 'c1', Names: ['/c1'], State: 'running', Status: 'Up', Image: 'nginx', Labels: { 'com.docker.compose.project': 'my-stack' } },
]);
const dc = DockerController.getInstance(1);
const result = await dc.getOrphanContainers(['my-stack']);
expect(Object.keys(result)).toHaveLength(0);
});
});
// ── Docker daemon unreachable ──────────────────────────────────────────
describe('DockerController - error paths', () => {
it('propagates connection errors from Docker daemon', async () => {
mockDocker.listContainers.mockRejectedValue(
new Error('connect ECONNREFUSED /var/run/docker.sock')
);
const dc = DockerController.getInstance(1);
await expect(dc.getOrphanContainers(['x'])).rejects.toThrow('ECONNREFUSED');
});
it('propagates errors from df() call', async () => {
mockDocker.df.mockRejectedValue(new Error('daemon not running'));
const dc = DockerController.getInstance(1);
await expect(dc.getDiskUsage()).rejects.toThrow('daemon not running');
});
});
// ── inspectNetwork ────────────────────────────────────────────────────
describe('DockerController - inspectNetwork', () => {
it('returns full network details from Docker API', async () => {
const inspectData = {
Id: 'abc123',
Name: 'my-network',
Driver: 'bridge',
Scope: 'local',
IPAM: { Config: [{ Subnet: '172.20.0.0/16', Gateway: '172.20.0.1' }] },
Containers: {
'ctr1': { Name: 'web', IPv4Address: '172.20.0.2/16', MacAddress: '02:42:ac:14:00:02' },
},
};
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });
const dc = DockerController.getInstance(1);
const result = await dc.inspectNetwork('abc123');
expect(result).toEqual(inspectData);
expect(mockDocker.getNetwork).toHaveBeenCalledWith('abc123');
});
it('propagates errors when network not found', async () => {
mockDocker.getNetwork.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('network not found')),
});
const dc = DockerController.getInstance(1);
await expect(dc.inspectNetwork('nonexistent')).rejects.toThrow('network not found');
});
});
// ── createNetwork ─────────────────────────────────────────────────────
describe('DockerController - createNetwork', () => {
it('creates a network with valid options', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-123' });
const dc = DockerController.getInstance(1);
const result = await dc.createNetwork({ Name: 'test-network', Driver: 'bridge' });
expect(result).toEqual({ id: 'new-net-123' });
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'test-network', Driver: 'bridge' });
});
it('rejects empty network name', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name');
});
it('rejects network name with invalid characters', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name');
});
it('accepts names with hyphens, underscores, and dots', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'ok-123' });
const dc = DockerController.getInstance(1);
await dc.createNetwork({ Name: 'my-net_v2.0' });
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-net_v2.0' });
});
it('propagates Docker daemon errors', async () => {
mockDocker.createNetwork.mockRejectedValue(new Error('network with name test already exists'));
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: 'test' })).rejects.toThrow('already exists');
});
it('rejects names starting with a dot', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: '.hidden' })).rejects.toThrow('Invalid network name');
});
it('rejects names starting with a hyphen', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: '-leading-dash' })).rejects.toThrow('Invalid network name');
});
it('rejects names with spaces', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: 'has spaces' })).rejects.toThrow('Invalid network name');
});
it('rejects names with slashes (path traversal)', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: 'foo/bar' })).rejects.toThrow('Invalid network name');
});
it('passes IPAM config through to Docker', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'ipam-net' });
const dc = DockerController.getInstance(1);
const options = {
Name: 'ipam-test',
Driver: 'bridge' as const,
IPAM: { Config: [{ Subnet: '10.0.0.0/24', Gateway: '10.0.0.1' }] },
Internal: true,
Attachable: true,
};
await dc.createNetwork(options);
expect(mockDocker.createNetwork).toHaveBeenCalledWith(options);
});
it('passes labels through to Docker', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'labeled-net' });
const dc = DockerController.getInstance(1);
const options = {
Name: 'labeled',
Labels: { env: 'test', team: 'infra' },
};
await dc.createNetwork(options);
expect(mockDocker.createNetwork).toHaveBeenCalledWith(options);
});
it('accepts single-character name', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'single' });
const dc = DockerController.getInstance(1);
await dc.createNetwork({ Name: 'a' });
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'a' });
});
});
// ── inspectNetwork edge cases ─────────────────────────────────────────
describe('DockerController - inspectNetwork edge cases', () => {
it('returns network with empty containers map', async () => {
const inspectData = {
Id: 'empty-net',
Name: 'isolated',
Driver: 'bridge',
Containers: {},
};
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });
const dc = DockerController.getInstance(1);
const result = await dc.inspectNetwork('empty-net');
expect(result.Containers).toEqual({});
});
it('returns network with multiple containers', async () => {
const inspectData = {
Id: 'multi-net',
Name: 'busy-network',
Driver: 'bridge',
Containers: {
'c1': { Name: 'web', IPv4Address: '172.20.0.2/16' },
'c2': { Name: 'db', IPv4Address: '172.20.0.3/16' },
'c3': { Name: 'cache', IPv4Address: '172.20.0.4/16' },
},
};
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });
const dc = DockerController.getInstance(1);
const result = await dc.inspectNetwork('multi-net');
expect(Object.keys(result.Containers ?? {})).toHaveLength(3);
});
it('propagates Docker daemon connection errors', async () => {
mockDocker.getNetwork.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('Cannot connect to Docker daemon')),
});
const dc = DockerController.getInstance(1);
await expect(dc.inspectNetwork('any-id')).rejects.toThrow('Cannot connect to Docker daemon');
});
});
// --- inspectImage --------------------------------------------------------------
describe('DockerController - inspectImage', () => {
it('returns combined inspect + history payload', async () => {
const inspectData = {
Id: 'sha256:abc12345',
RepoTags: ['nginx:1.27'],
Size: 187_000_000,
Architecture: 'amd64',
Os: 'linux',
Config: { Cmd: ['nginx', '-g', 'daemon off;'] },
};
const historyData = [
{ Id: 'layer1', Created: 1700000000, CreatedBy: '/bin/sh -c #(nop) ADD file:abc', Size: 72_000_000, Tags: null, Comment: '' },
{ Id: 'layer2', Created: 1700000010, CreatedBy: 'ENV NGINX_VERSION=1.27', Size: 0, Tags: null, Comment: '' },
];
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue(inspectData),
history: vi.fn().mockResolvedValue(historyData),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectImage('sha256:abc12345');
expect(result.inspect).toEqual(inspectData);
expect(result.history).toEqual(historyData);
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:abc12345');
});
it('propagates 404 from Dockerode when image is missing', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image: missing'), { statusCode: 404 })),
history: vi.fn().mockResolvedValue([]),
});
const dc = DockerController.getInstance(1);
await expect(dc.inspectImage('missing')).rejects.toThrow('No such image');
});
it('returns empty history when an image has none', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Id: 'sha256:empty', Size: 0 }),
history: vi.fn().mockResolvedValue([]),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectImage('sha256:empty');
expect(result.history).toEqual([]);
expect(result.inspect.Id).toBe('sha256:empty');
});
});
// --- resolveImageId --------------------------------------------------------------
describe('DockerController - resolveImageId', () => {
it('returns the canonical full Id from docker.getImage(id).inspect()', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Id: 'sha256:' + 'a'.repeat(64) }),
});
const dc = DockerController.getInstance(1);
const result = await dc.resolveImageId('a'.repeat(12));
expect(result).toBe('sha256:' + 'a'.repeat(64));
expect(mockDocker.getImage).toHaveBeenCalledWith('a'.repeat(12));
});
it('returns null on a 404 from Docker', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image'), { statusCode: 404 })),
});
const dc = DockerController.getInstance(1);
expect(await dc.resolveImageId('missing')).toBeNull();
});
it('rethrows a non-404 Docker error', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockRejectedValue(Object.assign(new Error('docker daemon unreachable'), { statusCode: 500 })),
});
const dc = DockerController.getInstance(1);
await expect(dc.resolveImageId('sha256:abc')).rejects.toThrow('docker daemon unreachable');
});
});
// --- label / image inspection for the label inventory --------------------------
describe('DockerController - inspectImageLabels', () => {
it('returns the image label map', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectImageLabels('sha256:img');
expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } });
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img');
});
it('returns null (not a silent empty map) and logs when the image inspect fails', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('No such image')),
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dc = DockerController.getInstance(1);
expect(await dc.inspectImageLabels('missing')).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('short-circuits an empty image id without inspecting', async () => {
const dc = DockerController.getInstance(1);
expect(await dc.inspectImageLabels('')).toBeNull();
expect(mockDocker.getImage).not.toHaveBeenCalled();
});
});
describe('DockerController - inspectContainerLabelsAndImage', () => {
it('returns labels and the image ref from container inspect', async () => {
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectContainerLabelsAndImage('c1');
expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' });
});
it('returns null and logs when the container inspect fails', async () => {
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('no such container')),
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dc = DockerController.getInstance(1);
expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
});
// --- createNetwork validation --------------------------------------------------
describe('createNetwork', () => {
it('rejects empty network name', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name');
});
it('rejects names with invalid characters', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: 'net work' })).rejects.toThrow('Invalid network name');
await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name');
await expect(dc.createNetwork({ Name: 'net;rm' })).rejects.toThrow('Invalid network name');
});
it('accepts valid network names and passes through to Docker', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-id' });
const dc = DockerController.getInstance(1);
const result = await dc.createNetwork({ Name: 'my-network_v2' });
expect(result.id).toBe('new-net-id');
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-network_v2' });
});
});
// --- removeContainers mixed results -------------------------------------------
describe('removeContainers', () => {
it('returns mixed results when some removals fail', async () => {
mockDocker.getContainer.mockImplementation((id: string) => {
if (id === 'fail-id') {
return { remove: vi.fn().mockRejectedValue(new Error('no such container')) };
}
return { remove: vi.fn().mockResolvedValue(undefined) };
});
const dc = DockerController.getInstance(1);
const results = await dc.removeContainers(['ok-id-000000', 'fail-id']);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ id: 'ok-id-000000', success: true });
expect(results[1]).toMatchObject({ id: 'fail-id', success: false });
});
it('returns empty array for empty input', async () => {
const dc = DockerController.getInstance(1);
const results = await dc.removeContainers([]);
expect(results).toEqual([]);
});
});
// ── Network connect / disconnect helpers ───────────────────────────────
describe('DockerController - connectContainerToNetwork', () => {
it('attaches a container to a network with no static IP', async () => {
const connect = vi.fn().mockResolvedValue(undefined);
mockDocker.getNetwork.mockReturnValue({ connect });
const dc = DockerController.getInstance(1);
await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234');
expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh');
expect(connect).toHaveBeenCalledWith({ Container: 'sencho-host-1234' });
});
it('attaches with a static IPv4 address when provided', async () => {
const connect = vi.fn().mockResolvedValue(undefined);
mockDocker.getNetwork.mockReturnValue({ connect });
const dc = DockerController.getInstance(1);
await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234', { ipv4Address: '172.30.0.2' });
expect(connect).toHaveBeenCalledWith({
Container: 'sencho-host-1234',
EndpointConfig: { IPAMConfig: { IPv4Address: '172.30.0.2' } },
});
});
it('treats 403 already-connected as success (idempotent)', async () => {
const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'endpoint already exists' });
mockDocker.getNetwork.mockReturnValue({ connect });
const dc = DockerController.getInstance(1);
await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined();
});
it('rethrows non-idempotent errors', async () => {
const connect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' });
mockDocker.getNetwork.mockReturnValue({ connect });
const dc = DockerController.getInstance(1);
await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
statusCode: 500,
});
});
it('rethrows a 403 whose message is unrelated to already-attached', async () => {
const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'host-mode container cannot join network' });
mockDocker.getNetwork.mockReturnValue({ connect });
const dc = DockerController.getInstance(1);
await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
statusCode: 403,
});
});
});
describe('DockerController - disconnectContainerFromNetwork', () => {
it('detaches a container from a network with force=true', async () => {
const disconnect = vi.fn().mockResolvedValue(undefined);
mockDocker.getNetwork.mockReturnValue({ disconnect });
const dc = DockerController.getInstance(1);
await dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234');
expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh');
expect(disconnect).toHaveBeenCalledWith({ Container: 'sencho-host-1234', Force: true });
});
it('treats 404 not-connected as success (idempotent)', async () => {
const disconnect = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network endpoint' });
mockDocker.getNetwork.mockReturnValue({ disconnect });
const dc = DockerController.getInstance(1);
await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined();
});
it('rethrows non-idempotent errors', async () => {
const disconnect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' });
mockDocker.getNetwork.mockReturnValue({ disconnect });
const dc = DockerController.getInstance(1);
await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
statusCode: 500,
});
});
});
// ── getDependencySnapshot ──────────────────────────────────────────────
describe('DockerController - getDependencySnapshot', () => {
beforeEach(() => {
// resolveProjectNameMap caches under a constant key; clear it so each test
// resolves stack ownership from its own mocked compose set.
CacheService.getInstance().invalidate('project-name-map');
});
it('maps service identity, networks, volume mounts, and published ports', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'abc123def456',
Names: ['/web-1'],
Image: 'nginx:alpine',
State: 'running',
Labels: { 'com.docker.compose.project': 'web', 'com.docker.compose.service': 'api' },
NetworkSettings: { Networks: { web_frontend: { NetworkID: 'net1', IPAddress: '172.18.0.2' } } },
Mounts: [
{ Type: 'volume', Name: 'web_data', Destination: '/data' },
{ Type: 'bind', Source: '/host/path', Destination: '/app' },
],
Ports: [
{ IP: '0.0.0.0', PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
{ PrivatePort: 9090, Type: 'tcp' },
],
},
]);
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'b', Name: 'bridge' },
{ Id: 'net1', Name: 'web_frontend', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'web' } },
]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'web_data', Driver: 'local', Labels: { 'com.docker.compose.project': 'web' } }] });
const dc = DockerController.getInstance(1);
const snap = await dc.getDependencySnapshot(['web']);
const c = snap.containers[0];
expect(c.service).toBe('api');
expect(c.stack).toBe('web');
expect(c.networks).toEqual([{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }]);
expect(c.volumes).toEqual(['web_data']); // bind mount dropped
expect(c.ports).toEqual([{ ip: '0.0.0.0', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }]); // unpublished 9090 dropped
expect(c.exitCode).toBeNull();
expect(snap.networks.find((n) => n.name === 'bridge')?.isSystem).toBe(true);
const frontend = snap.networks.find((n) => n.name === 'web_frontend');
expect(frontend?.isSystem).toBe(false);
expect(frontend?.stack).toBe('web');
expect(snap.volumes[0]).toMatchObject({ name: 'web_data', stack: 'web', composeProject: 'web' });
});
it('classifies a non-compose container as having no service or stack', async () => {
mockDocker.listContainers.mockResolvedValue([
{ Id: 'x', Names: ['/manual'], Image: 'redis', State: 'running', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
]);
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
const dc = DockerController.getInstance(1);
const snap = await dc.getDependencySnapshot([]);
expect(snap.containers[0].service).toBeNull();
expect(snap.containers[0].stack).toBeNull();
expect(snap.containers[0].composeProject).toBeNull();
});
it('parses exitCode from list Status for exited containers', async () => {
mockDocker.listContainers.mockResolvedValue([
{ Id: 'a', Names: ['/job-0'], Image: 'busybox', State: 'exited', Status: 'Exited (0) 5 minutes ago', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
{ Id: 'b', Names: ['/crash-0'], Image: 'busybox', State: 'exited', Status: 'Exited (137) 1 minute ago', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
{ Id: 'c', Names: ['/up-0'], Image: 'busybox', State: 'running', Status: 'Up 3 hours', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
{ Id: 'd', Names: ['/odd-0'], Image: 'busybox', State: 'exited', Status: 'Exited', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
]);
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
const snap = await DockerController.getInstance(1).getDependencySnapshot([]);
expect(snap.containers.find(c => c.id === 'a')?.exitCode).toBe(0);
expect(snap.containers.find(c => c.id === 'b')?.exitCode).toBe(137);
expect(snap.containers.find(c => c.id === 'c')?.exitCode).toBeNull();
expect(snap.containers.find(c => c.id === 'd')?.exitCode).toBeNull();
});
});
// --- getBulkStackStatuses uptime (runningSince from StartedAt) -----------------
describe('DockerController - getBulkStackStatuses uptime', () => {
beforeEach(() => {
// resolveProjectNameMap caches under a single key; flush so each test's
// stack names map cleanly to themselves (compose files do not exist here).
CacheService.getInstance().flush();
});
const runningContainer = (id: string, project: string, created: number) => ({
Id: id,
Names: [`/${id}`],
State: 'running',
Status: 'Up',
Image: 'nginx',
Created: created,
Labels: { 'com.docker.compose.project': project },
});
it('uses StartedAt (not Created) for runningSince', async () => {
const created = 1000; // ancient creation time
const startedIso = '2026-06-09T12:00:00.000Z';
const startedUnix = Math.floor(Date.parse(startedIso) / 1000);
mockDocker.listContainers.mockResolvedValue([runningContainer('sa-c1', 'sa-stack', created)]);
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } }),
});
const dc = DockerController.getInstance(1);
const result = await dc.getBulkStackStatuses(['sa-stack']);
expect(result['sa-stack'].status).toBe('running');
expect(result['sa-stack'].runningSince).toBe(startedUnix);
expect(result['sa-stack'].runningSince).not.toBe(created);
});
it('picks the oldest StartedAt across running containers in a stack', async () => {
const olderIso = '2026-06-09T10:00:00.000Z';
const newerIso = '2026-06-09T11:30:00.000Z';
const olderUnix = Math.floor(Date.parse(olderIso) / 1000);
mockDocker.listContainers.mockResolvedValue([
runningContainer('ow-c1', 'ow-stack', 5000),
runningContainer('ow-c2', 'ow-stack', 6000),
]);
const byId: Record<string, string> = { 'ow-c1': newerIso, 'ow-c2': olderIso };
mockDocker.getContainer.mockImplementation((id: string) => ({
inspect: vi.fn().mockResolvedValue({ State: { StartedAt: byId[id] } }),
}));
const dc = DockerController.getInstance(1);
const result = await dc.getBulkStackStatuses(['ow-stack']);
expect(result['ow-stack'].runningSince).toBe(olderUnix);
});
it('falls back to Created when inspect fails', async () => {
const created = 1234;
mockDocker.listContainers.mockResolvedValue([runningContainer('fb-c1', 'fb-stack', created)]);
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('inspect boom')),
});
const dc = DockerController.getInstance(1);
const result = await dc.getBulkStackStatuses(['fb-stack']);
expect(result['fb-stack'].status).toBe('running');
expect(result['fb-stack'].runningSince).toBe(created);
});
it('falls back to Created when StartedAt is the Docker zero-time', async () => {
const created = 4321;
mockDocker.listContainers.mockResolvedValue([runningContainer('zt-c1', 'zt-stack', created)]);
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '0001-01-01T00:00:00Z' } }),
});
const dc = DockerController.getInstance(1);
const result = await dc.getBulkStackStatuses(['zt-stack']);
expect(result['zt-stack'].runningSince).toBe(created);
});
it('derives uptime only from running containers, ignoring exited ones in the stack', async () => {
const startedIso = '2026-06-09T08:00:00.000Z';
const startedUnix = Math.floor(Date.parse(startedIso) / 1000);
mockDocker.listContainers.mockResolvedValue([
{ ...runningContainer('me-run', 'me-stack', 7000) },
{ Id: 'me-exit', Names: ['/me-exit'], State: 'exited', Status: 'Exited (0)', Image: 'nginx', Created: 100, Labels: { 'com.docker.compose.project': 'me-stack' } },
]);
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } }),
});
const dc = DockerController.getInstance(1);
const result = await dc.getBulkStackStatuses(['me-stack']);
expect(result['me-stack'].status).toBe('running');
// The exited container's ancient Created (100) must not drag uptime down.
expect(result['me-stack'].runningSince).toBe(startedUnix);
});
it('caches StartedAt so repeated calls do not re-inspect within the TTL', async () => {
const startedIso = '2026-06-09T09:00:00.000Z';
mockDocker.listContainers.mockResolvedValue([runningContainer('ca-c1', 'ca-stack', 1000)]);
const inspect = vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } });
mockDocker.getContainer.mockReturnValue({ inspect });
// getInstance() hands out fresh instances per request, so a shared (static)
// cache is what must dedupe the inspect across separate calls.
const startedUnix = Math.floor(Date.parse(startedIso) / 1000);
await DockerController.getInstance(1).getBulkStackStatuses(['ca-stack']);
const second = await DockerController.getInstance(1).getBulkStackStatuses(['ca-stack']);
expect(inspect).toHaveBeenCalledTimes(1);
// The cache must be read, not merely populated: the second call still
// resolves the real StartedAt rather than falling back to Created.
expect(second['ca-stack'].runningSince).toBe(startedUnix);
});
});
describe('DockerController - getBulkStackStatuses mainPort', () => {
beforeEach(() => {
CacheService.getInstance().flush();
});
const withPorts = (project: string, ports: { PrivatePort: number; PublicPort: number; Type?: string }[]) => ({
Id: `${project}-c1`, Names: [`/${project}-c1`], State: 'running', Status: 'Up',
Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project }, Ports: ports,
});
it('does not set mainPort for a UDP-only published port', async () => {
mockDocker.listContainers.mockResolvedValue([withPorts('udp-stack', [{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])]);
mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) });
const result = await DockerController.getInstance(1).getBulkStackStatuses(['udp-stack']);
expect(result['udp-stack'].status).toBe('running');
expect(result['udp-stack'].mainPort).toBeUndefined();
});
it('selects a TCP web port as mainPort', async () => {
mockDocker.listContainers.mockResolvedValue([withPorts('tcp-stack', [
{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' },
{ PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
])]);
mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) });
const result = await DockerController.getInstance(1).getBulkStackStatuses(['tcp-stack']);
expect(result['tcp-stack'].mainPort).toBe(8080);
});
});
describe('selectMainWebPort', () => {
it('prefers a known web-UI container port', () => {
expect(selectMainWebPort([
{ PrivatePort: 5432, PublicPort: 5432, Type: 'tcp' },
{ PrivatePort: 8080, PublicPort: 18080, Type: 'tcp' },
])).toBe(18080);
});
it('skips UDP ports and picks the TCP one', () => {
expect(selectMainWebPort([
{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' },
{ PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
])).toBe(8080);
});
it('returns undefined for a UDP-only published port', () => {
expect(selectMainWebPort([{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])).toBeUndefined();
});
it('treats a missing protocol as TCP', () => {
expect(selectMainWebPort([{ PrivatePort: 80, PublicPort: 8080 }])).toBe(8080);
});
it('falls back to the first TCP port when none match the web-UI list', () => {
expect(selectMainWebPort([{ PrivatePort: 12000, PublicPort: 12000, Type: 'tcp' }])).toBe(12000);
});
it('returns undefined when there are no ports', () => {
expect(selectMainWebPort([])).toBeUndefined();
});
});
describe('parseExitCode', () => {
it('extracts the code from an exited Status string', () => {
expect(parseExitCode('Exited (0) 5 minutes ago')).toBe(0);
expect(parseExitCode('Exited (137) 2 minutes ago')).toBe(137);
});
it('reads the code from a restarting Status string', () => {
expect(parseExitCode('Restarting (1) 3 seconds ago')).toBe(1);
});
it('returns null when no parenthesized code is present', () => {
expect(parseExitCode('Up 3 hours')).toBeNull();
expect(parseExitCode('Up 2 hours (healthy)')).toBeNull();
expect(parseExitCode('Created')).toBeNull();
expect(parseExitCode(undefined)).toBeNull();
});
});
describe('isContainerFailed', () => {
it('treats a dead container as failed', () => {
expect(isContainerFailed('dead', 'Dead')).toBe(true);
});
it('treats a crash-looping restart as failed but a clean restart as not', () => {
expect(isContainerFailed('restarting', 'Restarting (1) 5 seconds ago')).toBe(true);
expect(isContainerFailed('restarting', 'Restarting (0) 2 seconds ago')).toBe(false);
});
it('treats a non-zero exit as failed and a clean exit as not failed', () => {
expect(isContainerFailed('exited', 'Exited (137) 2 minutes ago')).toBe(true);
expect(isContainerFailed('exited', 'Exited (0) 5 minutes ago')).toBe(false);
});
it('treats an exited container with an unreadable code as failed', () => {
expect(isContainerFailed('exited', 'Exited')).toBe(true);
});
it('does not treat running, created, or paused containers as failed', () => {
expect(isContainerFailed('running', 'Up 2 hours')).toBe(false);
expect(isContainerFailed('created', 'Created')).toBe(false);
expect(isContainerFailed('paused', 'Up 2 hours (Paused)')).toBe(false);
});
});
describe('DockerController - getBulkStackStatuses partial status', () => {
beforeEach(() => {
CacheService.getInstance().flush();
});
const container = (id: string, project: string, state: string, status: string) => ({
Id: id, Names: [`/${id}`], State: state, Status: status,
Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project },
});
// Any running container triggers an inspect for uptime; stub a valid StartedAt.
const stubInspect = () => {
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }),
});
};
it('keeps a stack UP when a one-shot container exits cleanly', async () => {
stubInspect();
mockDocker.listContainers.mockResolvedValue([
container('clean-run', 'clean-stack', 'running', 'Up 2 hours'),
container('clean-init', 'clean-stack', 'exited', 'Exited (0) 5 minutes ago'),
]);
const result = await DockerController.getInstance(1).getBulkStackStatuses(['clean-stack']);
expect(result['clean-stack'].status).toBe('running');
expect(result['clean-stack'].running).toBe(1);
expect(result['clean-stack'].total).toBe(2);
});
it('marks a stack partial when a container crashes alongside a running one', async () => {
stubInspect();
mockDocker.listContainers.mockResolvedValue([
container('crash-run', 'crash-stack', 'running', 'Up 2 hours'),
container('crash-exit', 'crash-stack', 'exited', 'Exited (137) 1 minute ago'),
]);
const result = await DockerController.getInstance(1).getBulkStackStatuses(['crash-stack']);
expect(result['crash-stack'].status).toBe('partial');
expect(result['crash-stack'].running).toBe(1);
expect(result['crash-stack'].total).toBe(2);
});
it('marks a stack partial for a dead or restart-looping container', async () => {
stubInspect();
mockDocker.listContainers.mockResolvedValue([
container('d-run', 'dead-stack', 'running', 'Up 2 hours'),
container('d-dead', 'dead-stack', 'dead', 'Dead'),
container('r-run', 'restart-stack', 'running', 'Up 2 hours'),
container('r-loop', 'restart-stack', 'restarting', 'Restarting (1) 5 seconds ago'),
]);
const result = await DockerController.getInstance(1).getBulkStackStatuses(['dead-stack', 'restart-stack']);
expect(result['dead-stack'].status).toBe('partial');
expect(result['restart-stack'].status).toBe('partial');
});
it('keeps a stack running when a sibling is paused rather than crashed', async () => {
stubInspect();
mockDocker.listContainers.mockResolvedValue([
container('p-run', 'paused-stack', 'running', 'Up 2 hours'),
container('p-paused', 'paused-stack', 'paused', 'Up 2 hours (Paused)'),
]);
const result = await DockerController.getInstance(1).getBulkStackStatuses(['paused-stack']);
expect(result['paused-stack'].status).toBe('running');
expect(result['paused-stack'].running).toBe(1);
expect(result['paused-stack'].total).toBe(2);
});
it('reports running when every container is up', async () => {
stubInspect();
mockDocker.listContainers.mockResolvedValue([
container('all-1', 'all-stack', 'running', 'Up 2 hours'),
container('all-2', 'all-stack', 'running', 'Up 1 hour'),
]);
const result = await DockerController.getInstance(1).getBulkStackStatuses(['all-stack']);
expect(result['all-stack'].status).toBe('running');
expect(result['all-stack'].running).toBe(2);
expect(result['all-stack'].total).toBe(2);
});
it('reports exited when no container is running, even if some crashed', async () => {
mockDocker.listContainers.mockResolvedValue([
container('down-1', 'down-stack', 'exited', 'Exited (1) 3 minutes ago'),
container('down-2', 'down-stack', 'exited', 'Exited (0) 3 minutes ago'),
]);
const result = await DockerController.getInstance(1).getBulkStackStatuses(['down-stack']);
expect(result['down-stack'].status).toBe('exited');
expect(result['down-stack'].running).toBe(0);
expect(result['down-stack'].total).toBe(2);
});
it('reports unknown for a stack with no containers', async () => {
mockDocker.listContainers.mockResolvedValue([]);
const result = await DockerController.getInstance(1).getBulkStackStatuses(['empty-stack']);
expect(result['empty-stack'].status).toBe('unknown');
expect(result['empty-stack'].running).toBeUndefined();
expect(result['empty-stack'].total).toBeUndefined();
});
});
// ── getLegacyOrphanContainersByStack (#1565) ───────────────────────────
type OrphanDcSpies = {
fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]>;
smartFallback: (...a: unknown[]) => Promise<unknown[]>;
};
function spyOrphanDc(
dc: ReturnType<typeof DockerController.getInstance>,
method: keyof OrphanDcSpies,
) {
return vi.spyOn(dc as unknown as OrphanDcSpies, method);
}
describe('DockerController - getLegacyOrphanContainersByStack', () => {
it('returns [] when compose ps already manages containers', async () => {
const dc = DockerController.getInstance(1);
const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]);
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }]);
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
expect(fallbackSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
fallbackSpy.mockRestore();
});
it('returns legacy orphan IDs when compose ps is empty', async () => {
const dc = DockerController.getInstance(1);
const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]);
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c1' }]);
fetchSpy.mockRestore();
fallbackSpy.mockRestore();
});
it('returns [] when compose ps throws, even if fallback would match containers', async () => {
const dc = DockerController.getInstance(1);
const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose ps failed'));
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c2' }]);
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
// Must not consult name-matching fallback: those IDs may be healthy Compose runtimes.
expect(fallbackSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
fallbackSpy.mockRestore();
});
it('returns [] and skips fallback when compose ps stdout is unparseable', async () => {
mockExecFileAsync.mockResolvedValue({
stdout: 'this is not json at all just random text { broken [',
stderr: '',
});
const dc = DockerController.getInstance(1);
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]);
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
expect(fallbackSpy).not.toHaveBeenCalled();
fallbackSpy.mockRestore();
});
});
describe('DockerController - classifyLegacyOrphansForUpdate', () => {
async function classifyDc() {
const { default: DockerController } = await import('../services/DockerController');
return DockerController.getInstance(1);
}
it('returns none when compose ps already manages containers', async () => {
const dc = await classifyDc();
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'c1' }]);
await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({ status: 'none' });
});
it('returns orphans when compose ps is empty and fallback finds legacy containers', async () => {
const dc = await classifyDc();
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: 'legacy-c2' }]);
await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({
status: 'orphans',
ids: ['legacy-c1', 'legacy-c2'],
});
});
it('returns classification_failed when compose ps throws, even if fallback would match containers', async () => {
const dc = await classifyDc();
spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose boom'));
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c1' }]);
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
expect(result).toEqual({ status: 'classification_failed', error: 'compose boom' });
// Must not consult name-matching fallback: those IDs may be healthy Compose runtimes.
expect(fallbackSpy).not.toHaveBeenCalled();
});
it('returns classification_failed when compose ps is empty and fallback throws', async () => {
const dc = await classifyDc();
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
spyOrphanDc(dc, 'smartFallback').mockRejectedValue(new Error('fallback boom'));
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
expect(result).toEqual({ status: 'classification_failed', error: 'fallback boom' });
});
it('returns classification_failed when compose ps stdout is unparseable', async () => {
mockExecFileAsync.mockResolvedValue({
stdout: 'this is not json at all just random text { broken [',
stderr: '',
});
const dc = await classifyDc();
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]);
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
expect(result.status).toBe('classification_failed');
expect(String((result as { error?: string }).error ?? '')).toMatch(/unparseable JSON/i);
expect(fallbackSpy).not.toHaveBeenCalled();
});
});
describe('DockerController - smartFallback stack-dir evidence', () => {
const composeYaml = `services:\n web:\n image: nginx:alpine\n`;
let stackDir: string;
let tmpRoot: string;
function runningContainer(id: string, name: string, labels: Record<string, string> = {}) {
return { Id: id, Names: [name], Labels: labels, State: 'running', Status: 'Up', Ports: [] };
}
async function orphansWithEmptyPs() {
const dc = DockerController.getInstance(1);
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
return dc.getLegacyOrphanContainersByStack('my-stack');
}
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-orphan-'));
composeDirRef.current = tmpRoot;
stackDir = path.join(tmpRoot, 'my-stack');
await fs.mkdir(stackDir);
await fs.writeFile(path.join(stackDir, 'docker-compose.yml'), composeYaml);
});
afterEach(async () => {
composeDirRef.current = '/test/compose';
await fs.rm(tmpRoot, { recursive: true, force: true });
});
it('excludes name-matched containers without stack-dir evidence', async () => {
mockDocker.listContainers.mockResolvedValue([
runningContainer('unrelated-web', '/web'),
]);
await expect(orphansWithEmptyPs()).resolves.toEqual([]);
});
it('includes name-matched containers whose working_dir equals the stack dir', async () => {
mockDocker.listContainers.mockResolvedValue([
runningContainer('legacy-web', '/web', {
'com.docker.compose.project.working_dir': stackDir,
}),
]);
await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web' }]);
});
it('matches working_dir case-insensitively on win32', async () => {
if (process.platform !== 'win32') return;
mockDocker.listContainers.mockResolvedValue([
runningContainer('legacy-web-case', '/web', {
'com.docker.compose.project.working_dir': stackDir.toUpperCase(),
}),
]);
await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web-case' }]);
});
it('excludes name-matched containers whose working_dir points elsewhere', async () => {
mockDocker.listContainers.mockResolvedValue([
runningContainer('other-stack-web', '/web', {
'com.docker.compose.project.working_dir': path.join(tmpRoot, 'other-stack'),
}),
]);
await expect(orphansWithEmptyPs()).resolves.toEqual([]);
});
it('includes name-matched containers whose config_files path is under the stack dir', async () => {
mockDocker.listContainers.mockResolvedValue([
runningContainer('legacy-cfg', '/my-stack-web-1', {
'com.docker.compose.project.config_files': path.join(stackDir, 'docker-compose.yml'),
}),
]);
await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-cfg' }]);
});
it('surfaces listContainers failure as classification_failed on update', async () => {
mockDocker.listContainers.mockRejectedValue(new Error('daemon unavailable'));
const { default: DockerController } = await import('../services/DockerController');
const dc = DockerController.getInstance(1);
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
expect(result).toEqual({ status: 'classification_failed', error: 'daemon unavailable' });
});
});