234 lines
7.8 KiB
TypeScript
234 lines
7.8 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { Action, type IActionContext } from './base-action.ts';
|
|
import { logger } from '../logger.ts';
|
|
import { SHUTDOWN, UI } from '../constants.ts';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* ShutdownAction - Initiates system shutdown
|
|
*
|
|
* This action triggers a system shutdown using the standard shutdown command.
|
|
* It includes a configurable delay to allow VMs and services to gracefully terminate.
|
|
*/
|
|
export class ShutdownAction extends Action {
|
|
readonly type = 'shutdown';
|
|
|
|
/**
|
|
* Override shouldExecute to add shutdown-specific safety checks
|
|
*
|
|
* Key safety rules:
|
|
* 1. Shutdown should NEVER trigger unless UPS is actually on battery
|
|
* (low battery while on grid power is not an emergency - it's charging)
|
|
* 2. For power status changes, only trigger on transitions TO onBattery from online
|
|
* (ignore unknown → online at startup, and power restoration events)
|
|
* 3. For threshold violations, verify UPS is on battery before acting
|
|
*
|
|
* @param context Action context with UPS state
|
|
* @returns True if shutdown should execute
|
|
*/
|
|
protected override shouldExecute(context: IActionContext): boolean {
|
|
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
|
|
|
|
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
|
|
// A low battery while on grid power is not an emergency (the battery is charging)
|
|
if (context.powerStatus !== 'onBattery') {
|
|
logger.info(
|
|
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Handle threshold violations (UPS is confirmed on battery at this point)
|
|
if (context.triggerReason === 'thresholdViolation') {
|
|
// 'onlyPowerChanges' mode ignores thresholds
|
|
if (mode === 'onlyPowerChanges') {
|
|
logger.info('Shutdown action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
|
|
return false;
|
|
}
|
|
// Check if thresholds are actually exceeded
|
|
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
|
}
|
|
|
|
// Handle power status changes
|
|
if (context.triggerReason === 'powerStatusChange') {
|
|
// 'onlyThresholds' mode ignores power status changes
|
|
if (mode === 'onlyThresholds') {
|
|
logger.info(
|
|
'Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const prev = context.previousPowerStatus;
|
|
|
|
// Only trigger on transitions TO onBattery from online (real power loss)
|
|
if (prev === 'online') {
|
|
logger.info('Shutdown action triggered: power loss detected (online → onBattery)');
|
|
return true;
|
|
}
|
|
|
|
// For unknown → onBattery (daemon started while on battery):
|
|
// This is a startup scenario - be cautious. The user may have just started
|
|
// the daemon for testing, or the UPS may have been on battery for a while.
|
|
// Only trigger if mode explicitly includes power changes.
|
|
if (prev === 'unknown') {
|
|
if (
|
|
mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
|
|
mode === 'anyChange'
|
|
) {
|
|
logger.info(
|
|
'Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)',
|
|
);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Other transitions (e.g., onBattery → onBattery) should not trigger
|
|
logger.info(
|
|
`Shutdown action skipped: non-emergency transition (${prev} → ${context.powerStatus})`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// For 'anyChange' mode, always execute (UPS is already confirmed on battery)
|
|
if (mode === 'anyChange') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Execute the shutdown action
|
|
* @param context Action context with UPS state
|
|
*/
|
|
async execute(context: IActionContext): Promise<void> {
|
|
// Check if we should execute based on trigger mode and thresholds
|
|
if (!this.shouldExecute(context)) {
|
|
logger.info(
|
|
`Shutdown action skipped (trigger mode: ${
|
|
this.config.triggerMode || 'powerChangesAndThresholds'
|
|
})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
|
|
|
logger.log('');
|
|
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
|
|
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
|
|
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
|
|
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
|
|
logger.logBoxLine(`Runtime: ${context.batteryRuntime} minutes`);
|
|
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
|
logger.logBoxLine(`Shutdown delay: ${shutdownDelay} minutes`);
|
|
logger.logBoxEnd();
|
|
logger.log('');
|
|
|
|
try {
|
|
await this.executeShutdownCommand(shutdownDelay);
|
|
} catch (error) {
|
|
logger.error(
|
|
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
// Try alternative methods
|
|
await this.tryAlternativeShutdownMethods();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute the primary shutdown command
|
|
* @param delayMinutes Minutes to delay before shutdown
|
|
*/
|
|
private async executeShutdownCommand(delayMinutes: number): Promise<void> {
|
|
// Find shutdown command in common system paths
|
|
const shutdownPaths = [
|
|
'/sbin/shutdown',
|
|
'/usr/sbin/shutdown',
|
|
'/bin/shutdown',
|
|
'/usr/bin/shutdown',
|
|
];
|
|
|
|
let shutdownCmd = '';
|
|
for (const path of shutdownPaths) {
|
|
try {
|
|
if (fs.existsSync(path)) {
|
|
shutdownCmd = path;
|
|
logger.log(`Found shutdown command at: ${shutdownCmd}`);
|
|
break;
|
|
}
|
|
} catch (_e) {
|
|
// Continue checking other paths
|
|
}
|
|
}
|
|
|
|
if (shutdownCmd) {
|
|
// Execute shutdown command with delay to allow for VM graceful shutdown
|
|
const message = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
|
|
logger.log(`Executing: ${shutdownCmd} -h +${delayMinutes} "${message}"`);
|
|
|
|
const { stdout } = await execFileAsync(shutdownCmd, [
|
|
'-h',
|
|
`+${delayMinutes}`,
|
|
message,
|
|
]);
|
|
|
|
logger.log(`Shutdown initiated: ${stdout}`);
|
|
logger.log(`Allowing ${delayMinutes} minutes for VMs to shut down safely`);
|
|
} else {
|
|
throw new Error('Shutdown command not found in common paths');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try alternative shutdown methods if primary command fails
|
|
*/
|
|
private async tryAlternativeShutdownMethods(): Promise<void> {
|
|
logger.error('Trying alternative shutdown methods...');
|
|
|
|
const alternatives = [
|
|
{ cmd: 'poweroff', args: ['--force'] },
|
|
{ cmd: 'halt', args: ['-p'] },
|
|
{ cmd: 'systemctl', args: ['poweroff'] },
|
|
{ cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off
|
|
];
|
|
|
|
for (const alt of alternatives) {
|
|
try {
|
|
// First check if command exists in common system paths
|
|
const paths = [
|
|
`/sbin/${alt.cmd}`,
|
|
`/usr/sbin/${alt.cmd}`,
|
|
`/bin/${alt.cmd}`,
|
|
`/usr/bin/${alt.cmd}`,
|
|
];
|
|
|
|
let cmdPath = '';
|
|
for (const path of paths) {
|
|
if (fs.existsSync(path)) {
|
|
cmdPath = path;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (cmdPath) {
|
|
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
|
await execFileAsync(cmdPath, alt.args);
|
|
logger.log(`Alternative method ${alt.cmd} succeeded`);
|
|
return; // Exit if successful
|
|
}
|
|
} catch (_altError) {
|
|
logger.error(`Alternative method ${alt.cmd} failed`);
|
|
// Continue to next method
|
|
}
|
|
}
|
|
|
|
logger.error('All shutdown methods failed');
|
|
}
|
|
}
|