Skip to content

Commit c002815

Browse files
committed
feat: add basic docker controls
1 parent 3416667 commit c002815

File tree

9 files changed

+356
-18
lines changed

9 files changed

+356
-18
lines changed

api/dev/Unraid.net/myservers.cfg

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
[api]
2-
version="4.4.1"
3-
extraOrigins="https://google.com,https://test.com"
2+
version=""
3+
extraOrigins=""
44
[local]
5-
sandbox="yes"
5+
sandbox="no"
66
[remote]
7-
wanaccess="yes"
8-
wanport="8443"
9-
upnpEnabled="no"
10-
apikey="_______________________BIG_API_KEY_HERE_________________________"
11-
localApiKey="_______________________LOCAL_API_KEY_HERE_________________________"
12-
email="test@example.com"
13-
username="zspearmint"
14-
avatar="https://via.placeholder.com/200"
15-
regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0"
7+
wanaccess="no"
8+
wanport=""
9+
upnpEnabled=""
10+
apikey=""
11+
localApiKey=""
12+
email=""
13+
username=""
14+
avatar=""
15+
regWizTime=""
1616
accesstoken=""
1717
idtoken=""
1818
refreshtoken=""

api/src/graphql/schema/types/docker/docker.graphql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,13 @@ type Docker implements Node {
66

77
type Query {
88
docker: Docker!
9+
}
10+
11+
type DockerMutations {
12+
startContainer(id: ID!): DockerContainer!
13+
stopContainer(id: ID!): DockerContainer!
14+
}
15+
16+
extend type Docker {
17+
mutations: DockerMutations!
918
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { TestingModule } from '@nestjs/testing';
2+
import { Test } from '@nestjs/testing';
3+
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import type { DockerContainer } from '@app/graphql/generated/api/types.js';
7+
import { ContainerState } from '@app/graphql/generated/api/types.js';
8+
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
9+
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
10+
11+
describe('DockerMutationsResolver', () => {
12+
let resolver: DockerMutationsResolver;
13+
let dockerService: DockerService;
14+
15+
beforeEach(async () => {
16+
const module: TestingModule = await Test.createTestingModule({
17+
providers: [
18+
DockerMutationsResolver,
19+
{
20+
provide: DockerService,
21+
useValue: {
22+
startContainer: vi.fn(),
23+
stopContainer: vi.fn(),
24+
},
25+
},
26+
],
27+
}).compile();
28+
29+
resolver = module.get<DockerMutationsResolver>(DockerMutationsResolver);
30+
dockerService = module.get<DockerService>(DockerService);
31+
});
32+
33+
it('should be defined', () => {
34+
expect(resolver).toBeDefined();
35+
});
36+
37+
it('should start container', async () => {
38+
const mockContainer: DockerContainer = {
39+
id: '1',
40+
autoStart: false,
41+
command: 'test',
42+
created: 1234567890,
43+
image: 'test-image',
44+
imageId: 'test-image-id',
45+
ports: [],
46+
state: ContainerState.RUNNING,
47+
status: 'Up 2 hours',
48+
};
49+
vi.mocked(dockerService.startContainer).mockResolvedValue(mockContainer);
50+
51+
const result = await resolver.startContainer('1');
52+
expect(result).toEqual(mockContainer);
53+
expect(dockerService.startContainer).toHaveBeenCalledWith('1');
54+
});
55+
56+
it('should stop container', async () => {
57+
const mockContainer: DockerContainer = {
58+
id: '1',
59+
autoStart: false,
60+
command: 'test',
61+
created: 1234567890,
62+
image: 'test-image',
63+
imageId: 'test-image-id',
64+
ports: [],
65+
state: ContainerState.EXITED,
66+
status: 'Exited',
67+
};
68+
vi.mocked(dockerService.stopContainer).mockResolvedValue(mockContainer);
69+
70+
const result = await resolver.stopContainer('1');
71+
expect(result).toEqual(mockContainer);
72+
expect(dockerService.stopContainer).toHaveBeenCalledWith('1');
73+
});
74+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
2+
3+
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
4+
5+
import { Resource } from '@app/graphql/generated/api/types.js';
6+
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
7+
8+
@Resolver('DockerMutations')
9+
export class DockerMutationsResolver {
10+
constructor(private readonly dockerService: DockerService) {}
11+
12+
@ResolveField('startContainer')
13+
@UsePermissions({
14+
action: AuthActionVerb.UPDATE,
15+
resource: Resource.DOCKER,
16+
possession: AuthPossession.ANY,
17+
})
18+
public async startContainer(@Args('id') id: string) {
19+
return this.dockerService.startContainer(id);
20+
}
21+
22+
@ResolveField('stopContainer')
23+
@UsePermissions({
24+
action: AuthActionVerb.UPDATE,
25+
resource: Resource.DOCKER,
26+
possession: AuthPossession.ANY,
27+
})
28+
public async stopContainer(@Args('id') id: string) {
29+
return this.dockerService.stopContainer(id);
30+
}
31+
}
Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,77 @@
11
import type { TestingModule } from '@nestjs/testing';
22
import { Test } from '@nestjs/testing';
33

4-
import { beforeEach, describe, expect, it } from 'vitest';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
55

6+
import type { DockerContainer } from '@app/graphql/generated/api/types.js';
7+
import { ContainerState } from '@app/graphql/generated/api/types.js';
68
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
9+
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
710

811
describe('DockerResolver', () => {
912
let resolver: DockerResolver;
13+
let dockerService: DockerService;
1014

1115
beforeEach(async () => {
1216
const module: TestingModule = await Test.createTestingModule({
13-
providers: [DockerResolver],
17+
providers: [
18+
DockerResolver,
19+
{
20+
provide: DockerService,
21+
useValue: {
22+
getContainers: vi.fn(),
23+
},
24+
},
25+
],
1426
}).compile();
1527

1628
resolver = module.get<DockerResolver>(DockerResolver);
29+
dockerService = module.get<DockerService>(DockerService);
1730
});
1831

1932
it('should be defined', () => {
2033
expect(resolver).toBeDefined();
2134
});
35+
36+
it('should return docker object with id', () => {
37+
const result = resolver.docker();
38+
expect(result).toEqual({ id: 'docker' });
39+
});
40+
41+
it('should return containers from service', async () => {
42+
const mockContainers: DockerContainer[] = [
43+
{
44+
id: '1',
45+
autoStart: false,
46+
command: 'test',
47+
created: 1234567890,
48+
image: 'test-image',
49+
imageId: 'test-image-id',
50+
ports: [],
51+
state: ContainerState.EXITED,
52+
status: 'Exited',
53+
},
54+
{
55+
id: '2',
56+
autoStart: true,
57+
command: 'test2',
58+
created: 1234567891,
59+
image: 'test-image2',
60+
imageId: 'test-image-id2',
61+
ports: [],
62+
state: ContainerState.RUNNING,
63+
status: 'Up 2 hours',
64+
},
65+
];
66+
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
67+
68+
const result = await resolver.containers();
69+
expect(result).toEqual(mockContainers);
70+
expect(dockerService.getContainers).toHaveBeenCalledWith(false);
71+
});
72+
73+
it('should return mutations object with id', () => {
74+
const result = resolver.mutations();
75+
expect(result).toEqual({ id: 'docker-mutations' });
76+
});
2277
});

api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql';
33
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
44

55
import { Resource } from '@app/graphql/generated/api/types.js';
6+
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
67

78
@Resolver('Docker')
89
export class DockerResolver {
10+
constructor(private readonly dockerService: DockerService) {}
11+
912
@UsePermissions({
1013
action: AuthActionVerb.READ,
1114
resource: Resource.DOCKER,
@@ -25,10 +28,13 @@ export class DockerResolver {
2528
})
2629
@ResolveField()
2730
public async containers() {
28-
const { getDockerContainers } = await import(
29-
'@app/core/modules/docker/get-docker-containers.js'
30-
);
31+
return this.dockerService.getContainers(false);
32+
}
3133

32-
return getDockerContainers({ useCache: false });
34+
@ResolveField()
35+
public mutations() {
36+
return {
37+
id: 'docker-mutations',
38+
};
3339
}
3440
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { TestingModule } from '@nestjs/testing';
2+
import { Test } from '@nestjs/testing';
3+
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import type { DockerContainer } from '@app/graphql/generated/api/types.js';
7+
import { getDockerContainers } from '@app/core/modules/docker/get-docker-containers.js';
8+
import { docker } from '@app/core/utils/clients/docker.js';
9+
import { ContainerState } from '@app/graphql/generated/api/types.js';
10+
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
11+
12+
vi.mock('@app/core/utils/clients/docker.js', () => ({
13+
docker: {
14+
getContainer: vi.fn(),
15+
listContainers: vi.fn(),
16+
},
17+
}));
18+
19+
vi.mock('@app/core/modules/docker/get-docker-containers.js', () => ({
20+
getDockerContainers: vi.fn(),
21+
}));
22+
23+
describe('DockerService', () => {
24+
let service: DockerService;
25+
let mockContainer: any;
26+
27+
beforeEach(async () => {
28+
const module: TestingModule = await Test.createTestingModule({
29+
providers: [DockerService],
30+
}).compile();
31+
32+
service = module.get<DockerService>(DockerService);
33+
34+
mockContainer = {
35+
start: vi.fn(),
36+
stop: vi.fn(),
37+
};
38+
vi.mocked(docker.getContainer).mockReturnValue(mockContainer as any);
39+
});
40+
41+
it('should be defined', () => {
42+
expect(service).toBeDefined();
43+
});
44+
45+
it('should get containers', async () => {
46+
const mockContainers: DockerContainer[] = [
47+
{
48+
id: '1',
49+
autoStart: false,
50+
command: 'test',
51+
created: 1234567890,
52+
image: 'test-image',
53+
imageId: 'test-image-id',
54+
ports: [],
55+
state: ContainerState.EXITED,
56+
status: 'Exited',
57+
},
58+
];
59+
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
60+
61+
const result = await service.getContainers(false);
62+
expect(result).toEqual(mockContainers);
63+
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
64+
});
65+
66+
it('should start container', async () => {
67+
const mockContainers: DockerContainer[] = [
68+
{
69+
id: '1',
70+
autoStart: false,
71+
command: 'test',
72+
created: 1234567890,
73+
image: 'test-image',
74+
imageId: 'test-image-id',
75+
ports: [],
76+
state: ContainerState.RUNNING,
77+
status: 'Up 2 hours',
78+
},
79+
];
80+
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
81+
82+
const result = await service.startContainer('1');
83+
expect(result).toEqual(mockContainers[0]);
84+
expect(docker.getContainer).toHaveBeenCalledWith('1');
85+
expect(mockContainer.start).toHaveBeenCalled();
86+
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
87+
});
88+
89+
it('should stop container', async () => {
90+
const mockContainers: DockerContainer[] = [
91+
{
92+
id: '1',
93+
autoStart: false,
94+
command: 'test',
95+
created: 1234567890,
96+
image: 'test-image',
97+
imageId: 'test-image-id',
98+
ports: [],
99+
state: ContainerState.EXITED,
100+
status: 'Exited',
101+
},
102+
];
103+
vi.mocked(getDockerContainers).mockResolvedValue(mockContainers);
104+
105+
const result = await service.stopContainer('1');
106+
expect(result).toEqual(mockContainers[0]);
107+
expect(docker.getContainer).toHaveBeenCalledWith('1');
108+
expect(mockContainer.stop).toHaveBeenCalled();
109+
expect(getDockerContainers).toHaveBeenCalledWith({ useCache: false });
110+
});
111+
112+
it('should throw error if container not found after start', async () => {
113+
vi.mocked(getDockerContainers).mockResolvedValue([]);
114+
115+
await expect(service.startContainer('1')).rejects.toThrow(
116+
'Container 1 not found after starting'
117+
);
118+
});
119+
120+
it('should throw error if container not found after stop', async () => {
121+
vi.mocked(getDockerContainers).mockResolvedValue([]);
122+
123+
await expect(service.stopContainer('1')).rejects.toThrow('Container 1 not found after stopping');
124+
});
125+
});

0 commit comments

Comments
 (0)