feat: add basic docker network listing (#1317)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Enhanced Docker management now supports flexible client integration
for improved container operations.
  - Added a new method for retrieving Docker networks.

- **Bug Fixes**
- Removed outdated Docker network retrieval functionality to streamline
operations.
- Eliminated Docker event monitoring, ensuring a more stable application
initialization process.

- **Refactor**
- Streamlined the GraphQL API by removing legacy Docker container
queries and subscriptions.
- Removed Docker-related state management from the Redux store for
better clarity and performance.
- Removed deprecated Docker monitoring functionalities to simplify the
codebase.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-04-04 09:09:58 -04:00
committed by GitHub
parent 0e008aaf1e
commit c4fdff8149
15 changed files with 873 additions and 381 deletions

View File

@@ -1,90 +0,0 @@
import fs from 'fs';
import camelCaseKeys from 'camelcase-keys';
import type { ContainerPort, Docker, DockerContainer } from '@app/graphql/generated/api/types.js';
import { dockerLogger } from '@app/core/log.js';
import { docker } from '@app/core/utils/clients/docker.js';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
import { ContainerPortType, ContainerState } from '@app/graphql/generated/api/types.js';
import { getters, store } from '@app/store/index.js';
import { updateDockerState } from '@app/store/modules/docker.js';
export interface ContainerListingOptions {
useCache?: boolean;
}
/**
* Get all Docker containers.
* @returns All the in/active Docker containers on the system.
*/
export const getDockerContainers = async (
{ useCache }: ContainerListingOptions = { useCache: true }
): Promise<Array<DockerContainer>> => {
const dockerState = getters.docker();
if (useCache && dockerState.containers) {
dockerLogger.trace('Using docker container cache');
return dockerState.containers;
}
dockerLogger.trace('Skipping docker container cache');
/**
* Docker auto start file
*
* @note Doesn't exist if array is offline.
* @see https://github.com/limetech/webgui/issues/502#issue-480992547
*/
const autoStartFile = await fs.promises
.readFile(getters.paths()['docker-autostart'], 'utf8')
.then((file) => file.toString())
.catch(() => '');
const autoStarts = autoStartFile.split('\n');
const rawContainers = await docker
.listContainers({
all: true,
size: true,
})
// If docker throws an error return no containers
.catch(catchHandlers.docker);
// Cleanup container object
const containers: Array<DockerContainer> = rawContainers.map((container) => {
const names = container.Names[0];
const containerData: DockerContainer = camelCaseKeys<DockerContainer>(
{
labels: container.Labels ?? {},
sizeRootFs: undefined,
imageId: container.ImageID,
state:
typeof container.State === 'string'
? (ContainerState[container.State.toUpperCase()] ?? ContainerState.EXITED)
: ContainerState.EXITED,
autoStart: autoStarts.includes(names.split('/')[1]),
ports: container.Ports.map<ContainerPort>((port) => ({
...port,
type: ContainerPortType[port.Type.toUpperCase()],
})),
command: container.Command,
created: container.Created,
mounts: container.Mounts,
networkSettings: container.NetworkSettings,
hostConfig: {
networkMode: container.HostConfig.NetworkMode,
},
id: container.Id,
image: container.Image,
status: container.Status,
},
{ deep: true }
);
return containerData;
});
// Get all of the current containers
const installed = containers.length;
const running = containers.filter((container) => container.state === ContainerState.RUNNING).length;
store.dispatch(updateDockerState({ containers, installed, running }));
return containers;
};

View File

@@ -1,39 +0,0 @@
import camelCaseKeys from 'camelcase-keys';
import { type CoreContext, type CoreResult } from '@app/core/types/index.js';
import { docker } from '@app/core/utils/index.js';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission.js';
export const getDockerNetworks = async (context: CoreContext): Promise<CoreResult> => {
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'docker/network',
action: 'read',
possession: 'any',
});
const networks = await docker
.listNetworks()
// If docker throws an error return no networks
.catch(catchHandlers.docker)
.then((networks = []) =>
networks.map((object) =>
camelCaseKeys(object as unknown as Record<string, unknown>, { deep: true })
)
);
/**
* Get all Docker networks
*
* @memberof Core
* @module docker/get-networks
* @param {Core~Context} context
* @returns {Core~Result} All the in/active Docker networks on the system.
*/
return {
json: networks,
};
};

View File

@@ -1,4 +0,0 @@
// Created from 'create-ts-index'
export * from './get-docker-containers.js';
export * from './get-docker-networks.js';

View File

