Skip to content

Commit f4b5ba0

Browse files
authored
Merge pull request #12 from StuMason/feature/service-management
feat: add service management feature
2 parents 0e62e8d + 8db96ff commit f4b5ba0

File tree

6 files changed

+606
-15
lines changed

6 files changed

+606
-15
lines changed

docs/features/007-service-management.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,25 +80,25 @@ Implementation of one-click service management features through MCP resources, a
8080

8181
## Implementation Checklist
8282

83-
- [ ] Basic Service Management
83+
- [x] Basic Service Management
8484

85-
- [ ] List services resource
86-
- [ ] Get service details
87-
- [ ] Create service
88-
- [ ] Delete service
85+
- [x] List services resource
86+
- [x] Get service details
87+
- [x] Create service
88+
- [x] Delete service
8989

90-
- [ ] Service Type Support
90+
- [x] Service Type Support
9191

92-
- [ ] Development tools deployment
93-
- [ ] CMS system deployment
94-
- [ ] Monitoring tools deployment
95-
- [ ] Collaboration tools deployment
96-
- [ ] Database tools deployment
92+
- [x] Development tools deployment
93+
- [x] CMS system deployment
94+
- [x] Monitoring tools deployment
95+
- [x] Collaboration tools deployment
96+
- [x] Database tools deployment
9797

98-
- [ ] Resource Testing
99-
- [ ] Unit tests for service operations
100-
- [ ] Integration tests with mock data
101-
- [ ] Live test with real Coolify instance
98+
- [x] Resource Testing
99+
- [x] Unit tests for service operations
100+
- [x] Integration tests with mock data
101+
- [x] Live test with real Coolify instance
102102

103103
## Dependencies
104104

src/__tests__/coolify-client.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,4 +461,135 @@ describe('CoolifyClient', () => {
461461
await expect(client.getDatabase('invalid-uuid')).rejects.toThrow(errorMessage);
462462
});
463463
});
464+
465+
describe('Service Management', () => {
466+
const mockService = {
467+
id: 1,
468+
uuid: 'test-service-uuid',
469+
name: 'test-service',
470+
description: 'Test service',
471+
type: 'code-server' as const,
472+
status: 'running' as const,
473+
created_at: '2024-03-06T12:00:00Z',
474+
updated_at: '2024-03-06T12:00:00Z',
475+
project_uuid: 'test-project-uuid',
476+
environment_name: 'production',
477+
environment_uuid: 'test-env-uuid',
478+
server_uuid: 'test-server-uuid',
479+
domains: ['test-service.example.com'],
480+
};
481+
482+
beforeEach(() => {
483+
mockFetch.mockClear();
484+
});
485+
486+
it('should list services', async () => {
487+
mockFetch.mockResolvedValueOnce({
488+
ok: true,
489+
json: () => Promise.resolve([mockService]),
490+
});
491+
492+
const result = await client.listServices();
493+
494+
expect(result).toEqual([mockService]);
495+
expect(mockFetch).toHaveBeenCalledWith(
496+
'http://test.coolify.io/api/v1/services',
497+
expect.objectContaining({
498+
headers: {
499+
'Content-Type': 'application/json',
500+
Authorization: 'Bearer test-token',
501+
},
502+
}),
503+
);
504+
});
505+
506+
it('should get service details', async () => {
507+
mockFetch.mockResolvedValueOnce({
508+
ok: true,
509+
json: () => Promise.resolve(mockService),
510+
});
511+
512+
const result = await client.getService('test-service-uuid');
513+
514+
expect(result).toEqual(mockService);
515+
expect(mockFetch).toHaveBeenCalledWith(
516+
'http://test.coolify.io/api/v1/services/test-service-uuid',
517+
expect.objectContaining({
518+
headers: {
519+
'Content-Type': 'application/json',
520+
Authorization: 'Bearer test-token',
521+
},
522+
}),
523+
);
524+
});
525+
526+
it('should create service', async () => {
527+
const createData = {
528+
type: 'code-server' as const,
529+
name: 'test-service',
530+
description: 'Test service',
531+
project_uuid: 'test-project-uuid',
532+
environment_name: 'production',
533+
server_uuid: 'test-server-uuid',
534+
instant_deploy: true,
535+
};
536+
537+
const mockResponse = {
538+
uuid: 'test-service-uuid',
539+
domains: ['test-service.example.com'],
540+
};
541+
542+
mockFetch.mockResolvedValueOnce({
543+
ok: true,
544+
json: () => Promise.resolve(mockResponse),
545+
});
546+
547+
const result = await client.createService(createData);
548+
549+
expect(result).toEqual(mockResponse);
550+
expect(mockFetch).toHaveBeenCalledWith(
551+
'http://test.coolify.io/api/v1/services',
552+
expect.objectContaining({
553+
method: 'POST',
554+
body: JSON.stringify(createData),
555+
headers: {
556+
'Content-Type': 'application/json',
557+
Authorization: 'Bearer test-token',
558+
},
559+
}),
560+
);
561+
});
562+
563+
it('should delete service', async () => {
564+
const mockResponse = { message: 'Service deleted' };
565+
mockFetch.mockResolvedValueOnce({
566+
ok: true,
567+
json: () => Promise.resolve(mockResponse),
568+
});
569+
570+
const result = await client.deleteService('test-service-uuid', {
571+
deleteConfigurations: true,
572+
deleteVolumes: true,
573+
});
574+
575+
expect(result).toEqual(mockResponse);
576+
expect(mockFetch).toHaveBeenCalledWith(
577+
'http://test.coolify.io/api/v1/services/test-service-uuid?delete_configurations=true&delete_volumes=true',
578+
expect.objectContaining({
579+
method: 'DELETE',
580+
headers: {
581+
'Content-Type': 'application/json',
582+
Authorization: 'Bearer test-token',
583+
},
584+
}),
585+
);
586+
});
587+
588+
it('should handle service errors', async () => {
589+
const errorMessage = 'Service not found';
590+
mockFetch.mockRejectedValue(new Error(errorMessage));
591+
592+
await expect(client.getService('invalid-uuid')).rejects.toThrow(errorMessage);
593+
});
594+
});
464595
});

