feat(proxmox): add Proxmox CLI auto-detection and interactive action setup improvements
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-02 - 5.5.0 - feat(proxmox)
|
||||||
|
add Proxmox CLI auto-detection and interactive action setup improvements
|
||||||
|
|
||||||
|
- Add Proxmox action support for CLI mode using qm/pct with automatic fallback to REST API mode
|
||||||
|
- Expose proxmoxMode configuration and update CLI wizards to auto-detect local Proxmox tools before prompting for API credentials
|
||||||
|
- Expand interactive action creation to support shutdown, webhook, script, and Proxmox actions with improved displayed details
|
||||||
|
- Update documentation to cover Proxmox CLI/API modes and clarify shutdown delay units in minutes
|
||||||
|
|
||||||
## 2026-03-30 - 5.4.1 - fix(deps)
|
## 2026-03-30 - 5.4.1 - fix(deps)
|
||||||
bump tsdeno and net-snmp patch dependencies
|
bump tsdeno and net-snmp patch dependencies
|
||||||
|
|
||||||
|
|||||||
63
readme.md
63
readme.md
@@ -12,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
|
|
||||||
- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon
|
- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon
|
||||||
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT
|
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT
|
||||||
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown
|
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown (auto-detects CLI tools — no API token needed on Proxmox hosts)
|
||||||
- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes
|
- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes
|
||||||
- **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical
|
- **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical
|
||||||
- **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
|
- **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
|
||||||
@@ -250,10 +250,9 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
|||||||
"type": "proxmox",
|
"type": "proxmox",
|
||||||
"triggerMode": "onlyThresholds",
|
"triggerMode": "onlyThresholds",
|
||||||
"thresholds": { "battery": 30, "runtime": 15 },
|
"thresholds": { "battery": 30, "runtime": 15 },
|
||||||
"proxmoxHost": "localhost",
|
"proxmoxMode": "auto",
|
||||||
"proxmoxPort": 8006,
|
"proxmoxExcludeIds": [],
|
||||||
"proxmoxTokenId": "root@pam!nupst",
|
"proxmoxForceStop": true
|
||||||
"proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "shutdown",
|
"type": "shutdown",
|
||||||
@@ -364,7 +363,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
|||||||
| `shutdown` | Graceful system shutdown with configurable delay |
|
| `shutdown` | Graceful system shutdown with configurable delay |
|
||||||
| `webhook` | HTTP POST/GET notification to external services |
|
| `webhook` | HTTP POST/GET notification to external services |
|
||||||
| `script` | Execute custom shell scripts from `/etc/nupst/` |
|
| `script` | Execute custom shell scripts from `/etc/nupst/` |
|
||||||
| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers via REST API |
|
| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers (CLI or API) |
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
@@ -396,7 +395,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
|||||||
|
|
||||||
| Field | Description | Default |
|
| Field | Description | Default |
|
||||||
| --------------- | ---------------------------------- | ------- |
|
| --------------- | ---------------------------------- | ------- |
|
||||||
| `shutdownDelay` | Seconds to wait before shutdown | `5` |
|
| `shutdownDelay` | Minutes to wait before shutdown | `5` |
|
||||||
|
|
||||||
#### Webhook Action
|
#### Webhook Action
|
||||||
|
|
||||||
@@ -438,11 +437,38 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
|||||||
|
|
||||||
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
|
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
|
||||||
|
|
||||||
|
NUPST supports **two operation modes** for Proxmox:
|
||||||
|
|
||||||
|
| Mode | Description | Requirements |
|
||||||
|
| ------ | -------------------------------------------------------------- | ------------------------- |
|
||||||
|
| `cli` | Uses `qm`/`pct` commands directly — **no API token needed** 🎉 | Running as root on Proxmox host |
|
||||||
|
| `api` | Uses Proxmox REST API via HTTPS | API token required |
|
||||||
|
| `auto` | Prefers CLI if available, falls back to API (default) | — |
|
||||||
|
|
||||||
|
> 💡 **On a Proxmox host running as root** (the typical setup), NUPST auto-detects `qm` and `pct` CLI tools and uses them directly. No API token setup required!
|
||||||
|
|
||||||
|
**CLI mode example** (simplest — auto-detected on Proxmox hosts):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "proxmox",
|
"type": "proxmox",
|
||||||
"thresholds": { "battery": 30, "runtime": 15 },
|
"thresholds": { "battery": 30, "runtime": 15 },
|
||||||
"triggerMode": "onlyThresholds",
|
"triggerMode": "onlyThresholds",
|
||||||
|
"proxmoxMode": "auto",
|
||||||
|
"proxmoxExcludeIds": [100, 101],
|
||||||
|
"proxmoxStopTimeout": 120,
|
||||||
|
"proxmoxForceStop": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API mode example** (for remote Proxmox hosts or non-root setups):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "proxmox",
|
||||||
|
"thresholds": { "battery": 30, "runtime": 15 },
|
||||||
|
"triggerMode": "onlyThresholds",
|
||||||
|
"proxmoxMode": "api",
|
||||||
"proxmoxHost": "localhost",
|
"proxmoxHost": "localhost",
|
||||||
"proxmoxPort": 8006,
|
"proxmoxPort": 8006,
|
||||||
"proxmoxTokenId": "root@pam!nupst",
|
"proxmoxTokenId": "root@pam!nupst",
|
||||||
@@ -456,17 +482,18 @@ Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the h
|
|||||||
|
|
||||||
| Field | Description | Default |
|
| Field | Description | Default |
|
||||||
| --------------------- | ----------------------------------------------- | ------------- |
|
| --------------------- | ----------------------------------------------- | ------------- |
|
||||||
| `proxmoxHost` | Proxmox API host | `localhost` |
|
| `proxmoxMode` | Operation mode | `auto` |
|
||||||
| `proxmoxPort` | Proxmox API port | `8006` |
|
| `proxmoxHost` | Proxmox API host (API mode only) | `localhost` |
|
||||||
|
| `proxmoxPort` | Proxmox API port (API mode only) | `8006` |
|
||||||
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
|
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
|
||||||
| `proxmoxTokenId` | API token ID (e.g. `root@pam!nupst`) | Required |
|
| `proxmoxTokenId` | API token ID (API mode only) | — |
|
||||||
| `proxmoxTokenSecret` | API token secret (UUID) | Required |
|
| `proxmoxTokenSecret` | API token secret (API mode only) | — |
|
||||||
| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` |
|
| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` |
|
||||||
| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` |
|
| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` |
|
||||||
| `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` |
|
| `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` |
|
||||||
| `proxmoxInsecure` | Skip TLS verification (self-signed certs) | `true` |
|
| `proxmoxInsecure` | Skip TLS verification (API mode only) | `true` |
|
||||||
|
|
||||||
**Setting up the API token on Proxmox:**
|
**Setting up the API token** (only needed for API mode):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create token with full privileges (no privilege separation)
|
# Create token with full privileges (no privilege separation)
|
||||||
@@ -631,7 +658,7 @@ Full SNMPv3 support with authentication and encryption:
|
|||||||
|
|
||||||
### Network Security
|
### Network Security
|
||||||
|
|
||||||
- Connects only to UPS devices and optionally Proxmox on local network
|
- Connects only to UPS devices and optionally Proxmox on local network (CLI mode uses local tools — no network needed for VM shutdown)
|
||||||
- HTTP API disabled by default; token-required when enabled
|
- HTTP API disabled by default; token-required when enabled
|
||||||
- No external internet connections
|
- No external internet connections
|
||||||
|
|
||||||
@@ -741,7 +768,13 @@ upsc ups@localhost # if NUT CLI is installed
|
|||||||
### Proxmox VMs Not Shutting Down
|
### Proxmox VMs Not Shutting Down
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verify API token works
|
# CLI mode: verify qm/pct are available and you're root
|
||||||
|
which qm pct
|
||||||
|
whoami # should be 'root'
|
||||||
|
qm list # should list VMs
|
||||||
|
pct list # should list containers
|
||||||
|
|
||||||
|
# API mode: verify API token works
|
||||||
curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \
|
curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \
|
||||||
https://localhost:8006/api2/json/nodes/$(hostname)/qemu
|
https://localhost:8006/api2/json/nodes/$(hostname)/qemu
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/nupst',
|
name: '@serve.zone/nupst',
|
||||||
version: '5.4.1',
|
version: '5.5.0',
|
||||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export interface IActionConfig {
|
|||||||
proxmoxForceStop?: boolean;
|
proxmoxForceStop?: boolean;
|
||||||
/** Skip TLS verification for self-signed certificates (default: true) */
|
/** Skip TLS verification for self-signed certificates (default: true) */
|
||||||
proxmoxInsecure?: boolean;
|
proxmoxInsecure?: boolean;
|
||||||
|
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
|
||||||
|
proxmoxMode?: 'auto' | 'api' | 'cli';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { Action, type IActionContext } from './base-action.ts';
|
import { Action, type IActionContext } from './base-action.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
import { PROXMOX, UI } from '../constants.ts';
|
import { PROXMOX, UI } from '../constants.ts';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
||||||
*
|
*
|
||||||
* Uses the Proxmox REST API via HTTPS with API token authentication.
|
* Supports two operation modes:
|
||||||
* Shuts down running QEMU VMs and LXC containers, waits for completion,
|
* - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
|
||||||
* and optionally force-stops any that don't respond.
|
* - API mode: Uses the Proxmox REST API via HTTPS with API token authentication
|
||||||
|
*
|
||||||
|
* In 'auto' mode (default), CLI is preferred when available, falling back to API.
|
||||||
*
|
*
|
||||||
* This action should be placed BEFORE shutdown actions in the action chain
|
* This action should be placed BEFORE shutdown actions in the action chain
|
||||||
* so that VMs are stopped before the host is shut down.
|
* so that VMs are stopped before the host is shut down.
|
||||||
@@ -16,6 +24,77 @@ import { PROXMOX, UI } from '../constants.ts';
|
|||||||
export class ProxmoxAction extends Action {
|
export class ProxmoxAction extends Action {
|
||||||
readonly type = 'proxmox';
|
readonly type = 'proxmox';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Proxmox CLI tools (qm, pct) are available on the system
|
||||||
|
* Used by CLI wizards and by execute() for auto-detection
|
||||||
|
*/
|
||||||
|
static detectCliAvailability(): {
|
||||||
|
available: boolean;
|
||||||
|
qmPath: string | null;
|
||||||
|
pctPath: string | null;
|
||||||
|
isRoot: boolean;
|
||||||
|
} {
|
||||||
|
let qmPath: string | null = null;
|
||||||
|
let pctPath: string | null = null;
|
||||||
|
|
||||||
|
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||||
|
if (!qmPath) {
|
||||||
|
const p = `${dir}/qm`;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) qmPath = p;
|
||||||
|
} catch (_e) {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pctPath) {
|
||||||
|
const p = `${dir}/pct`;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) pctPath = p;
|
||||||
|
} catch (_e) {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRoot = !!(process.getuid && process.getuid() === 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: qmPath !== null && pctPath !== null && isRoot,
|
||||||
|
qmPath,
|
||||||
|
pctPath,
|
||||||
|
isRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the operation mode based on config and environment
|
||||||
|
*/
|
||||||
|
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | { mode: 'api'; qmPath?: undefined; pctPath?: undefined } {
|
||||||
|
const configuredMode = this.config.proxmoxMode || 'auto';
|
||||||
|
|
||||||
|
if (configuredMode === 'api') {
|
||||||
|
return { mode: 'api' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = ProxmoxAction.detectCliAvailability();
|
||||||
|
|
||||||
|
if (configuredMode === 'cli') {
|
||||||
|
if (!detection.qmPath || !detection.pctPath) {
|
||||||
|
throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?');
|
||||||
|
}
|
||||||
|
if (!detection.isRoot) {
|
||||||
|
throw new Error('CLI mode requires root access');
|
||||||
|
}
|
||||||
|
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect
|
||||||
|
if (detection.available && detection.qmPath && detection.pctPath) {
|
||||||
|
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||||
|
}
|
||||||
|
return { mode: 'api' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the Proxmox shutdown action
|
* Execute the Proxmox shutdown action
|
||||||
*/
|
*/
|
||||||
@@ -29,30 +108,21 @@ export class ProxmoxAction extends Action {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
const resolved = this.resolveMode();
|
||||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
|
||||||
const node = this.config.proxmoxNode || os.hostname();
|
const node = this.config.proxmoxNode || os.hostname();
|
||||||
const tokenId = this.config.proxmoxTokenId;
|
|
||||||
const tokenSecret = this.config.proxmoxTokenSecret;
|
|
||||||
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
|
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
|
||||||
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
|
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
|
||||||
const forceStop = this.config.proxmoxForceStop !== false; // default true
|
const forceStop = this.config.proxmoxForceStop !== false; // default true
|
||||||
const insecure = this.config.proxmoxInsecure !== false; // default true
|
|
||||||
|
|
||||||
if (!tokenId || !tokenSecret) {
|
|
||||||
logger.error('Proxmox API token ID and secret are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
|
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
|
||||||
|
logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`);
|
||||||
logger.logBoxLine(`Node: ${node}`);
|
logger.logBoxLine(`Node: ${node}`);
|
||||||
logger.logBoxLine(`API: ${host}:${port}`);
|
if (resolved.mode === 'api') {
|
||||||
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
logger.logBoxLine(`API: ${host}:${port}`);
|
||||||
|
}
|
||||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
|
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
|
||||||
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||||
if (excludeIds.size > 0) {
|
if (excludeIds.size > 0) {
|
||||||
@@ -62,9 +132,34 @@ export class ProxmoxAction extends Action {
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Collect running VMs and CTs
|
let runningVMs: Array<{ vmid: number; name: string }>;
|
||||||
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure);
|
let runningCTs: Array<{ vmid: number; name: string }>;
|
||||||
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
|
|
||||||
|
if (resolved.mode === 'cli') {
|
||||||
|
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
|
||||||
|
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
|
||||||
|
} else {
|
||||||
|
// API mode - validate token
|
||||||
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
const tokenId = this.config.proxmoxTokenId;
|
||||||
|
const tokenSecret = this.config.proxmoxTokenSecret;
|
||||||
|
const insecure = this.config.proxmoxInsecure !== false;
|
||||||
|
|
||||||
|
if (!tokenId || !tokenSecret) {
|
||||||
|
logger.error('Proxmox API token ID and secret are required for API mode');
|
||||||
|
logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure);
|
||||||
|
runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, insecure);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out excluded IDs
|
// Filter out excluded IDs
|
||||||
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
|
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
|
||||||
@@ -78,15 +173,33 @@ export class ProxmoxAction extends Action {
|
|||||||
|
|
||||||
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
||||||
|
|
||||||
// Send shutdown commands to all VMs and CTs
|
// Send shutdown commands
|
||||||
for (const vm of vmsToStop) {
|
if (resolved.mode === 'cli') {
|
||||||
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure);
|
for (const vm of vmsToStop) {
|
||||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
|
||||||
}
|
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of ctsToStop) {
|
||||||
|
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
|
||||||
|
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
const insecure = this.config.proxmoxInsecure !== false;
|
||||||
|
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||||
|
};
|
||||||
|
|
||||||
for (const ct of ctsToStop) {
|
for (const vm of vmsToStop) {
|
||||||
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure);
|
await this.shutdownVMApi(baseUrl, node, vm.vmid, headers, insecure);
|
||||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of ctsToStop) {
|
||||||
|
await this.shutdownCTApi(baseUrl, node, ct.vmid, headers, insecure);
|
||||||
|
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll until all stopped or timeout
|
// Poll until all stopped or timeout
|
||||||
@@ -95,23 +208,31 @@ export class ProxmoxAction extends Action {
|
|||||||
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
|
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
|
||||||
];
|
];
|
||||||
|
|
||||||
const remaining = await this.waitForShutdown(
|
const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
|
||||||
baseUrl,
|
|
||||||
node,
|
|
||||||
allIds,
|
|
||||||
headers,
|
|
||||||
insecure,
|
|
||||||
stopTimeout,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remaining.length > 0 && forceStop) {
|
if (remaining.length > 0 && forceStop) {
|
||||||
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
|
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
|
||||||
for (const item of remaining) {
|
for (const item of remaining) {
|
||||||
try {
|
try {
|
||||||
if (item.type === 'qemu') {
|
if (resolved.mode === 'cli') {
|
||||||
await this.stopVM(baseUrl, node, item.vmid, headers, insecure);
|
if (item.type === 'qemu') {
|
||||||
|
await this.stopVMCli(resolved.qmPath, item.vmid);
|
||||||
|
} else {
|
||||||
|
await this.stopCTCli(resolved.pctPath, item.vmid);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.stopCT(baseUrl, node, item.vmid, headers, insecure);
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
const insecure = this.config.proxmoxInsecure !== false;
|
||||||
|
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||||
|
};
|
||||||
|
if (item.type === 'qemu') {
|
||||||
|
await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure);
|
||||||
|
} else {
|
||||||
|
await this.stopCTApi(baseUrl, node, item.vmid, headers, insecure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -134,6 +255,110 @@ export class ProxmoxAction extends Action {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── CLI-based methods ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of running QEMU VMs via qm list
|
||||||
|
*/
|
||||||
|
private async getRunningVMsCli(
|
||||||
|
qmPath: string,
|
||||||
|
): Promise<Array<{ vmid: number; name: string }>> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(qmPath, ['list']);
|
||||||
|
return this.parseQmList(stdout);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to list VMs via CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of running LXC containers via pct list
|
||||||
|
*/
|
||||||
|
private async getRunningCTsCli(
|
||||||
|
pctPath: string,
|
||||||
|
): Promise<Array<{ vmid: number; name: string }>> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(pctPath, ['list']);
|
||||||
|
return this.parsePctList(stdout);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to list CTs via CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse qm list output
|
||||||
|
* Format: VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
|
||||||
|
*/
|
||||||
|
private parseQmList(output: string): Array<{ vmid: number; name: string }> {
|
||||||
|
const results: Array<{ vmid: number; name: string }> = [];
|
||||||
|
const lines = output.trim().split('\n');
|
||||||
|
|
||||||
|
// Skip header line
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const match = lines[i].match(/^\s*(\d+)\s+(\S+)\s+(running|stopped|paused)/);
|
||||||
|
if (match && match[3] === 'running') {
|
||||||
|
results.push({ vmid: parseInt(match[1], 10), name: match[2] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse pct list output
|
||||||
|
* Format: VMID Status Lock Name
|
||||||
|
*/
|
||||||
|
private parsePctList(output: string): Array<{ vmid: number; name: string }> {
|
||||||
|
const results: Array<{ vmid: number; name: string }> = [];
|
||||||
|
const lines = output.trim().split('\n');
|
||||||
|
|
||||||
|
// Skip header line
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const match = lines[i].match(/^\s*(\d+)\s+(running|stopped)\s+\S*\s*(.*)/);
|
||||||
|
if (match && match[2] === 'running') {
|
||||||
|
results.push({ vmid: parseInt(match[1], 10), name: match[3]?.trim() || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shutdownVMCli(qmPath: string, vmid: number): Promise<void> {
|
||||||
|
await execFileAsync(qmPath, ['shutdown', String(vmid)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shutdownCTCli(pctPath: string, vmid: number): Promise<void> {
|
||||||
|
await execFileAsync(pctPath, ['shutdown', String(vmid)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopVMCli(qmPath: string, vmid: number): Promise<void> {
|
||||||
|
await execFileAsync(qmPath, ['stop', String(vmid)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopCTCli(pctPath: string, vmid: number): Promise<void> {
|
||||||
|
await execFileAsync(pctPath, ['stop', String(vmid)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get VM/CT status via CLI
|
||||||
|
* Returns the status string (e.g., 'running', 'stopped')
|
||||||
|
*/
|
||||||
|
private async getStatusCli(
|
||||||
|
toolPath: string,
|
||||||
|
vmid: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync(toolPath, ['status', String(vmid)]);
|
||||||
|
// Output format: "status: running\n"
|
||||||
|
const status = stdout.trim().split(':')[1]?.trim() || 'unknown';
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API-based methods ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an API request to the Proxmox server
|
* Make an API request to the Proxmox server
|
||||||
*/
|
*/
|
||||||
@@ -173,9 +398,9 @@ export class ProxmoxAction extends Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of running QEMU VMs
|
* Get list of running QEMU VMs via API
|
||||||
*/
|
*/
|
||||||
private async getRunningVMs(
|
private async getRunningVMsApi(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
node: string,
|
node: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
@@ -201,9 +426,9 @@ export class ProxmoxAction extends Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of running LXC containers
|
* Get list of running LXC containers via API
|
||||||
*/
|
*/
|
||||||
private async getRunningCTs(
|
private async getRunningCTsApi(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
node: string,
|
node: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
@@ -228,10 +453,7 @@ export class ProxmoxAction extends Action {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async shutdownVMApi(
|
||||||
* Send graceful shutdown to a QEMU VM
|
|
||||||
*/
|
|
||||||
private async shutdownVM(
|
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
node: string,
|
node: string,
|
||||||
vmid: number,
|
vmid: number,
|
||||||
@@ -246,10 +468,7 @@ export class ProxmoxAction extends Action {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async shutdownCTApi(
|
||||||
* Send graceful shutdown to an LXC container
|
|
||||||
*/
|
|
||||||
private async shutdownCT(
|
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
node: string,
|
node: string,
|
||||||
vmid: number,
|
vmid: number,
|
||||||
@@ -264,10 +483,7 @@ export class ProxmoxAction extends Action {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async stopVMApi(
|
||||||
* Force-stop a QEMU VM
|
|
||||||
*/
|
|
||||||
private async stopVM(
|
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
node: string,
|
node: string,
|
||||||
vmid: number,
|
vmid: number,
|
||||||
@@ -282,10 +498,7 @@ export class ProxmoxAction extends Action {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async stopCTApi(
|
||||||
* Force-stop an LXC container
|
|
||||||
*/
|
|
||||||
private async stopCT(
|
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
node: string,
|
node: string,
|
||||||
vmid: number,
|
vmid: number,
|
||||||
@@ -300,15 +513,15 @@ export class ProxmoxAction extends Action {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Shared methods ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for VMs/CTs to shut down, return any that are still running after timeout
|
* Wait for VMs/CTs to shut down, return any that are still running after timeout
|
||||||
*/
|
*/
|
||||||
private async waitForShutdown(
|
private async waitForShutdown(
|
||||||
baseUrl: string,
|
|
||||||
node: string,
|
|
||||||
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
|
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
|
||||||
headers: Record<string, string>,
|
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||||
insecure: boolean,
|
node: string,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
|
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -323,12 +536,27 @@ export class ProxmoxAction extends Action {
|
|||||||
|
|
||||||
for (const item of remaining) {
|
for (const item of remaining) {
|
||||||
try {
|
try {
|
||||||
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
let status: string;
|
||||||
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
|
||||||
data: { status: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (response.data?.status === 'running') {
|
if (resolved.mode === 'cli') {
|
||||||
|
const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!;
|
||||||
|
status = await this.getStatusCli(toolPath, item.vmid);
|
||||||
|
} else {
|
||||||
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
const insecure = this.config.proxmoxInsecure !== false;
|
||||||
|
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||||
|
};
|
||||||
|
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
||||||
|
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
||||||
|
data: { status: string };
|
||||||
|
};
|
||||||
|
status = response.data?.status || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'running') {
|
||||||
stillRunning.push(item);
|
stillRunning.push(item);
|
||||||
} else {
|
} else {
|
||||||
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
|
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Nupst } from '../nupst.ts';
|
|||||||
import { type ITableColumn, logger } from '../logger.ts';
|
import { type ITableColumn, logger } from '../logger.ts';
|
||||||
import { symbols, theme } from '../colors.ts';
|
import { symbols, theme } from '../colors.ts';
|
||||||
import type { IActionConfig } from '../actions/base-action.ts';
|
import type { IActionConfig } from '../actions/base-action.ts';
|
||||||
|
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||||
import * as helpers from '../helpers/index.ts';
|
import * as helpers from '../helpers/index.ts';
|
||||||
|
|
||||||
@@ -65,11 +66,146 @@ export class ActionHandler {
|
|||||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
// Action type (currently only shutdown is supported)
|
// Action type selection
|
||||||
const type = 'shutdown';
|
logger.log(` ${theme.dim('Action Type:')}`);
|
||||||
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
|
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
|
||||||
|
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
|
||||||
|
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
|
||||||
|
logger.log(` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`);
|
||||||
|
|
||||||
// Battery threshold
|
const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `);
|
||||||
|
const typeValue = parseInt(typeInput, 10) || 1;
|
||||||
|
|
||||||
|
const newAction: Partial<IActionConfig> = {};
|
||||||
|
|
||||||
|
if (typeValue === 1) {
|
||||||
|
// Shutdown action
|
||||||
|
newAction.type = 'shutdown';
|
||||||
|
|
||||||
|
const delayStr = await prompt(
|
||||||
|
` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `,
|
||||||
|
);
|
||||||
|
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
||||||
|
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||||
|
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.shutdownDelay = shutdownDelay;
|
||||||
|
} else if (typeValue === 2) {
|
||||||
|
// Webhook action
|
||||||
|
newAction.type = 'webhook';
|
||||||
|
|
||||||
|
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
|
||||||
|
if (!url.trim()) {
|
||||||
|
logger.error('Webhook URL is required.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.webhookUrl = url.trim();
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.log(` ${theme.dim('HTTP Method:')}`);
|
||||||
|
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
||||||
|
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
||||||
|
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
|
||||||
|
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `);
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
newAction.webhookTimeout = timeout * 1000;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 3) {
|
||||||
|
// Script action
|
||||||
|
newAction.type = 'script';
|
||||||
|
|
||||||
|
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `);
|
||||||
|
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
||||||
|
logger.error('Script path must end with .sh.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.scriptPath = scriptPath.trim();
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `);
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
newAction.scriptTimeout = timeout * 1000;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 4) {
|
||||||
|
// Proxmox action
|
||||||
|
newAction.type = 'proxmox';
|
||||||
|
|
||||||
|
// Auto-detect CLI availability
|
||||||
|
const detection = ProxmoxAction.detectCliAvailability();
|
||||||
|
|
||||||
|
if (detection.available) {
|
||||||
|
logger.log('');
|
||||||
|
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||||
|
logger.dim(` qm: ${detection.qmPath}`);
|
||||||
|
logger.dim(` pct: ${detection.pctPath}`);
|
||||||
|
newAction.proxmoxMode = 'cli';
|
||||||
|
} else {
|
||||||
|
logger.log('');
|
||||||
|
if (!detection.isRoot) {
|
||||||
|
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||||
|
} else {
|
||||||
|
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||||
|
}
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Proxmox API Settings:');
|
||||||
|
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||||
|
|
||||||
|
const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `);
|
||||||
|
newAction.proxmoxHost = pxHost.trim() || 'localhost';
|
||||||
|
|
||||||
|
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `);
|
||||||
|
const pxPort = parseInt(pxPortInput, 10);
|
||||||
|
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||||
|
|
||||||
|
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `);
|
||||||
|
if (pxNode.trim()) {
|
||||||
|
newAction.proxmoxNode = pxNode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
|
||||||
|
if (!tokenId.trim()) {
|
||||||
|
logger.error('Token ID is required for API mode.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.proxmoxTokenId = tokenId.trim();
|
||||||
|
|
||||||
|
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
|
||||||
|
if (!tokenSecret.trim()) {
|
||||||
|
logger.error('Token Secret is required for API mode.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.proxmoxTokenSecret = tokenSecret.trim();
|
||||||
|
|
||||||
|
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `);
|
||||||
|
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||||
|
newAction.proxmoxMode = 'api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common Proxmox settings (both modes)
|
||||||
|
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `);
|
||||||
|
if (excludeInput.trim()) {
|
||||||
|
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `);
|
||||||
|
const stopTimeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
|
||||||
|
newAction.proxmoxStopTimeout = stopTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `);
|
||||||
|
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||||
|
} else {
|
||||||
|
logger.error('Invalid action type.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery threshold (all action types)
|
||||||
|
logger.log('');
|
||||||
const batteryStr = await prompt(
|
const batteryStr = await prompt(
|
||||||
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
|
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
|
||||||
);
|
);
|
||||||
@@ -89,6 +225,8 @@ export class ActionHandler {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newAction.thresholds = { battery, runtime };
|
||||||
|
|
||||||
// Trigger mode
|
// Trigger mode
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||||
@@ -113,33 +251,13 @@ export class ActionHandler {
|
|||||||
'': 'onlyThresholds', // Default
|
'': 'onlyThresholds', // Default
|
||||||
};
|
};
|
||||||
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
|
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
|
||||||
|
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
|
||||||
// Shutdown delay
|
|
||||||
const delayStr = await prompt(
|
|
||||||
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
|
|
||||||
);
|
|
||||||
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
|
||||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
|
||||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the action
|
|
||||||
const newAction: IActionConfig = {
|
|
||||||
type,
|
|
||||||
thresholds: {
|
|
||||||
battery,
|
|
||||||
runtime,
|
|
||||||
},
|
|
||||||
triggerMode: triggerMode as IActionConfig['triggerMode'],
|
|
||||||
shutdownDelay,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add to target (UPS or group)
|
// Add to target (UPS or group)
|
||||||
if (!target!.actions) {
|
if (!target!.actions) {
|
||||||
target!.actions = [];
|
target!.actions = [];
|
||||||
}
|
}
|
||||||
target!.actions.push(newAction);
|
target!.actions.push(newAction as IActionConfig);
|
||||||
|
|
||||||
await this.nupst.getDaemon().saveConfig(config);
|
await this.nupst.getDaemon().saveConfig(config);
|
||||||
|
|
||||||
@@ -350,11 +468,19 @@ export class ActionHandler {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rows = target.actions.map((action, index) => {
|
const rows = target.actions.map((action, index) => {
|
||||||
let details = `${action.shutdownDelay || 5}s delay`;
|
let details = `${action.shutdownDelay || 5}min delay`;
|
||||||
if (action.type === 'proxmox') {
|
if (action.type === 'proxmox') {
|
||||||
const host = action.proxmoxHost || 'localhost';
|
const mode = action.proxmoxMode || 'auto';
|
||||||
const port = action.proxmoxPort || 8006;
|
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
||||||
details = `${host}:${port}`;
|
details = 'CLI mode';
|
||||||
|
} else {
|
||||||
|
const host = action.proxmoxHost || 'localhost';
|
||||||
|
const port = action.proxmoxPort || 8006;
|
||||||
|
details = `API ${host}:${port}`;
|
||||||
|
}
|
||||||
|
if (action.proxmoxExcludeIds?.length) {
|
||||||
|
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
||||||
|
}
|
||||||
} else if (action.type === 'webhook') {
|
} else if (action.type === 'webhook') {
|
||||||
details = action.webhookUrl || theme.dim('N/A');
|
details = action.webhookUrl || theme.dim('N/A');
|
||||||
} else if (action.type === 'script') {
|
} else if (action.type === 'script') {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { IUpsdConfig } from '../upsd/types.ts';
|
|||||||
import type { TProtocol } from '../protocol/types.ts';
|
import type { TProtocol } from '../protocol/types.ts';
|
||||||
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||||
import type { IActionConfig } from '../actions/base-action.ts';
|
import type { IActionConfig } from '../actions/base-action.ts';
|
||||||
|
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||||
import { UPSD } from '../constants.ts';
|
import { UPSD } from '../constants.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1184,37 +1185,58 @@ export class UpsHandler {
|
|||||||
// Proxmox action
|
// Proxmox action
|
||||||
action.type = 'proxmox';
|
action.type = 'proxmox';
|
||||||
|
|
||||||
logger.log('');
|
// Auto-detect CLI availability
|
||||||
logger.info('Proxmox API Settings:');
|
const detection = ProxmoxAction.detectCliAvailability();
|
||||||
logger.dim('Requires a Proxmox API token. Create one with:');
|
|
||||||
logger.dim(' pveum user token add root@pam nupst --privsep=0');
|
|
||||||
|
|
||||||
const pxHost = await prompt('Proxmox Host [localhost]: ');
|
if (detection.available) {
|
||||||
action.proxmoxHost = pxHost.trim() || 'localhost';
|
logger.log('');
|
||||||
|
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||||
|
logger.dim(` qm: ${detection.qmPath}`);
|
||||||
|
logger.dim(` pct: ${detection.pctPath}`);
|
||||||
|
action.proxmoxMode = 'cli';
|
||||||
|
} else {
|
||||||
|
logger.log('');
|
||||||
|
if (!detection.isRoot) {
|
||||||
|
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||||
|
} else {
|
||||||
|
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||||
|
}
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Proxmox API Settings:');
|
||||||
|
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||||
|
|
||||||
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
|
const pxHost = await prompt('Proxmox Host [localhost]: ');
|
||||||
const pxPort = parseInt(pxPortInput, 10);
|
action.proxmoxHost = pxHost.trim() || 'localhost';
|
||||||
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
|
||||||
|
|
||||||
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
|
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
|
||||||
if (pxNode.trim()) {
|
const pxPort = parseInt(pxPortInput, 10);
|
||||||
action.proxmoxNode = pxNode.trim();
|
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||||
|
|
||||||
|
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
|
||||||
|
if (pxNode.trim()) {
|
||||||
|
action.proxmoxNode = pxNode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
|
||||||
|
if (!tokenId.trim()) {
|
||||||
|
logger.warn('Token ID is required for API mode, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.proxmoxTokenId = tokenId.trim();
|
||||||
|
|
||||||
|
const tokenSecret = await prompt('API Token Secret: ');
|
||||||
|
if (!tokenSecret.trim()) {
|
||||||
|
logger.warn('Token Secret is required for API mode, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.proxmoxTokenSecret = tokenSecret.trim();
|
||||||
|
|
||||||
|
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
|
||||||
|
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||||
|
action.proxmoxMode = 'api';
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
|
// Common Proxmox settings (both modes)
|
||||||
if (!tokenId.trim()) {
|
|
||||||
logger.warn('Token ID is required for Proxmox action, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
action.proxmoxTokenId = tokenId.trim();
|
|
||||||
|
|
||||||
const tokenSecret = await prompt('API Token Secret: ');
|
|
||||||
if (!tokenSecret.trim()) {
|
|
||||||
logger.warn('Token Secret is required for Proxmox action, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
action.proxmoxTokenSecret = tokenSecret.trim();
|
|
||||||
|
|
||||||
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
|
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
|
||||||
if (excludeInput.trim()) {
|
if (excludeInput.trim()) {
|
||||||
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
||||||
@@ -1229,9 +1251,6 @@ export class UpsHandler {
|
|||||||
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): ');
|
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): ');
|
||||||
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||||
|
|
||||||
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
|
|
||||||
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
|
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
|
||||||
logger.dim('in the action chain so VMs shut down before the host.');
|
logger.dim('in the action chain so VMs shut down before the host.');
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ export const PROXMOX = {
|
|||||||
|
|
||||||
/** Proxmox API base path */
|
/** Proxmox API base path */
|
||||||
API_BASE: '/api2/json',
|
API_BASE: '/api2/json',
|
||||||
|
|
||||||
|
/** Common paths to search for Proxmox CLI tools (qm, pct) */
|
||||||
|
CLI_TOOL_PATHS: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'] as readonly string[],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user