diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 9ef7b9ca7..49bb543df 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -27,6 +27,7 @@ import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.modu import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js'; +import { UPSModule } from '@app/unraid-api/graph/resolvers/ups/ups.module.js'; import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js'; import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js'; import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js'; @@ -46,6 +47,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; FlashBackupModule, RCloneModule, SettingsModule, + UPSModule, ], providers: [ ConfigResolver, diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.inputs.ts b/api/src/unraid-api/graph/resolvers/ups/ups.inputs.ts new file mode 100644 index 000000000..975ae673a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/ups/ups.inputs.ts @@ -0,0 +1,138 @@ +import { Field, InputType, Int, registerEnumType } from '@nestjs/graphql'; + +import { Max, Min } from 'class-validator'; + +/** + * Service state for UPS daemon + */ +export enum UPSServiceState { + ENABLE = 'enable', + DISABLE = 'disable', +} + +/** + * UPS cable types + */ +export enum UPSCableType { + USB = 'usb', + SIMPLE = 'simple', + SMART = 'smart', + ETHER = 'ether', + CUSTOM = 'custom', +} + +/** + * UPS communication types + */ +export enum UPSType { + USB = 'usb', + APCSMART = 'apcsmart', + NET = 'net', + SNMP = 'snmp', + DUMB = 'dumb', + PCNET = 'pcnet', + MODBUS = 'modbus', +} + +/** + * Kill UPS power after shutdown option + */ +export enum UPSKillPower { + YES = 'yes', + NO = 'no', +} + +// Register enums with GraphQL +registerEnumType(UPSServiceState, { + name: 'UPSServiceState', + description: 'Service state for UPS daemon', +}); + +registerEnumType(UPSCableType, { + name: 'UPSCableType', + description: 'UPS cable connection types', +}); + +registerEnumType(UPSType, { + name: 'UPSType', + description: 'UPS communication protocols', +}); + +registerEnumType(UPSKillPower, { + name: 'UPSKillPower', + description: 'Kill UPS power after shutdown option', +}); + +@InputType() +export class UPSConfigInput { + @Field(() => UPSServiceState, { + nullable: true, + description: 'Enable or disable the UPS monitoring service', + }) + service?: UPSServiceState; + + @Field(() => UPSCableType, { + nullable: true, + description: 'Type of cable connecting the UPS to the server', + }) + upsCable?: UPSCableType; + + @Field({ + nullable: true, + description: + 'Custom cable configuration (only used when upsCable is CUSTOM). Format depends on specific UPS model', + }) + customUpsCable?: string; + + @Field(() => UPSType, { + nullable: true, + description: 'UPS communication protocol', + }) + upsType?: UPSType; + + @Field({ + nullable: true, + description: + "Device path or network address for UPS connection. Examples: '/dev/ttyUSB0' for USB, '192.168.1.100:3551' for network", + }) + device?: string; + + @Field(() => Int, { + nullable: true, + description: + 'Override UPS capacity for runtime calculations. Unit: watts (W). Leave unset to use UPS-reported capacity', + }) + @Min(0, { message: 'Override UPS capacity must be a positive number' }) + overrideUpsCapacity?: number; + + @Field(() => Int, { + nullable: true, + description: + 'Battery level percentage to initiate shutdown. Unit: percent (%) - Valid range: 0-100', + }) + @Min(0, { message: 'Battery level must be between 0 and 100' }) + @Max(100, { message: 'Battery level must be between 0 and 100' }) + batteryLevel?: number; + + @Field(() => Int, { + nullable: true, + description: 'Runtime left in minutes to initiate shutdown. Unit: minutes', + }) + @Min(0, { message: 'Minutes must be 0 or greater' }) + minutes?: number; + + @Field(() => Int, { + nullable: true, + description: + 'Time on battery before shutdown. Unit: seconds. Set to 0 to disable timeout-based shutdown', + }) + @Min(0, { message: 'Timeout must be 0 or greater (0 disables timeout-based shutdown)' }) + timeout?: number; + + @Field(() => UPSKillPower, { + nullable: true, + description: + 'Turn off UPS power after system shutdown. Useful for ensuring complete power cycle', + }) + killUps?: UPSKillPower; +} diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.model.ts b/api/src/unraid-api/graph/resolvers/ups/ups.model.ts new file mode 100644 index 000000000..78fee0900 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/ups/ups.model.ts @@ -0,0 +1,171 @@ +import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class UPSBattery { + @Field(() => Int, { + description: + 'Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged', + }) + chargeLevel!: number; + + @Field(() => Int, { + description: + 'Estimated runtime remaining on battery power. Unit: seconds. Example: 3600 means 1 hour of runtime remaining', + }) + estimatedRuntime!: number; + + @Field({ + description: + "Battery health status. Possible values: 'Good', 'Replace', 'Unknown'. Indicates if the battery needs replacement", + }) + health!: string; +} + +@ObjectType() +export class UPSPower { + @Field(() => Float, { + description: + 'Input voltage from the wall outlet/mains power. Unit: volts (V). Example: 120.5 for typical US household voltage', + }) + inputVoltage!: number; + + @Field(() => Float, { + description: + 'Output voltage being delivered to connected devices. Unit: volts (V). Example: 120.5 - should match input voltage when on mains power', + }) + outputVoltage!: number; + + @Field(() => Int, { + description: + 'Current load on the UPS as a percentage of its capacity. Unit: percent (%). Example: 25 means UPS is loaded at 25% of its maximum capacity', + }) + loadPercentage!: number; +} + +@ObjectType() +export class UPSDevice { + @Field(() => ID, { + description: + 'Unique identifier for the UPS device. Usually based on the model name or a generated ID', + }) + id!: string; + + @Field({ description: 'Display name for the UPS device. Can be customized by the user' }) + name!: string; + + @Field({ description: "UPS model name/number. Example: 'APC Back-UPS Pro 1500'" }) + model!: string; + + @Field({ + description: + "Current operational status of the UPS. Common values: 'Online', 'On Battery', 'Low Battery', 'Replace Battery', 'Overload', 'Offline'. 'Online' means running on mains power, 'On Battery' means running on battery backup", + }) + status!: string; + + @Field(() => UPSBattery, { description: 'Battery-related information' }) + battery!: UPSBattery; + + @Field(() => UPSPower, { description: 'Power-related information' }) + power!: UPSPower; +} + +@ObjectType() +export class UPSConfiguration { + @Field({ + nullable: true, + description: + "UPS service state. Values: 'enable' or 'disable'. Controls whether the UPS monitoring service is running", + }) + service?: string; + + @Field({ + nullable: true, + description: + "Type of cable connecting the UPS to the server. Common values: 'usb', 'smart', 'ether', 'custom'. Determines communication protocol", + }) + upsCable?: string; + + @Field({ + nullable: true, + description: + "Custom cable configuration string. Only used when upsCable is set to 'custom'. Format depends on specific UPS model", + }) + customUpsCable?: string; + + @Field({ + nullable: true, + description: + "UPS communication type. Common values: 'usb', 'net', 'snmp', 'dumb', 'pcnet', 'modbus'. Defines how the server communicates with the UPS", + }) + upsType?: string; + + @Field({ + nullable: true, + description: + "Device path or network address for UPS connection. Examples: '/dev/ttyUSB0' for USB, '192.168.1.100:3551' for network. Depends on upsType setting", + }) + device?: string; + + @Field(() => Int, { + nullable: true, + description: + 'Override UPS capacity for runtime calculations. Unit: volt-amperes (VA). Example: 1500 for a 1500VA UPS. Leave unset to use UPS-reported capacity', + }) + overrideUpsCapacity?: number; + + @Field(() => Int, { + nullable: true, + description: + 'Battery level threshold for shutdown. Unit: percent (%). Example: 10 means shutdown when battery reaches 10%. System will shutdown when battery drops to this level', + }) + batteryLevel?: number; + + @Field(() => Int, { + nullable: true, + description: + 'Runtime threshold for shutdown. Unit: minutes. Example: 5 means shutdown when 5 minutes runtime remaining. System will shutdown when estimated runtime drops below this', + }) + minutes?: number; + + @Field(() => Int, { + nullable: true, + description: + 'Timeout for UPS communications. Unit: seconds. Example: 0 means no timeout. Time to wait for UPS response before considering it offline', + }) + timeout?: number; + + @Field({ + nullable: true, + description: + "Kill UPS power after shutdown. Values: 'yes' or 'no'. If 'yes', tells UPS to cut power after system shutdown. Useful for ensuring complete power cycle", + }) + killUps?: string; + + @Field({ + nullable: true, + description: + "Network Information Server (NIS) IP address. Default: '0.0.0.0' (listen on all interfaces). IP address for apcupsd network information server", + }) + nisIp?: string; + + @Field({ + nullable: true, + description: + "Network server mode. Values: 'on' or 'off'. Enable to allow network clients to monitor this UPS", + }) + netServer?: string; + + @Field({ + nullable: true, + description: + "UPS name for network monitoring. Used to identify this UPS on the network. Example: 'SERVER_UPS'", + }) + upsName?: string; + + @Field({ + nullable: true, + description: + 'Override UPS model name. Used for display purposes. Leave unset to use UPS-reported model', + }) + modelName?: string; +} diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.module.ts b/api/src/unraid-api/graph/resolvers/ups/ups.module.ts new file mode 100644 index 000000000..a11ae4481 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/ups/ups.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { PubSub } from 'graphql-subscriptions'; + +import { UPSResolver } from '@app/unraid-api/graph/resolvers/ups/ups.resolver.js'; +import { UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js'; + +@Module({ + providers: [UPSResolver, UPSService, { provide: PubSub, useValue: new PubSub() }], +}) +export class UPSModule {} diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/ups/ups.resolver.spec.ts new file mode 100644 index 000000000..8d5118424 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/ups/ups.resolver.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { PubSub } from 'graphql-subscriptions'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + UPSCableType, + UPSConfigInput, + UPSKillPower, + UPSServiceState, + UPSType, +} from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js'; +import { UPSResolver } from '@app/unraid-api/graph/resolvers/ups/ups.resolver.js'; +import { UPSData, UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js'; + +describe('UPSResolver', () => { + let resolver: UPSResolver; + let service: UPSService; + let pubSub: PubSub; + + const mockUPSData: UPSData = { + MODEL: 'Test UPS', + STATUS: 'Online', + BCHARGE: '100', + TIMELEFT: '3600', + LINEV: '120.5', + OUTPUTV: '120.5', + LOADPCT: '25', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UPSResolver, + { + provide: UPSService, + useValue: { + getUPSData: vi.fn().mockResolvedValue(mockUPSData), + configureUPS: vi.fn().mockResolvedValue(undefined), + getCurrentConfig: vi.fn().mockResolvedValue({}), + }, + }, + { + provide: PubSub, + useValue: { + publish: vi.fn(), + asyncIterableIterator: vi.fn(), + }, + }, + ], + }).compile(); + + resolver = module.get(UPSResolver); + service = module.get(UPSService); + pubSub = module.get(PubSub); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); + + describe('upsDevices', () => { + it('should return an array of UPS devices', async () => { + const result = await resolver.upsDevices(); + expect(result).toBeInstanceOf(Array); + expect(result[0].model).toBe('Test UPS'); + expect(service.getUPSData).toHaveBeenCalled(); + }); + }); + + describe('configureUps', () => { + it('should call the configureUPS service method with full config and return true', async () => { + const config: UPSConfigInput = { + service: UPSServiceState.ENABLE, + upsCable: UPSCableType.USB, + upsType: UPSType.USB, + batteryLevel: 10, + minutes: 5, + timeout: 0, + killUps: UPSKillPower.NO, + }; + const result = await resolver.configureUps(config); + expect(result).toBe(true); + expect(service.configureUPS).toHaveBeenCalledWith(config); + expect(pubSub.publish).toHaveBeenCalledWith('upsUpdates', expect.any(Object)); + }); + + it('should handle partial config updates', async () => { + const partialConfig: UPSConfigInput = { + batteryLevel: 15, + minutes: 10, + }; + const result = await resolver.configureUps(partialConfig); + expect(result).toBe(true); + expect(service.configureUPS).toHaveBeenCalledWith(partialConfig); + expect(pubSub.publish).toHaveBeenCalledWith('upsUpdates', expect.any(Object)); + }); + + it('should handle empty config updates', async () => { + const emptyConfig: UPSConfigInput = {}; + const result = await resolver.configureUps(emptyConfig); + expect(result).toBe(true); + expect(service.configureUPS).toHaveBeenCalledWith(emptyConfig); + expect(pubSub.publish).toHaveBeenCalledWith('upsUpdates', expect.any(Object)); + }); + }); + + describe('upsUpdates', () => { + it('should return an async iterator', () => { + resolver.upsUpdates(); + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith('upsUpdates'); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts b/api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts new file mode 100644 index 000000000..ed2eab9dc --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts @@ -0,0 +1,86 @@ +import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql'; + +import { PubSub } from 'graphql-subscriptions'; + +import { UPSConfigInput } from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js'; +import { UPSConfiguration, UPSDevice } from '@app/unraid-api/graph/resolvers/ups/ups.model.js'; +import { UPSData, UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js'; + +@Resolver(() => UPSDevice) +export class UPSResolver { + constructor( + private readonly upsService: UPSService, + private readonly pubSub: PubSub + ) {} + + private createUPSDevice(upsData: UPSData, id: string): UPSDevice { + return { + id, + name: upsData.MODEL || 'My UPS', + model: upsData.MODEL || 'APC Back-UPS Pro 1500', + status: upsData.STATUS || 'Online', + battery: { + chargeLevel: parseInt(upsData.BCHARGE || '100', 10), + estimatedRuntime: parseInt(upsData.TIMELEFT || '3600', 10), + health: 'Good', + }, + power: { + inputVoltage: parseFloat(upsData.LINEV || '120.5'), + outputVoltage: parseFloat(upsData.OUTPUTV || '120.5'), + loadPercentage: parseInt(upsData.LOADPCT || '25', 10), + }, + }; + } + + @Query(() => [UPSDevice]) + async upsDevices(): Promise { + const upsData = await this.upsService.getUPSData(); + // Assuming single UPS for now, but this could be expanded to support multiple devices + return [this.createUPSDevice(upsData, upsData.MODEL || 'ups1')]; + } + + @Query(() => UPSDevice, { nullable: true }) + async upsDeviceById(@Args('id') id: string): Promise { + const upsData = await this.upsService.getUPSData(); + const deviceId = upsData.MODEL || 'ups1'; + if (id === deviceId) { + return this.createUPSDevice(upsData, id); + } + return null; + } + + @Query(() => UPSConfiguration) + async upsConfiguration(): Promise { + const config = await this.upsService.getCurrentConfig(); + return { + service: config.SERVICE, + upsCable: config.UPSCABLE, + customUpsCable: config.CUSTOMUPSCABLE, + upsType: config.UPSTYPE, + device: config.DEVICE, + overrideUpsCapacity: config.OVERRIDE_UPS_CAPACITY, + batteryLevel: config.BATTERYLEVEL, + minutes: config.MINUTES, + timeout: config.TIMEOUT, + killUps: config.KILLUPS, + nisIp: config.NISIP, + netServer: config.NETSERVER, + upsName: config.UPSNAME, + modelName: config.MODELNAME, + }; + } + + @Mutation(() => Boolean) + async configureUps(@Args('config') config: UPSConfigInput): Promise { + await this.upsService.configureUPS(config); + const updatedData = await this.upsService.getUPSData(); + const newDevice = this.createUPSDevice(updatedData, updatedData.MODEL || 'ups1'); + this.pubSub.publish('upsUpdates', { upsUpdates: newDevice }); + return true; + } + + @Subscription(() => UPSDevice) + upsUpdates() { + return this.pubSub.asyncIterableIterator('upsUpdates'); + } +} diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.service.spec.ts b/api/src/unraid-api/graph/resolvers/ups/ups.service.spec.ts new file mode 100644 index 000000000..662a32264 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/ups/ups.service.spec.ts @@ -0,0 +1,462 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { mkdtemp, readFile, rm, unlink, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + UPSCableType, + UPSConfigInput, + UPSKillPower, + UPSServiceState, + UPSType, +} from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js'; +import { UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js'; + +// Mock dependencies +vi.mock('execa'); +vi.mock('@app/core/utils/files/file-exists.js'); + +const mockExeca = vi.mocked((await import('execa')).execa); +const mockFileExists = vi.mocked((await import('@app/core/utils/files/file-exists.js')).fileExists); + +describe('UPSService', () => { + let service: UPSService; + let tempDir: string; + let configPath: string; + let backupPath: string; + + const mockCurrentConfig = { + SERVICE: 'disable', + UPSCABLE: 'usb', + UPSTYPE: 'usb', + DEVICE: '/dev/ttyUSB0', + BATTERYLEVEL: 20, + MINUTES: 15, + TIMEOUT: 30, + KILLUPS: 'no', + NISIP: '0.0.0.0', + NETSERVER: 'off', + UPSNAME: 'MyUPS', + MODELNAME: 'APC UPS', + }; + + // Helper to create config file content + const createConfigContent = (config: Record): string => { + const lines = ['# APC UPS Configuration File']; + for (const [key, value] of Object.entries(config)) { + if (value !== undefined) { + lines.push( + `${key} ${typeof value === 'string' && value.includes(' ') ? `"${value}"` : value}` + ); + } + } + return lines.join('\n') + '\n'; + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Create temporary directory for test files + tempDir = await mkdtemp(join(tmpdir(), 'ups-test-')); + configPath = join(tempDir, 'apcupsd.conf'); + backupPath = `${configPath}.backup`; + + const module: TestingModule = await Test.createTestingModule({ + providers: [UPSService], + }).compile(); + + service = module.get(UPSService); + + // Override the config path to use our temp directory + Object.defineProperty(service, 'configPath', { + value: configPath, + writable: false, + configurable: true, + }); + + // Mock logger methods on the service instance + vi.spyOn(service['logger'], 'debug').mockImplementation(() => {}); + vi.spyOn(service['logger'], 'warn').mockImplementation(() => {}); + vi.spyOn(service['logger'], 'error').mockImplementation(() => {}); + + // Default mocks + mockFileExists.mockImplementation(async (path) => { + if (path === configPath) { + return true; + } + return false; + }); + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any); + + // Create initial config file + await writeFile(configPath, createConfigContent(mockCurrentConfig)); + }); + + afterEach(async () => { + // Clean up temp directory + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('configureUPS', () => { + it('should merge partial config with existing values', async () => { + const partialConfig: UPSConfigInput = { + batteryLevel: 25, + minutes: 10, + }; + + await service.configureUPS(partialConfig); + + // Read the written config file + const writtenConfig = await readFile(configPath, 'utf-8'); + + // Should preserve existing values for fields not provided + expect(writtenConfig).toContain('SERVICE disable'); // preserved from existing + expect(writtenConfig).toContain('UPSTYPE usb'); // preserved from existing + expect(writtenConfig).toContain('BATTERYLEVEL 25'); // updated value + expect(writtenConfig).toContain('MINUTES 10'); // updated value + expect(writtenConfig).toContain('UPSNAME MyUPS'); // preserved + expect(writtenConfig).toContain('DEVICE /dev/ttyUSB0'); // preserved + }); + + it('should use default values when neither input nor existing config provide values', async () => { + // Write empty config file + await writeFile(configPath, '# Empty config\n'); + + const partialConfig: UPSConfigInput = { + service: UPSServiceState.ENABLE, + }; + + await service.configureUPS(partialConfig); + + const writtenConfig = await readFile(configPath, 'utf-8'); + + expect(writtenConfig).toContain('SERVICE enable'); // provided value + expect(writtenConfig).toContain('UPSTYPE usb'); // default value + expect(writtenConfig).toContain('BATTERYLEVEL 10'); // default value + expect(writtenConfig).toContain('MINUTES 5'); // default value + }); + + it('should handle custom cable configuration', async () => { + const config: UPSConfigInput = { + upsCable: UPSCableType.CUSTOM, + customUpsCable: 'custom-config-string', + }; + + await service.configureUPS(config); + + const writtenConfig = await readFile(configPath, 'utf-8'); + expect(writtenConfig).toContain('UPSCABLE custom-config-string'); + }); + + it('should validate required fields after merging', async () => { + // Write config without device field + await writeFile( + configPath, + createConfigContent({ + SERVICE: 'disable', + UPSTYPE: 'usb', + }) + ); + + const config: UPSConfigInput = { + upsType: UPSType.NET, // requires device + // device not provided and not in existing config + }; + + await expect(service.configureUPS(config)).rejects.toThrow( + 'device is required for non-USB UPS types' + ); + }); + + it('should handle killpower configuration for enable + yes', async () => { + // Create a mock rc.6 file for this test + const mockRc6Path = join(tempDir, 'mock-rc.6'); + await writeFile(mockRc6Path, '/sbin/poweroff', 'utf-8'); + service['rc6Path'] = mockRc6Path; + + // Update mock to indicate rc6 file exists + mockFileExists.mockImplementation(async (path) => { + if (path === configPath || path === mockRc6Path) { + return true; + } + return false; + }); + + const config: UPSConfigInput = { + service: UPSServiceState.ENABLE, + killUps: UPSKillPower.YES, + }; + + await service.configureUPS(config); + + // Should have modified the rc.6 file + const rc6Content = await readFile(mockRc6Path, 'utf-8'); + expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff'); + + expect(mockExeca).toHaveBeenCalledWith('/etc/rc.d/rc.apcupsd', ['start'], { + timeout: 10000, + }); + }); + + it('should handle killpower configuration for disable case', async () => { + // Create a mock rc.6 file with killpower already enabled + const mockRc6Path = join(tempDir, 'mock-rc.6-2'); + await writeFile(mockRc6Path, '/etc/apcupsd/apccontrol killpower; /sbin/poweroff', 'utf-8'); + service['rc6Path'] = mockRc6Path; + + const config: UPSConfigInput = { + service: UPSServiceState.DISABLE, + killUps: UPSKillPower.YES, // should be ignored since service is disabled + }; + + await service.configureUPS(config); + + // Should NOT have modified the rc.6 file since service is disabled + const rc6Content = await readFile(mockRc6Path, 'utf-8'); + expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff'); + }); + + it('should start service when enabled', async () => { + const config: UPSConfigInput = { + service: UPSServiceState.ENABLE, + }; + + await service.configureUPS(config); + + expect(mockExeca).toHaveBeenCalledWith('/etc/rc.d/rc.apcupsd', ['start'], { + timeout: 10000, + }); + }); + + it('should not start service when disabled', async () => { + const config: UPSConfigInput = { + service: UPSServiceState.DISABLE, + }; + + await service.configureUPS(config); + + // Should not call start + expect(mockExeca).not.toHaveBeenCalledWith( + '/etc/rc.d/rc.apcupsd', + ['start'], + expect.any(Object) + ); + }); + + it('should preserve existing config values not provided in input', async () => { + const config: UPSConfigInput = { + batteryLevel: 50, // only update battery level + }; + + await service.configureUPS(config); + + const configContent = await readFile(configPath, 'utf-8'); + + // Should preserve existing values in the generated format + expect(configContent).toContain('UPSNAME MyUPS'); + expect(configContent).toContain('UPSCABLE usb'); + expect(configContent).toContain('UPSTYPE usb'); + expect(configContent).toContain('DEVICE /dev/ttyUSB0'); + expect(configContent).toContain('MINUTES 15'); // from existing config + expect(configContent).toContain('TIMEOUT 30'); // from existing config + expect(configContent).toContain('NETSERVER off'); + expect(configContent).toContain('NISIP 0.0.0.0'); + expect(configContent).toContain('MODELNAME "APC UPS"'); // Values with spaces get quoted + + // Should update provided value + expect(configContent).toContain('BATTERYLEVEL 50'); + + // Should preserve other values not in ordered list + expect(configContent).toContain('SERVICE disable'); + expect(configContent).toContain('KILLUPS no'); + }); + + it('should create backup before writing new config', async () => { + const originalContent = await readFile(configPath, 'utf-8'); + + const config: UPSConfigInput = { + batteryLevel: 30, + }; + + await service.configureUPS(config); + + // Should create backup with original content + const backupContent = await readFile(backupPath, 'utf-8'); + expect(backupContent).toBe(originalContent); + + // Should write new config + const newContent = await readFile(configPath, 'utf-8'); + expect(newContent).toContain('BATTERYLEVEL 30'); + expect(newContent).not.toBe(originalContent); + }); + + it('should handle errors gracefully and restore backup', async () => { + const originalContent = await readFile(configPath, 'utf-8'); + + const config: UPSConfigInput = { + batteryLevel: 30, + }; + + // Temporarily override generateApcupsdConfig to throw error + const originalGenerate = service['generateApcupsdConfig'].bind(service); + service['generateApcupsdConfig'] = vi.fn().mockImplementation(() => { + throw new Error('Generation failed'); + }); + + // Since we can't easily mock fs operations with real files, + // we'll test a different error path + await expect(service.configureUPS(config)).rejects.toThrow( + 'Failed to configure UPS: Generation failed' + ); + + // Restore original method + service['generateApcupsdConfig'] = originalGenerate; + + // Config should remain unchanged + const currentContent = await readFile(configPath, 'utf-8'); + expect(currentContent).toBe(originalContent); + }); + }); + + describe('killpower functionality', () => { + let tempRc6Path: string; + + beforeEach(async () => { + // Create a temporary rc.6 file for testing + tempRc6Path = join(tempDir, 'rc.6'); + + // Create a mock rc.6 content + const mockRc6Content = `#!/bin/sh +# Shutdown script +echo "Shutting down..." +/sbin/poweroff +exit 0 +`; + await writeFile(tempRc6Path, mockRc6Content, 'utf-8'); + + // Override the rc6Path in the service (we'll need to make it configurable) + service['rc6Path'] = tempRc6Path; + + // Update mock to indicate rc6 file exists + mockFileExists.mockImplementation(async (path) => { + if (path === configPath || path === tempRc6Path) { + return true; + } + return false; + }); + }); + + it('should enable killpower when killUps=yes and service=enable', async () => { + const config: UPSConfigInput = { + killUps: UPSKillPower.YES, + service: UPSServiceState.ENABLE, + upsType: UPSType.USB, + }; + + await service.configureUPS(config); + + const rc6Content = await readFile(tempRc6Path, 'utf-8'); + expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff'); + // The file still contains "exit 0" on a separate line + expect(rc6Content).toContain('exit 0'); + }); + + it('should disable killpower when killUps=no', async () => { + // First enable killpower + const enableConfig: UPSConfigInput = { + killUps: UPSKillPower.YES, + service: UPSServiceState.ENABLE, + upsType: UPSType.USB, + }; + await service.configureUPS(enableConfig); + + // Then disable it + const disableConfig: UPSConfigInput = { + killUps: UPSKillPower.NO, + service: UPSServiceState.ENABLE, + upsType: UPSType.USB, + }; + await service.configureUPS(disableConfig); + + const rc6Content = await readFile(tempRc6Path, 'utf-8'); + expect(rc6Content).not.toContain('apccontrol killpower'); + expect(rc6Content).toContain('/sbin/poweroff\nexit 0'); // Should be restored + }); + + it('should not enable killpower when service=disable', async () => { + const config: UPSConfigInput = { + killUps: UPSKillPower.YES, + service: UPSServiceState.DISABLE, // Service is disabled + upsType: UPSType.USB, + }; + + await service.configureUPS(config); + + const rc6Content = await readFile(tempRc6Path, 'utf-8'); + expect(rc6Content).not.toContain('apccontrol killpower'); + }); + + it('should handle missing rc.6 file gracefully', async () => { + // Remove the file + await unlink(tempRc6Path); + + // Update mock to indicate rc6 file does NOT exist + mockFileExists.mockImplementation(async (path) => { + if (path === configPath) { + return true; + } + return false; + }); + + const config: UPSConfigInput = { + killUps: UPSKillPower.YES, + service: UPSServiceState.ENABLE, + upsType: UPSType.USB, + }; + + // Should not throw - just skip killpower configuration + await expect(service.configureUPS(config)).resolves.not.toThrow(); + }); + + it('should be idempotent - enabling killpower multiple times', async () => { + const config: UPSConfigInput = { + killUps: UPSKillPower.YES, + service: UPSServiceState.ENABLE, + upsType: UPSType.USB, + }; + + // Enable killpower twice + await service.configureUPS(config); + const firstContent = await readFile(tempRc6Path, 'utf-8'); + + await service.configureUPS(config); + const secondContent = await readFile(tempRc6Path, 'utf-8'); + + // Content should be the same after second run + expect(firstContent).toBe(secondContent); + // Should only have one instance of killpower + expect((secondContent.match(/apccontrol killpower/g) || []).length).toBe(1); + }); + + it('should be idempotent - disabling killpower multiple times', async () => { + const config: UPSConfigInput = { + killUps: UPSKillPower.NO, + service: UPSServiceState.ENABLE, + upsType: UPSType.USB, + }; + + // Disable killpower twice (when it's not enabled) + await service.configureUPS(config); + const firstContent = await readFile(tempRc6Path, 'utf-8'); + + await service.configureUPS(config); + const secondContent = await readFile(tempRc6Path, 'utf-8'); + + // Content should be the same after second run + expect(firstContent).toBe(secondContent); + expect(secondContent).not.toContain('apccontrol killpower'); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/ups/ups.service.ts b/api/src/unraid-api/graph/resolvers/ups/ups.service.ts new file mode 100644 index 000000000..1827064d3 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/ups/ups.service.ts @@ -0,0 +1,387 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { readFile, writeFile } from 'fs/promises'; + +import { execa } from 'execa'; +import { z } from 'zod'; + +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { UPSConfigInput } from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js'; + +const UPSSchema = z.object({ + MODEL: z.string().optional(), + STATUS: z.string().optional(), + BCHARGE: z.string().optional(), + TIMELEFT: z.string().optional(), + LINEV: z.string().optional(), + OUTPUTV: z.string().optional(), + LOADPCT: z.string().optional(), +}); + +export type UPSData = z.infer; + +const UPSConfigSchema = z.object({ + SERVICE: z.string().optional(), + UPSCABLE: z.string().optional(), + CUSTOMUPSCABLE: z.string().optional(), + UPSTYPE: z.string().optional(), + DEVICE: z.string().optional(), + OVERRIDE_UPS_CAPACITY: z.number().optional(), + BATTERYLEVEL: z.number().optional(), + MINUTES: z.number().optional(), + TIMEOUT: z.number().optional(), + KILLUPS: z.string().optional(), + NISIP: z.string().optional(), + NETSERVER: z.string().optional(), + UPSNAME: z.string().optional(), + MODELNAME: z.string().optional(), +}); + +export type UPSConfig = z.infer; + +@Injectable() +export class UPSService { + private readonly logger = new Logger(UPSService.name); + private readonly configPath = '/etc/apcupsd/apcupsd.conf'; + private rc6Path = '/etc/rc.d/rc.6'; // Made non-readonly for testing + + async getUPSData(): Promise { + try { + const { stdout } = await execa('/sbin/apcaccess', [], { + timeout: 10000, + reject: false, // Handle errors manually + }); + if (!stdout || stdout.trim().length === 0) { + throw new Error('No UPS data returned from apcaccess'); + } + const parsedData = this.parseUPSData(stdout); + return UPSSchema.parse(parsedData); + } catch (error) { + this.logger.error('Error getting UPS data:', error); + throw new Error( + `Failed to get UPS data: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + async configureUPS(config: UPSConfigInput): Promise { + try { + const currentConfig = await this.getCurrentConfig(); + const mergedConfig = this.mergeConfigurations(config, currentConfig); + + this.validateConfiguration(mergedConfig); + + await this.stopUPSService(); + + const newConfig = this.prepareConfigObject(mergedConfig, currentConfig); + + await this.writeConfigurationWithBackup(newConfig, currentConfig); + + await this.configureKillPower(mergedConfig); + + if (mergedConfig.service === 'enable') { + await this.startUPSService(); + } + } catch (error) { + this.logger.error('Error configuring UPS:', error); + throw new Error( + `Failed to configure UPS: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private mergeConfigurations(config: UPSConfigInput, currentConfig: UPSConfig) { + return { + service: config.service ?? currentConfig.SERVICE ?? 'disable', + upsCable: config.upsCable ?? currentConfig.UPSCABLE ?? 'usb', + customUpsCable: config.customUpsCable ?? currentConfig.CUSTOMUPSCABLE, + upsType: config.upsType ?? currentConfig.UPSTYPE ?? 'usb', + device: config.device ?? currentConfig.DEVICE ?? '', + overrideUpsCapacity: config.overrideUpsCapacity ?? currentConfig.OVERRIDE_UPS_CAPACITY, + batteryLevel: config.batteryLevel ?? currentConfig.BATTERYLEVEL ?? 10, + minutes: config.minutes ?? currentConfig.MINUTES ?? 5, + timeout: config.timeout ?? currentConfig.TIMEOUT ?? 0, + killUps: config.killUps ?? currentConfig.KILLUPS ?? 'no', + }; + } + + private validateConfiguration(config: ReturnType): void { + if (!config.upsType) { + throw new Error('upsType is required'); + } + if (!config.device && config.upsType !== 'usb') { + throw new Error('device is required for non-USB UPS types'); + } + } + + private async stopUPSService(): Promise { + try { + await execa('/etc/rc.d/rc.apcupsd', ['stop'], { timeout: 10000 }); + } catch (error) { + this.logger.warn('Failed to stop apcupsd service (may not be running):', error); + } + } + + private async startUPSService(): Promise { + try { + await execa('/etc/rc.d/rc.apcupsd', ['start'], { timeout: 10000 }); + this.logger.debug('Successfully started apcupsd service'); + } catch (error) { + this.logger.error('Failed to start apcupsd service:', error); + throw new Error( + `Configuration written successfully, but failed to start service: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private prepareConfigObject( + mergedConfig: ReturnType, + currentConfig: UPSConfig + ): Partial { + const cable = + mergedConfig.upsCable === 'custom' ? mergedConfig.customUpsCable : mergedConfig.upsCable; + + const newConfig: Partial = { + NISIP: currentConfig.NISIP || '0.0.0.0', + SERVICE: mergedConfig.service, + UPSTYPE: mergedConfig.upsType, + DEVICE: mergedConfig.device || '', + BATTERYLEVEL: mergedConfig.batteryLevel, + MINUTES: mergedConfig.minutes, + TIMEOUT: mergedConfig.timeout, + UPSCABLE: cable, + KILLUPS: mergedConfig.killUps, + NETSERVER: currentConfig.NETSERVER, + UPSNAME: currentConfig.UPSNAME, + MODELNAME: currentConfig.MODELNAME, + }; + + if ( + mergedConfig.overrideUpsCapacity !== undefined && + mergedConfig.overrideUpsCapacity !== null + ) { + newConfig.OVERRIDE_UPS_CAPACITY = mergedConfig.overrideUpsCapacity; + } + + return newConfig; + } + + private async writeConfigurationWithBackup( + newConfig: Partial, + currentConfig: UPSConfig + ): Promise { + const backupPath = `${this.configPath}.backup`; + + await this.createBackup(backupPath); + + const configContent = this.generateApcupsdConfig(newConfig, currentConfig); + + try { + await writeFile(this.configPath, configContent, 'utf-8'); + this.logger.debug('Successfully wrote new UPS configuration'); + } catch (error) { + await this.restoreBackup(backupPath); + throw error; + } + } + + private async createBackup(backupPath: string): Promise { + try { + if (await fileExists(this.configPath)) { + const currentContent = await readFile(this.configPath, 'utf-8'); + await writeFile(backupPath, currentContent, 'utf-8'); + this.logger.debug(`Backed up current config to ${backupPath}`); + } + } catch (error) { + this.logger.warn('Failed to create config backup:', error); + } + } + + private async restoreBackup(backupPath: string): Promise { + try { + if (await fileExists(backupPath)) { + const backupContent = await readFile(backupPath, 'utf-8'); + await writeFile(this.configPath, backupContent, 'utf-8'); + this.logger.warn('Restored config from backup after write failure'); + } + } catch (restoreError) { + this.logger.error('Failed to restore config backup:', restoreError); + } + } + + private async configureKillPower( + config: ReturnType + ): Promise { + // Only configure killpower if service is enabled + if (config.service !== 'enable') { + this.logger.debug('Skipping killpower configuration: service is not enabled'); + return; + } + + const shouldEnableKillPower = config.killUps === 'yes'; + + try { + await this.modifyRc6File(shouldEnableKillPower); + } catch (error) { + // If file doesn't exist, just skip (e.g., in tests) + if (error instanceof Error && error.message.includes('not found')) { + this.logger.debug(`Skipping killpower configuration: ${this.rc6Path} not found`); + return; + } + throw error; + } + } + + private async modifyRc6File(enableKillPower: boolean): Promise { + const content = await this.readFileIfExists(this.rc6Path); + if (!content) { + throw new Error(`${this.rc6Path} not found`); + } + + const killPowerCommand = '/etc/apcupsd/apccontrol killpower; /sbin/poweroff'; + const normalPoweroff = '/sbin/poweroff'; + const hasKillPower = content.includes('apccontrol killpower'); + + // Check if modification is needed + if (enableKillPower && hasKillPower) { + this.logger.debug('Killpower already enabled in rc.6'); + return; + } + if (!enableKillPower && !hasKillPower) { + this.logger.debug('Killpower already disabled in rc.6'); + return; + } + + // Modify content + const modifiedContent = enableKillPower + ? content.replace(normalPoweroff, killPowerCommand) + : content.replace(killPowerCommand, normalPoweroff); + + // Write the modified content + try { + await writeFile(this.rc6Path, modifiedContent, 'utf-8'); + this.logger.debug( + enableKillPower ? 'Added killpower to rc.6' : 'Removed killpower from rc.6' + ); + } catch (error) { + const action = enableKillPower ? 'enable' : 'disable'; + this.logger.error(`Failed to update rc.6 for killpower ${action}:`, error); + throw new Error( + `Failed to ${action} killpower in ${this.rc6Path}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private async readFileIfExists(path: string): Promise { + try { + return await readFile(path, 'utf-8'); + } catch (error) { + // File doesn't exist or can't be read + return null; + } + } + + private parseUPSData(data: string): any { + return data + .split('\n') + .map((line) => line.split(': ')) + .filter((parts) => parts.length === 2 && parts[0] && parts[1]) + .reduce( + (upsData, [key, value]) => { + upsData[key.trim()] = value.trim(); + return upsData; + }, + {} as Record + ); + } + + async getCurrentConfig(): Promise { + try { + const configContent = await this.readFileIfExists(this.configPath); + if (!configContent) { + this.logger.warn(`UPS config file not found at ${this.configPath}`); + return {}; + } + + const config = this.parseApcupsdConfig(configContent); + return UPSConfigSchema.parse(config); + } catch (error) { + this.logger.error('Error reading UPS config:', error); + throw new Error( + `Failed to read UPS config: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private parseApcupsdConfig(content: string): Record { + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => line.match(/^([A-Z_]+)\s+(.+)$/)) + .filter((match): match is RegExpMatchArray => match !== null) + .reduce( + (config, match) => { + const [, directive, value] = match; + let parsedValue = value.trim(); + + // Remove quotes if present + if (parsedValue.startsWith('"') && parsedValue.endsWith('"')) { + parsedValue = parsedValue.slice(1, -1); + } + + // Convert numeric values + config[directive] = /^\d+$/.test(parsedValue) + ? parseInt(parsedValue, 10) + : parsedValue; + + return config; + }, + {} as Record + ); + } + + private generateApcupsdConfig(config: Partial, existingConfig: UPSConfig): string { + // Merge with existing config, new values override existing ones + const mergedConfig = { ...existingConfig, ...config }; + + // Define the order of directives + const orderedDirectives = [ + 'UPSNAME', + 'UPSCABLE', + 'UPSTYPE', + 'DEVICE', + 'BATTERYLEVEL', + 'MINUTES', + 'TIMEOUT', + 'NETSERVER', + 'NISIP', + 'MODELNAME', + ]; + + const lines: string[] = []; + lines.push('# APC UPS Configuration File'); + lines.push('# Generated by Unraid API'); + lines.push(''); + + // Add ordered directives first + for (const directive of orderedDirectives) { + if (mergedConfig[directive] !== undefined) { + const value = mergedConfig[directive]; + lines.push( + `${directive} ${typeof value === 'string' && value.includes(' ') ? `"${value}"` : value}` + ); + } + } + + // Add any remaining directives not in the ordered list + for (const [directive, value] of Object.entries(mergedConfig)) { + if (!orderedDirectives.includes(directive as any) && value !== undefined) { + lines.push( + `${directive} ${typeof value === 'string' && value.includes(' ') ? `"${value}"` : value}` + ); + } + } + + return lines.join('\n') + '\n'; + } +}