src/__tests__/mcp-server.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,87 @@ describe('CoolifyMcpServer', () => {
271271
await expect(server.get_database('invalid-uuid')).rejects.toThrow(errorMessage);
272272
});
273273
});
274+
275+
describe('Service Management', () => {
276+
const mockService = {
277+
id: 1,
278+
uuid: 'test-service-uuid',
279+
name: 'test-service',
280+
description: 'Test service',
281+
type: 'code-server' as const,
282+
status: 'running' as const,
283+
created_at: '2024-03-06T12:00:00Z',
284+
updated_at: '2024-03-06T12:00:00Z',
285+
project_uuid: 'test-project-uuid',
286+
environment_name: 'production',
287+
environment_uuid: 'test-env-uuid',
288+
server_uuid: 'test-server-uuid',
289+
domains: ['test-service.example.com'],
290+
};
291+
292+
it('should list services', async () => {
293+
const spy = jest.spyOn(server['client'], 'listServices').mockResolvedValue([mockService]);
294+
295+
const result = await server.list_services();
296+
297+
expect(result).toEqual([mockService]);
298+
expect(spy).toHaveBeenCalled();
299+
});
300+
301+
it('should get service details', async () => {
302+
const spy = jest.spyOn(server['client'], 'getService').mockResolvedValue(mockService);
303+
304+
const result = await server.get_service('test-service-uuid');
305+
306+
expect(result).toEqual(mockService);
307+
expect(spy).toHaveBeenCalledWith('test-service-uuid');
308+
});
309+
310+
it('should create service', async () => {
311+
const createData = {
312+
type: 'code-server' as const,
313+
name: 'test-service',
314+
description: 'Test service',
315+
project_uuid: 'test-project-uuid',
316+
environment_name: 'production',
317+
server_uuid: 'test-server-uuid',
318+
instant_deploy: true,
319+
};
320+
321+
const mockResponse = {
322+
uuid: 'test-service-uuid',
323+
domains: ['test-service.example.com'],
324+
};
325+
326+
const spy = jest.spyOn(server['client'], 'createService').mockResolvedValue(mockResponse);
327+
328+
const result = await server.create_service(createData);
329+
330+
expect(result).toEqual(mockResponse);
331+
expect(spy).toHaveBeenCalledWith(createData);
332+
});
333+
334+
it('should delete service', async () => {
335+
const mockResponse = { message: 'Service deleted' };
336+
const spy = jest.spyOn(server['client'], 'deleteService').mockResolvedValue(mockResponse);
337+
338+
const result = await server.delete_service('test-service-uuid', {
339+
deleteConfigurations: true,
340+
deleteVolumes: true,
341+
});
342+
343+
expect(result).toEqual(mockResponse);
344+
expect(spy).toHaveBeenCalledWith('test-service-uuid', {
345+
deleteConfigurations: true,
346+
deleteVolumes: true,
347+
});
348+
});
349+
350+
it('should handle service errors', async () => {
351+
const errorMessage = 'Service not found';
352+
jest.spyOn(server['client'], 'getService').mockRejectedValue(new Error(errorMessage));
353+
354+
await expect(server.get_service('invalid-uuid')).rejects.toThrow(errorMessage);
355+
});
356+
});
274357
});

src/lib/coolify-client.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
Deployment,
1313
Database,
1414
DatabaseUpdateRequest,
15+
Service,
16+
CreateServiceRequest,
17+
DeleteServiceOptions,
1518
} from '../types/coolify.js';
1619

1720
export class CoolifyClient {
@@ -178,5 +181,45 @@ export class CoolifyClient {
178181
});
179182
}
180183

184+
async listServices(): Promise<Service[]> {
185+
return this.request<Service[]>('/services');
186+
}
187+
188+
async getService(uuid: string): Promise<Service> {
189+
return this.request<Service>(`/services/${uuid}`);
190+
}
191+
192+
async createService(data: CreateServiceRequest): Promise<{ uuid: string; domains: string[] }> {
193+
return this.request<{ uuid: string; domains: string[] }>('/services', {
194+
method: 'POST',
195+
body: JSON.stringify(data),
196+
});
197+
}
198+
199+
async deleteService(uuid: string, options?: DeleteServiceOptions): Promise<{ message: string }> {
200+
const queryParams = new URLSearchParams();
201+
if (options) {
202+
if (options.deleteConfigurations !== undefined) {
203+
queryParams.set('delete_configurations', options.deleteConfigurations.toString());
204+
}
205+
if (options.deleteVolumes !== undefined) {
206+
queryParams.set('delete_volumes', options.deleteVolumes.toString());
207+
}
208+
if (options.dockerCleanup !== undefined) {
209+
queryParams.set('docker_cleanup', options.dockerCleanup.toString());
210+
}
211+
if (options.deleteConnectedNetworks !== undefined) {
212+
queryParams.set('delete_connected_networks', options.deleteConnectedNetworks.toString());
213+
}
214+
}
215+
216+
const queryString = queryParams.toString();
217+
const url = queryString ? `/services/${uuid}?${queryString}` : `/services/${uuid}`;
218+
219+
return this.request<{ message: string }>(url, {
220+
method: 'DELETE',
221+
});
222+
}
223+
181224
// Add more methods as needed for other endpoints
182225
}

0 commit comments

Comments
 (0)