diff --git a/src/commands/general/index.ts b/src/commands/general/index.ts index 1ecd336f..67fd14e0 100644 --- a/src/commands/general/index.ts +++ b/src/commands/general/index.ts @@ -4,6 +4,7 @@ import simulatorService from "../../lib/services/simulator"; import { initAction, InitActionOptions } from "./init"; import { startAction, StartActionOptions } from "./start"; import {localnetCompatibleVersion} from "../../lib/config/simulator"; +import {StopAction} from "./stop"; export function initializeGeneralCommands(program: Command) { program @@ -24,5 +25,13 @@ export function initializeGeneralCommands(program: Command) { .option("--reset-db", "Reset Database", false) .action((options: StartActionOptions) => startAction(options, simulatorService)); + program + .command("stop") + .description("Stop all running localnet services.") + .action(async () => { + const stopAction = new StopAction(); + await stopAction.stop(); + }); + return program; } diff --git a/src/commands/general/stop.ts b/src/commands/general/stop.ts new file mode 100644 index 00000000..4583f647 --- /dev/null +++ b/src/commands/general/stop.ts @@ -0,0 +1,25 @@ +import { BaseAction } from "../../lib/actions/BaseAction"; +import { SimulatorService } from "../../lib/services/simulator"; +import { ISimulatorService } from "../../lib/interfaces/ISimulatorService"; + +export class StopAction extends BaseAction { + private simulatorService: ISimulatorService; + + constructor() { + super(); + this.simulatorService = new SimulatorService(); + } + + public async stop(): Promise { + try{ + await this.confirmPrompt( + "Are you sure you want to stop all running GenLayer containers? This will halt all active processes." + ); + console.log(`Stopping Docker containers...`); + await this.simulatorService.stopDockerContainers(); + console.log(`All running GenLayer containers have been successfully stopped.`); + }catch (error) { + console.error("An error occurred while stopping the containers:", error) + } + } +} diff --git a/src/lib/interfaces/ISimulatorService.ts b/src/lib/interfaces/ISimulatorService.ts index 27189fdb..1e9a0e05 100644 --- a/src/lib/interfaces/ISimulatorService.ts +++ b/src/lib/interfaces/ISimulatorService.ts @@ -12,8 +12,9 @@ export interface ISimulatorService { getAiProvidersOptions(withHint: boolean): Array<{name: string; value: string}>; getFrontendUrl(): string; openFrontend(): Promise; - resetDockerContainers(): Promise; - resetDockerImages(): Promise; + stopDockerContainers(): Promise; + resetDockerContainers(): Promise; + resetDockerImages(): Promise; checkCliVersion(): Promise; cleanDatabase(): Promise; addConfigToEnvFile(newConfig: Record): void; diff --git a/src/lib/services/simulator.ts b/src/lib/services/simulator.ts index f757892b..5cf69300 100644 --- a/src/lib/services/simulator.ts +++ b/src/lib/services/simulator.ts @@ -1,4 +1,4 @@ -import Docker from "dockerode" +import Docker, {ContainerInfo} from "dockerode"; import * as fs from "fs"; import * as dotenv from "dotenv"; import * as path from "path"; @@ -48,6 +48,39 @@ export class SimulatorService implements ISimulatorService { this.docker = new Docker(); } + private readEnvConfigValue(key: string): string { + const envFilePath = path.join(this.location, ".env"); + // Transform the config string to object + const envConfig = dotenv.parse(fs.readFileSync(envFilePath, "utf8")); + return envConfig[key]; + } + + private async getGenlayerContainers(): Promise { + const containers = await this.docker.listContainers({ all: true }); + return containers.filter(container => + container.Names.some(name => + name.startsWith(CONTAINERS_NAME_PREFIX) || name.includes("ollama") + ) + ); + } + + private async stopAndRemoveContainers(remove: boolean = false): Promise { + const genlayerContainers = await this.getGenlayerContainers(); + + for (const containerInfo of genlayerContainers) { + const container = this.docker.getContainer(containerInfo.Id); + if (containerInfo.State === "running") { + await container.stop(); + } + + const isOllamaContainer = containerInfo.Names.some(name => name.includes("ollama")); + + if (remove && !isOllamaContainer) { + await container.remove(); + } + } + } + public addConfigToEnvFile(newConfig: Record): void { const envFilePath = path.join(this.location, ".env"); @@ -76,13 +109,6 @@ export class SimulatorService implements ISimulatorService { return this.composeOptions; } - private readEnvConfigValue(key: string): string { - const envFilePath = path.join(this.location, ".env"); - // Transform the config string to object - const envConfig = dotenv.parse(fs.readFileSync(envFilePath, "utf8")); - return envConfig[key]; - } - public async checkCliVersion(): Promise { const update = await updateCheck(pkg); if (update && update.latest !== pkg.version) { @@ -218,25 +244,15 @@ export class SimulatorService implements ISimulatorService { return true; } - public async resetDockerContainers(): Promise { - const containers = await this.docker.listContainers({ all: true }); - const genlayerContainers = containers.filter(container => - container.Names.some(name => - name.startsWith(CONTAINERS_NAME_PREFIX) - ) - ); + public async stopDockerContainers(): Promise { + await this.stopAndRemoveContainers(false); + } - for (const containerInfo of genlayerContainers) { - const container = this.docker.getContainer(containerInfo.Id); - if (containerInfo.State === "running") { - await container.stop(); - } - await container.remove(); - } - return true; + public async resetDockerContainers(): Promise { + await this.stopAndRemoveContainers(true); } - public async resetDockerImages(): Promise { + public async resetDockerImages(): Promise { const images = await this.docker.listImages(); const genlayerImages = images.filter(image => image.RepoTags?.some(tag => tag.startsWith(IMAGES_NAME_PREFIX)) @@ -246,8 +262,6 @@ export class SimulatorService implements ISimulatorService { const image = this.docker.getImage(imageInfo.Id); await image.remove({force: true}); } - - return true; } public async cleanDatabase(): Promise { diff --git a/tests/actions/stop.test.ts b/tests/actions/stop.test.ts new file mode 100644 index 00000000..9a308894 --- /dev/null +++ b/tests/actions/stop.test.ts @@ -0,0 +1,58 @@ +import { describe, test, vi, beforeEach, afterEach, expect } from "vitest"; +import { StopAction } from "../../src/commands/general/stop"; +import { SimulatorService } from "../../src/lib/services/simulator"; +import { ISimulatorService } from "../../src/lib/interfaces/ISimulatorService"; +import inquirer from "inquirer"; + +vi.mock("../../src/lib/services/simulator"); +vi.mock("inquirer"); + +describe("StopAction", () => { + let stopAction: StopAction; + let mockSimulatorService: ISimulatorService; + + beforeEach(() => { + vi.clearAllMocks(); + + mockSimulatorService = { + stopDockerContainers: vi.fn(), + } as unknown as ISimulatorService; + + SimulatorService.prototype.stopDockerContainers = mockSimulatorService.stopDockerContainers; + + stopAction = new StopAction(); + (stopAction as any).simulatorService = mockSimulatorService; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("should stop containers if user confirms", async () => { + vi.mocked(inquirer.prompt).mockResolvedValue({ confirmAction: true }); + + await stopAction.stop(); + + expect(inquirer.prompt).toHaveBeenCalledWith([ + { + type: "confirm", + name: "confirmAction", + message: "Are you sure you want to stop all running GenLayer containers? This will halt all active processes.", + default: true, + }, + ]); + expect(mockSimulatorService.stopDockerContainers).toHaveBeenCalled(); + }); + + test("should abort if user cancels", async () => { + vi.mocked(inquirer.prompt).mockResolvedValue({ confirmAction: false }); + + console.log = vi.fn(); + + await stopAction.stop(); + + expect(inquirer.prompt).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Operation aborted!"); + expect(mockSimulatorService.stopDockerContainers).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/stop.test.ts b/tests/commands/stop.test.ts new file mode 100644 index 00000000..5999bb4a --- /dev/null +++ b/tests/commands/stop.test.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; +import { vi, describe, beforeEach, afterEach, test, expect } from "vitest"; +import { initializeGeneralCommands } from "../../src/commands/general"; +import { StopAction } from "../../src/commands/general/stop"; + +vi.mock("../../src/commands/general/stop"); + +describe("stop command", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeGeneralCommands(program); + + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("doesn't require arguments or options", async () => { + expect(() => program.parse(["node", "test", "stop"])).not.toThrow(); + expect(StopAction).toHaveBeenCalledTimes(1); + expect(StopAction.prototype.stop).toHaveBeenCalledWith(); + }); +}); diff --git a/tests/services/simulator.test.ts b/tests/services/simulator.test.ts index c82628b8..92782f3b 100644 --- a/tests/services/simulator.test.ts +++ b/tests/services/simulator.test.ts @@ -329,7 +329,7 @@ describe("SimulatorService - Docker Tests", () => { const result = await simulatorService.resetDockerContainers(); - expect(result).toBe(true); + expect(result).toBe(undefined); expect(mockListContainers).toHaveBeenCalledWith({ all: true }); // Ensure only the relevant containers were stopped and removed @@ -341,6 +341,36 @@ describe("SimulatorService - Docker Tests", () => { expect(mockRemove).toHaveBeenCalledTimes(2); }); + test("should stop all running GenLayer containers", async () => { + const mockContainers = [ + { + Id: "container1", + Names: [`${CONTAINERS_NAME_PREFIX}container1`], + State: "running", + }, + { + Id: "container2", + Names: [`${CONTAINERS_NAME_PREFIX}container2`], + State: "exited", + }, + ]; + + vi.mocked(Docker.prototype.listContainers).mockResolvedValue(mockContainers as any); + + const mockStop = vi.fn().mockResolvedValue(undefined); + const mockGetContainer = vi.mocked(Docker.prototype.getContainer); + mockGetContainer.mockImplementation(() => ({ + stop: mockStop, + } as unknown as Docker.Container)); + + await simulatorService.stopDockerContainers(); + + expect(mockGetContainer).toHaveBeenCalledWith("container1"); + expect(mockGetContainer).toHaveBeenCalledWith("container2"); + expect(mockStop).toHaveBeenCalledTimes(1); + }); + + test("should remove Docker images with the specified prefix", async () => { const mockImages = [ { @@ -366,7 +396,7 @@ describe("SimulatorService - Docker Tests", () => { const result = await simulatorService.resetDockerImages(); - expect(result).toBe(true); + expect(result).toBe(undefined); expect(mockListImages).toHaveBeenCalled(); expect(mockGetImage).toHaveBeenCalledWith("image1"); expect(mockGetImage).toHaveBeenCalledWith("image2"); diff --git a/vitest.config.ts b/vitest.config.ts index 72da1a8c..625a376d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + testTimeout: 10000, coverage: { exclude: [...configDefaults.exclude, '*.js', 'tests/**/*.ts', 'src/types', 'scripts'], }