Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/server/src/agents/agents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PrismaService } from '../common/database/prisma.service';
export class AgentsService {
private readonly logger = new Logger(AgentsService.name);

constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService) { }

async register(
payload: AgentRegisterPayload,
Expand Down Expand Up @@ -104,4 +104,12 @@ export class AgentsService {
findAll(): Promise<Agent[]> {
return this.prisma.agent.findMany({ orderBy: { createdAt: 'desc' } });
}

/**
* Permanently removes the agent record from the database.
*/
async delete(id: string): Promise<void> {
await this.prisma.agent.delete({ where: { id } });
this.logger.log(`Agent deleted id=${id}`);
}
}
6 changes: 1 addition & 5 deletions apps/server/src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
Controller,
Get,
ServiceUnavailableException,
} from '@nestjs/common';
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import {
ApiOkResponse,
ApiOperation,
Expand Down
26 changes: 25 additions & 1 deletion apps/server/src/hosts/hosts.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ describe('HostsController', () => {
let app: INestApplication;
let role: UserRole;
const updateDisplayName = jest.fn();
const deleteHost = jest.fn();

beforeEach(async () => {
role = 'ADMIN';
updateDisplayName.mockReset();
deleteHost.mockReset();

const authGuard: CanActivate = {
canActivate(context: ExecutionContext) {
Expand All @@ -39,7 +41,7 @@ describe('HostsController', () => {
Reflector,
{
provide: HostsService,
useValue: { updateDisplayName },
useValue: { updateDisplayName, deleteHost },
},
{ provide: APP_GUARD, useValue: authGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
Expand Down Expand Up @@ -115,4 +117,26 @@ describe('HostsController', () => {
.send({ displayName: 'prod-web-1' })
.expect(404);
});

describe('DELETE /hosts/:id', () => {
it('lets an admin delete a host', async () => {
deleteHost.mockResolvedValue(undefined);

await request(app.getHttpServer() as App)
.delete('/hosts/host-1')
.expect(200);

expect(deleteHost).toHaveBeenCalledWith('host-1');
});

it('forbids VIEWER from deleting a host', async () => {
role = 'VIEWER';

await request(app.getHttpServer() as App)
.delete('/hosts/host-1')
.expect(403);

expect(deleteHost).not.toHaveBeenCalled();
});
});
});
20 changes: 19 additions & 1 deletion apps/server/src/hosts/hosts.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Expand All @@ -9,6 +10,7 @@ import {
import {
ApiBearerAuth,
ApiBody,
ApiConflictResponse,
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
Expand All @@ -22,7 +24,7 @@ import { HostsService } from './hosts.service';
@ApiTags('hosts')
@Controller('hosts')
export class HostsController {
constructor(private readonly hostsService: HostsService) {}
constructor(private readonly hostsService: HostsService) { }

@Get()
@ApiOperation({ summary: 'List registered Docker hosts (agents)' })
Expand Down Expand Up @@ -74,4 +76,20 @@ export class HostsController {
}
return result;
}

@Delete(':id')
@Roles('ADMIN')
@ApiBearerAuth()
@ApiOperation({
summary: 'Delete a host that has been inactive for at least 7 days',
})
@ApiOkResponse({ description: 'Host deleted' })
@ApiUnauthorizedResponse({ description: 'Missing or invalid token' })
@ApiForbiddenResponse({ description: 'Requires the ADMIN role' })
@ApiConflictResponse({
description: 'Host is active or was active within the last 7 days',
})
async deleteHost(@Param('id') id: string) {
await this.hostsService.deleteHost(id);
}
}
70 changes: 70 additions & 0 deletions apps/server/src/hosts/hosts.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { AgentsService } from '../agents/agents.service';
import { AgentsGateway } from '../agents/agents.gateway';
import { ContainerInventoryService } from '../agents/container-inventory.service';
Expand Down Expand Up @@ -82,4 +83,73 @@ describe('HostsService', () => {
service.updateDisplayName('missing', 'prod-web-1'),
).resolves.toBeNull();
});

describe('deleteHost', () => {
it('throws NotFoundException if host does not exist', async () => {
const service = makeService({
findById: jest.fn().mockResolvedValue(null),
});

await expect(service.deleteHost('missing')).rejects.toThrow(
NotFoundException,
);
});

it('throws ConflictException if host is ONLINE', async () => {
const service = makeService({
findById: jest.fn().mockResolvedValue(makeAgent({ status: 'ONLINE' })),
});

await expect(service.deleteHost('host-1')).rejects.toThrow(
ConflictException,
);
});

it('throws ConflictException if host is OFFLINE but lastSeen is < 7 days ago', async () => {
const fourDaysAgo = new Date();
fourDaysAgo.setDate(fourDaysAgo.getDate() - 4);

const service = makeService({
findById: jest.fn().mockResolvedValue(
makeAgent({ status: 'OFFLINE', lastSeen: fourDaysAgo }),
),
});

await expect(service.deleteHost('host-1')).rejects.toThrow(
ConflictException,
);
});

it('deletes the host if OFFLINE and lastSeen is > 7 days ago', async () => {
const eightDaysAgo = new Date();
eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);
const deleteMock = jest.fn();

const service = makeService({
findById: jest.fn().mockResolvedValue(
makeAgent({ status: 'OFFLINE', lastSeen: eightDaysAgo }),
),
delete: deleteMock,
});

await service.deleteHost('host-1');
expect(deleteMock).toHaveBeenCalledWith('host-1');
});

it('falls back to createdAt if lastSeen is null and deletes if > 7 days ago', async () => {
const eightDaysAgo = new Date();
eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);
const deleteMock = jest.fn();

const service = makeService({
findById: jest.fn().mockResolvedValue(
makeAgent({ status: 'OFFLINE', lastSeen: null, createdAt: eightDaysAgo }),
),
delete: deleteMock,
});

await service.deleteHost('host-1');
expect(deleteMock).toHaveBeenCalledWith('host-1');
});
});
});
31 changes: 29 additions & 2 deletions apps/server/src/hosts/hosts.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type {
ContainerSummary,
HostCpuMetrics,
Expand Down Expand Up @@ -52,7 +52,7 @@ export class HostsService {
private readonly inventory: ContainerInventoryService,
private readonly agentsGateway: AgentsGateway,
private readonly hostMetrics: HostMetricsService,
) {}
) { }

async listHosts(): Promise<HostDto[]> {
const agents = await this.agentsService.findAll();
Expand Down Expand Up @@ -118,6 +118,33 @@ export class HostsService {
updatedAt: snapshot?.updatedAt ? snapshot.updatedAt.toISOString() : null,
};
}

/**
* Enforces business rules for deleting a host:
* - Cannot be missing
* - Cannot be currently ONLINE
* - Must have been inactive for at least 7 days
*/
async deleteHost(hostId: string): Promise<void> {
const agent = await this.agentsService.findById(hostId);
if (!agent) {
throw new NotFoundException(`Host not found: ${hostId}`);
}

if (agent.status === 'ONLINE') {
throw new ConflictException('Cannot delete an active host');
}

const lastSeenDate = agent.lastSeen || agent.createdAt;
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);

if (lastSeenDate > sevenDaysAgo) {
throw new ConflictException('Host must be inactive for at least 7 days');
}

await this.agentsService.delete(agent.id);
}
}

function toHostDto(
Expand Down
4 changes: 1 addition & 3 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ async function bootstrap() {
* - production, unset → false (same-origin only; no reflected Origin)
* - development, unset → Vite dev server defaults
*/
function resolveCorsOrigin(
config: ConfigService,
): boolean | string[] {
function resolveCorsOrigin(config: ConfigService): boolean | string[] {
const configured = config.get<string>('CORS_ORIGINS');
if (configured != null && configured.trim() !== '') {
return configured
Expand Down
Loading