fix(proxmox): require confirmed battery state before running Proxmox actions

This commit is contained in:
2026-05-29 14:02:37 +00:00
parent 45b7b44b5c
commit 4944071f0b
5 changed files with 164 additions and 6 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## Pending
### Fixes
- require confirmed battery state before running Proxmox actions (proxmox)
- Skip Proxmox actions when UPS status is unreachable, unknown, or online
- Respect triggerMode for confirmed onBattery power changes and threshold violations
- Add tests for Proxmox action execution gating
- Bump @git.zone/tsdeno to ^1.4.0
## 2026-04-16 - 5.11.1 - fix(deps) ## 2026-04-16 - 5.11.1 - fix(deps)
remove unused smartchangelog dependency remove unused smartchangelog dependency
+1 -1
View File
@@ -64,6 +64,6 @@
}, },
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34", "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"devDependencies": { "devDependencies": {
"@git.zone/tsdeno": "^1.3.1" "@git.zone/tsdeno": "^1.4.0"
} }
} }
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.: .:
devDependencies: devDependencies:
'@git.zone/tsdeno': '@git.zone/tsdeno':
specifier: ^1.3.1 specifier: ^1.4.0
version: 1.3.1 version: 1.4.0
packages: packages:
@@ -36,8 +36,8 @@ packages:
'@design.estate/dees-element@2.2.4': '@design.estate/dees-element@2.2.4':
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==} resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
'@git.zone/tsdeno@1.3.1': '@git.zone/tsdeno@1.4.0':
resolution: {integrity: sha512-1shZOSwMUfmonIe8OsX40EcQcS7/WFt+y+5+TQ7N6QmU+Hp6zjrfFvQwanr5Qcgh/E54WFNTfJk/PsJ26s6Oxw==} resolution: {integrity: sha512-84kFa/uKPTlzeLxtHoFxefk6O9khsWWQ2PCWNbCNYIUqWHUvN9COpGq0GXWtsoxLWPhTTIeHsOX4+O55uT2MPw==}
hasBin: true hasBin: true
'@lit-labs/ssr-dom-shim@1.5.1': '@lit-labs/ssr-dom-shim@1.5.1':
@@ -826,7 +826,7 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@git.zone/tsdeno@1.3.1': '@git.zone/tsdeno@1.4.0':
dependencies: dependencies:
'@push.rocks/early': 4.0.4 '@push.rocks/early': 4.0.4
'@push.rocks/smartcli': 4.0.21 '@push.rocks/smartcli': 4.0.21
+85
View File
@@ -42,6 +42,7 @@ import { createInitialUpsStatus } from '../ts/ups-status.ts';
import { MigrationV4_2ToV4_3 } from '../ts/migrations/migration-v4.2-to-v4.3.ts'; import { MigrationV4_2ToV4_3 } from '../ts/migrations/migration-v4.2-to-v4.3.ts';
import { MigrationV4_3ToV4_4 } from '../ts/migrations/migration-v4.3-to-v4.4.ts'; import { MigrationV4_3ToV4_4 } from '../ts/migrations/migration-v4.3-to-v4.4.ts';
import { ActionHandler } from '../ts/cli/action-handler.ts'; import { ActionHandler } from '../ts/cli/action-handler.ts';
import { ProxmoxAction } from '../ts/actions/proxmox-action.ts';
import { renderUpgradeChangelog } from '../ts/upgrade-changelog.ts'; import { renderUpgradeChangelog } from '../ts/upgrade-changelog.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
@@ -927,6 +928,12 @@ class TestAction extends Action {
} }
} }
class TestProxmoxAction extends ProxmoxAction {
public testShouldExecute(context: IActionContext): boolean {
return this.shouldExecute(context);
}
}
function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext { function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext {
return { return {
upsId: 'test-ups', upsId: 'test-ups',
@@ -1063,6 +1070,84 @@ Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
); );
}); });
Deno.test('ProxmoxAction.shouldExecute: skips unreachable and unknown UPS status changes', () => {
const action = new TestProxmoxAction({ type: 'proxmox' });
assertEquals(
action.testShouldExecute(createMockContext({
powerStatus: 'unreachable',
previousPowerStatus: 'online',
triggerReason: 'powerStatusChange',
})),
false,
);
assertEquals(
action.testShouldExecute(createMockContext({
powerStatus: 'unknown',
previousPowerStatus: 'online',
triggerReason: 'powerStatusChange',
})),
false,
);
});
Deno.test('ProxmoxAction.shouldExecute: skips thresholds unless UPS is confirmed on battery', () => {
const action = new TestProxmoxAction({
type: 'proxmox',
triggerMode: 'onlyThresholds',
thresholds: { battery: 50, runtime: 20 },
});
assertEquals(
action.testShouldExecute(createMockContext({
powerStatus: 'unreachable',
triggerReason: 'thresholdViolation',
batteryCapacity: 10,
batteryRuntime: 5,
})),
false,
);
assertEquals(
action.testShouldExecute(createMockContext({
powerStatus: 'online',
triggerReason: 'thresholdViolation',
batteryCapacity: 10,
batteryRuntime: 5,
})),
false,
);
});
Deno.test('ProxmoxAction.shouldExecute: allows confirmed battery events', () => {
const powerChangeAction = new TestProxmoxAction({ type: 'proxmox' });
const thresholdAction = new TestProxmoxAction({
type: 'proxmox',
triggerMode: 'onlyThresholds',
thresholds: { battery: 50, runtime: 20 },
});
assertEquals(
powerChangeAction.testShouldExecute(createMockContext({
powerStatus: 'onBattery',
previousPowerStatus: 'online',
triggerReason: 'powerStatusChange',
})),
true,
);
assertEquals(
thresholdAction.testShouldExecute(createMockContext({
powerStatus: 'onBattery',
triggerReason: 'thresholdViolation',
batteryCapacity: 10,
batteryRuntime: 5,
})),
true,
);
});
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Action Handler Tests // Action Handler Tests
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+63
View File
@@ -30,6 +30,69 @@ export class ProxmoxAction extends Action {
readonly type = 'proxmox'; readonly type = 'proxmox';
private static readonly activeRunKeys = new Set<string>(); private static readonly activeRunKeys = new Set<string>();
protected override shouldExecute(context: IActionContext): boolean {
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
if (context.powerStatus !== 'onBattery') {
if (context.powerStatus === 'unreachable') {
logger.info(
'Proxmox action skipped: UPS is unreachable (communication failure, actual state unknown)',
);
} else {
logger.info(
`Proxmox action skipped: UPS is not on battery (status: ${context.powerStatus})`,
);
}
return false;
}
if (context.triggerReason === 'thresholdViolation') {
if (mode === 'onlyPowerChanges') {
logger.info('Proxmox action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
return false;
}
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
}
if (context.triggerReason === 'powerStatusChange') {
if (mode === 'onlyThresholds') {
logger.info('Proxmox action skipped: triggerMode is onlyThresholds, ignoring power change');
return false;
}
const previousPowerStatus = context.previousPowerStatus;
if (previousPowerStatus === 'online') {
logger.info('Proxmox action triggered: power loss detected (online -> onBattery)');
return true;
}
if (previousPowerStatus === 'unknown') {
if (
mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
mode === 'anyChange'
) {
logger.info(
'Proxmox action triggered: UPS on battery at daemon startup (unknown -> onBattery)',
);
return true;
}
return false;
}
logger.info(
`Proxmox action skipped: non-emergency transition (${previousPowerStatus} -> ${context.powerStatus})`,
);
return false;
}
if (mode === 'anyChange') {
return true;
}
return false;
}
private static findCliTool(command: string): string | null { private static findCliTool(command: string): string | null {
for (const dir of PROXMOX.CLI_TOOL_PATHS) { for (const dir of PROXMOX.CLI_TOOL_PATHS) {
const candidate = `${dir}/${command}`; const candidate = `${dir}/${command}`;