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
20 changes: 18 additions & 2 deletions packages/backend/src/clusters/clusters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,24 @@ export class ClustersService {
}

async findAll() {
return await this.prismaService.cluster.findMany({
const clusters = await this.prismaService.cluster.findMany({
select: {
id: true,
shardIds: true,
status: true,
botId: true,
containerId: true,
},
orderBy: { id: 'asc' },
});

return await Promise.all(
clusters.map(async (c) => ({
id: c.id,
shardIds: c.shardIds,
status: await this.dockerService.reconcileClusterStatus(c),
})),
);
}

async findOne(id: number) {
Expand All @@ -52,6 +62,8 @@ export class ClustersService {
id: true,
shardIds: true,
status: true,
botId: true,
containerId: true,
},
where: { id },
});
Expand All @@ -60,7 +72,11 @@ export class ClustersService {
throw new NotFoundException();
}

return resource;
return {
id: resource.id,
shardIds: resource.shardIds,
status: await this.dockerService.reconcileClusterStatus(resource),
};
}

async remove(id: number) {
Expand Down
54 changes: 54 additions & 0 deletions packages/backend/src/docker/docker.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,58 @@ describe('DockerService', () => {
await expect(service.start(bot, cluster)).resolves.toBe('new-container-id');
});
});

describe('reconcileClusterStatus', () => {
const baseCluster = { id: 0, botId: 'bot-1', containerId: 'container-id' };

it('flips DB RUNNING→STOPPED when the container has been stopped out-of-band', async () => {
dockerSocket.apiCall.mockResolvedValueOnce({ State: { Running: false } } as never);

const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' });

expect(status).toBe('STOPPED');
expect(prismaService.cluster.update).toHaveBeenCalledWith({
where: { botId_id: { botId: 'bot-1', id: 0 } },
data: { status: 'STOPPED' },
});
});

it('clears containerId and marks STOPPED when the container has vanished (404)', async () => {
dockerSocket.apiCall.mockRejectedValueOnce(new DockerAPIHttpError(404, 'no such container'));

const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' });

expect(status).toBe('STOPPED');
expect(prismaService.cluster.update).toHaveBeenCalledWith({
where: { botId_id: { botId: 'bot-1', id: 0 } },
data: { status: 'STOPPED', containerId: null },
});
});

it('does not touch DB when the actual state matches the stored one', async () => {
dockerSocket.apiCall.mockResolvedValueOnce({ State: { Running: true } } as never);

const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' });

expect(status).toBe('RUNNING');
expect(prismaService.cluster.update).not.toHaveBeenCalled();
});

it('skips transient states (STARTING/UPDATING/ERROR) without hitting Docker', async () => {
const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'STARTING' });

expect(status).toBe('STARTING');
expect(dockerSocket.apiCall).not.toHaveBeenCalled();
expect(prismaService.cluster.update).not.toHaveBeenCalled();
});

it('returns current status without throwing when Docker is unreachable', async () => {
dockerSocket.apiCall.mockRejectedValueOnce(new DockerAPIHttpError(500, 'socket error'));

const status = await service.reconcileClusterStatus({ ...baseCluster, status: 'RUNNING' });

expect(status).toBe('RUNNING');
expect(prismaService.cluster.update).not.toHaveBeenCalled();
});
});
});
40 changes: 39 additions & 1 deletion packages/backend/src/docker/docker.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type DockerContainerCreated,
type DockerContainerCreationBody,
} from '@hallmaster/docker.js';
import { Bot, Cluster, DockerImage } from '@hallmaster/prisma-client';
import { Bot, Cluster, ClusterStatus, DockerImage } from '@hallmaster/prisma-client';
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

Expand Down Expand Up @@ -69,6 +69,44 @@ export class DockerService {
}
}

async reconcileClusterStatus(cluster: {
id: number;
botId: string;
containerId: string | null;
status: ClusterStatus;
}): Promise<ClusterStatus> {
if (cluster.status !== 'RUNNING' && cluster.status !== 'STOPPED') {
return cluster.status;
}
if (cluster.containerId === null) {
return cluster.status;
}

const api = new DockerContainersAPI(this.dockerSocket);

try {
const container = await api.get(cluster.containerId);
const actual: ClusterStatus = container.State.Running ? 'RUNNING' : 'STOPPED';

if (actual !== cluster.status) {
await this.prismaService.cluster.update({
where: { botId_id: { botId: cluster.botId, id: cluster.id } },
data: { status: actual },
});
}
return actual;
} catch (e) {
if (this.isContainerNotFound(e)) {
await this.prismaService.cluster.update({
where: { botId_id: { botId: cluster.botId, id: cluster.id } },
data: { status: 'STOPPED', containerId: null },
});
return 'STOPPED';
}
return cluster.status;
}
}

async verifyImage(dockerImage: {
serverName: string;
image: string;
Expand Down
Loading