diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index d7f8aa999..5c2cb56de 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -507,7 +507,6 @@ export function DockerSchema(): z.ZodObject> { __typename: z.literal('Docker').optional(), containers: z.array(DockerContainerSchema()).nullish(), id: z.string(), - mutations: DockerMutationsSchema(), networks: z.array(DockerNetworkSchema()).nullish() }) } diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index b6d4683fd..0e28eb93b 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -574,7 +574,6 @@ export type Docker = Node & { __typename?: 'Docker'; containers?: Maybe>; id: Scalars['ID']['output']; - mutations: DockerMutations; networks?: Maybe>; }; @@ -850,6 +849,7 @@ export type Mutation = { deleteNotification: NotificationOverview; /** Delete a user */ deleteUser?: Maybe; + docker?: Maybe; enableDynamicRemoteAccess: Scalars['Boolean']['output']; login?: Maybe; /** Pause parity check */ @@ -2558,7 +2558,6 @@ export type DisplayResolvers = ResolversObject<{ containers?: Resolver>, ParentType, ContextType>; id?: Resolver; - mutations?: Resolver; networks?: Resolver>, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; @@ -2776,6 +2775,7 @@ export type MutationResolvers; deleteNotification?: Resolver>; deleteUser?: Resolver, ParentType, ContextType, RequireFields>; + docker?: Resolver, ParentType, ContextType>; enableDynamicRemoteAccess?: Resolver>; login?: Resolver, ParentType, ContextType, RequireFields>; pauseParityCheck?: Resolver, ParentType, ContextType>; diff --git a/api/src/graphql/schema/types/docker/docker.graphql b/api/src/graphql/schema/types/docker/docker.graphql index 6f982a8ac..3dd1153bd 100644 --- a/api/src/graphql/schema/types/docker/docker.graphql +++ b/api/src/graphql/schema/types/docker/docker.graphql @@ -9,10 +9,12 @@ type Query { } type DockerMutations { - startContainer(id: ID!): DockerContainer! - stopContainer(id: ID!): DockerContainer! + """ Stop a container """ + stop(id: ID!): DockerContainer! + """ Start a container """ + start(id: ID!): DockerContainer! } -extend type Docker { - mutations: DockerMutations! -} \ No newline at end of file +type Mutation { + docker: DockerMutations +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts index cf60a8bee..c83375c06 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts @@ -19,8 +19,8 @@ describe('DockerMutationsResolver', () => { { provide: DockerService, useValue: { - startContainer: vi.fn(), - stopContainer: vi.fn(), + start: vi.fn(), + stop: vi.fn(), }, }, ], @@ -34,7 +34,7 @@ describe('DockerMutationsResolver', () => { expect(resolver).toBeDefined(); }); - it('should start container', async () => { + it('should start', async () => { const mockContainer: DockerContainer = { id: '1', autoStart: false, @@ -46,14 +46,14 @@ describe('DockerMutationsResolver', () => { state: ContainerState.RUNNING, status: 'Up 2 hours', }; - vi.mocked(dockerService.startContainer).mockResolvedValue(mockContainer); + vi.mocked(dockerService.start).mockResolvedValue(mockContainer); - const result = await resolver.startContainer('1'); + const result = await resolver.start('1'); expect(result).toEqual(mockContainer); - expect(dockerService.startContainer).toHaveBeenCalledWith('1'); + expect(dockerService.start).toHaveBeenCalledWith('1'); }); - it('should stop container', async () => { + it('should stop', async () => { const mockContainer: DockerContainer = { id: '1', autoStart: false, @@ -65,10 +65,10 @@ describe('DockerMutationsResolver', () => { state: ContainerState.EXITED, status: 'Exited', }; - vi.mocked(dockerService.stopContainer).mockResolvedValue(mockContainer); + vi.mocked(dockerService.stop).mockResolvedValue(mockContainer); - const result = await resolver.stopContainer('1'); + const result = await resolver.stop('1'); expect(result).toEqual(mockContainer); - expect(dockerService.stopContainer).toHaveBeenCalledWith('1'); + expect(dockerService.stop).toHaveBeenCalledWith('1'); }); }); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts index 1fbef2d24..2ddaba4a1 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts @@ -9,23 +9,23 @@ import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.ser export class DockerMutationsResolver { constructor(private readonly dockerService: DockerService) {} - @ResolveField('startContainer') + @ResolveField('start') @UsePermissions({ action: AuthActionVerb.UPDATE, resource: Resource.DOCKER, possession: AuthPossession.ANY, }) - public async startContainer(@Args('id') id: string) { - return this.dockerService.startContainer(id); + public async start(@Args('id') id: string) { + return this.dockerService.start(id); } - @ResolveField('stopContainer') + @ResolveField('stop') @UsePermissions({ action: AuthActionVerb.UPDATE, resource: Resource.DOCKER, possession: AuthPossession.ANY, }) - public async stopContainer(@Args('id') id: string) { - return this.dockerService.stopContainer(id); + public async stop(@Args('id') id: string) { + return this.dockerService.stop(id); } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts index f4621ea8b..8be518144 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts @@ -69,9 +69,4 @@ describe('DockerResolver', () => { 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' }); - }); }); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index 0a9b78d75..9c33c052a 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -35,11 +35,4 @@ export class DockerResolver { public async networks() { return this.dockerService.getNetworks({ useCache: false }); } - - @ResolveField() - public mutations() { - return { - id: 'docker-mutations', - }; - } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts index f1802671d..1d380692e 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts @@ -183,7 +183,7 @@ describe('DockerService', () => { mockListContainers.mockResolvedValue(mockContainers); mockContainer.start.mockResolvedValue(undefined); - const result = await service.startContainer('abc123def456'); + const result = await service.start('abc123def456'); expect(result).toEqual({ id: 'abc123def456', @@ -234,7 +234,7 @@ describe('DockerService', () => { mockListContainers.mockResolvedValue(mockContainers); mockContainer.stop.mockResolvedValue(undefined); - const result = await service.stopContainer('abc123def456'); + const result = await service.stop('abc123def456'); expect(result).toEqual({ id: 'abc123def456', @@ -254,7 +254,7 @@ describe('DockerService', () => { mounts: [], }); - expect(mockContainer.stop).toHaveBeenCalled(); + expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 }); expect(mockListContainers).toHaveBeenCalledWith({ all: true, size: true, @@ -265,8 +265,8 @@ describe('DockerService', () => { mockListContainers.mockResolvedValue([]); mockContainer.start.mockResolvedValue(undefined); - await expect(service.startContainer('abc123def456')).rejects.toThrow( - 'Container abc123def456 not found after starting' + await expect(service.start('not-found')).rejects.toThrow( + 'Container not-found not found after starting' ); }); @@ -274,8 +274,8 @@ describe('DockerService', () => { mockListContainers.mockResolvedValue([]); mockContainer.stop.mockResolvedValue(undefined); - await expect(service.stopContainer('abc123def456')).rejects.toThrow( - 'Container abc123def456 not found after stopping' + await expect(service.stop('not-found')).rejects.toThrow( + 'Container not-found not found after stopping' ); }); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index 8e5c7d62f..fcefa0e82 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -11,10 +11,11 @@ import type { ContainerPort, DockerContainer, DockerNetwork } from '@app/graphql 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 { sleep } from '@app/core/utils/misc/sleep.js'; import { ContainerPortType, ContainerState } from '@app/graphql/generated/api/types.js'; import { getters } from '@app/store/index.js'; -interface ContainerListingOptions { +interface ContainerListingOptions extends Docker.ContainerListOptions { useCache: boolean; } @@ -26,6 +27,7 @@ interface NetworkListingOptions { export class DockerService implements OnModuleInit { private client: Docker; private containerCache: Array = []; + private autoStarts: string[] = []; private dockerWatcher: null | typeof DockerEE = null; private readonly logger = new Logger(DockerService.name); @@ -124,53 +126,62 @@ export class DockerService implements OnModuleInit { await pubsub.publish(PUBSUB_CHANNEL.INFO, this.appUpdateEvent); }, 500); - public async getContainers({ useCache }: ContainerListingOptions): Promise { + public transformContainer(container: Docker.ContainerInfo): DockerContainer { + return camelCaseKeys( + { + labels: container.Labels ?? {}, + sizeRootFs: undefined, + imageId: container.ImageID, + state: + typeof container.State === 'string' + ? (ContainerState[container.State.toUpperCase()] ?? ContainerState.EXITED) + : ContainerState.EXITED, + autoStart: this.autoStarts.includes(container.Names[0].split('/')[1]), + ports: container.Ports.map((port) => ({ + ...port, + type: ContainerPortType[port.Type.toUpperCase()], + })), + command: container.Command, + created: container.Created, + mounts: container.Mounts, + networkSettings: container.NetworkSettings, + hostConfig: { + networkMode: container.HostConfig.NetworkMode, + }, + id: container.Id, + image: container.Image, + status: container.Status, + }, + { deep: true } + ); + } + + public async getContainers( + { + useCache = false, + all = true, + size = true, + ...listOptions + }: Partial = { useCache: false } + ): Promise { 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, + all, + size, + ...listOptions, }) // If docker throws an error return no containers .catch(catchHandlers.docker); - - const autoStarts = await this.getAutoStarts(); + this.autoStarts = await this.getAutoStarts(); // Cleanup container object this.containerCache = rawContainers.map((container) => { - const names = container.Names[0]; - const containerData: DockerContainer = camelCaseKeys( - { - labels: container.Labels ?? {}, - sizeRootFs: undefined, - imageId: container.ImageID, - state: - typeof container.State === 'string' - ? (ContainerState[container.State.toUpperCase()] ?? ContainerState.EXITED) - : ContainerState.EXITED, - autoStart: autoStarts.includes(names.split('/')[1]), - ports: container.Ports.map((port) => ({ - ...port, - type: ContainerPortType[port.Type.toUpperCase()], - })), - command: container.Command, - created: container.Created, - mounts: container.Mounts, - networkSettings: container.NetworkSettings, - hostConfig: { - networkMode: container.HostConfig.NetworkMode, - }, - id: container.Id, - image: container.Image, - status: container.Status, - }, - { deep: true } - ); + const containerData: DockerContainer = this.transformContainer(container); return containerData; }); return this.containerCache; @@ -193,7 +204,7 @@ export class DockerService implements OnModuleInit { ); } - public async startContainer(id: string): Promise { + public async start(id: string): Promise { const container = this.client.getContainer(id); await container.start(); const containers = await this.getContainers({ useCache: false }); @@ -204,11 +215,23 @@ export class DockerService implements OnModuleInit { return updatedContainer; } - public async stopContainer(id: string): Promise { + public async stop(id: string): Promise { const container = this.client.getContainer(id); - await container.stop(); - const containers = await this.getContainers({ useCache: false }); - const updatedContainer = containers.find((c) => c.id === id); + await container.stop({ t: 10 }); + let containers = await this.getContainers({ useCache: false }); + let updatedContainer: DockerContainer | undefined; + for (let i = 0; i < 5; i++) { + await sleep(500); + // Refresh the containers list on each attempt + containers = await this.getContainers({ useCache: false }); + updatedContainer = containers.find((c) => c.id === id); + this.logger.debug( + `Container ${id} state after stop attempt ${i + 1}: ${updatedContainer?.state}` + ); + if (updatedContainer?.state === ContainerState.EXITED) { + return updatedContainer; + } + } if (!updatedContainer) { throw new Error(`Container ${id} not found after stopping`); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index c7947793f..954fb1847 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -8,4 +8,11 @@ export class MutationResolver { __typename: 'ArrayMutations', }; } + + @ResolveField() + public async docker() { + return { + __typename: 'DockerMutations', + }; + } }