mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
feat: basic docker controls (#1292)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Chores** - Updated configuration settings by resetting various parameters to default values. - Enhanced module integration with new Docker components. - **New Features** - Introduced GraphQL API endpoints to start and stop Docker containers. - Added a backend service to manage Docker container operations. - **Tests** - Expanded test suites covering Docker resolvers and service methods to ensure reliable container management. - **Refactor** - Consolidated code imports and streamlined internal calls for clearer structure. - Enhanced type safety for Docker container retrieval parameters. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
[api]
|
||||
version="4.4.1"
|
||||
version="4.1.3"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
[local]
|
||||
sandbox="yes"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import camelCaseKeys from 'camelcase-keys';
|
||||
import { ContainerInfo } from 'dockerode';
|
||||
|
||||
import type { ContainerPort, Docker, DockerContainer } from '@app/graphql/generated/api/types.js';
|
||||
import { dockerLogger } from '@app/core/log.js';
|
||||
@@ -11,13 +10,16 @@ import { ContainerPortType, ContainerState } from '@app/graphql/generated/api/ty
|
||||
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 } = { useCache: true }
|
||||
{ useCache }: ContainerListingOptions = { useCache: true }
|
||||
): Promise<Array<DockerContainer>> => {
|
||||
const dockerState = getters.docker();
|
||||
if (useCache && dockerState.containers) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Types from '@app/graphql/generated/api/types.js';
|
||||
|
||||
import { z } from 'zod'
|
||||
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskInput, ArrayDiskStatus, ArrayDiskType, ArrayMutations, ArrayMutationsaddDiskToArrayArgs, ArrayMutationsclearArrayDiskStatisticsArgs, ArrayMutationsmountArrayDiskArgs, ArrayMutationsremoveDiskFromArrayArgs, ArrayMutationssetStateArgs, ArrayMutationsunmountArrayDiskArgs, ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
|
||||
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskInput, ArrayDiskStatus, ArrayDiskType, ArrayMutations, ArrayMutationsaddDiskToArrayArgs, ArrayMutationsclearArrayDiskStatisticsArgs, ArrayMutationsmountArrayDiskArgs, ArrayMutationsremoveDiskFromArrayArgs, ArrayMutationssetStateArgs, ArrayMutationsunmountArrayDiskArgs, ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerMutations, DockerMutationsstartContainerArgs, DockerMutationsstopContainerArgs, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
|
||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
|
||||
type Properties<T> = Required<{
|
||||
@@ -501,6 +501,7 @@ export function DockerSchema(): z.ZodObject<Properties<Docker>> {
|
||||
__typename: z.literal('Docker').optional(),
|
||||
containers: z.array(DockerContainerSchema()).nullish(),
|
||||
id: z.string(),
|
||||
mutations: DockerMutationsSchema(),
|
||||
networks: z.array(DockerNetworkSchema()).nullish()
|
||||
})
|
||||
}
|
||||
@@ -526,6 +527,26 @@ export function DockerContainerSchema(): z.ZodObject<Properties<DockerContainer>
|
||||
})
|
||||
}
|
||||
|
||||
export function DockerMutationsSchema(): z.ZodObject<Properties<DockerMutations>> {
|
||||
return z.object({
|
||||
__typename: z.literal('DockerMutations').optional(),
|
||||
startContainer: DockerContainerSchema(),
|
||||
stopContainer: DockerContainerSchema()
|
||||
})
|
||||
}
|
||||
|
||||
export function DockerMutationsstartContainerArgsSchema(): z.ZodObject<Properties<DockerMutationsstartContainerArgs>> {
|
||||
return z.object({
|
||||
id: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DockerMutationsstopContainerArgsSchema(): z.ZodObject<Properties<DockerMutationsstopContainerArgs>> {
|
||||
return z.object({
|
||||
id: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DockerNetworkSchema(): z.ZodObject<Properties<DockerNetwork>> {
|
||||
return z.object({
|
||||
__typename: z.literal('DockerNetwork').optional(),
|
||||
|
||||
@@ -555,6 +555,7 @@ export type Docker = Node & {
|
||||
__typename?: 'Docker';
|
||||
containers?: Maybe<Array<DockerContainer>>;
|
||||
id: Scalars['ID']['output'];
|
||||
mutations: DockerMutations;
|
||||
networks?: Maybe<Array<DockerNetwork>>;
|
||||
};
|
||||
|
||||
@@ -578,6 +579,22 @@ export type DockerContainer = {
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type DockerMutations = {
|
||||
__typename?: 'DockerMutations';
|
||||
startContainer: DockerContainer;
|
||||
stopContainer: DockerContainer;
|
||||
};
|
||||
|
||||
|
||||
export type DockerMutationsstartContainerArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type DockerMutationsstopContainerArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DockerNetwork = {
|
||||
__typename?: 'DockerNetwork';
|
||||
attachable: Scalars['Boolean']['output'];
|
||||
@@ -2002,6 +2019,7 @@ export type ResolversTypes = ResolversObject<{
|
||||
Display: ResolverTypeWrapper<Display>;
|
||||
Docker: ResolverTypeWrapper<Docker>;
|
||||
DockerContainer: ResolverTypeWrapper<DockerContainer>;
|
||||
DockerMutations: ResolverTypeWrapper<DockerMutations>;
|
||||
DockerNetwork: ResolverTypeWrapper<DockerNetwork>;
|
||||
DynamicRemoteAccessStatus: ResolverTypeWrapper<DynamicRemoteAccessStatus>;
|
||||
DynamicRemoteAccessType: DynamicRemoteAccessType;
|
||||
@@ -2128,6 +2146,7 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
Display: Display;
|
||||
Docker: Docker;
|
||||
DockerContainer: DockerContainer;
|
||||
DockerMutations: DockerMutations;
|
||||
DockerNetwork: DockerNetwork;
|
||||
DynamicRemoteAccessStatus: DynamicRemoteAccessStatus;
|
||||
EnableDynamicRemoteAccessInput: EnableDynamicRemoteAccessInput;
|
||||
@@ -2452,6 +2471,7 @@ export type DisplayResolvers<ContextType = Context, ParentType extends Resolvers
|
||||
export type DockerResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Docker'] = ResolversParentTypes['Docker']> = ResolversObject<{
|
||||
containers?: Resolver<Maybe<Array<ResolversTypes['DockerContainer']>>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
mutations?: Resolver<ResolversTypes['DockerMutations'], ParentType, ContextType>;
|
||||
networks?: Resolver<Maybe<Array<ResolversTypes['DockerNetwork']>>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
@@ -2475,6 +2495,12 @@ export type DockerContainerResolvers<ContextType = Context, ParentType extends R
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type DockerMutationsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['DockerMutations'] = ResolversParentTypes['DockerMutations']> = ResolversObject<{
|
||||
startContainer?: Resolver<ResolversTypes['DockerContainer'], ParentType, ContextType, RequireFields<DockerMutationsstartContainerArgs, 'id'>>;
|
||||
stopContainer?: Resolver<ResolversTypes['DockerContainer'], ParentType, ContextType, RequireFields<DockerMutationsstopContainerArgs, 'id'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type DockerNetworkResolvers<ContextType = Context, ParentType extends ResolversParentTypes['DockerNetwork'] = ResolversParentTypes['DockerNetwork']> = ResolversObject<{
|
||||
attachable?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
configFrom?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
|
||||
@@ -3322,6 +3348,7 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
|
||||
Display?: DisplayResolvers<ContextType>;
|
||||
Docker?: DockerResolvers<ContextType>;
|
||||
DockerContainer?: DockerContainerResolvers<ContextType>;
|
||||
DockerMutations?: DockerMutationsResolvers<ContextType>;
|
||||
DockerNetwork?: DockerNetworkResolvers<ContextType>;
|
||||
DynamicRemoteAccessStatus?: DynamicRemoteAccessStatusResolvers<ContextType>;
|
||||
Flash?: FlashResolvers<ContextType>;
|
||||
|
||||
@@ -6,4 +6,13 @@ type Docker implements Node {
|
||||
|
||||
type Query {
|
||||
docker: Docker!
|
||||
}
|
||||
|
||||
type DockerMutations {
|
||||
startContainer(id: ID!): DockerContainer!
|
||||
stopContainer(id: ID!): DockerContainer!
|
||||
}
|
||||
|
||||
extend type Docker {
|
||||
mutations: DockerMutations!
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DockerContainer } from '@app/graphql/generated/api/types.js';
|
||||
import { ContainerState } from '@app/graphql/generated/api/types.js';
|
||||
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
|
||||
describe('DockerMutationsResolver', () => {
|
||||
let resolver: DockerMutationsResolver;
|
||||
let dockerService: DockerService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DockerMutationsResolver,
|
||||
{
|
||||
provide: DockerService,
|
||||
useValue: {
|
||||
startContainer: vi.fn(),
|
||||
stopContainer: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<DockerMutationsResolver>(DockerMutationsResolver);
|
||||
dockerService = module.get<DockerService>(DockerService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
|
||||
it('should start container', async () => {
|
||||
const mockContainer: DockerContainer = {
|
||||
id: '1',
|
||||
autoStart: false,
|
||||
command: 'test',
|
||||
created: 1234567890,
|
||||
image: 'test-image',
|
||||
imageId: 'test-image-id',
|
||||
ports: [],
|
||||
state: ContainerState.RUNNING,
|
||||
status: 'Up 2 hours',
|
||||
};
|
||||
vi.mocked(dockerService.startContainer).mockResolvedValue(mockContainer);
|
||||
|
||||
const result = await resolver.startContainer('1');
|
||||
expect(result).toEqual(mockContainer);
|
||||
expect(dockerService.startContainer).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should stop container', async () => {
|
||||
const mockContainer: DockerContainer = {
|
||||
id: '1',
|
||||
autoStart: false,
|
||||
command: 'test',
|
||||
created: 1234567890,
|
||||
image: 'test-image',
|
||||
imageId: 'test-image-id',
|
||||
ports: [],
|
||||
state: ContainerState.EXITED,
|
||||
status: 'Exited',
|
||||
};
|
||||
vi.mocked(dockerService.stopContainer).mockResolvedValue(mockContainer);
|
||||
|
||||
const result = await resolver.stopContainer('1');
|
||||
expect(result).toEqual(mockContainer);
|
||||
expect(dockerService.stopContainer).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { Resource } from '@app/graphql/generated/api/types.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
|
||||
@Resolver('DockerMutations')
|
||||
export class DockerMutationsResolver {
|
||||
constructor(private readonly dockerService: DockerService) {}
|
||||
|
||||
@ResolveField('startContainer')
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async startContainer(@Args('id') id: string) {
|
||||
return this.dockerService.startContainer(id);
|
||||
}
|
||||
|
||||
@ResolveField('stopContainer')
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async stopContainer(@Args('id') id: string) {
|
||||
return this.dockerService.stopContainer(id);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,77 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DockerContainer } from '@app/graphql/generated/api/types.js';
|
||||
import { ContainerState } from '@app/graphql/generated/api/types.js';
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
|
||||
describe('DockerResolver', () => {
|
||||
let resolver: DockerResolver;
|
||||
let dockerService: DockerService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [DockerResolver],
|
||||
providers: [
|
||||
DockerResolver,
|
||||
{
|
||||
provide: DockerService,
|
||||
useValue: {
|
||||
getContainers: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<DockerResolver>(DockerResolver);
|
||||
dockerService = module.get<DockerService>(DockerService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return docker object with id', () => {
|
||||
const result = resolver.docker();
|
||||
expect(result).toEqual({ id: 'docker' });
|
||||
});
|
||||
|
||||
it('should return containers from service', async () => {
|
||||
const mockContainers: DockerContainer[] = [
|
||||
{
|
||||
id: '1',
|
||||
autoStart: false,
|
||||
command: 'test',
|
||||
created: 1234567890,
|
||||
image: 'test-image',
|
||||
imageId: 'test-image-id',
|
||||
ports: [],
|
||||
state: ContainerState.EXITED,
|
||||
status: 'Exited',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
autoStart: true,
|
||||
command: 'test2',
|
||||
created: 1234567891,
|
||||
image: 'test-image2',
|
||||
imageId: 'test-image-id2',
|
||||
ports: [],
|
||||
state: ContainerState.RUNNING,
|
||||
status: 'Up 2 hours',
|
||||
},
|
||||
];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
|
||||
const result = await resolver.containers();
|
||||
expect(result).toEqual(mockContainers);
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ useCache: false });
|
||||
});
|
||||
|
||||
it('should return mutations object with id', () => {
|
||||
const result = resolver.mutations();
|
||||
expect(result).toEqual({ id: 'docker-mutations' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,12 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { Resource } from '@app/graphql/generated/api/types.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
|
||||
@Resolver('Docker')
|
||||
export class DockerResolver {
|
||||
constructor(private readonly dockerService: DockerService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.DOCKER,
|
||||
@@ -25,10 +28,13 @@ export class DockerResolver {
|
||||
})
|
||||
@ResolveField()
|
||||
public async containers() {
|
||||
const { getDockerContainers } = await import(
|
||||
'@app/core/modules/docker/get-docker-containers.js'
|
||||
);
|
||||
return this.dockerService.getContainers({ useCache: false });
|
||||
}
|
||||
|
||||
return getDockerContainers({ useCache: false });
|
||||
@ResolveField()
|
||||
public mutations() {
|
||||
return {
|
||||
id: 'docker-mutations',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
125
api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts
Normal file
125
api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
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(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/core/modules/docker/get-docker-containers.js', () => ({
|
||||
getDockerContainers: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DockerService', () => {
|
||||
let service: DockerService;
|
||||
let mockContainer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [DockerService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DockerService>(DockerService);
|
||||
|
||||
mockContainer = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
vi.mocked(docker.getContainer).mockReturnValue(mockContainer as any);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get containers', async () => {
|
||||
const mockContainers: DockerContainer[] = [
|
||||
{
|
||||
id: '1',
|
||||
autoStart: false,
|
||||
command: 'test',
|
||||
created: 1234567890,
|
||||
image: 'test-image',
|
||||
imageId: 'test-image-id',
|
||||
ports: [],
|
||||
state: ContainerState.EXITED,
|
||||
status: 'Exited',
|
||||
},
|
||||
];
|
||||
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
|
||||
|
||||
const result = await service.getContainers({ useCache: false });
|
||||
expect(result).toEqual(mockContainers);
|
||||
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
|
||||
});
|
||||
|
||||
it('should start container', async () => {
|
||||
const mockContainers: DockerContainer[] = [
|
||||
{
|
||||
id: '1',
|
||||
autoStart: false,
|
||||
command: 'test',
|
||||
created: 1234567890,
|
||||
image: 'test-image',
|
||||
imageId: 'test-image-id',
|
||||
ports: [],
|
||||
state: ContainerState.RUNNING,
|
||||
status: 'Up 2 hours',
|
||||
},
|
||||
];
|
||||
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
|
||||
|
||||
const result = await service.startContainer('1');
|
||||
expect(result).toEqual(mockContainers[0]);
|
||||
expect(docker.getContainer).toHaveBeenCalledWith('1');
|
||||
expect(mockContainer.start).toHaveBeenCalled();
|
||||
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
|
||||
});
|
||||
|
||||
it('should stop container', async () => {
|
||||
const mockContainers: DockerContainer[] = [
|
||||
{
|
||||
id: '1',
|
||||
autoStart: false,
|
||||
command: 'test',
|
||||
created: 1234567890,
|
||||
image: 'test-image',
|
||||
imageId: 'test-image-id',
|
||||
ports: [],
|
||||
state: ContainerState.EXITED,
|
||||
status: 'Exited',
|
||||
},
|
||||
];
|
||||
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
|
||||
|
||||
const result = await service.stopContainer('1');
|
||||
expect(result).toEqual(mockContainers[0]);
|
||||
expect(docker.getContainer).toHaveBeenCalledWith('1');
|
||||
expect(mockContainer.stop).toHaveBeenCalled();
|
||||
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
|
||||
});
|
||||
|
||||
it('should throw error if container not found after start', async () => {
|
||||
vi.mocked(getDockerContainers).mockResolvedValue([]);
|
||||
|
||||
await expect(service.startContainer('1')).rejects.toThrow(
|
||||
'Container 1 not found after starting'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if container not found after stop', async () => {
|
||||
vi.mocked(getDockerContainers).mockResolvedValue([]);
|
||||
|
||||
await expect(service.stopContainer('1')).rejects.toThrow('Container 1 not found after stopping');
|
||||
});
|
||||
});
|
||||
37
api/src/unraid-api/graph/resolvers/docker/docker.service.ts
Normal file
37
api/src/unraid-api/graph/resolvers/docker/docker.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class DockerService {
|
||||
public async getContainers({ useCache }: ContainerListingOptions): Promise<DockerContainer[]> {
|
||||
return getDockerContainers({ useCache });
|
||||
}
|
||||
|
||||
public async startContainer(id: string): Promise<DockerContainer> {
|
||||
const container = docker.getContainer(id);
|
||||
await container.start();
|
||||
const containers = await this.getContainers({ useCache: false });
|
||||
const updatedContainer = containers.find((c) => c.id === id);
|
||||
if (!updatedContainer) {
|
||||
throw new Error(`Container ${id} not found after starting`);
|
||||
}
|
||||
return updatedContainer;
|
||||
}
|
||||
|
||||
public async stopContainer(id: string): Promise<DockerContainer> {
|
||||
const container = docker.getContainer(id);
|
||||
await container.stop();
|
||||
const containers = await this.getContainers({ useCache: false });
|
||||
const updatedContainer = containers.find((c) => c.id === id);
|
||||
if (!updatedContainer) {
|
||||
throw new Error(`Container ${id} not found after stopping`);
|
||||
}
|
||||
return updatedContainer;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resol
|
||||
import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.js';
|
||||
import { DisksResolver } from '@app/unraid-api/graph/resolvers/disks/disks.resolver.js';
|
||||
import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js';
|
||||
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js';
|
||||
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
|
||||
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
|
||||
@@ -38,6 +40,8 @@ import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js
|
||||
DisksResolver,
|
||||
DisplayResolver,
|
||||
DockerResolver,
|
||||
DockerMutationsResolver,
|
||||
DockerService,
|
||||
FlashResolver,
|
||||
MutationResolver,
|
||||
InfoResolver,
|
||||
|
||||
Reference in New Issue
Block a user