diff --git a/api/src/core/modules/docker/get-docker-containers.ts b/api/src/core/modules/docker/get-docker-containers.ts deleted file mode 100644 index 4c78056b6..000000000 --- a/api/src/core/modules/docker/get-docker-containers.ts +++ /dev/null @@ -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> => { - 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 = rawContainers.map((container) => { - const names = container.Names[0]; - const containerData: DockerContainer = camelCaseKeys( - { - 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((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; -}; diff --git a/api/src/core/modules/docker/get-docker-networks.ts b/api/src/core/modules/docker/get-docker-networks.ts deleted file mode 100644 index f2beae1fa..000000000 --- a/api/src/core/modules/docker/get-docker-networks.ts +++ /dev/null @@ -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 => { - 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, { 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, - }; -}; diff --git a/api/src/core/modules/docker/index.ts b/api/src/core/modules/docker/index.ts deleted file mode 100644 index fca07f91a..000000000 --- a/api/src/core/modules/docker/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Created from 'create-ts-index' - -export * from './get-docker-containers.js'; -export * from './get-docker-networks.js'; diff --git a/api/src/core/modules/index.ts b/api/src/core/modules/index.ts index a08e81bfd..e299be1b0 100644 --- a/api/src/core/modules/index.ts +++ b/api/src/core/modules/index.ts @@ -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'; diff --git a/api/src/graphql/schema/types/docker/container.graphql b/api/src/graphql/schema/types/docker/container.graphql index 5bd747053..0c88b770a 100644 --- a/api/src/graphql/schema/types/docker/container.graphql +++ b/api/src/graphql/schema/types/docker/container.graphql @@ -1,12 +1,3 @@ -type Query { - """All Docker containers""" - dockerContainers(all: Boolean): [DockerContainer!]! -} - -type Subscription { - dockerContainer(id: ID!): DockerContainer! - dockerContainers: [DockerContainer] -} enum ContainerPortType { TCP diff --git a/api/src/index.ts b/api/src/index.ts index 7d937ebcf..1b8dfd699 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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 | 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(); diff --git a/api/src/store/index.ts b/api/src/store/index.ts index 7f4afffed..d8f08f020 100644 --- a/api/src/store/index.ts +++ b/api/src/store/index.ts @@ -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, diff --git a/api/src/store/modules/docker.ts b/api/src/store/modules/docker.ts deleted file mode 100644 index 37696cfe9..000000000 --- a/api/src/store/modules/docker.ts +++ /dev/null @@ -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>) { - 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; diff --git a/api/src/store/store-sync.ts b/api/src/store/store-sync.ts index 4689ee97c..d97e4d317 100644 --- a/api/src/store/store-sync.ts +++ b/api/src/store/store-sync.ts @@ -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 ( diff --git a/api/src/store/sync/info-apps-sync.ts b/api/src/store/sync/info-apps-sync.ts deleted file mode 100644 index 6746190ab..000000000 --- a/api/src/store/sync/info-apps-sync.ts +++ /dev/null @@ -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[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); -}; diff --git a/api/src/store/watch/docker-watch.ts b/api/src/store/watch/docker-watch.ts deleted file mode 100644 index 4eccfa67b..000000000 --- a/api/src/store/watch/docker-watch.ts +++ /dev/null @@ -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 => { - // 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; -}; diff --git a/api/src/store/watch/var-run-watch.ts b/api/src/store/watch/var-run-watch.ts deleted file mode 100644 index d1bfb8abd..000000000 --- a/api/src/store/watch/var-run-watch.ts +++ /dev/null @@ -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: [] })); - } - }); -}; diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index e2871f231..0a9b78d75 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -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 { diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts index 3c693cead..f1802671d 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts @@ -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); - 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 }, + }, + }); + }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index 5c5ec46ee..8e5c7d62f 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -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 = []; + 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 { + // 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 { + /** + * 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 { - 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( + { + 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((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 { + return this.client + .listNetworks() + .catch(catchHandlers.docker) + .then( + (networks = []) => + networks.map((object) => + camelCaseKeys(object as unknown as Record, { deep: true }) + ) as DockerNetwork[] + ); } public async startContainer(id: string): Promise { - 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 { - 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);