nupst/ts/daemon.ts

524 lines
19 KiB
TypeScript
Raw Normal View History

2025-03-25 09:06:23 +00:00
import * as fs from 'fs';
import * as path from 'path';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
2025-03-26 13:13:01 +00:00
import { NupstSnmp } from './snmp/manager.js';
import type { ISnmpConfig } from './snmp/types.js';
2025-03-25 09:06:23 +00:00
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
2025-03-25 09:06:23 +00:00
/**
* Configuration interface for the daemon
*/
export interface INupstConfig {
2025-03-25 09:06:23 +00:00
/** SNMP configuration settings */
snmp: ISnmpConfig;
2025-03-25 09:06:23 +00:00
/** Threshold settings for initiating shutdown */
thresholds: {
/** Shutdown when battery below this percentage */
battery: number;
/** Shutdown when runtime below this minutes */
runtime: number;
};
/** Check interval in milliseconds */
checkInterval: number;
}
/**
* Daemon class for monitoring UPS and handling shutdown
* Responsible for loading/saving config and monitoring the UPS status
*/
export class NupstDaemon {
/** Default configuration path */
private readonly CONFIG_PATH = '/etc/nupst/config.json';
/** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = {
2025-03-25 09:06:23 +00:00
snmp: {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
// SNMPv3 defaults (used only if version === 3)
securityLevel: 'authPriv',
username: '',
authProtocol: 'SHA',
authKey: '',
privProtocol: 'AES',
privKey: '',
// UPS model for OID selection
upsModel: 'cyberpower'
},
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
checkInterval: 30000, // Check every 30 seconds
};
private config: INupstConfig;
2025-03-25 09:06:23 +00:00
private snmp: NupstSnmp;
private isRunning: boolean = false;
/**
* Create a new daemon instance with the given SNMP manager
*/
constructor(snmp: NupstSnmp) {
this.snmp = snmp;
this.config = this.DEFAULT_CONFIG;
}
/**
* Load configuration from file
* @throws Error if configuration file doesn't exist
*/
public async loadConfig(): Promise<INupstConfig> {
2025-03-25 09:06:23 +00:00
try {
// Check if config file exists
const configExists = fs.existsSync(this.CONFIG_PATH);
if (!configExists) {
const errorMsg = `No configuration found at ${this.CONFIG_PATH}`;
this.logConfigError(errorMsg);
throw new Error(errorMsg);
}
// Read and parse config
const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8');
this.config = JSON.parse(configData);
return this.config;
} catch (error) {
if (error.message.includes('No configuration found')) {
throw error; // Re-throw the no configuration error
}
this.logConfigError(`Error loading configuration: ${error.message}`);
throw new Error('Failed to load configuration');
}
}
/**
* Save configuration to file
*/
public async saveConfig(config: INupstConfig): Promise<void> {
2025-03-25 09:06:23 +00:00
try {
const configDir = path.dirname(this.CONFIG_PATH);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(config, null, 2));
this.config = config;
console.log('┌─ Configuration Saved ─────────────────────┐');
console.log(`│ Location: ${this.CONFIG_PATH}`);
console.log('└──────────────────────────────────────────┘');
} catch (error) {
console.error('Error saving configuration:', error);
}
}
/**
* Helper method to log configuration errors consistently
*/
private logConfigError(message: string): void {
console.error('┌─ Configuration Error ─────────────────────┐');
console.error(`${message}`);
console.error('│ Please run \'nupst setup\' first to create a configuration.');
console.error('└──────────────────────────────────────────┘');
}
/**
* Get the current configuration
*/
public getConfig(): INupstConfig {
2025-03-25 09:06:23 +00:00
return this.config;
}
/**
* Get the SNMP instance
*/
public getNupstSnmp(): NupstSnmp {
return this.snmp;
}
/**
* Start the monitoring daemon
*/
public async start(): Promise<void> {
if (this.isRunning) {
console.log('Daemon is already running');
return;
}
console.log('Starting NUPST daemon...');
try {
// Load configuration - this will throw an error if config doesn't exist
await this.loadConfig();
this.logConfigLoaded();
// Log version information
this.snmp.getNupst().logVersionInfo(false); // Don't check for updates immediately on startup
// Check for updates in the background
this.snmp.getNupst().checkForUpdates().then(updateAvailable => {
if (updateAvailable) {
const updateStatus = this.snmp.getNupst().getUpdateStatus();
console.log('┌─ Update Available ───────────────────────┐');
console.log(`│ Current Version: ${updateStatus.currentVersion}`);
console.log(`│ Latest Version: ${updateStatus.latestVersion}`);
console.log('│ Run "sudo nupst update" to update');
console.log('└──────────────────────────────────────────┘');
}
}).catch(() => {}); // Ignore errors checking for updates
2025-03-25 09:06:23 +00:00
// Start UPS monitoring
this.isRunning = true;
await this.monitor();
} catch (error) {
this.isRunning = false;
console.error(`Daemon failed to start: ${error.message}`);
process.exit(1); // Exit with error
}
}
/**
* Log the loaded configuration settings
*/
private logConfigLoaded(): void {
console.log('┌─ Configuration Loaded ─────────────────────┐');
console.log('│ SNMP Settings:');
console.log(`│ Host: ${this.config.snmp.host}`);
console.log(`│ Port: ${this.config.snmp.port}`);
console.log(`│ Version: ${this.config.snmp.version}`);
console.log('│ Thresholds:');
console.log(`│ Battery: ${this.config.thresholds.battery}%`);
console.log(`│ Runtime: ${this.config.thresholds.runtime} minutes`);
console.log(`│ Check Interval: ${this.config.checkInterval / 1000} seconds`);
console.log('└──────────────────────────────────────────┘');
}
/**
* Stop the monitoring daemon
*/
public stop(): void {
console.log('Stopping NUPST daemon...');
this.isRunning = false;
}
/**
* Monitor the UPS status and trigger shutdown when necessary
*/
private async monitor(): Promise<void> {
console.log('Starting UPS monitoring...');
let lastStatus: 'online' | 'onBattery' | 'unknown' = 'unknown';
let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
2025-03-25 09:06:23 +00:00
// Monitor continuously
while (this.isRunning) {
try {
const status = await this.snmp.getUpsStatus(this.config.snmp);
const currentTime = Date.now();
const shouldLogStatus = (currentTime - lastLogTime) >= LOG_INTERVAL;
2025-03-25 09:06:23 +00:00
// Log status changes
if (status.powerStatus !== lastStatus) {
console.log('┌─ Power Status Change ─────────────────────┐');
console.log(`│ Status changed: ${lastStatus}${status.powerStatus}`);
2025-03-25 09:06:23 +00:00
console.log('└──────────────────────────────────────────┘');
lastStatus = status.powerStatus;
lastLogTime = currentTime; // Reset log timer when status changes
}
// Log status periodically (at least every 5 minutes)
else if (shouldLogStatus) {
const timestamp = new Date().toISOString();
console.log('┌─ Periodic Status Update ──────────────────┐');
console.log(`│ Timestamp: ${timestamp}`);
console.log(`│ Power Status: ${status.powerStatus}`);
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
console.log('└──────────────────────────────────────────┘');
lastLogTime = currentTime;
2025-03-25 09:06:23 +00:00
}
// Handle battery power status
if (status.powerStatus === 'onBattery') {
await this.handleOnBatteryStatus(status);
}
// Wait before next check
await this.sleep(this.config.checkInterval);
} catch (error) {
console.error('Error during UPS monitoring:', error);
await this.sleep(this.config.checkInterval);
}
}
console.log('UPS monitoring stopped');
}
/**
* Handle UPS status when running on battery
*/
private async handleOnBatteryStatus(status: {
powerStatus: string,
batteryCapacity: number,
batteryRuntime: number
}): Promise<void> {
console.log('┌─ UPS Status ────────────────────────────┐');
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
2025-03-25 09:06:23 +00:00
console.log('└──────────────────────────────────────────┘');
// Check battery threshold
if (status.batteryCapacity < this.config.thresholds.battery) {
console.log('⚠️ WARNING: Battery capacity below threshold');
console.log(`Current: ${status.batteryCapacity}% | Threshold: ${this.config.thresholds.battery}%`);
await this.initiateShutdown('Battery capacity below threshold');
2025-03-25 09:06:23 +00:00
return;
}
// Check runtime threshold
if (status.batteryRuntime < this.config.thresholds.runtime) {
console.log('⚠️ WARNING: Runtime below threshold');
console.log(`Current: ${status.batteryRuntime} min | Threshold: ${this.config.thresholds.runtime} min`);
await this.initiateShutdown('Runtime below threshold');
2025-03-25 09:06:23 +00:00
return;
}
}
/**
* Initiate system shutdown with UPS monitoring during shutdown
* @param reason Reason for shutdown
*/
public async initiateShutdown(reason: string): Promise<void> {
console.log(`Initiating system shutdown due to: ${reason}`);
// Set a longer delay for shutdown to allow VMs and services to close
const shutdownDelayMinutes = 5;
try {
// 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;
console.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
console.log(`Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`);
const { stdout } = await execFileAsync(shutdownCmd, [
'-h',
`+${shutdownDelayMinutes}`,
`UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`
]);
console.log('Shutdown initiated:', stdout);
console.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
} else {
// Try using the PATH to find shutdown
try {
console.log('Shutdown command not found in common paths, trying via PATH...');
const { stdout } = await execAsync(`shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`, {
env: process.env // Pass the current environment
});
console.log('Shutdown initiated:', stdout);
} catch (e) {
throw new Error(`Shutdown command not found: ${e.message}`);
}
}
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low
console.log('Monitoring UPS during shutdown process...');
await this.monitorDuringShutdown();
} catch (error) {
console.error('Failed to initiate shutdown:', error);
// Try 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) {
console.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH environment
console.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env // Pass the current environment
});
return; // Exit if successful
}
} catch (altError) {
console.error(`Alternative method ${alt.cmd} failed:`, altError);
// Continue to next method
}
}
console.error('All shutdown methods failed');
}
}
/**
* Monitor UPS during system shutdown
* Force immediate shutdown if battery gets critically low
*/
private async monitorDuringShutdown(): Promise<void> {
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
const CHECK_INTERVAL = 30000; // Check every 30 seconds during shutdown
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now();
console.log(`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`);
// Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) {
try {
console.log('Checking UPS status during shutdown...');
const status = await this.snmp.getUpsStatus(this.config.snmp);
console.log(`Current battery: ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`);
// If battery runtime gets critically low, force immediate shutdown
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
console.log('┌─ EMERGENCY SHUTDOWN ─────────────────────┐');
console.log(`│ Battery runtime critically low: ${status.batteryRuntime} minutes`);
console.log('│ Forcing immediate shutdown!');
console.log('└──────────────────────────────────────────┘');
try {
// 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) {
if (fs.existsSync(path)) {
shutdownCmd = path;
console.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
}
if (shutdownCmd) {
console.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
await execFileAsync(shutdownCmd, ['-h', 'now', 'EMERGENCY: UPS battery critically low, shutting down NOW']);
} else {
// Try using the PATH to find shutdown
console.log('Shutdown command not found in common paths, trying via PATH...');
await execAsync('shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"', {
env: process.env // Pass the current environment
});
}
} catch (error) {
console.error('Emergency shutdown failed, trying alternative methods...');
// Try alternative shutdown methods in sequence
const alternatives = [
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] }
];
for (const alt of alternatives) {
try {
// Check common 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) {
console.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH
console.log(`Emergency: trying ${alt.cmd} via PATH`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env
});
return; // Exit if successful
}
} catch (altError) {
// Continue to next method
}
}
console.error('All emergency shutdown methods failed');
}
// Stop monitoring after initiating emergency shutdown
return;
}
// Wait before checking again
await this.sleep(CHECK_INTERVAL);
} catch (error) {
console.error('Error monitoring UPS during shutdown:', error);
await this.sleep(CHECK_INTERVAL);
}
}
console.log('UPS monitoring during shutdown completed');
}
2025-03-25 09:06:23 +00:00
/**
* Sleep for the specified milliseconds
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}