mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
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:
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './get-docker-containers.js';
|
||||
export * from './get-docker-networks.js';
|
||||
@@ -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';
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
type Query {
|
||||
"""All Docker containers"""
|
||||
dockerContainers(all: Boolean): [DockerContainer!]!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
dockerContainer(id: ID!): DockerContainer!
|
||||
dockerContainers: [DockerContainer]
|
||||
}
|
||||
|
||||
enum ContainerPortType {
|
||||
TCP
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: [] }));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user