Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/src/unraid-api/graph/resolvers/array/array.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.serv
ArrayResolver,
ParityCheckMutationsResolver,
],
exports: [ArrayService],
})
export class ArrayModule {}
6 changes: 5 additions & 1 deletion api/src/unraid-api/graph/resolvers/disks/disks.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Module } from '@nestjs/common';

import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.js';
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 { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js';

@Module({
providers: [DisksResolver, DisksService],
imports: [ArrayModule, NotificationsModule],
providers: [DisksResolver, DisksService, InternalBootNotificationService],
exports: [DisksResolver, DisksService],
})
export class DisksModule {}
83 changes: 83 additions & 0 deletions api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,89 @@ describe('DisksService', () => {
});
});

describe('getInternalBootDevices', () => {
it('should return disks that match the Unraid internal boot partition layout', async () => {
mockExeca.mockResolvedValue({
stdout: JSON.stringify({
blockdevices: [
{
name: 'sda',
path: '/dev/sda',
type: 'disk',
children: [
{
name: 'sda1',
path: '/dev/sda1',
type: 'part',
partlabel: 'BIOS Boot Partition',
parttype: '21686148-6449-6e6f-744e-656564454649',
},
{
name: 'sda2',
path: '/dev/sda2',
type: 'part',
partlabel: 'EFI System Partition',
parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',
},
{
name: 'sda3',
path: '/dev/sda3',
type: 'part',
partlabel: 'Unraid Boot Partition',
parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',
},
{
name: 'sda4',
path: '/dev/sda4',
type: 'part',
partlabel: '',
parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',
},
],
},
{
name: 'sdb',
path: '/dev/sdb',
type: 'disk',
children: [
{
name: 'sdb1',
path: '/dev/sdb1',
type: 'part',
partlabel: 'EFI System Partition',
parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',
},
],
},
],
}),
stderr: '',
exitCode: 0,
failed: false,
command: '',
cwd: '',
isCanceled: false,
} as unknown as Awaited<ReturnType<typeof execa>>);

const disks = await service.getInternalBootDevices();

expect(mockExeca).toHaveBeenCalledWith('lsblk', [
'-J',
'-o',
'NAME,PATH,TYPE,PARTLABEL,PARTTYPE',
]);
expect(disks).toHaveLength(1);
expect(disks[0]?.device).toBe('/dev/sda');
expect(disks[0]?.id).toBe('S4ENNF0N123456');
});

it('should return an empty array when lsblk fails', async () => {
mockExeca.mockRejectedValue(new Error('lsblk failed'));

await expect(service.getInternalBootDevices()).resolves.toEqual([]);
});
});

