fix: vms now can detect starting of libvirt and start local hypervisor (#1356)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Enhanced virtual machine services now benefit from improved
initialization, dynamic monitoring, and cleaner shutdown, ensuring a
smoother operational experience.
- Strengthened input validation for array state changes helps users
avoid improper commands and enhances overall reliability.

- **Bug Fixes**
- Refined state and disk management logic prevents redundant operations
and incorrect transitions, leading to a more stable system.

- **Refactor**
- Streamlined status reporting delivers clearer and more accurate array
state information for end-users.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-04-14 14:59:06 -04:00
committed by GitHub
parent d74d9f1246
commit ad0f4c8b55
8 changed files with 322 additions and 233 deletions

View File

@@ -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!

View File

@@ -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;
};

View File

@@ -1,3 +0,0 @@
// Created from 'create-ts-index'
export * from './array-is-running.js';

View File

@@ -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';

View File

@@ -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',

View File

@@ -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<typeof vi.fn>;
let mockGetState: ReturnType<typeof vi.fn>;
let mockEmcmd: ReturnType<typeof vi.fn>;
let mockGetArrayDataUtil: ReturnType<typeof vi.fn>;
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>(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();
});
});
});

View File

@@ -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<UnraidArray> {
const { store } = await import('@app/store/index.js');
return getArrayDataUtil(store.getState);
}
async updateArrayState({ desiredState }: ArrayStateInput): Promise<UnraidArray> {
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<UnraidArray> {
if (this.arrayIsRunning()) {
if (await this.arrayIsRunning()) {
throw new ArrayRunningError();
}
@@ -92,7 +104,7 @@ export class ArrayService {
}
async removeDiskFromArray(input: ArrayDiskInput): Promise<UnraidArray> {
if (this.arrayIsRunning()) {
if (await this.arrayIsRunning()) {
throw new ArrayRunningError();
}
@@ -109,7 +121,7 @@ export class ArrayService {
}
async mountArrayDisk(id: string): Promise<UnraidArray> {
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<UnraidArray> {
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<UnraidArray> {
if (!this.arrayIsRunning()) {
if (!(await this.arrayIsRunning())) {
throw new BadRequestException('Array must be running to clear disk statistics');
}

View File

@@ -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<typeof HypervisorClass> | 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<boolean> {
// 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<void> {
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<void> {
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<void> {
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, VmState[]> = {
[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'}`
);