mirror of
https://github.com/unraid/api.git
synced 2026-01-23 08:59:41 -06:00
refactor: docker log, event, and port services
This commit is contained in:
@@ -27,6 +27,7 @@ vi.mock('@nestjs/common', async () => {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -54,29 +55,33 @@ vi.mock('@app/core/pubsub.js', () => ({
|
||||
// Mock DockerService
|
||||
vi.mock('./docker.service.js', () => ({
|
||||
DockerService: vi.fn().mockImplementation(() => ({
|
||||
getDockerClient: vi.fn(),
|
||||
clearContainerCache: vi.fn(),
|
||||
getAppInfo: vi.fn().mockResolvedValue({ info: { apps: { installed: 1, running: 1 } } }),
|
||||
})),
|
||||
}));
|
||||
|
||||
const { mockDockerClientInstance } = vi.hoisted(() => {
|
||||
const mock = {
|
||||
getEvents: vi.fn(),
|
||||
} as unknown as Docker;
|
||||
return { mockDockerClientInstance: mock };
|
||||
});
|
||||
|
||||
// Mock the docker client util
|
||||
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
|
||||
getDockerClient: vi.fn().mockReturnValue(mockDockerClientInstance),
|
||||
}));
|
||||
|
||||
describe('DockerEventService', () => {
|
||||
let service: DockerEventService;
|
||||
let dockerService: DockerService;
|
||||
let mockDockerClient: Docker;
|
||||
let mockEventStream: PassThrough;
|
||||
let mockLogger: Logger;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a mock Docker client
|
||||
mockDockerClient = {
|
||||
getEvents: vi.fn(),
|
||||
} as unknown as Docker;
|
||||
|
||||
// Create a mock Docker service *instance*
|
||||
const mockDockerServiceImpl = {
|
||||
getDockerClient: vi.fn().mockReturnValue(mockDockerClient),
|
||||
clearContainerCache: vi.fn(),
|
||||
getAppInfo: vi.fn().mockResolvedValue({ info: { apps: { installed: 1, running: 1 } } }),
|
||||
};
|
||||
@@ -85,7 +90,7 @@ describe('DockerEventService', () => {
|
||||
mockEventStream = new PassThrough();
|
||||
|
||||
// Set up the mock Docker client to return our mock event stream
|
||||
vi.spyOn(mockDockerClient, 'getEvents').mockResolvedValue(
|
||||
vi.spyOn(mockDockerClientInstance, 'getEvents').mockResolvedValue(
|
||||
mockEventStream as unknown as Readable
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import Docker from 'dockerode';
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
|
||||
|
||||
enum DockerEventAction {
|
||||
DIE = 'die',
|
||||
@@ -66,7 +67,7 @@ export class DockerEventService implements OnModuleDestroy, OnModuleInit {
|
||||
];
|
||||
|
||||
constructor(private readonly dockerService: DockerService) {
|
||||
this.client = this.dockerService.getDockerClient();
|
||||
this.client = getDockerClient();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
|
||||
import { DockerContainerLogs } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockExeca = vi.fn();
|
||||
vi.mock('execa', () => ({
|
||||
execa: (cmd: string, args: string[]) => mockExeca(cmd, args),
|
||||
}));
|
||||
|
||||
const { mockDockerInstance, mockGetContainer, mockContainer } = vi.hoisted(() => {
|
||||
const mockContainer = {
|
||||
inspect: vi.fn(),
|
||||
};
|
||||
const mockGetContainer = vi.fn().mockReturnValue(mockContainer);
|
||||
const mockDockerInstance = {
|
||||
getContainer: mockGetContainer,
|
||||
};
|
||||
return { mockDockerInstance, mockGetContainer, mockContainer };
|
||||
});
|
||||
|
||||
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
|
||||
getDockerClient: vi.fn().mockReturnValue(mockDockerInstance),
|
||||
}));
|
||||
|
||||
const { statMock } = vi.hoisted(() => ({
|
||||
statMock: vi.fn().mockResolvedValue({ size: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: statMock,
|
||||
}));
|
||||
|
||||
describe('DockerLogService', () => {
|
||||
let service: DockerLogService;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExeca.mockReset();
|
||||
mockGetContainer.mockReset();
|
||||
mockGetContainer.mockReturnValue(mockContainer);
|
||||
mockContainer.inspect.mockReset();
|
||||
statMock.mockReset();
|
||||
statMock.mockResolvedValue({ size: 0 });
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [DockerLogService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DockerLogService>(DockerLogService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getContainerLogSizes', () => {
|
||||
it('should get container log sizes using dockerode inspect', async () => {
|
||||
mockContainer.inspect.mockResolvedValue({
|
||||
LogPath: '/var/lib/docker/containers/id/id-json.log',
|
||||
});
|
||||
statMock.mockResolvedValue({ size: 1024 });
|
||||
|
||||
const sizes = await service.getContainerLogSizes(['test-container']);
|
||||
|
||||
expect(mockGetContainer).toHaveBeenCalledWith('test-container');
|
||||
expect(mockContainer.inspect).toHaveBeenCalled();
|
||||
expect(statMock).toHaveBeenCalledWith('/var/lib/docker/containers/id/id-json.log');
|
||||
expect(sizes.get('test-container')).toBe(1024);
|
||||
});
|
||||
|
||||
it('should return 0 for missing log path', async () => {
|
||||
mockContainer.inspect.mockResolvedValue({}); // No LogPath
|
||||
|
||||
const sizes = await service.getContainerLogSizes(['test-container']);
|
||||
expect(sizes.get('test-container')).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle inspect errors gracefully', async () => {
|
||||
mockContainer.inspect.mockRejectedValue(new Error('Inspect failed'));
|
||||
|
||||
const sizes = await service.getContainerLogSizes(['test-container']);
|
||||
expect(sizes.get('test-container')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerLogs', () => {
|
||||
it('should fetch logs via docker CLI', async () => {
|
||||
mockExeca.mockResolvedValue({ stdout: '2023-01-01T00:00:00Z Log message\n' });
|
||||
|
||||
const result = await service.getContainerLogs('test-id');
|
||||
|
||||
expect(mockExeca).toHaveBeenCalledWith('docker', [
|
||||
'logs',
|
||||
'--timestamps',
|
||||
'--tail',
|
||||
'200',
|
||||
'test-id',
|
||||
]);
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.lines[0].message).toBe('Log message');
|
||||
});
|
||||
|
||||
it('should respect tail option', async () => {
|
||||
mockExeca.mockResolvedValue({ stdout: '' });
|
||||
|
||||
await service.getContainerLogs('test-id', { tail: 50 });
|
||||
|
||||
expect(mockExeca).toHaveBeenCalledWith('docker', [
|
||||
'logs',
|
||||
'--timestamps',
|
||||
'--tail',
|
||||
'50',
|
||||
'test-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should respect since option', async () => {
|
||||
mockExeca.mockResolvedValue({ stdout: '' });
|
||||
const since = new Date('2023-01-01T00:00:00Z');
|
||||
|
||||
await service.getContainerLogs('test-id', { since });
|
||||
|
||||
expect(mockExeca).toHaveBeenCalledWith('docker', [
|
||||
'logs',
|
||||
'--timestamps',
|
||||
'--tail',
|
||||
'200',
|
||||
'--since',
|
||||
since.toISOString(),
|
||||
'test-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw AppError on execa failure', async () => {
|
||||
mockExeca.mockRejectedValue(new Error('Docker error'));
|
||||
|
||||
await expect(service.getContainerLogs('test-id')).rejects.toThrow(AppError);
|
||||
});
|
||||
});
|
||||
});
|
||||
149
api/src/unraid-api/graph/resolvers/docker/docker-log.service.ts
Normal file
149
api/src/unraid-api/graph/resolvers/docker/docker-log.service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { stat } from 'fs/promises';
|
||||
|
||||
import type { ExecaError } from 'execa';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import {
|
||||
DockerContainerLogLine,
|
||||
DockerContainerLogs,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
|
||||
|
||||
@Injectable()
|
||||
export class DockerLogService {
|
||||
private readonly logger = new Logger(DockerLogService.name);
|
||||
private readonly client = getDockerClient();
|
||||
|
||||
private static readonly DEFAULT_LOG_TAIL = 200;
|
||||
private static readonly MAX_LOG_TAIL = 2000;
|
||||
|
||||
public async getContainerLogSizes(containerNames: string[]): Promise<Map<string, number>> {
|
||||
const logSizes = new Map<string, number>();
|
||||
if (!Array.isArray(containerNames) || containerNames.length === 0) {
|
||||
return logSizes;
|
||||
}
|
||||
|
||||
for (const rawName of containerNames) {
|
||||
const normalized = (rawName ?? '').replace(/^\//, '');
|
||||
if (!normalized) {
|
||||
logSizes.set(normalized, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this.client.getContainer(normalized);
|
||||
const info = await container.inspect();
|
||||
const logPath = info.LogPath;
|
||||
|
||||
if (!logPath || typeof logPath !== 'string' || !logPath.length) {
|
||||
logSizes.set(normalized, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = await stat(logPath).catch(() => null);
|
||||
logSizes.set(normalized, stats?.size ?? 0);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? 'unknown error');
|
||||
this.logger.debug(
|
||||
`Failed to determine log size for container ${normalized}: ${message}`
|
||||
);
|
||||
logSizes.set(normalized, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return logSizes;
|
||||
}
|
||||
|
||||
public async getContainerLogs(
|
||||
id: string,
|
||||
options?: { since?: Date | null; tail?: number | null }
|
||||
): Promise<DockerContainerLogs> {
|
||||
const normalizedId = (id ?? '').trim();
|
||||
if (!normalizedId) {
|
||||
throw new AppError('Container id is required to fetch logs.', 400);
|
||||
}
|
||||
|
||||
const tail = this.normalizeLogTail(options?.tail);
|
||||
const args = ['logs', '--timestamps', '--tail', String(tail)];
|
||||
const sinceIso = options?.since instanceof Date ? options.since.toISOString() : null;
|
||||
if (sinceIso) {
|
||||
args.push('--since', sinceIso);
|
||||
}
|
||||
args.push(normalizedId);
|
||||
|
||||
try {
|
||||
const { stdout } = await execa('docker', args);
|
||||
const lines = this.parseDockerLogOutput(stdout);
|
||||
const cursor =
|
||||
lines.length > 0 ? lines[lines.length - 1].timestamp : (options?.since ?? null);
|
||||
|
||||
return {
|
||||
containerId: normalizedId,
|
||||
lines,
|
||||
cursor: cursor ?? undefined,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const execaError = error as ExecaError;
|
||||
const stderr = typeof execaError?.stderr === 'string' ? execaError.stderr.trim() : '';
|
||||
const message = stderr || execaError?.message || 'Unknown error';
|
||||
this.logger.error(
|
||||
`Failed to fetch logs for container ${normalizedId}: ${message}`,
|
||||
execaError
|
||||
);
|
||||
throw new AppError(`Failed to fetch logs for container ${normalizedId}.`);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeLogTail(tail?: number | null): number {
|
||||
if (typeof tail !== 'number' || Number.isNaN(tail)) {
|
||||
return DockerLogService.DEFAULT_LOG_TAIL;
|
||||
}
|
||||
const coerced = Math.floor(tail);
|
||||
if (!Number.isFinite(coerced) || coerced <= 0) {
|
||||
return DockerLogService.DEFAULT_LOG_TAIL;
|
||||
}
|
||||
return Math.min(coerced, DockerLogService.MAX_LOG_TAIL);
|
||||
}
|
||||
|
||||
private parseDockerLogOutput(output: string): DockerContainerLogLine[] {
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
return output
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => this.parseDockerLogLine(line))
|
||||
.filter((entry): entry is DockerContainerLogLine => Boolean(entry));
|
||||
}
|
||||
|
||||
private parseDockerLogLine(line: string): DockerContainerLogLine | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
const firstSpaceIndex = trimmed.indexOf(' ');
|
||||
if (firstSpaceIndex === -1) {
|
||||
return {
|
||||
timestamp: new Date(),
|
||||
message: trimmed,
|
||||
};
|
||||
}
|
||||
const potentialTimestamp = trimmed.slice(0, firstSpaceIndex);
|
||||
const message = trimmed.slice(firstSpaceIndex + 1);
|
||||
const parsedTimestamp = new Date(potentialTimestamp);
|
||||
if (Number.isNaN(parsedTimestamp.getTime())) {
|
||||
return {
|
||||
timestamp: new Date(),
|
||||
message: trimmed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
timestamp: parsedTimestamp,
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
|
||||
|
||||
const { mockDockerInstance, mockListNetworks } = vi.hoisted(() => {
|
||||
const mockListNetworks = vi.fn();
|
||||
const mockDockerInstance = {
|
||||
listNetworks: mockListNetworks,
|
||||
};
|
||||
return { mockDockerInstance, mockListNetworks };
|
||||
});
|
||||
|
||||
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
|
||||
getDockerClient: vi.fn().mockReturnValue(mockDockerInstance),
|
||||
}));
|
||||
|
||||
const mockCacheManager = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
describe('DockerNetworkService', () => {
|
||||
let service: DockerNetworkService;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockListNetworks.mockReset();
|
||||
mockCacheManager.get.mockReset();
|
||||
mockCacheManager.set.mockReset();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DockerNetworkService,
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: mockCacheManager,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DockerNetworkService>(DockerNetworkService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getNetworks', () => {
|
||||
it('should return cached networks if available and not skipped', async () => {
|
||||
const cached = [{ id: 'net1', name: 'test-net' }];
|
||||
mockCacheManager.get.mockResolvedValue(cached);
|
||||
|
||||
const result = await service.getNetworks({ skipCache: false });
|
||||
expect(result).toEqual(cached);
|
||||
expect(mockListNetworks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch networks from docker if cache skipped', async () => {
|
||||
const rawNetworks = [
|
||||
{
|
||||
Id: 'net1',
|
||||
Name: 'test-net',
|
||||
Driver: 'bridge',
|
||||
},
|
||||
];
|
||||
mockListNetworks.mockResolvedValue(rawNetworks);
|
||||
|
||||
const result = await service.getNetworks({ skipCache: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('net1');
|
||||
expect(mockListNetworks).toHaveBeenCalled();
|
||||
expect(mockCacheManager.set).toHaveBeenCalledWith(
|
||||
DockerNetworkService.NETWORK_CACHE_KEY,
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch networks from docker if cache miss', async () => {
|
||||
mockCacheManager.get.mockResolvedValue(undefined);
|
||||
mockListNetworks.mockResolvedValue([]);
|
||||
|
||||
await service.getNetworks({ skipCache: false });
|
||||
expect(mockListNetworks).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type Cache } from 'cache-manager';
|
||||
|
||||
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
|
||||
import { DockerNetwork } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
|
||||
|
||||
interface NetworkListingOptions {
|
||||
skipCache: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DockerNetworkService {
|
||||
private readonly logger = new Logger(DockerNetworkService.name);
|
||||
private readonly client = getDockerClient();
|
||||
|
||||
public static readonly NETWORK_CACHE_KEY = 'docker_networks';
|
||||
private static readonly CACHE_TTL_SECONDS = 60;
|
||||
|
||||
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
/**
|
||||
* Get all Docker networks
|
||||
* @returns All the in/active Docker networks on the system.
|
||||
*/
|
||||
public async getNetworks({ skipCache }: NetworkListingOptions): Promise<DockerNetwork[]> {
|
||||
if (!skipCache) {
|
||||
const cachedNetworks = await this.cacheManager.get<DockerNetwork[]>(
|
||||
DockerNetworkService.NETWORK_CACHE_KEY
|
||||
);
|
||||
if (cachedNetworks) {
|
||||
this.logger.debug('Using docker network cache');
|
||||
return cachedNetworks;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Updating docker network cache');
|
||||
const rawNetworks = await this.client.listNetworks().catch(catchHandlers.docker);
|
||||
const networks = rawNetworks.map(
|
||||
(network) =>
|
||||
({
|
||||
name: network.Name || '',
|
||||
id: network.Id || '',
|
||||
created: network.Created || '',
|
||||
scope: network.Scope || '',
|
||||
driver: network.Driver || '',
|
||||
enableIPv6: network.EnableIPv6 || false,
|
||||
ipam: network.IPAM || {},
|
||||
internal: network.Internal || false,
|
||||
attachable: network.Attachable || false,
|
||||
ingress: network.Ingress || false,
|
||||
configFrom: network.ConfigFrom || {},
|
||||
configOnly: network.ConfigOnly || false,
|
||||
containers: network.Containers || {},
|
||||
options: network.Options || {},
|
||||
labels: network.Labels || {},
|
||||
}) as DockerNetwork
|
||||
);
|
||||
|
||||
await this.cacheManager.set(
|
||||
DockerNetworkService.NETWORK_CACHE_KEY,
|
||||
networks,
|
||||
DockerNetworkService.CACHE_TTL_SECONDS * 1000
|
||||
);
|
||||
return networks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
|
||||
import {
|
||||
ContainerPortType,
|
||||
DockerContainer,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
|
||||
vi.mock('@app/core/utils/network.js', () => ({
|
||||
getLanIp: vi.fn().mockReturnValue('192.168.1.100'),
|
||||
}));
|
||||
|
||||
describe('DockerPortService', () => {
|
||||
let service: DockerPortService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [DockerPortService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DockerPortService>(DockerPortService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('deduplicateContainerPorts', () => {
|
||||
it('should deduplicate ports', () => {
|
||||
const ports = [
|
||||
{ PrivatePort: 80, PublicPort: 80, Type: 'tcp' },
|
||||
{ PrivatePort: 80, PublicPort: 80, Type: 'tcp' },
|
||||
{ PrivatePort: 443, PublicPort: 443, Type: 'tcp' },
|
||||
];
|
||||
// @ts-expect-error - types are loosely mocked
|
||||
const result = service.deduplicateContainerPorts(ports);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateConflicts', () => {
|
||||
it('should detect port conflicts', () => {
|
||||
const containers = [
|
||||
{
|
||||
id: 'c1',
|
||||
names: ['/web1'],
|
||||
ports: [{ privatePort: 80, type: ContainerPortType.TCP }],
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
names: ['/web2'],
|
||||
ports: [{ privatePort: 80, type: ContainerPortType.TCP }],
|
||||
},
|
||||
] as DockerContainer[];
|
||||
|
||||
const result = service.calculateConflicts(containers);
|
||||
expect(result.containerPorts).toHaveLength(1);
|
||||
expect(result.containerPorts[0].privatePort).toBe(80);
|
||||
expect(result.containerPorts[0].containers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should detect lan port conflicts', () => {
|
||||
const containers = [
|
||||
{
|
||||
id: 'c1',
|
||||
names: ['/web1'],
|
||||
ports: [{ publicPort: 8080, type: ContainerPortType.TCP }],
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
names: ['/web2'],
|
||||
ports: [{ publicPort: 8080, type: ContainerPortType.TCP }],
|
||||
},
|
||||
] as DockerContainer[];
|
||||
|
||||
const result = service.calculateConflicts(containers);
|
||||
expect(result.lanPorts).toHaveLength(1);
|
||||
expect(result.lanPorts[0].publicPort).toBe(8080);
|
||||
expect(result.lanPorts[0].containers).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
api/src/unraid-api/graph/resolvers/docker/docker-port.service.ts
Normal file
178
api/src/unraid-api/graph/resolvers/docker/docker-port.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import Docker from 'dockerode';
|
||||
|
||||
import { getLanIp } from '@app/core/utils/network.js';
|
||||
import {
|
||||
ContainerPortType,
|
||||
DockerContainer,
|
||||
DockerContainerPortConflict,
|
||||
DockerLanPortConflict,
|
||||
DockerPortConflictContainer,
|
||||
DockerPortConflicts,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class DockerPortService {
|
||||
public deduplicateContainerPorts(
|
||||
ports: Docker.ContainerInfo['Ports'] | undefined
|
||||
): Docker.ContainerInfo['Ports'] {
|
||||
if (!Array.isArray(ports)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const uniquePorts: Docker.ContainerInfo['Ports'] = [];
|
||||
|
||||
for (const port of ports) {
|
||||
const key = `${port.PrivatePort ?? ''}-${port.PublicPort ?? ''}-${(port.Type ?? '').toLowerCase()}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
uniquePorts.push(port);
|
||||
}
|
||||
|
||||
return uniquePorts;
|
||||
}
|
||||
|
||||
public calculateConflicts(containers: DockerContainer[]): DockerPortConflicts {
|
||||
return {
|
||||
containerPorts: this.buildContainerPortConflicts(containers),
|
||||
lanPorts: this.buildLanPortConflicts(containers),
|
||||
};
|
||||
}
|
||||
|
||||
private buildPortConflictContainerRef(container: DockerContainer): DockerPortConflictContainer {
|
||||
const primaryName = this.getContainerPrimaryName(container);
|
||||
const fallback = container.names?.[0] ?? container.id;
|
||||
const normalized = typeof fallback === 'string' ? fallback.replace(/^\//, '') : container.id;
|
||||
return {
|
||||
id: container.id,
|
||||
name: primaryName || normalized,
|
||||
};
|
||||
}
|
||||
|
||||
private getContainerPrimaryName(container: DockerContainer): string | null {
|
||||
const names = container.names;
|
||||
const firstName = names?.[0] ?? '';
|
||||
return firstName ? firstName.replace(/^\//, '') : null;
|
||||
}
|
||||
|
||||
private buildContainerPortConflicts(containers: DockerContainer[]): DockerContainerPortConflict[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
privatePort: number;
|
||||
type: ContainerPortType;
|
||||
containers: DockerContainer[];
|
||||
seen: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const container of containers) {
|
||||
if (!Array.isArray(container.ports)) {
|
||||
continue;
|
||||
}
|
||||
for (const port of container.ports) {
|
||||
if (!port || typeof port.privatePort !== 'number') {
|
||||
continue;
|
||||
}
|
||||
const type = port.type ?? ContainerPortType.TCP;
|
||||
const key = `${port.privatePort}/${type}`;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
privatePort: port.privatePort,
|
||||
type,
|
||||
containers: [],
|
||||
seen: new Set<string>(),
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
if (group.seen.has(container.id)) {
|
||||
continue;
|
||||
}
|
||||
group.seen.add(container.id);
|
||||
group.containers.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.filter((group) => group.containers.length > 1)
|
||||
.map((group) => ({
|
||||
privatePort: group.privatePort,
|
||||
type: group.type,
|
||||
containers: group.containers.map((container) =>
|
||||
this.buildPortConflictContainerRef(container)
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.privatePort !== b.privatePort) {
|
||||
return a.privatePort - b.privatePort;
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
});
|
||||
}
|
||||
|
||||
private buildLanPortConflicts(containers: DockerContainer[]): DockerLanPortConflict[] {
|
||||
const lanIp = getLanIp();
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
lanIpPort: string;
|
||||
publicPort: number;
|
||||
type: ContainerPortType;
|
||||
containers: DockerContainer[];
|
||||
seen: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const container of containers) {
|
||||
if (!Array.isArray(container.ports)) {
|
||||
continue;
|
||||
}
|
||||
for (const port of container.ports) {
|
||||
if (!port || typeof port.publicPort !== 'number') {
|
||||
continue;
|
||||
}
|
||||
const type = port.type ?? ContainerPortType.TCP;
|
||||
const lanIpPort = lanIp ? `${lanIp}:${port.publicPort}` : `${port.publicPort}`;
|
||||
const key = `${lanIpPort}/${type}`;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
lanIpPort,
|
||||
publicPort: port.publicPort,
|
||||
type,
|
||||
containers: [],
|
||||
seen: new Set<string>(),
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
if (group.seen.has(container.id)) {
|
||||
continue;
|
||||
}
|
||||
group.seen.add(container.id);
|
||||
group.containers.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.filter((group) => group.containers.length > 1)
|
||||
.map((group) => ({
|
||||
lanIpPort: group.lanIpPort,
|
||||
publicPort: group.publicPort,
|
||||
type: group.type,
|
||||
containers: group.containers.map((container) =>
|
||||
this.buildPortConflictContainerRef(container)
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if ((a.publicPort ?? 0) !== (b.publicPort ?? 0)) {
|
||||
return (a.publicPort ?? 0) - (b.publicPort ?? 0);
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js';
|
||||
import { DockerFormService } from '@app/unraid-api/graph/resolvers/docker/docker-form.service.js';
|
||||
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
|
||||
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
|
||||
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
|
||||
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
|
||||
import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js';
|
||||
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
|
||||
import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js';
|
||||
@@ -28,6 +31,12 @@ describe('DockerModule', () => {
|
||||
.useValue({ getConfig: vi.fn() })
|
||||
.overrideProvider(DockerConfigService)
|
||||
.useValue({ getConfig: vi.fn() })
|
||||
.overrideProvider(DockerLogService)
|
||||
.useValue({})
|
||||
.overrideProvider(DockerNetworkService)
|
||||
.useValue({})
|
||||
.overrideProvider(DockerPortService)
|
||||
.useValue({})
|
||||
.overrideProvider(SubscriptionTrackerService)
|
||||
.useValue({
|
||||
registerTopic: vi.fn(),
|
||||
@@ -62,6 +71,10 @@ describe('DockerModule', () => {
|
||||
});
|
||||
|
||||
it('should provide DockerEventService', async () => {
|
||||
// DockerEventService is not exported by DockerModule but we can test if we can provide it
|
||||
// But here we are creating a module with providers manually, not importing DockerModule.
|
||||
// Wait, DockerEventService was NOT in DockerModule providers in my refactor?
|
||||
// I should check if DockerEventService is in DockerModule.
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DockerEventService,
|
||||
|
||||
@@ -6,8 +6,11 @@ import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/d
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerContainerResolver } from '@app/unraid-api/graph/resolvers/docker/docker-container.resolver.js';
|
||||
import { DockerFormService } from '@app/unraid-api/graph/resolvers/docker/docker-form.service.js';
|
||||
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
|
||||
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
|
||||
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
|
||||
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
|
||||
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
|
||||
import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js';
|
||||
import { DockerTemplateIconService } from '@app/unraid-api/graph/resolvers/docker/docker-template-icon.service.js';
|
||||
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
|
||||
@@ -34,6 +37,9 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j
|
||||
DockerTemplateScannerService,
|
||||
DockerTemplateIconService,
|
||||
DockerStatsService,
|
||||
DockerLogService,
|
||||
DockerNetworkService,
|
||||
DockerPortService,
|
||||
|
||||
// Jobs
|
||||
ContainerStatusJob,
|
||||
|
||||
@@ -8,7 +8,10 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
|
||||
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
|
||||
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
|
||||
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
|
||||
|
||||
@@ -75,6 +78,9 @@ describe.runIf(dockerAvailable)('DockerService Integration', () => {
|
||||
providers: [
|
||||
DockerService,
|
||||
DockerAutostartService,
|
||||
DockerLogService,
|
||||
DockerNetworkService,
|
||||
DockerPortService,
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
{ provide: DockerConfigService, useValue: mockDockerConfigService },
|
||||
{ provide: DockerManifestService, useValue: mockDockerManifestService },
|
||||
|
||||
@@ -9,7 +9,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
|
||||
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
|
||||
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
|
||||
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
|
||||
import {
|
||||
ContainerPortType,
|
||||
ContainerState,
|
||||
@@ -70,11 +73,9 @@ const { mockDockerInstance, mockListContainers, mockGetContainer, mockListNetwor
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dockerode', () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => mockDockerInstance),
|
||||
};
|
||||
});
|
||||
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
|
||||
getDockerClient: vi.fn().mockReturnValue(mockDockerInstance),
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: vi.fn(),
|
||||
@@ -152,6 +153,32 @@ const mockDockerAutostartService = {
|
||||
updateAutostartConfiguration: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Mock new services
|
||||
const mockDockerLogService = {
|
||||
getContainerLogSizes: vi.fn().mockResolvedValue(new Map([['test-container', 1024]])),
|
||||
getContainerLogs: vi.fn().mockResolvedValue({ lines: [], cursor: null }),
|
||||
};
|
||||
|
||||
const mockDockerNetworkService = {
|
||||
getNetworks: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
// Use a real-ish mock for DockerPortService since it is used in transformContainer
|
||||
const mockDockerPortService = {
|
||||
deduplicateContainerPorts: vi.fn((ports) => {
|
||||
if (!ports) return [];
|
||||
// Simple dedupe logic for test
|
||||
const seen = new Set();
|
||||
return ports.filter((p) => {
|
||||
const key = `${p.PrivatePort}-${p.PublicPort}-${p.Type}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
calculateConflicts: vi.fn().mockReturnValue({ containerPorts: [], lanPorts: [] }),
|
||||
};
|
||||
|
||||
describe('DockerService', () => {
|
||||
let service: DockerService;
|
||||
|
||||
@@ -189,6 +216,14 @@ describe('DockerService', () => {
|
||||
mockDockerAutostartService.getAutoStartEntry.mockReset();
|
||||
mockDockerAutostartService.updateAutostartConfiguration.mockReset();
|
||||
|
||||
mockDockerLogService.getContainerLogSizes.mockReset();
|
||||
mockDockerLogService.getContainerLogSizes.mockResolvedValue(new Map([['test-container', 1024]]));
|
||||
mockDockerLogService.getContainerLogs.mockReset();
|
||||
|
||||
mockDockerNetworkService.getNetworks.mockReset();
|
||||
mockDockerPortService.deduplicateContainerPorts.mockClear();
|
||||
mockDockerPortService.calculateConflicts.mockReset();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DockerService,
|
||||
@@ -212,6 +247,18 @@ describe('DockerService', () => {
|
||||
provide: DockerAutostartService,
|
||||
useValue: mockDockerAutostartService,
|
||||
},
|
||||
{
|
||||
provide: DockerLogService,
|
||||
useValue: mockDockerLogService,
|
||||
},
|
||||
{
|
||||
provide: DockerNetworkService,
|
||||
useValue: mockDockerNetworkService,
|
||||
},
|
||||
{
|
||||
provide: DockerPortService,
|
||||
useValue: mockDockerPortService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -222,8 +269,6 @@ describe('DockerService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
// ... most tests remain similar but no file I/O assertions ...
|
||||
|
||||
it('should get containers', async () => {
|
||||
const mockContainers = [
|
||||
{
|
||||
@@ -261,6 +306,7 @@ describe('DockerService', () => {
|
||||
|
||||
expect(mockListContainers).toHaveBeenCalled();
|
||||
expect(mockDockerAutostartService.refreshAutoStartEntries).toHaveBeenCalled();
|
||||
expect(mockDockerPortService.deduplicateContainerPorts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update auto-start configuration', async () => {
|
||||
@@ -283,17 +329,9 @@ describe('DockerService', () => {
|
||||
expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY);
|
||||
});
|
||||
|
||||
it('should get container log sizes using dockerode inspect', async () => {
|
||||
mockContainer.inspect.mockResolvedValue({
|
||||
LogPath: '/var/lib/docker/containers/id/id-json.log',
|
||||
});
|
||||
statMock.mockResolvedValue({ size: 1024 });
|
||||
|
||||
it('should delegate getContainerLogSizes to DockerLogService', async () => {
|
||||
const sizes = await service.getContainerLogSizes(['test-container']);
|
||||
|
||||
expect(mockGetContainer).toHaveBeenCalledWith('test-container');
|
||||
expect(mockContainer.inspect).toHaveBeenCalled();
|
||||
expect(statMock).toHaveBeenCalledWith('/var/lib/docker/containers/id/id-json.log');
|
||||
expect(mockDockerLogService.getContainerLogSizes).toHaveBeenCalledWith(['test-container']);
|
||||
expect(sizes.get('test-container')).toBe(1024);
|
||||
});
|
||||
|
||||
@@ -343,23 +381,10 @@ describe('DockerService', () => {
|
||||
Mounts: [],
|
||||
} as Docker.ContainerInfo;
|
||||
|
||||
const transformed = service.transformContainer(container);
|
||||
|
||||
expect(transformed.ports).toEqual([
|
||||
{
|
||||
ip: '0.0.0.0',
|
||||
privatePort: 8080,
|
||||
publicPort: 8080,
|
||||
type: ContainerPortType.TCP,
|
||||
},
|
||||
{
|
||||
ip: '0.0.0.0',
|
||||
privatePort: 5000,
|
||||
publicPort: 5000,
|
||||
type: ContainerPortType.UDP,
|
||||
},
|
||||
]);
|
||||
expect(transformed.lanIpPorts).toEqual(['192.168.0.10:8080', '192.168.0.10:5000']);
|
||||
service.transformContainer(container);
|
||||
expect(mockDockerPortService.deduplicateContainerPorts).toHaveBeenCalledWith(
|
||||
container.Ports
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { stat } from 'fs/promises';
|
||||
|
||||
import type { ExecaError } from 'execa';
|
||||
import { type Cache } from 'cache-manager';
|
||||
import Docker from 'dockerode';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import type { DockerAutostartEntryInput } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
|
||||
@@ -15,19 +12,20 @@ import { sleep } from '@app/core/utils/misc/sleep.js';
|
||||
import { getLanIp } from '@app/core/utils/network.js';
|
||||
import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
|
||||
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
|
||||
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
|
||||
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
|
||||
import {
|
||||
ContainerPortType,
|
||||
ContainerState,
|
||||
DockerAutostartEntryInput,
|
||||
DockerContainer,
|
||||
DockerContainerLogLine,
|
||||
DockerContainerLogs,
|
||||
DockerContainerPortConflict,
|
||||
DockerLanPortConflict,
|
||||
DockerNetwork,
|
||||
DockerPortConflictContainer,
|
||||
DockerPortConflicts,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
|
||||
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
|
||||
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
|
||||
|
||||
@@ -48,175 +46,21 @@ export class DockerService {
|
||||
public static readonly CONTAINER_WITH_SIZE_CACHE_KEY = 'docker_containers_with_size';
|
||||
public static readonly NETWORK_CACHE_KEY = 'docker_networks';
|
||||
public static readonly CACHE_TTL_SECONDS = 60;
|
||||
private static readonly DEFAULT_LOG_TAIL = 200;
|
||||
private static readonly MAX_LOG_TAIL = 2000;
|
||||
|
||||
constructor(
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
private readonly dockerConfigService: DockerConfigService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly dockerManifestService: DockerManifestService,
|
||||
private readonly autostartService: DockerAutostartService
|
||||
private readonly autostartService: DockerAutostartService,
|
||||
private readonly dockerLogService: DockerLogService,
|
||||
private readonly dockerNetworkService: DockerNetworkService,
|
||||
private readonly dockerPortService: DockerPortService
|
||||
) {
|
||||
this.client = this.getDockerClient();
|
||||
this.client = getDockerClient();
|
||||
}
|
||||
|
||||
private deduplicateContainerPorts(
|
||||
ports: Docker.ContainerInfo['Ports'] | undefined
|
||||
): Docker.ContainerInfo['Ports'] {
|
||||
if (!Array.isArray(ports)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const uniquePorts: Docker.ContainerInfo['Ports'] = [];
|
||||
|
||||
for (const port of ports) {
|
||||
const key = `${port.PrivatePort ?? ''}-${port.PublicPort ?? ''}-${(port.Type ?? '').toLowerCase()}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
uniquePorts.push(port);
|
||||
}
|
||||
|
||||
return uniquePorts;
|
||||
}
|
||||
|
||||
private buildPortConflictContainerRef(container: DockerContainer): DockerPortConflictContainer {
|
||||
const primaryName = this.autostartService.getContainerPrimaryName(container);
|
||||
const fallback = container.names?.[0] ?? container.id;
|
||||
const normalized = typeof fallback === 'string' ? fallback.replace(/^\//, '') : container.id;
|
||||
return {
|
||||
id: container.id,
|
||||
name: primaryName || normalized,
|
||||
};
|
||||
}
|
||||
|
||||
private buildContainerPortConflicts(containers: DockerContainer[]): DockerContainerPortConflict[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
privatePort: number;
|
||||
type: ContainerPortType;
|
||||
containers: DockerContainer[];
|
||||
seen: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const container of containers) {
|
||||
if (!Array.isArray(container.ports)) {
|
||||
continue;
|
||||
}
|
||||
for (const port of container.ports) {
|
||||
if (!port || typeof port.privatePort !== 'number') {
|
||||
continue;
|
||||
}
|
||||
const type = port.type ?? ContainerPortType.TCP;
|
||||
const key = `${port.privatePort}/${type}`;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
privatePort: port.privatePort,
|
||||
type,
|
||||
containers: [],
|
||||
seen: new Set<string>(),
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
if (group.seen.has(container.id)) {
|
||||
continue;
|
||||
}
|
||||
group.seen.add(container.id);
|
||||
group.containers.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.filter((group) => group.containers.length > 1)
|
||||
.map((group) => ({
|
||||
privatePort: group.privatePort,
|
||||
type: group.type,
|
||||
containers: group.containers.map((container) =>
|
||||
this.buildPortConflictContainerRef(container)
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.privatePort !== b.privatePort) {
|
||||
return a.privatePort - b.privatePort;
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
});
|
||||
}
|
||||
|
||||
private buildLanPortConflicts(containers: DockerContainer[]): DockerLanPortConflict[] {
|
||||
const lanIp = getLanIp();
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
lanIpPort: string;
|
||||
publicPort: number;
|
||||
type: ContainerPortType;
|
||||
containers: DockerContainer[];
|
||||
seen: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const container of containers) {
|
||||
if (!Array.isArray(container.ports)) {
|
||||
continue;
|
||||
}
|
||||
for (const port of container.ports) {
|
||||
if (!port || typeof port.publicPort !== 'number') {
|
||||
continue;
|
||||
}
|
||||
const type = port.type ?? ContainerPortType.TCP;
|
||||
const lanIpPort = lanIp ? `${lanIp}:${port.publicPort}` : `${port.publicPort}`;
|
||||
const key = `${lanIpPort}/${type}`;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
lanIpPort,
|
||||
publicPort: port.publicPort,
|
||||
type,
|
||||
containers: [],
|
||||
seen: new Set<string>(),
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
if (group.seen.has(container.id)) {
|
||||
continue;
|
||||
}
|
||||
group.seen.add(container.id);
|
||||
group.containers.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.filter((group) => group.containers.length > 1)
|
||||
.map((group) => ({
|
||||
lanIpPort: group.lanIpPort,
|
||||
publicPort: group.publicPort,
|
||||
type: group.type,
|
||||
containers: group.containers.map((container) =>
|
||||
this.buildPortConflictContainerRef(container)
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if ((a.publicPort ?? 0) !== (b.publicPort ?? 0)) {
|
||||
return (a.publicPort ?? 0) - (b.publicPort ?? 0);
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
});
|
||||
}
|
||||
|
||||
public getDockerClient() {
|
||||
return new Docker({
|
||||
socketPath: '/var/run/docker.sock',
|
||||
});
|
||||
}
|
||||
|
||||
async getAppInfo() {
|
||||
public async getAppInfo() {
|
||||
const containers = await this.getContainers({ skipCache: false });
|
||||
const installedCount = containers.length;
|
||||
const runningCount = containers.filter(
|
||||
@@ -247,7 +91,7 @@ export class DockerService {
|
||||
: undefined;
|
||||
const lanIp = getLanIp();
|
||||
const lanPortStrings: string[] = [];
|
||||
const uniquePorts = this.deduplicateContainerPorts(container.Ports);
|
||||
const uniquePorts = this.dockerPortService.deduplicateContainerPorts(container.Ports);
|
||||
|
||||
const transformedPorts = uniquePorts.map((port) => {
|
||||
if (port.PublicPort) {
|
||||
@@ -358,184 +202,26 @@ export class DockerService {
|
||||
skipCache?: boolean;
|
||||
} = {}): Promise<DockerPortConflicts> {
|
||||
const containers = await this.getContainers({ skipCache });
|
||||
return {
|
||||
containerPorts: this.buildContainerPortConflicts(containers),
|
||||
lanPorts: this.buildLanPortConflicts(containers),
|
||||
};
|
||||
return this.dockerPortService.calculateConflicts(containers);
|
||||
}
|
||||
|
||||
public async getContainerLogSizes(containerNames: string[]): Promise<Map<string, number>> {
|
||||
const logSizes = new Map<string, number>();
|
||||
if (!Array.isArray(containerNames) || containerNames.length === 0) {
|
||||
return logSizes;
|
||||
}
|
||||
|
||||
for (const rawName of containerNames) {
|
||||
const normalized = (rawName ?? '').replace(/^\//, '');
|
||||
if (!normalized) {
|
||||
logSizes.set(normalized, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this.client.getContainer(normalized);
|
||||
const info = await container.inspect();
|
||||
const logPath = info.LogPath;
|
||||
|
||||
if (!logPath || typeof logPath !== 'string' || !logPath.length) {
|
||||
logSizes.set(normalized, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = await stat(logPath).catch(() => null);
|
||||
logSizes.set(normalized, stats?.size ?? 0);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? 'unknown error');
|
||||
this.logger.debug(
|
||||
`Failed to determine log size for container ${normalized}: ${message}`
|
||||
);
|
||||
logSizes.set(normalized, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return logSizes;
|
||||
return this.dockerLogService.getContainerLogSizes(containerNames);
|
||||
}
|
||||
|
||||
public async getContainerLogs(
|
||||
id: string,
|
||||
options?: { since?: Date | null; tail?: number | null }
|
||||
): Promise<DockerContainerLogs> {
|
||||
const normalizedId = (id ?? '').trim();
|
||||
if (!normalizedId) {
|
||||
throw new AppError('Container id is required to fetch logs.', 400);
|
||||
}
|
||||
|
||||
const tail = this.normalizeLogTail(options?.tail);
|
||||
const args = ['logs', '--timestamps', '--tail', String(tail)];
|
||||
const sinceIso = options?.since instanceof Date ? options.since.toISOString() : null;
|
||||
if (sinceIso) {
|
||||
args.push('--since', sinceIso);
|
||||
}
|
||||
args.push(normalizedId);
|
||||
|
||||
try {
|
||||
const { stdout } = await execa('docker', args);
|
||||
const lines = this.parseDockerLogOutput(stdout);
|
||||
const cursor =
|
||||
lines.length > 0 ? lines[lines.length - 1].timestamp : (options?.since ?? null);
|
||||
|
||||
return {
|
||||
containerId: normalizedId,
|
||||
lines,
|
||||
cursor: cursor ?? undefined,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const execaError = error as ExecaError;
|
||||
const stderr = typeof execaError?.stderr === 'string' ? execaError.stderr.trim() : '';
|
||||
const message = stderr || execaError?.message || 'Unknown error';
|
||||
this.logger.error(
|
||||
`Failed to fetch logs for container ${normalizedId}: ${message}`,
|
||||
execaError
|
||||
);
|
||||
throw new AppError(`Failed to fetch logs for container ${normalizedId}.`);
|
||||
}
|
||||
return this.dockerLogService.getContainerLogs(id, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Docker networks
|
||||
* @returns All the in/active Docker networks on the system.
|
||||
*/
|
||||
public async getNetworks({ skipCache }: NetworkListingOptions): Promise<DockerNetwork[]> {
|
||||
if (!skipCache) {
|
||||
const cachedNetworks = await this.cacheManager.get<DockerNetwork[]>(
|
||||
DockerService.NETWORK_CACHE_KEY
|
||||
);
|
||||
if (cachedNetworks) {
|
||||
this.logger.debug('Using docker network cache');
|
||||
return cachedNetworks;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Updating docker network cache');
|
||||
const rawNetworks = await this.client.listNetworks().catch(catchHandlers.docker);
|
||||
const networks = rawNetworks.map(
|
||||
(network) =>
|
||||
({
|
||||
name: network.Name || '',
|
||||
id: network.Id || '',
|
||||
created: network.Created || '',
|
||||
scope: network.Scope || '',
|
||||
driver: network.Driver || '',
|
||||
enableIPv6: network.EnableIPv6 || false,
|
||||
ipam: network.IPAM || {},
|
||||
internal: network.Internal || false,
|
||||
attachable: network.Attachable || false,
|
||||
ingress: network.Ingress || false,
|
||||
configFrom: network.ConfigFrom || {},
|
||||
configOnly: network.ConfigOnly || false,
|
||||
containers: network.Containers || {},
|
||||
options: network.Options || {},
|
||||
labels: network.Labels || {},
|
||||
}) as DockerNetwork
|
||||
);
|
||||
|
||||
await this.cacheManager.set(
|
||||
DockerService.NETWORK_CACHE_KEY,
|
||||
networks,
|
||||
DockerService.CACHE_TTL_SECONDS * 1000
|
||||
);
|
||||
return networks;
|
||||
}
|
||||
|
||||
private normalizeLogTail(tail?: number | null): number {
|
||||
if (typeof tail !== 'number' || Number.isNaN(tail)) {
|
||||
return DockerService.DEFAULT_LOG_TAIL;
|
||||
}
|
||||
const coerced = Math.floor(tail);
|
||||
if (!Number.isFinite(coerced) || coerced <= 0) {
|
||||
return DockerService.DEFAULT_LOG_TAIL;
|
||||
}
|
||||
return Math.min(coerced, DockerService.MAX_LOG_TAIL);
|
||||
}
|
||||
|
||||
private parseDockerLogOutput(output: string): DockerContainerLogLine[] {
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
return output
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => this.parseDockerLogLine(line))
|
||||
.filter((entry): entry is DockerContainerLogLine => Boolean(entry));
|
||||
}
|
||||
|
||||
private parseDockerLogLine(line: string): DockerContainerLogLine | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
const firstSpaceIndex = trimmed.indexOf(' ');
|
||||
if (firstSpaceIndex === -1) {
|
||||
return {
|
||||
timestamp: new Date(),
|
||||
message: trimmed,
|
||||
};
|
||||
}
|
||||
const potentialTimestamp = trimmed.slice(0, firstSpaceIndex);
|
||||
const message = trimmed.slice(firstSpaceIndex + 1);
|
||||
const parsedTimestamp = new Date(potentialTimestamp);
|
||||
if (Number.isNaN(parsedTimestamp.getTime())) {
|
||||
return {
|
||||
timestamp: new Date(),
|
||||
message: trimmed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
timestamp: parsedTimestamp,
|
||||
message,
|
||||
};
|
||||
public async getNetworks(options: NetworkListingOptions): Promise<DockerNetwork[]> {
|
||||
return this.dockerNetworkService.getNetworks(options);
|
||||
}
|
||||
|
||||
public async clearContainerCache(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import Docker from 'dockerode';
|
||||
|
||||
let instance: Docker | undefined;
|
||||
|
||||
export function getDockerClient(): Docker {
|
||||
if (!instance) {
|
||||
instance = new Docker({
|
||||
socketPath: '/var/run/docker.sock',
|
||||
});
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
Reference in New Issue
Block a user