@@ -3,7 +3,6 @@
export * from './array/index.js';
export * from './debug/index.js';
export * from './disks/index.js';
export * from './docker/index.js';
export * from './services/index.js';
export * from './settings/index.js';
export * from './shares/index.js';

View File

@@ -1,12 +1,3 @@
type Query {
"""All Docker containers"""
dockerContainers(all: Boolean): [DockerContainer!]!
}
type Subscription {
dockerContainer(id: ID!): DockerContainer!
dockerContainers: [DockerContainer]
}
enum ContainerPortType {
TCP

View File

@@ -28,7 +28,6 @@ import { startStoreSync } from '@app/store/store-sync.js';
import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch.js';
import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch.js';
import { StateManager } from '@app/store/watch/state-watch.js';
import { setupVarRunWatch } from '@app/store/watch/var-run-watch.js';
let server: NestFastifyApplication<RawServerDefault> | null = null;
@@ -76,9 +75,6 @@ export const viteNodeApp = async () => {
// Start listening to key file changes
setupRegistrationKeyWatch();
// Start listening to docker events
setupVarRunWatch();
// Start listening to dynamix config file changes
setupDynamixConfigWatch();

View File

@@ -3,7 +3,6 @@ import { configureStore } from '@reduxjs/toolkit';
import { listenerMiddleware } from '@app/store/listeners/listener-middleware.js';
import { cache } from '@app/store/modules/cache.js';
import { configReducer } from '@app/store/modules/config.js';
import { docker } from '@app/store/modules/docker.js';
import { dynamicRemoteAccessReducer } from '@app/store/modules/dynamic-remote-access.js';
import { dynamix } from '@app/store/modules/dynamix.js';
import { emhttp } from '@app/store/modules/emhttp.js';
@@ -23,7 +22,6 @@ export const store = configureStore({
registration: registration.reducer,
remoteGraphQL: remoteGraphQLReducer,
cache: cache.reducer,
docker: docker.reducer,
upnp: upnp.reducer,
dynamix: dynamix.reducer,
},
@@ -40,7 +38,6 @@ export type ApiStore = typeof store;
export const getters = {
cache: () => store.getState().cache,
config: () => store.getState().config,
docker: () => store.getState().docker,
dynamicRemoteAccess: () => store.getState().dynamicRemoteAccess,
dynamix: () => store.getState().dynamix,
emhttp: () => store.getState().emhttp,

View File

@@ -1,34 +0,0 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { type DockerContainer } from '@app/graphql/generated/api/types.js';
import { DaemonConnectionStatus } from '@app/store/types.js';
type DockerState = {
status: DaemonConnectionStatus;
installed: number | null;
running: number | null;
containers: DockerContainer[];
};
const initialState: DockerState = {
status: DaemonConnectionStatus.DISCONNECTED,
installed: null,
running: null,
containers: [],
};
export const docker = createSlice({
name: 'docker',
initialState,
reducers: {
updateDockerState(state, action: PayloadAction<Partial<typeof initialState>>) {
state.status = action.payload.status ?? initialState.status;
state.installed = action.payload.installed ?? initialState.installed;
state.running = action.payload.running ?? initialState.running;
state.containers = action.payload.containers ?? initialState.containers;
},
},
});
export const { updateDockerState } = docker.actions;

View File

@@ -6,7 +6,6 @@ import { isEqual } from 'lodash-es';
import type { RootState } from '@app/store/index.js';
import { NODE_ENV } from '@app/environment.js';
import { store } from '@app/store/index.js';
import { syncInfoApps } from '@app/store/sync/info-apps-sync.js';
import { syncRegistration } from '@app/store/sync/registration-sync.js';
import { FileLoadStatus } from '@app/store/types.js';
@@ -21,9 +20,6 @@ export const startStoreSync = async () => {
if (state.config.status === FileLoadStatus.LOADED) {
// Update registration
await syncRegistration(lastState);
// Update docker app counts
await syncInfoApps(lastState);
}
if (

View File

@@ -1,48 +0,0 @@
import { isEqual } from 'lodash-es';
import type { StoreSubscriptionHandler } from '@app/store/types.js';
import { logger } from '@app/core/log.js';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { store } from '@app/store/index.js';
import { DaemonConnectionStatus } from '@app/store/types.js';
type InfoAppsEvent = {
info: {
apps: {
installed: number | null;
running: number | null;
};
};
};
export const createInfoAppsEvent = (
state: Parameters<StoreSubscriptionHandler>[0]
): InfoAppsEvent | null => {
// Docker state isn't loaded
if (state === null || state.docker.status === DaemonConnectionStatus.DISCONNECTED) return null;
return {
info: {
apps: {
installed: state?.docker.installed,
running: state?.docker.running,
},
},
};
};
export const syncInfoApps: StoreSubscriptionHandler = async (lastState) => {
const lastEvent = createInfoAppsEvent(lastState);
const currentEvent = createInfoAppsEvent(store.getState());
// Skip if either event resolved to null
if (lastEvent === null || currentEvent === null) return;
// Skip this if it's the same as the last one
if (isEqual(lastEvent, currentEvent)) return;
logger.debug('Docker container count was updated, publishing event');
// Publish to graphql
await pubsub.publish(PUBSUB_CHANNEL.INFO, currentEvent);
};

View File

@@ -1,51 +0,0 @@
import DockerEE from 'docker-event-emitter';
import { debounce } from 'lodash-es';
import { dockerLogger } from '@app/core/log.js';
import { docker } from '@app/core/utils/clients/docker.js';
import { store } from '@app/store/index.js';
import { updateDockerState } from '@app/store/modules/docker.js';
const updateContainerCache = async () => {
try {
const { getDockerContainers } = await import(
'@app/core/modules/docker/get-docker-containers.js'
);
await getDockerContainers({ useCache: false });
} catch (err) {
dockerLogger.warn('Caught error getting containers %o', err);
store.dispatch(
updateDockerState({
installed: null,
running: null,
containers: [],
})
);
}
};
const debouncedContainerCacheUpdate = debounce(updateContainerCache, 500);
export const setupDockerWatch = async (): Promise<DockerEE> => {
// Only watch container events equal to start/stop
const watchedActions = ['die', 'kill', 'oom', 'pause', 'restart', 'start', 'stop', 'unpause'];
// Create docker event emitter instance
dockerLogger.debug('Creating docker event emitter instance');
const dee = new DockerEE(docker);
// On Docker event update info with { apps: { installed, started } }
dee.on('container', async (data: { Type: 'container'; Action: 'start' | 'stop'; from: string }) => {
// Only listen to container events
if (!watchedActions.includes(data.Action)) {
return;
}
dockerLogger.debug(`[${data.from}] ${data.Type}->${data.Action}`);
await debouncedContainerCacheUpdate();
});
// Get docker container count on first start
await debouncedContainerCacheUpdate();
await dee.start();
dockerLogger.debug('Binding to docker events');
return dee;
};

View File

@@ -1,27 +0,0 @@
import type DockerEE from 'docker-event-emitter';
import { watch } from 'chokidar';
import { dockerLogger } from '@app/core/log.js';
import { getters, store } from '@app/store/index.js';
import { updateDockerState } from '@app/store/modules/docker.js';
import { setupDockerWatch } from '@app/store/watch/docker-watch.js';
export const setupVarRunWatch = () => {
const paths = getters.paths();
let dockerWatcher: null | typeof DockerEE = null;
watch(paths['var-run'], { ignoreInitial: false })
.on('add', async (path) => {
if (path === paths['docker-socket']) {
dockerLogger.debug('Starting docker watch');
dockerWatcher = await setupDockerWatch();
}
})
.on('unlink', (path) => {
if (path === paths['docker-socket'] && dockerWatcher) {
dockerLogger.debug('Stopping docker watch');
dockerWatcher?.stop?.();
store.dispatch(updateDockerState({ installed: null, running: null, containers: [] }));
}
});
};

View File

@@ -31,6 +31,11 @@ export class DockerResolver {
return this.dockerService.getContainers({ useCache: false });
}
@ResolveField()
public async networks() {
return this.dockerService.getNetworks({ useCache: false });
}
@ResolveField()
public mutations() {
return {

View File

@@ -1,28 +1,94 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import Docker from 'dockerode';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DockerContainer } from '@app/graphql/generated/api/types.js';
import { getDockerContainers } from '@app/core/modules/docker/get-docker-containers.js';
import { docker } from '@app/core/utils/clients/docker.js';
import { ContainerState } from '@app/graphql/generated/api/types.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
vi.mock('@app/core/utils/clients/docker.js', () => ({
docker: {
getContainer: vi.fn(),
listContainers: vi.fn(),
// Mock chokidar
vi.mock('chokidar', () => ({
watch: vi.fn().mockReturnValue({
on: vi.fn().mockReturnValue({
on: vi.fn(),
}),
}),
}));
// Mock docker-event-emitter
vi.mock('docker-event-emitter', () => ({
default: vi.fn().mockReturnValue({
on: vi.fn(),
start: vi.fn().mockResolvedValue(undefined),
}),
}));
// Mock pubsub
vi.mock('@app/core/pubsub.js', () => ({
pubsub: {
publish: vi.fn().mockResolvedValue(undefined),
},
PUBSUB_CHANNEL: {
INFO: 'info',
},
}));
vi.mock('@app/core/modules/docker/get-docker-containers.js', () => ({
getDockerContainers: vi.fn(),
interface DockerError extends NodeJS.ErrnoException {
address: string;
}
const mockContainer = {
start: vi.fn(),
stop: vi.fn(),
};
// Create properly typed mock functions
const mockListContainers = vi.fn();
const mockGetContainer = vi.fn().mockReturnValue(mockContainer);
const mockListNetworks = vi.fn();
const mockDockerInstance = {
getContainer: mockGetContainer,
listContainers: mockListContainers,
listNetworks: mockListNetworks,
modem: {
Promise: Promise,
protocol: 'http',
socketPath: '/var/run/docker.sock',
headers: {},
sshOptions: {
agentForward: undefined,
},
},
} as unknown as Docker;
vi.mock('dockerode', () => {
return {
default: vi.fn().mockImplementation(() => mockDockerInstance),
};
});
// Mock the store getters
vi.mock('@app/store/index.js', () => ({
getters: {
docker: vi.fn().mockReturnValue({ containers: [] }),
paths: vi.fn().mockReturnValue({
'docker-autostart': '/path/to/docker-autostart',
'docker-socket': '/var/run/docker.sock',
'var-run': '/var/run',
}),
},
}));
// Mock fs/promises
vi.mock('fs/promises', () => ({
readFile: vi.fn().mockResolvedValue(''),
}));
describe('DockerService', () => {
let service: DockerService;
let mockContainer: any;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -31,11 +97,11 @@ describe('DockerService', () => {
service = module.get<DockerService>(DockerService);
mockContainer = {
start: vi.fn(),
stop: vi.fn(),
};
vi.mocked(docker.getContainer).mockReturnValue(mockContainer as any);
// Reset mock container methods
mockContainer.start.mockReset();
mockContainer.stop.mockReset();
mockListContainers.mockReset();
mockListNetworks.mockReset();
});
it('should be defined', () => {
@@ -43,9 +109,33 @@ describe('DockerService', () => {
});
it('should get containers', async () => {
const mockContainers: DockerContainer[] = [
const mockContainers = [
{
id: '1',
Id: 'abc123def456',
Names: ['/test-container'],
Image: 'test-image',
ImageID: 'test-image-id',
Command: 'test',
Created: 1234567890,
State: 'exited',
Status: 'Exited',
Ports: [],
Labels: {},
HostConfig: {
NetworkMode: 'bridge',
},
NetworkSettings: {},
Mounts: [],
},
];
mockListContainers.mockResolvedValue(mockContainers);
const result = await service.getContainers({ useCache: false });
expect(result).toEqual([
{
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
@@ -54,72 +144,603 @@ describe('DockerService', () => {
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
},
];
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
]);
const result = await service.getContainers({ useCache: false });
expect(result).toEqual(mockContainers);
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
expect(mockListContainers).toHaveBeenCalledWith({
all: true,
size: true,
});
});
it('should start container', async () => {
const mockContainers: DockerContainer[] = [
const mockContainers = [
{
id: '1',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.RUNNING,
status: 'Up 2 hours',
Id: 'abc123def456',
Names: ['/test-container'],
Image: 'test-image',
ImageID: 'test-image-id',
Command: 'test',
Created: 1234567890,
State: 'running',
Status: 'Up 2 hours',
Ports: [],
Labels: {},
HostConfig: {
NetworkMode: 'bridge',
},
NetworkSettings: {},
Mounts: [],
},
];
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
const result = await service.startContainer('1');
expect(result).toEqual(mockContainers[0]);
expect(docker.getContainer).toHaveBeenCalledWith('1');
mockListContainers.mockResolvedValue(mockContainers);
mockContainer.start.mockResolvedValue(undefined);
const result = await service.startContainer('abc123def456');
expect(result).toEqual({
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.RUNNING,
status: 'Up 2 hours',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
});
expect(mockContainer.start).toHaveBeenCalled();
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
expect(mockListContainers).toHaveBeenCalledWith({
all: true,
size: true,
});
});
it('should stop container', async () => {
const mockContainers: DockerContainer[] = [
const mockContainers = [
{
id: '1',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
Id: 'abc123def456',
Names: ['/test-container'],
Image: 'test-image',
ImageID: 'test-image-id',
Command: 'test',
Created: 1234567890,
State: 'exited',
Status: 'Exited',
Ports: [],
Labels: {},
HostConfig: {
NetworkMode: 'bridge',
},
NetworkSettings: {},
Mounts: [],
},
];
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
const result = await service.stopContainer('1');
expect(result).toEqual(mockContainers[0]);
expect(docker.getContainer).toHaveBeenCalledWith('1');
mockListContainers.mockResolvedValue(mockContainers);
mockContainer.stop.mockResolvedValue(undefined);
const result = await service.stopContainer('abc123def456');
expect(result).toEqual({
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
});
expect(mockContainer.stop).toHaveBeenCalled();
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
expect(mockListContainers).toHaveBeenCalledWith({
all: true,
size: true,
});
});
it('should throw error if container not found after start', async () => {
vi.mocked(getDockerContainers).mockResolvedValue([]);
mockListContainers.mockResolvedValue([]);
mockContainer.start.mockResolvedValue(undefined);
await expect(service.startContainer('1')).rejects.toThrow(
'Container 1 not found after starting'
await expect(service.startContainer('abc123def456')).rejects.toThrow(
'Container abc123def456 not found after starting'
);
});
it('should throw error if container not found after stop', async () => {
vi.mocked(getDockerContainers).mockResolvedValue([]);
mockListContainers.mockResolvedValue([]);
mockContainer.stop.mockResolvedValue(undefined);
await expect(service.stopContainer('1')).rejects.toThrow('Container 1 not found after stopping');
await expect(service.stopContainer('abc123def456')).rejects.toThrow(
'Container abc123def456 not found after stopping'
);
});
it('should get networks', async () => {
const mockNetworks = [
{
Id: 'network1',
Name: 'bridge',
Created: '2023-01-01T00:00:00Z',
Scope: 'local',
Driver: 'bridge',
EnableIPv6: false,
IPAM: {
Driver: 'default',
Config: [
{
Subnet: '172.17.0.0/16',
Gateway: '172.17.0.1',
},
],
},
Internal: false,
Attachable: false,
Ingress: false,
ConfigFrom: {
Network: '',
},
ConfigOnly: false,
Containers: {},
Options: {
'com.docker.network.bridge.default_bridge': 'true',
'com.docker.network.bridge.enable_icc': 'true',
'com.docker.network.bridge.enable_ip_masquerade': 'true',
'com.docker.network.bridge.host_binding_ipv4': '0.0.0.0',
'com.docker.network.bridge.name': 'docker0',
'com.docker.network.driver.mtu': '1500',
},
Labels: {},
},
];
mockListNetworks.mockResolvedValue(mockNetworks);
const result = await service.getNetworks({ useCache: false });
expect(result).toEqual([
{
id: 'network1',
name: 'bridge',
created: '2023-01-01T00:00:00Z',
scope: 'local',
driver: 'bridge',
enableIpv6: false,
ipam: {
driver: 'default',
config: [
{
subnet: '172.17.0.0/16',
gateway: '172.17.0.1',
},
],
},
internal: false,
attachable: false,
ingress: false,
configFrom: {
network: '',
},
configOnly: false,
containers: {},
options: {
comDockerNetworkBridgeDefaultBridge: 'true',
comDockerNetworkBridgeEnableIcc: 'true',
comDockerNetworkBridgeEnableIpMasquerade: 'true',
comDockerNetworkBridgeHostBindingIpv4: '0.0.0.0',
comDockerNetworkBridgeName: 'docker0',
comDockerNetworkDriverMtu: '1500',
},
labels: {},
},
]);
expect(mockListNetworks).toHaveBeenCalled();
});
it('should handle empty networks list', async () => {
mockListNetworks.mockResolvedValue([]);
const result = await service.getNetworks({ useCache: false });
expect(result).toEqual([]);
expect(mockListNetworks).toHaveBeenCalled();
});
it('should handle docker error', async () => {
const error = new Error('Docker error') as DockerError;
error.code = 'ENOENT';
error.address = '/var/run/docker.sock';
mockListNetworks.mockRejectedValue(error);
await expect(service.getNetworks({ useCache: false })).rejects.toThrow(
'Docker socket unavailable.'
);
expect(mockListNetworks).toHaveBeenCalled();
});
describe('getters', () => {
it('should return correct installed count', () => {
// Setup mock containers
const mockContainers = [
{
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.RUNNING,
status: 'Up 2 hours',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
},
{
id: 'def456ghi789',
autoStart: false,
command: 'test2',
created: 1234567891,
image: 'test-image2',
imageId: 'test-image-id2',
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
},
];
// Manually set the container cache
(service as any).containerCache = mockContainers;
expect(service.installed).toBe(2);
});
it('should return correct running count', () => {
// Setup mock containers
const mockContainers = [
{
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.RUNNING,
status: 'Up 2 hours',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
},
{
id: 'def456ghi789',
autoStart: false,
command: 'test2',
created: 1234567891,
image: 'test-image2',
imageId: 'test-image-id2',
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
},
];
// Manually set the container cache
(service as any).containerCache = mockContainers;
expect(service.running).toBe(1);
});
it('should return correct appUpdateEvent', () => {
// Setup mock containers
const mockContainers = [
{
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.RUNNING,
status: 'Up 2 hours',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
},
{
id: 'def456ghi789',
autoStart: false,
command: 'test2',
created: 1234567891,
image: 'test-image2',
imageId: 'test-image-id2',
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
},
];
// Manually set the container cache
(service as any).containerCache = mockContainers;
expect(service.appUpdateEvent).toEqual({
info: {
apps: { installed: 2, running: 1 },
},
});
});
});
describe('watchers', () => {
it('should setup docker watcher when docker socket is added', async () => {
// Mock the setupDockerWatch method
const setupDockerWatchSpy = vi.spyOn(service as any, 'setupDockerWatch');
setupDockerWatchSpy.mockResolvedValue({} as any);
// Get the watch function from chokidar
const { watch } = await import('chokidar');
// Mock the on method to simulate the add event
const mockOn = vi.fn().mockImplementation((event, callback) => {
if (event === 'add') {
// Simulate the add event with the docker socket path
callback('/var/run/docker.sock');
}
return { on: vi.fn() };
});
// Replace the watch function's on method
(watch as any).mockReturnValue({
on: mockOn,
});
// Call the setupVarRunWatch method
await (service as any).setupVarRunWatch();
// Verify that setupDockerWatch was called
expect(setupDockerWatchSpy).toHaveBeenCalled();
});
it('should stop docker watcher when docker socket is removed', async () => {
// Get the watch function from chokidar
const { watch } = await import('chokidar');
// Create a mock stop function
const mockStop = vi.fn();
// Set up the dockerWatcher before calling setupVarRunWatch
(service as any).dockerWatcher = { stop: mockStop };
// Mock the on method to simulate the unlink event
let unlinkCallback: (path: string) => void = () => {};
const mockOn = vi.fn().mockImplementation((event, callback) => {
if (event === 'unlink') {
unlinkCallback = callback;
}
return { on: mockOn };
});
// Replace the watch function's on method
(watch as any).mockReturnValue({
on: mockOn,
});
// Call the setupVarRunWatch method
(service as any).setupVarRunWatch();
// Verify that the on method was called with 'unlink'
expect(mockOn).toHaveBeenCalledWith('unlink', expect.any(Function));
expect(unlinkCallback).toBeDefined();
// Trigger the unlink event
unlinkCallback('/var/run/docker.sock');
// Verify that the stop method was called
expect(mockStop).toHaveBeenCalled();
expect((service as any).dockerWatcher).toBeNull();
expect((service as any).containerCache).toEqual([]);
});
it('should setup docker watch correctly', async () => {
// Get the DockerEE import
const DockerEE = (await import('docker-event-emitter')).default;
// Mock the debouncedContainerCacheUpdate method
const debouncedContainerCacheUpdateSpy = vi.spyOn(
service as any,
'debouncedContainerCacheUpdate'
);
debouncedContainerCacheUpdateSpy.mockResolvedValue(undefined);
// Call the setupDockerWatch method
const result = await (service as any).setupDockerWatch();
// Verify that DockerEE was instantiated with the client
expect(DockerEE).toHaveBeenCalledWith(mockDockerInstance);
// Verify that the on method was called with the correct arguments
const dockerEEInstance = DockerEE();
expect(dockerEEInstance.on).toHaveBeenCalledWith('container', expect.any(Function));
// Verify that the start method was called
expect(dockerEEInstance.start).toHaveBeenCalled();
// Verify that debouncedContainerCacheUpdate was called
expect(debouncedContainerCacheUpdateSpy).toHaveBeenCalled();
// Verify that the result is the DockerEE instance
expect(result).toBe(dockerEEInstance);
});
it('should call debouncedContainerCacheUpdate when container event is received', async () => {
// Get the DockerEE import
const DockerEE = (await import('docker-event-emitter')).default;
// Mock the on method to capture the callback
const mockOnCallback = vi.fn();
const mockOn = vi.fn().mockImplementation((event, callback) => {
if (event === 'container') {
mockOnCallback(callback);
}
return { on: vi.fn() };
});
// Replace the DockerEE constructor's on method
(DockerEE as any).mockReturnValue({
on: mockOn,
start: vi.fn().mockResolvedValue(undefined),
});
// Mock the debouncedContainerCacheUpdate method
const debouncedContainerCacheUpdateSpy = vi.spyOn(
service as any,
'debouncedContainerCacheUpdate'
);
debouncedContainerCacheUpdateSpy.mockResolvedValue(undefined);
// Call the setupDockerWatch method
await (service as any).setupDockerWatch();
// Get the callback function that was passed to the on method
const containerEventCallback = mockOnCallback.mock.calls[0][0];
// Call the callback with a container event
await containerEventCallback({
Type: 'container',
Action: 'start',
from: 'test-container',
});
// Verify that debouncedContainerCacheUpdate was called
expect(debouncedContainerCacheUpdateSpy).toHaveBeenCalled();
});
it('should not call debouncedContainerCacheUpdate for non-watched container events', async () => {
// Get the DockerEE import
const DockerEE = (await import('docker-event-emitter')).default;
// Mock the debouncedContainerCacheUpdate method
const debouncedContainerCacheUpdateSpy = vi.spyOn(
service as any,
'debouncedContainerCacheUpdate'
);
debouncedContainerCacheUpdateSpy.mockResolvedValue(undefined);
// Create a mock on function that captures the callback
let containerCallback: (data: {
Type: string;
Action: string;
from: string;
}) => void = () => {};
const mockOn = vi.fn().mockImplementation((event, callback) => {
if (event === 'container') {
containerCallback = callback;
}
return { on: vi.fn() };
});
// Replace the DockerEE constructor's on method
(DockerEE as any).mockReturnValue({
on: mockOn,
start: vi.fn().mockResolvedValue(undefined),
});
// Call the setupDockerWatch method
await (service as any).setupDockerWatch();
// Reset the spy after setup
debouncedContainerCacheUpdateSpy.mockReset();
// Call the callback with a non-watched container event
await containerCallback({
Type: 'container',
Action: 'create',
from: 'test-container',
});
// Verify that debouncedContainerCacheUpdate was not called
expect(debouncedContainerCacheUpdateSpy).not.toHaveBeenCalled();
});
it('should call getContainers and publish appUpdateEvent in debouncedContainerCacheUpdate', async () => {
// Mock the client's listContainers method
const mockListContainers = vi.fn().mockResolvedValue([]);
(service as any).client = {
listContainers: mockListContainers,
};
// Mock the getContainers method
const getContainersSpy = vi.spyOn(service, 'getContainers');
// Get the pubsub import
const { pubsub, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js');
// Call the debouncedContainerCacheUpdate method directly and wait for the debounce
service['debouncedContainerCacheUpdate']();
// Force the debounced function to execute immediately
await new Promise((resolve) => setTimeout(resolve, 600));
// Verify that getContainers was called with useCache: false
expect(getContainersSpy).toHaveBeenCalledWith({ useCache: false });
// Verify that pubsub.publish was called with the correct arguments
expect(pubsub.publish).toHaveBeenCalledWith('info', {
info: {
apps: { installed: 0, running: 0 },
},
});
});
});
});

View File

@@ -1,20 +1,200 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { readFile } from 'fs/promises';
import type { DockerContainer } from '@app/graphql/generated/api/types.js';
import {
ContainerListingOptions,
getDockerContainers,
} from '@app/core/modules/docker/get-docker-containers.js';
import { docker } from '@app/core/utils/clients/docker.js';
import camelCaseKeys from 'camelcase-keys';
import { watch } from 'chokidar';
import DockerEE from 'docker-event-emitter';
import Docker from 'dockerode';
import { debounce } from 'lodash-es';
import type { ContainerPort, DockerContainer, DockerNetwork } from '@app/graphql/generated/api/types.js';
import { dockerLogger } from '@app/core/log.js';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
import { ContainerPortType, ContainerState } from '@app/graphql/generated/api/types.js';
import { getters } from '@app/store/index.js';
interface ContainerListingOptions {
useCache: boolean;
}
interface NetworkListingOptions {
useCache: boolean;
}
@Injectable()
export class DockerService {
export class DockerService implements OnModuleInit {
private client: Docker;
private containerCache: Array<DockerContainer> = [];
private dockerWatcher: null | typeof DockerEE = null;
private readonly logger = new Logger(DockerService.name);
constructor() {
this.client = this.getDockerClient();
}
private getDockerClient() {
return new Docker({
socketPath: '/var/run/docker.sock',
});
}
private setupVarRunWatch() {
const paths = getters.paths();
watch(paths['var-run'], { ignoreInitial: false })
.on('add', async (path) => {
if (path === paths['docker-socket']) {
dockerLogger.debug('Starting docker watch');
this.dockerWatcher = await this.setupDockerWatch();
}
})
.on('unlink', (path) => {
if (path === paths['docker-socket'] && this.dockerWatcher) {
dockerLogger.debug('Stopping docker watch');
this.dockerWatcher?.stop?.();
this.dockerWatcher = null;
this.containerCache = [];
}
});
}
get installed() {
return this.containerCache.length;
}
get running() {
return this.containerCache.filter((container) => container.state === ContainerState.RUNNING)
.length;
}
get appUpdateEvent() {
return {
info: {
apps: { installed: this.installed, running: this.running },
},
};
}
private async setupDockerWatch(): Promise<DockerEE> {
// Only watch container events equal to start/stop
const watchedActions = ['die', 'kill', 'oom', 'pause', 'restart', 'start', 'stop', 'unpause'];
// Create docker event emitter instance
dockerLogger.debug('Creating docker event emitter instance');
const dee = new DockerEE(this.client);
// On Docker event update info with { apps: { installed, started } }
dee.on(
'container',
async (data: { Type: 'container'; Action: 'start' | 'stop'; from: string }) => {
// Only listen to container events
if (!watchedActions.includes(data.Action)) {
return;
}
dockerLogger.debug(`[${data.from}] ${data.Type}->${data.Action}`);
await this.debouncedContainerCacheUpdate();
}
);
// Get docker container count on first start
await this.debouncedContainerCacheUpdate();
await dee.start();
dockerLogger.debug('Binding to docker events');
return dee;
}
public async onModuleInit() {
this.setupVarRunWatch();
}
public async getAutoStarts(): Promise<string[]> {
/**
* Docker auto start file
*
* @note Doesn't exist if array is offline.
* @see https://github.com/limetech/webgui/issues/502#issue-480992547
*/
const autoStartFile = await readFile(getters.paths()['docker-autostart'], 'utf8')
.then((file) => file.toString())
.catch(() => '');
return autoStartFile.split('\n');
}
private debouncedContainerCacheUpdate = debounce(async () => {
await this.getContainers({ useCache: false });
await pubsub.publish(PUBSUB_CHANNEL.INFO, this.appUpdateEvent);
}, 500);
public async getContainers({ useCache }: ContainerListingOptions): Promise<DockerContainer[]> {
return getDockerContainers({ useCache });
if (useCache && this.containerCache.length > 0) {
this.logger.debug('Using docker container cache');
return this.containerCache;
}
this.logger.debug('Updating docker container cache');
const rawContainers = await this.client
.listContainers({
all: true,
size: true,
})
// If docker throws an error return no containers
.catch(catchHandlers.docker);
const autoStarts = await this.getAutoStarts();
// Cleanup container object
this.containerCache = rawContainers.map((container) => {
const names = container.Names[0];
const containerData: DockerContainer = camelCaseKeys<DockerContainer>(
{
labels: container.Labels ?? {},
sizeRootFs: undefined,
imageId: container.ImageID,
state:
typeof container.State === 'string'
? (ContainerState[container.State.toUpperCase()] ?? ContainerState.EXITED)
: ContainerState.EXITED,
autoStart: autoStarts.includes(names.split('/')[1]),
ports: container.Ports.map<ContainerPort>((port) => ({
...port,
type: ContainerPortType[port.Type.toUpperCase()],
})),
command: container.Command,
created: container.Created,
mounts: container.Mounts,
networkSettings: container.NetworkSettings,
hostConfig: {
networkMode: container.HostConfig.NetworkMode,
},
id: container.Id,
image: container.Image,
status: container.Status,
},
{ deep: true }
);
return containerData;
});
return this.containerCache;
}
/**
* Get all Docker networks
* @todo filtering / cache / proper typing
* @returns All the in/active Docker networks on the system.
*/
public async getNetworks({ useCache }: NetworkListingOptions): Promise<DockerNetwork[]> {
return this.client
.listNetworks()
.catch(catchHandlers.docker)
.then(
(networks = []) =>
networks.map((object) =>
camelCaseKeys(object as unknown as Record<string, unknown>, { deep: true })
) as DockerNetwork[]
);
}
public async startContainer(id: string): Promise<DockerContainer> {
const container = docker.getContainer(id);
const container = this.client.getContainer(id);
await container.start();
const containers = await this.getContainers({ useCache: false });
const updatedContainer = containers.find((c) => c.id === id);
@@ -25,7 +205,7 @@ export class DockerService {
}
public async stopContainer(id: string): Promise<DockerContainer> {
const container = docker.getContainer(id);
const container = this.client.getContainer(id);
await container.stop();
const containers = await this.getContainers({ useCache: false });
const updatedContainer = containers.find((c) => c.id === id);