Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ type Vars implements Node {
fsState: String
bootEligible: Boolean
enableBootTransfer: String
bootedFromFlashWithInternalBootSetup: Boolean
reservedNames: String

"""Human friendly string of array events happening"""
Expand Down
5 changes: 3 additions & 2 deletions api/src/unraid-api/graph/resolvers/disks/disks.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.
import { DisksResolver } from '@app/unraid-api/graph/resolvers/disks/disks.resolver.js';
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
import { InternalBootNotificationService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-notification.service.js';
import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js';
import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js';

@Module({
imports: [ArrayModule, NotificationsModule],
providers: [DisksResolver, DisksService, InternalBootNotificationService],
exports: [DisksResolver, DisksService],
providers: [DisksResolver, DisksService, InternalBootNotificationService, InternalBootStateService],
exports: [DisksResolver, DisksService, InternalBootStateService],
})
export class DisksModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
import { InternalBootNotificationService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-notification.service.js';
import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js';
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';

const mockArrayService = {
getArrayData: vi.fn(),
};

const mockDisksService = {
getInternalBootDevices: vi.fn(),
const mockInternalBootStateService = {
getBootedFromFlashWithInternalBootSetupForBootDisk: vi.fn(),
};

const mockNotificationsService = {
Expand All @@ -37,8 +37,8 @@ describe('InternalBootNotificationService', () => {
useValue: mockArrayService,
},
{
provide: DisksService,
useValue: mockDisksService,
provide: InternalBootStateService,
useValue: mockInternalBootStateService,
},
{
provide: NotificationsService,
Expand All @@ -62,7 +62,9 @@ describe('InternalBootNotificationService', () => {
device: 'sda',
},
});
mockDisksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]);
mockInternalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk.mockResolvedValue(
true
);
mockNotificationsService.notifyIfUnique.mockResolvedValue(null);

await service.onApplicationBootstrap();
Expand All @@ -84,7 +86,9 @@ describe('InternalBootNotificationService', () => {
device: 'nvme0n1',
},
});
mockDisksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]);
mockInternalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk.mockResolvedValue(
true
);

await service.onApplicationBootstrap();

Expand All @@ -99,7 +103,9 @@ describe('InternalBootNotificationService', () => {
device: 'sda',
},
});
mockDisksService.getInternalBootDevices.mockResolvedValue([]);
mockInternalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk.mockResolvedValue(
false
);

await service.onApplicationBootstrap();

Expand All @@ -120,7 +126,9 @@ describe('InternalBootNotificationService', () => {
device: 'sda',
},
});
mockDisksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]);
mockInternalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk.mockResolvedValue(
true
);
mockNotificationsService.notifyIfUnique.mockResolvedValue(null);

const bootstrapPromise = service.onApplicationBootstrap();
Expand All @@ -139,7 +147,9 @@ describe('InternalBootNotificationService', () => {
mockArrayService.getArrayData.mockResolvedValue({
boot: undefined,
});
mockDisksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]);
mockInternalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk.mockResolvedValue(
true
);

const bootstrapPromise = service.onApplicationBootstrap();

Expand All @@ -163,7 +173,9 @@ describe('InternalBootNotificationService', () => {
device: 'sda',
},
});
mockDisksService.getInternalBootDevices.mockRejectedValue(new Error('lsblk failed'));
mockInternalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk.mockRejectedValue(
new Error('lsblk failed')
);

await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();

Expand All @@ -183,7 +195,9 @@ describe('InternalBootNotificationService', () => {
device: 'sda',
},
});
mockDisksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]);
mockInternalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk.mockResolvedValue(
true
);
mockNotificationsService.notifyIfUnique.mockRejectedValue(new Error('notify failed'));

await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import type { NotificationData } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js';
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';

