diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index b9e202d1f..0107f5f1a 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -183,12 +183,6 @@ enum ArrayDiskFsColor { type UnraidArray implements Node { id: ID! - """Array state before this query/mutation""" - previousState: ArrayState - - """Array state after this query/mutation""" - pendingState: ArrayPendingState - """Current array state""" state: ArrayState! @@ -222,13 +216,6 @@ enum ArrayState { NO_DATA_DISKS } -enum ArrayPendingState { - STARTING - STOPPING - NO_DATA_DISKS - TOO_MANY_MISSING_DISKS -} - type Share implements Node { id: ID! diff --git a/api/src/core/utils/array/array-is-running.ts b/api/src/core/utils/array/array-is-running.ts deleted file mode 100644 index f7af3bd84..000000000 --- a/api/src/core/utils/array/array-is-running.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getters } from '@app/store/index.js'; -import { ArrayState } from '@app/unraid-api/graph/resolvers/array/array.model.js'; - -/** - * Is the array running? - */ -export const arrayIsRunning = () => { - const emhttp = getters.emhttp(); - return emhttp.var.mdState === ArrayState.STARTED; -}; diff --git a/api/src/core/utils/array/index.ts b/api/src/core/utils/array/index.ts deleted file mode 100644 index dd7c0b9ae..000000000 --- a/api/src/core/utils/array/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Created from 'create-ts-index' - -export * from './array-is-running.js'; diff --git a/api/src/core/utils/index.ts b/api/src/core/utils/index.ts index 78908be91..c3424568a 100644 --- a/api/src/core/utils/index.ts +++ b/api/src/core/utils/index.ts @@ -1,6 +1,5 @@ // Created from 'create-ts-index' -export * from './array/index.js'; export * from './clients/index.js'; export * from './plugins/index.js'; export * from './shares/index.js'; diff --git a/api/src/unraid-api/graph/resolvers/array/array.model.ts b/api/src/unraid-api/graph/resolvers/array/array.model.ts index a61a40777..8a8739b51 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.model.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.model.ts @@ -1,5 +1,7 @@ import { Field, ID, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { IsEnum } from 'class-validator'; + import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long.js'; import { Node } from '@app/unraid-api/graph/resolvers/base.model.js'; @@ -134,15 +136,6 @@ export class UnraidArray implements Node { @Field(() => ID) id!: string; - @Field(() => ArrayState, { nullable: true, description: 'Array state before this query/mutation' }) - previousState?: ArrayState; - - @Field(() => ArrayPendingState, { - nullable: true, - description: 'Array state after this query/mutation', - }) - pendingState?: ArrayPendingState; - @Field(() => ArrayState, { description: 'Current array state' }) state!: ArrayState; @@ -171,12 +164,6 @@ export class ArrayDiskInput { slot?: number; } -@InputType() -export class ArrayStateInput { - @Field(() => ArrayStateInputState, { description: 'Array state' }) - desiredState!: ArrayStateInputState; -} - export enum ArrayStateInputState { START = 'START', STOP = 'STOP', @@ -186,6 +173,13 @@ registerEnumType(ArrayStateInputState, { name: 'ArrayStateInputState', }); +@InputType() +export class ArrayStateInput { + @Field(() => ArrayStateInputState, { description: 'Array state' }) + @IsEnum(ArrayStateInputState) + desiredState!: ArrayStateInputState; +} + export enum ArrayState { STARTED = 'STARTED', STOPPED = 'STOPPED', @@ -220,17 +214,6 @@ registerEnumType(ArrayDiskStatus, { name: 'ArrayDiskStatus', }); -export enum ArrayPendingState { - STARTING = 'STARTING', - STOPPING = 'STOPPING', - NO_DATA_DISKS = 'NO_DATA_DISKS', - TOO_MANY_MISSING_DISKS = 'TOO_MANY_MISSING_DISKS', -} - -registerEnumType(ArrayPendingState, { - name: 'ArrayPendingState', -}); - export enum ArrayDiskType { DATA = 'DATA', PARITY = 'PARITY', diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts index 197d04f39..fc858bb4d 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts @@ -1,16 +1,18 @@ import type { TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getArrayData } from '@app/core/modules/array/get-array-data.js'; +import { ArrayRunningError } from '@app/core/errors/array-running-error.js'; +import { getArrayData as getArrayDataUtil } from '@app/core/modules/array/get-array-data.js'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; -import { getters } from '@app/store/index.js'; import { ArrayDiskInput, ArrayState, ArrayStateInput, ArrayStateInputState, + UnraidArray, } from '@app/unraid-api/graph/resolvers/array/array.model.js'; import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; @@ -18,35 +20,49 @@ vi.mock('@app/core/utils/clients/emcmd.js', () => ({ emcmd: vi.fn(), })); -vi.mock('@app/store/index.js', () => ({ - getters: { - emhttp: vi.fn(), - }, -})); - vi.mock('@app/core/modules/array/get-array-data.js', () => ({ getArrayData: vi.fn(), })); +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(), + }, + store: { + getState: vi.fn(), + }, +})); + describe('ArrayService', () => { let service: ArrayService; - let mockArrayData: any; + let mockArrayData: UnraidArray; + let mockEmhttp: ReturnType; + let mockGetState: ReturnType; + let mockEmcmd: ReturnType; + let mockGetArrayDataUtil: ReturnType; beforeEach(async () => { + vi.resetAllMocks(); + + const storeMock = await import('@app/store/index.js'); + mockEmhttp = vi.mocked(storeMock.getters.emhttp); + mockGetState = vi.mocked(storeMock.store.getState); + + mockEmcmd = vi.mocked(emcmd); + mockGetArrayDataUtil = vi.mocked(getArrayDataUtil); + const module: TestingModule = await Test.createTestingModule({ providers: [ArrayService], }).compile(); service = module.get(ArrayService); - // Mock getters.emhttp() - vi.mocked(getters.emhttp).mockReturnValue({ + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STOPPED, }, } as any); - // Mock getArrayData mockArrayData = { id: 'array', state: ArrayState.STOPPED, @@ -62,153 +78,182 @@ describe('ArrayService', () => { total: '15', }, }, - boot: null, + boot: undefined, parities: [], disks: [], caches: [], }; - vi.mocked(getArrayData).mockReturnValue(mockArrayData); + mockGetArrayDataUtil.mockResolvedValue(mockArrayData); + + mockGetState.mockReturnValue({ + /* mock state if needed by getArrayDataUtil */ + }); }); it('should be defined', () => { expect(service).toBeDefined(); }); - it('should update array state', async () => { - const input: ArrayStateInput = { - desiredState: ArrayStateInputState.START, - }; - const result = await service.updateArrayState(input); - expect(result).toEqual(mockArrayData); - expect(emcmd).toHaveBeenCalledWith({ - cmdStart: 'Start', - startState: 'STOPPED', + describe('getArrayData', () => { + it('should call getArrayDataUtil with store.getState and return its result', async () => { + const result = await service.getArrayData(); + expect(result).toEqual(mockArrayData); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + expect(mockGetArrayDataUtil).toHaveBeenCalledWith(mockGetState); }); }); - it('should add disk to array', async () => { - const input: ArrayDiskInput = { - id: 'test-disk', - slot: 1, - }; - const result = await service.addDiskToArray(input); - expect(result).toEqual(mockArrayData); - expect(emcmd).toHaveBeenCalledWith({ - changeDevice: 'apply', - 'slotId.1': 'test-disk', + describe('updateArrayState', () => { + it('should START a STOPPED array', async () => { + const input: ArrayStateInput = { desiredState: ArrayStateInputState.START }; + const expectedArrayData = { ...mockArrayData, state: ArrayState.STARTED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState(input); + + expect(result).toEqual(expectedArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + }); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + }); + + it('should STOP a STARTED array', async () => { + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); + const input: ArrayStateInput = { desiredState: ArrayStateInputState.STOP }; + const expectedArrayData = { ...mockArrayData, state: ArrayState.STOPPED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState(input); + + expect(result).toEqual(expectedArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + cmdStop: 'Stop', + startState: 'STARTED', + }); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + }); + + it('should throw error if trying to START an already STARTED array', async () => { + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); + const input: ArrayStateInput = { desiredState: ArrayStateInputState.START }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + + it('should throw error if trying to STOP an already STOPPED array', async () => { + const input: ArrayStateInput = { desiredState: ArrayStateInputState.STOP }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + expect(mockEmcmd).not.toHaveBeenCalled(); }); }); - it('should remove disk from array', async () => { - const input: ArrayDiskInput = { - id: 'test-disk', - slot: 1, - }; - const result = await service.removeDiskFromArray(input); - expect(result).toEqual(mockArrayData); - expect(emcmd).toHaveBeenCalledWith({ - changeDevice: 'apply', - 'slotId.1': '', + describe('addDiskToArray', () => { + const input: ArrayDiskInput = { id: 'test-disk', slot: 1 }; + + it('should add disk to array when STOPPED', async () => { + const result = await service.addDiskToArray(input); + expect(result).toEqual(mockArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + changeDevice: 'apply', + 'slotId.1': 'test-disk', + }); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + }); + + it('should throw ArrayRunningError when array is STARTED', async () => { + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); + await expect(service.addDiskToArray(input)).rejects.toThrow(new ArrayRunningError()); + expect(mockEmcmd).not.toHaveBeenCalled(); }); }); - it('should mount array disk', async () => { - // Mock array as running - vi.mocked(getters.emhttp).mockReturnValue({ - var: { - mdState: ArrayState.STARTED, - }, - } as any); + describe('removeDiskFromArray', () => { + const input: ArrayDiskInput = { id: 'test-disk', slot: 1 }; - const result = await service.mountArrayDisk('test-disk'); - expect(result).toEqual(mockArrayData); - expect(emcmd).toHaveBeenCalledWith({ - mount: 'apply', - 'diskId.test-disk': '1', + it('should remove disk from array when STOPPED', async () => { + const result = await service.removeDiskFromArray(input); + expect(result).toEqual(mockArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + changeDevice: 'apply', + 'slotId.1': '', + }); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + }); + + it('should throw ArrayRunningError when array is STARTED', async () => { + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); + await expect(service.removeDiskFromArray(input)).rejects.toThrow(new ArrayRunningError()); + expect(mockEmcmd).not.toHaveBeenCalled(); }); }); - it('should unmount array disk', async () => { - // Mock array as running - vi.mocked(getters.emhttp).mockReturnValue({ - var: { - mdState: ArrayState.STARTED, - }, - } as any); + describe('mountArrayDisk', () => { + const diskId = 'test-disk'; - const result = await service.unmountArrayDisk('test-disk'); - expect(result).toEqual(mockArrayData); - expect(emcmd).toHaveBeenCalledWith({ - unmount: 'apply', - 'diskId.test-disk': '1', + it('should mount disk when array is STARTED', async () => { + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); + const result = await service.mountArrayDisk(diskId); + expect(result).toEqual(mockArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + mount: 'apply', + [`diskId.${diskId}`]: '1', + }); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + }); + + it('should throw BadRequestException when array is STOPPED', async () => { + await expect(service.mountArrayDisk(diskId)).rejects.toThrow( + new BadRequestException('Array must be running to mount disks') + ); + expect(mockEmcmd).not.toHaveBeenCalled(); }); }); - it('should clear array disk statistics', async () => { - // Mock array as running - vi.mocked(getters.emhttp).mockReturnValue({ - var: { - mdState: ArrayState.STARTED, - }, - } as any); + describe('unmountArrayDisk', () => { + const diskId = 'test-disk'; - const result = await service.clearArrayDiskStatistics('test-disk'); - expect(result).toEqual(mockArrayData); - expect(emcmd).toHaveBeenCalledWith({ - clearStats: 'apply', - 'diskId.test-disk': '1', + it('should unmount disk when array is STARTED', async () => { + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); + const result = await service.unmountArrayDisk(diskId); + expect(result).toEqual(mockArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + unmount: 'apply', + [`diskId.${diskId}`]: '1', + }); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + }); + + it('should throw BadRequestException when array is STOPPED', async () => { + await expect(service.unmountArrayDisk(diskId)).rejects.toThrow( + new BadRequestException('Array must be running to unmount disks') + ); + expect(mockEmcmd).not.toHaveBeenCalled(); }); }); - it('should throw error when array is running for add disk', async () => { - // Mock array as running - vi.mocked(getters.emhttp).mockReturnValue({ - var: { - mdState: ArrayState.STARTED, - }, - } as any); + describe('clearArrayDiskStatistics', () => { + const diskId = 'test-disk'; - const input: ArrayDiskInput = { - id: 'test-disk', - slot: 1, - }; - await expect(service.addDiskToArray(input)).rejects.toThrow( - 'Array needs to be stopped before any changes can occur.' - ); - }); + it('should clear stats when array is STARTED', async () => { + mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); + const result = await service.clearArrayDiskStatistics(diskId); + expect(result).toEqual(mockArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + clearStats: 'apply', + [`diskId.${diskId}`]: '1', + }); + expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); + }); - it('should throw error when array is running for remove disk', async () => { - // Mock array as running - vi.mocked(getters.emhttp).mockReturnValue({ - var: { - mdState: ArrayState.STARTED, - }, - } as any); - - const input: ArrayDiskInput = { - id: 'test-disk', - slot: 1, - }; - await expect(service.removeDiskFromArray(input)).rejects.toThrow( - 'Array needs to be stopped before any changes can occur.' - ); - }); - - it('should throw error when array is not running for mount disk', async () => { - await expect(service.mountArrayDisk('test-disk')).rejects.toThrow( - 'Array must be running to mount disks' - ); - }); - - it('should throw error when array is not running for unmount disk', async () => { - await expect(service.unmountArrayDisk('test-disk')).rejects.toThrow( - 'Array must be running to unmount disks' - ); - }); - - it('should throw error when array is not running for clear disk statistics', async () => { - await expect(service.clearArrayDiskStatistics('test-disk')).rejects.toThrow( - 'Array must be running to clear disk statistics' - ); + it('should throw BadRequestException when array is STOPPED', async () => { + await expect(service.clearArrayDiskStatistics(diskId)).rejects.toThrow( + new BadRequestException('Array must be running to clear disk statistics') + ); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.ts b/api/src/unraid-api/graph/resolvers/array/array.service.ts index 6d2539a90..117e15132 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.ts @@ -7,16 +7,19 @@ import { AppError } from '@app/core/errors/app-error.js'; import { ArrayRunningError } from '@app/core/errors/array-running-error.js'; import { getArrayData as getArrayDataUtil } from '@app/core/modules/array/get-array-data.js'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; -import { arrayIsRunning as arrayIsRunningUtil } from '@app/core/utils/index.js'; import { ArrayDiskInput, - ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, UnraidArray, } from '@app/unraid-api/graph/resolvers/array/array.model.js'; +enum ArrayPendingState { + STARTING = 'STARTING', + STOPPING = 'STOPPING', +} + @Injectable() export class ArrayService { private pendingState: ArrayPendingState | null = null; @@ -25,38 +28,47 @@ export class ArrayService { * Is the array running? * @todo Refactor this to include this util in the service directly */ - private arrayIsRunning() { - return arrayIsRunningUtil(); - } + private arrayIsRunning = async () => { + const { getters } = await import('@app/store/index.js'); + const emhttp = getters.emhttp(); + return emhttp.var.mdState === ArrayState.STARTED; + }; - public getArrayData() { - return getArrayDataUtil(); + private getArrayState = async () => { + const { getters } = await import('@app/store/index.js'); + const emhttp = getters.emhttp(); + return emhttp.var.mdState; + }; + + public async getArrayData(): Promise { + const { store } = await import('@app/store/index.js'); + return getArrayDataUtil(store.getState); } async updateArrayState({ desiredState }: ArrayStateInput): Promise { - const startState = this.arrayIsRunning() ? ArrayState.STARTED : ArrayState.STOPPED; - const pendingState = + if (this.pendingState) { + throw new BadRequestException( + new AppError(`Array state is still being updated. Changing to ${this.pendingState}`) + ); + } + const isRunning = await this.arrayIsRunning(); + const startState = isRunning ? ArrayState.STARTED : ArrayState.STOPPED; + const newPendingState = desiredState === ArrayStateInputState.STOP ? ArrayPendingState.STOPPING : ArrayPendingState.STARTING; - // Prevent this running multiple times at once - if (this.pendingState) { - throw new BadRequestException( - new AppError(`Array state is still being updated. Changing to ${pendingState}`) - ); - } - // Prevent starting/stopping array when it's already in the same state + if ( - (this.arrayIsRunning() && desiredState === ArrayStateInputState.START) || - (!this.arrayIsRunning() && desiredState === ArrayStateInputState.STOP) + (isRunning && desiredState === ArrayStateInputState.START) || + (!isRunning && desiredState === ArrayStateInputState.STOP) ) { throw new BadRequestException(new AppError(`The array is already ${startState}`)); } // Set lock then start/stop array - this.pendingState = pendingState; + this.pendingState = newPendingState; const command = { [`cmd${capitalCase(desiredState)}`]: capitalCase(desiredState), startState: constantCase(startState), @@ -75,7 +87,7 @@ export class ArrayService { } async addDiskToArray(input: ArrayDiskInput): Promise { - if (this.arrayIsRunning()) { + if (await this.arrayIsRunning()) { throw new ArrayRunningError(); } @@ -92,7 +104,7 @@ export class ArrayService { } async removeDiskFromArray(input: ArrayDiskInput): Promise { - if (this.arrayIsRunning()) { + if (await this.arrayIsRunning()) { throw new ArrayRunningError(); } @@ -109,7 +121,7 @@ export class ArrayService { } async mountArrayDisk(id: string): Promise { - if (!this.arrayIsRunning()) { + if (!(await this.arrayIsRunning())) { throw new BadRequestException('Array must be running to mount disks'); } @@ -123,7 +135,7 @@ export class ArrayService { } async unmountArrayDisk(id: string): Promise { - if (!this.arrayIsRunning()) { + if (!(await this.arrayIsRunning())) { throw new BadRequestException('Array must be running to unmount disks'); } @@ -137,7 +149,7 @@ export class ArrayService { } async clearArrayDiskStatistics(id: string): Promise { - if (!this.arrayIsRunning()) { + if (!(await this.arrayIsRunning())) { throw new BadRequestException('Array must be running to clear disk statistics'); } diff --git a/api/src/unraid-api/graph/resolvers/vms/vms.service.ts b/api/src/unraid-api/graph/resolvers/vms/vms.service.ts index 5881d43b0..35a1b1a2e 100644 --- a/api/src/unraid-api/graph/resolvers/vms/vms.service.ts +++ b/api/src/unraid-api/graph/resolvers/vms/vms.service.ts @@ -1,33 +1,37 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { constants } from 'fs'; import { access } from 'fs/promises'; import type { Domain, Hypervisor as HypervisorClass } from '@unraid/libvirt'; import { ConnectListAllDomainsFlags, DomainState, Hypervisor } from '@unraid/libvirt'; +import { FSWatcher, watch } from 'chokidar'; import { GraphQLError } from 'graphql'; import { getters } from '@app/store/index.js'; import { VmDomain, VmState } from '@app/unraid-api/graph/resolvers/vms/vms.model.js'; @Injectable() -export class VmsService implements OnModuleInit { +export class VmsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(VmsService.name); private hypervisor: InstanceType | null = null; private isVmsAvailable: boolean = false; + private watcher: FSWatcher | null = null; private uri: string; + private pidPath: string; constructor() { this.uri = process.env.LIBVIRT_URI ?? 'qemu:///system'; + this.pidPath = getters.paths()?.['libvirt-pid'] ?? '/var/run/libvirt/libvirtd.pid'; + this.logger.debug(`Using libvirt PID path: ${this.pidPath}`); } private async isLibvirtRunning(): Promise { - // Skip PID check for session URIs if (this.uri.includes('session')) { return true; } try { - await access(getters.paths()['libvirt-pid'], constants.F_OK | constants.R_OK); + await access(this.pidPath, constants.F_OK | constants.R_OK); return true; } catch (error) { return false; @@ -35,28 +39,111 @@ export class VmsService implements OnModuleInit { } async onModuleInit() { + this.logger.debug(`Initializing VMs service with URI: ${this.uri}`); + await this.attemptHypervisorInitializationAndWatch(); + } + + async onModuleDestroy() { + this.logger.debug('Closing file watcher...'); + await this.watcher?.close(); + this.logger.debug('Closing hypervisor connection...'); + try { + await this.hypervisor?.connectClose(); + } catch (error) { + this.logger.warn(`Error closing hypervisor connection: ${(error as Error).message}`); + } + this.hypervisor = null; + this.isVmsAvailable = false; + this.logger.debug('VMs service cleanup complete.'); + } + + private async attemptHypervisorInitializationAndWatch(): Promise { try { - this.logger.debug(`Initializing VMs service with URI: ${this.uri}`); await this.initializeHypervisor(); this.isVmsAvailable = true; this.logger.debug(`VMs service initialized successfully with URI: ${this.uri}`); + await this.setupWatcher(); } catch (error) { this.isVmsAvailable = false; this.logger.warn( - `VMs are not available: ${error instanceof Error ? error.message : 'Unknown error'}` + `Initial hypervisor connection failed: ${error instanceof Error ? error.message : 'Unknown error'}. Setting up watcher.` ); + await this.setupWatcher(); } } + private async setupWatcher(): Promise { + if (this.watcher) { + this.logger.debug('Closing existing file watcher before setting up a new one.'); + await this.watcher.close(); + } + + this.logger.debug(`Setting up watcher for PID file: ${this.pidPath}`); + this.watcher = watch(this.pidPath, { + ignoreInitial: true, + atomic: true, + awaitWriteFinish: true, + }); + + this.watcher + .on('add', async () => { + this.logger.log( + `Libvirt PID file detected at ${this.pidPath}. Attempting connection...` + ); + try { + await this.initializeHypervisor(); + this.isVmsAvailable = true; + this.logger.log( + 'Hypervisor connection established successfully after PID file detection.' + ); + } catch (error) { + this.isVmsAvailable = false; + this.logger.error( + `Failed to initialize hypervisor after PID file detection: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }) + .on('unlink', async () => { + this.logger.warn( + `Libvirt PID file removed from ${this.pidPath}. Hypervisor likely stopped.` + ); + this.isVmsAvailable = false; + try { + if (this.hypervisor) { + await this.hypervisor.connectClose(); + this.logger.debug('Hypervisor connection closed due to PID file removal.'); + } + } catch (closeError) { + this.logger.error( + `Error closing hypervisor connection after PID unlink: ${closeError instanceof Error ? closeError.message : 'Unknown error'}` + ); + } + this.hypervisor = null; + }) + .on('error', (error: unknown) => { + this.logger.error( + `Watcher error for ${this.pidPath}: ${error instanceof Error ? error.message : error}` + ); + }); + } + private async initializeHypervisor(): Promise { - this.logger.debug('Checking if libvirt is running...'); + if (this.hypervisor && this.isVmsAvailable) { + this.logger.debug('Hypervisor connection assumed active based on availability flag.'); + return; + } + + this.logger.debug('Checking if libvirt process is running via PID file...'); const running = await this.isLibvirtRunning(); if (!running) { throw new Error('Libvirt is not running'); } - this.logger.debug('Libvirt is running, creating hypervisor instance...'); + this.logger.debug('Libvirt appears to be running, creating hypervisor instance...'); + + if (!this.hypervisor) { + this.hypervisor = new Hypervisor({ uri: this.uri }); + } - this.hypervisor = new Hypervisor({ uri: this.uri }); try { this.logger.debug('Attempting to connect to hypervisor...'); await this.hypervisor.connectOpen(); @@ -85,15 +172,12 @@ export class VmsService implements OnModuleInit { const info = await domain.getInfo(); this.logger.debug(`Current domain state: ${info.state}`); - // Map VmState to DomainState for comparison const currentState = this.mapDomainStateToVmState(info.state); - // Validate state transition if (!this.isValidStateTransition(currentState, targetState)) { throw new Error(`Invalid state transition from ${currentState} to ${targetState}`); } - // Perform state transition switch (targetState) { case VmState.RUNNING: if (currentState === VmState.SHUTOFF) { @@ -156,7 +240,6 @@ export class VmsService implements OnModuleInit { } private isValidStateTransition(currentState: VmState, targetState: VmState): boolean { - // Define valid state transitions const validTransitions: Record = { [VmState.NOSTATE]: [VmState.RUNNING, VmState.SHUTOFF], [VmState.RUNNING]: [VmState.PAUSED, VmState.SHUTOFF], @@ -216,16 +299,13 @@ export class VmsService implements OnModuleInit { const domain = await this.hypervisor.domainLookupByUUIDString(uuid); this.logger.debug(`Found domain, rebooting...`); - // First try graceful shutdown await domain.shutdown(); - // Wait for shutdown to complete const shutdownSuccess = await this.waitForDomainShutdown(domain); if (!shutdownSuccess) { throw new Error('Graceful shutdown failed, please force stop the VM and try again'); } - // Start the domain again await domain.create(); return true; } catch (error) { @@ -245,10 +325,8 @@ export class VmsService implements OnModuleInit { const domain = await this.hypervisor.domainLookupByUUIDString(uuid); this.logger.debug(`Found domain, resetting...`); - // Force stop the domain await domain.destroy(); - // Start the domain again await domain.create(); return true; } catch (error) { @@ -269,7 +347,6 @@ export class VmsService implements OnModuleInit { try { const hypervisor = this.hypervisor; this.logger.debug('Getting all domains...'); - // Get both active and inactive domains const domains = await hypervisor.connectListAllDomains( ConnectListAllDomainsFlags.ACTIVE | ConnectListAllDomainsFlags.INACTIVE ); @@ -280,11 +357,6 @@ export class VmsService implements OnModuleInit { const info = await domain.getInfo(); const name = await domain.getName(); const uuid = await domain.getUUIDString(); - this.logger.debug( - `Found domain: ${name} (${uuid}) with state ${DomainState[info.state]}` - ); - - // Map DomainState to VmState using our existing function const state = this.mapDomainStateToVmState(info.state); return { @@ -297,11 +369,15 @@ export class VmsService implements OnModuleInit { return resolvedDomains; } catch (error: unknown) { - // If we hit an error expect libvirt to be offline - this.isVmsAvailable = false; - this.logger.error( - `Failed to get domains: ${error instanceof Error ? error.message : 'Unknown error'}` - ); + if (error instanceof Error && error.message.includes('virConnectListAllDomains')) { + this.logger.error( + `Failed to list domains, possibly due to connection issue: ${error.message}` + ); + } else { + this.logger.error( + `Failed to get domains: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } throw new GraphQLError( `Failed to get domains: ${error instanceof Error ? error.message : 'Unknown error'}` );