// --- Test getTemperature ---
describe('getTemperature', () => {
it('should return temperature for a disk', async () => {
Expand Down
140 changes: 139 additions & 1 deletion api/src/unraid-api/graph/resolvers/disks/disks.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

import type { Systeminformation } from 'systeminformation';
Expand All @@ -25,6 +25,10 @@ const SmartAttributeSchema = z.object({
.nullable(),
});

const GPT_BIOS_BOOT = '21686148-6449-6e6f-744e-656564454649';
const GPT_EFI = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b';
const GPT_LINUX = '0fc63daf-8483-4772-8e79-3d69d8477de4';

type SmartAttribute = z.infer<typeof SmartAttributeSchema>;

const SmartDataSchema = z.object({
Expand All @@ -51,15 +55,29 @@ interface EmhttpDeviceRecord {
device?: unknown;
}

interface LsblkDeviceRecord {
name: string;
path: string;
type: string;
partlabel: string;
parttype: string;
children: LsblkDeviceRecord[];
}

const normalizeDeviceName = (value: string | undefined): string => {
if (!value) {
return '';
}
return value.startsWith('/dev/') ? value.slice('/dev/'.length) : value;
};

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;

@Injectable()
export class DisksService {
private readonly logger = new Logger(DisksService.name);

constructor(private readonly configService: ConfigService) {}

private getEmhttpDevices(): EmhttpDevice[] {
Expand Down Expand Up @@ -146,6 +164,126 @@ export class DisksService {
return disk;
}

public async getInternalBootDevices(): Promise<Disk[]> {
const [disks, deviceNames] = await Promise.all([
this.getDisks(),
this.getInternalBootDeviceNames(),
]);

return disks.filter((disk) => deviceNames.has(normalizeDeviceName(disk.device)));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private async getInternalBootDeviceNames(): Promise<Set<string>> {
try {
const { stdout } = await execa('lsblk', ['-J', '-o', 'NAME,PATH,TYPE,PARTLABEL,PARTTYPE']);

const devices = this.parseLsblkDevices(stdout);
return new Set(
devices
.filter((device) => this.matchesInternalBootLayout(device))
.map((device) => normalizeDeviceName(device.path))
.filter((device) => device.length > 0)
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to inspect internal boot devices via lsblk: ${message}`);
return new Set();
}
}

private parseLsblkDevices(stdout: string): LsblkDeviceRecord[] {
try {
const parsed = JSON.parse(stdout) as unknown;
if (!isRecord(parsed)) {
return [];
}

const blockDevices = parsed.blockdevices;
if (!Array.isArray(blockDevices)) {
return [];
}

return blockDevices
.map((device) => this.toLsblkDeviceRecord(device))
.filter((device): device is LsblkDeviceRecord => device !== null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to parse lsblk output: ${message}`);
return [];
}
}

private toLsblkDeviceRecord(value: unknown): LsblkDeviceRecord | null {
if (!isRecord(value)) {
return null;
}

const children = Array.isArray(value.children)
? value.children
.map((child) => this.toLsblkDeviceRecord(child))
.filter((child): child is LsblkDeviceRecord => child !== null)
: [];

return {
name: typeof value.name === 'string' ? value.name : '',
path: typeof value.path === 'string' ? value.path : '',
type: typeof value.type === 'string' ? value.type : '',
partlabel: typeof value.partlabel === 'string' ? value.partlabel : '',
parttype: typeof value.parttype === 'string' ? value.parttype : '',
children,
};
}

private matchesInternalBootLayout(device: LsblkDeviceRecord): boolean {
if (device.type !== 'disk') {
return false;
}

const partitions = [...device.children]
.filter((child) => child.type === 'part')
.sort((left, right) => this.comparePartitions(left.name, right.name));

if (partitions.length < 4) {
return false;
}

const [biosPartition, efiPartition, unraidPartition, dataPartition] = partitions;

return (
biosPartition.partlabel === 'BIOS Boot Partition' &&
this.normalizePartitionType(biosPartition.parttype) === GPT_BIOS_BOOT &&
efiPartition.partlabel === 'EFI System Partition' &&
this.normalizePartitionType(efiPartition.parttype) === GPT_EFI &&
unraidPartition.partlabel === 'Unraid Boot Partition' &&
this.normalizePartitionType(unraidPartition.parttype) === GPT_LINUX &&
this.normalizePartitionType(dataPartition.parttype) === GPT_LINUX
);
}

private comparePartitions(left: string, right: string): number {
const leftNumber = this.getPartitionNumber(left);
const rightNumber = this.getPartitionNumber(right);

if (leftNumber !== null && rightNumber !== null && leftNumber !== rightNumber) {
return leftNumber - rightNumber;
}

return left.localeCompare(right);
}

private getPartitionNumber(name: string): number | null {
const match = name.match(/(?:p)?(\d+)$/);
if (!match?.[1]) {
return null;
}

return Number.parseInt(match[1], 10);
}

private normalizePartitionType(value: string): string {
return value.trim().toLowerCase();
}

private async parseDisk(
disk: Systeminformation.DiskLayoutData,
partitionsToParse: Systeminformation.BlockDevicesData[],
Expand Down
Loading
Loading