mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
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:
@@ -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!
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
// Created from 'create-ts-index'
|
||||
|
||||
export * from './array-is-running.js';
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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'}`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user