Expand All @@ -16,7 +16,7 @@ export class InternalBootNotificationService implements OnApplicationBootstrap {

constructor(
private readonly arrayService: ArrayService,
private readonly disksService: DisksService,
private readonly internalBootStateService: InternalBootStateService,
private readonly notificationsService: NotificationsService
) {}

Expand All @@ -39,8 +39,11 @@ export class InternalBootNotificationService implements OnApplicationBootstrap {
}

try {
const internalBootDevices = await this.disksService.getInternalBootDevices();
if (internalBootDevices.length === 0) {
const bootedFromFlashWithInternalBootSetup =
await this.internalBootStateService.getBootedFromFlashWithInternalBootSetupForBootDisk(
bootDisk
);
if (!bootedFromFlashWithInternalBootSetup) {
this.logger.debug(
`Skipping internal boot notification: no internal boot candidates found for ${bootDisk.device ?? bootDisk.id}`
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';

import type { ArrayDisk } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';

@Injectable()
export class InternalBootStateService {
constructor(
private readonly arrayService: ArrayService,
private readonly disksService: DisksService
) {}

public async getBootedFromFlashWithInternalBootSetup(): Promise<boolean> {
const array = await this.arrayService.getArrayData();
return this.getBootedFromFlashWithInternalBootSetupForBootDisk(array.boot);
}

public async getBootedFromFlashWithInternalBootSetupForBootDisk(
bootDisk: Pick<ArrayDisk, 'type'> | null | undefined
): Promise<boolean> {
if (!bootDisk || bootDisk.type !== ArrayDiskType.FLASH) {
return false;
}

const internalBootDevices = await this.disksService.getInternalBootDevices();
return internalBootDevices.length > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { emcmd } from '@app/core/utils/clients/emcmd.js';
import { getters } from '@app/store/index.js';
import { loadStateFileSync } from '@app/store/services/state-file-loader.js';
import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js';
import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js';

vi.mock('@app/core/utils/clients/emcmd.js', () => ({
Expand All @@ -25,17 +26,27 @@ vi.mock('@app/store/services/state-file-loader.js', () => ({
}));

describe('OnboardingInternalBootService', () => {
const internalBootStateService = {
getBootedFromFlashWithInternalBootSetup: vi.fn(),
};

beforeEach(() => {
vi.clearAllMocks();
internalBootStateService.getBootedFromFlashWithInternalBootSetup.mockResolvedValue(false);
vi.mocked(getters.emhttp).mockReturnValue({
devices: [],
disks: [],
} as unknown as ReturnType<typeof getters.emhttp>);
});

const createService = () =>
new OnboardingInternalBootService(
internalBootStateService as unknown as InternalBootStateService
);

it('runs the internal boot emcmd sequence and returns success', async () => {
vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited<ReturnType<typeof emcmd>>);
const service = new OnboardingInternalBootService();
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
Expand Down Expand Up @@ -116,7 +127,7 @@ describe('OnboardingInternalBootService', () => {
stderr: '',
exitCode: 0,
} as Awaited<ReturnType<typeof execa>>);
const service = new OnboardingInternalBootService();
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
Expand Down Expand Up @@ -183,7 +194,7 @@ describe('OnboardingInternalBootService', () => {
stderr: '',
exitCode: 1,
} as Awaited<ReturnType<typeof execa>>);
const service = new OnboardingInternalBootService();
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
Expand All @@ -201,7 +212,7 @@ describe('OnboardingInternalBootService', () => {
});

it('returns validation error for duplicate devices', async () => {
const service = new OnboardingInternalBootService();
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
Expand All @@ -218,9 +229,48 @@ describe('OnboardingInternalBootService', () => {
expect(vi.mocked(emcmd)).not.toHaveBeenCalled();
});

it('returns validation error when internal boot is already configured while booted from flash', async () => {
internalBootStateService.getBootedFromFlashWithInternalBootSetup.mockResolvedValue(true);
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
});

expect(result).toMatchObject({
ok: false,
code: 3,
});
expect(result.output).toContain('internal boot is already configured');
expect(vi.mocked(emcmd)).not.toHaveBeenCalled();
});

it('returns failure output when the internal boot state lookup throws', async () => {
internalBootStateService.getBootedFromFlashWithInternalBootSetup.mockRejectedValue(
new Error('state lookup failed')
);
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
});

expect(result.ok).toBe(false);
expect(result.code).toBe(1);
expect(result.output).toContain('mkbootpool: command failed or timed out');
expect(result.output).toContain('state lookup failed');
expect(vi.mocked(emcmd)).not.toHaveBeenCalled();
});

it('returns failure output when emcmd command throws', async () => {
vi.mocked(emcmd).mockRejectedValue(new Error('socket failure'));
const service = new OnboardingInternalBootService();
const service = createService();

const result = await service.createInternalBootPool({
poolName: 'cache',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getters } from '@app/store/index.js';
import { loadStateFileSync } from '@app/store/services/state-file-loader.js';
import { StateFileKey } from '@app/store/types.js';
import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js';

const INTERNAL_BOOT_COMMAND_TIMEOUT_MS = 180000;
const EFI_BOOT_PATH = '\\EFI\\BOOT\\BOOTX64.EFI';
Expand All @@ -32,6 +33,12 @@ const isEmhttpDeviceRecord = (value: unknown): value is EmhttpDeviceRecord => {

@Injectable()
export class OnboardingInternalBootService {
constructor(private readonly internalBootStateService: InternalBootStateService) {}

private async isBootedFromFlashWithInternalBootSetup(): Promise<boolean> {
return this.internalBootStateService.getBootedFromFlashWithInternalBootSetup();
}

private async runStep(
commandText: string,
command: Record<string, string>,
Expand Down Expand Up @@ -342,6 +349,14 @@ export class OnboardingInternalBootService {
}

try {
if (await this.isBootedFromFlashWithInternalBootSetup()) {
return {
ok: false,
code: 3,
output: 'mkbootpool: internal boot is already configured while the system is still booted from flash',
};
}

await this.runStep(
'debug=cmdCreatePool,cmdAssignDisk,cmdMakeBootable',
{ debug: 'cmdCreatePool,cmdAssignDisk,cmdMakeBootable' },
Expand Down
3 changes: 3 additions & 0 deletions api/src/unraid-api/graph/resolvers/vars/vars.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ export class Vars extends Node {
@Field({ nullable: true })
enableBootTransfer?: string;

@Field({ nullable: true })
bootedFromFlashWithInternalBootSetup?: boolean;

@Field({ nullable: true })
reservedNames?: string;

Expand Down
Loading
Loading