Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ nut:

poll_interval: 15 # Poll interval in seconds — should be shorter than the NUT shutdown delay on any client
status_file: "/config/wolnut_state.json" # Path to status file, recommended you change this to be outside container if using Docker
reset_state_on_resume: true # true (default) discards the saved client state when wolnut restarts during a UPS battery event.
# Set to false if wolnut's own host is shut down by the outage, so it can still wake the clients
# that were online before power was lost. See docs/configuration.md

wake_on:
restore_delay_sec: 30 # Delay (in seconds) after power is restored before sending WOL
Expand Down
11 changes: 11 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ The file path where `wolnut` will store its state. This allows the service to re
- **Type**: `string`
- **Default**: `"/config/wolnut_state.json"`

### `reset_state_on_resume`

Controls what `wolnut` does with the state persisted in `status_file` when it starts up and finds that the UPS was on battery the last time it ran — in other words, when `wolnut` itself was restarted during an outage.

- **Type**: `boolean`
- **Default**: `true`

When `true` (the default, and the historical behaviour), the saved per-client state is cleared on startup. `wolnut` will then only wake clients whose "was online before the outage" status it observed itself, so it will *not* send WOL packets for an outage it did not stay running through.

Set it to `false` if `wolnut` runs on a machine that is itself shut down by the outage. In that case the saved state is the only record of which clients were online before power was lost, so keeping it is what allows `wolnut` to wake them once it boots back up. `wolnut` resumes as if the UPS were still on battery and takes the normal power restoration path as soon as the UPS reports `OL` again.

---

## `nut`
Expand Down
151 changes: 151 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,164 @@

from wolnut import main
from wolnut.cli import wolnut, get_battery_percent
from wolnut.config import ClientConfig, NutConfig, WakeOnConfig, WolnutConfig
from wolnut.state import ClientStateTracker


@pytest.fixture
def runner():
return CliRunner()


class LoopBreaker(Exception):
"""Raised from the patched time.sleep to break out of main()'s infinite loop."""


CLIENT = ClientConfig(name="client-1", host="192.168.1.10", mac="DE:AD:BE:EF:00:01")


def _make_config(status_file: str, reset_state_on_resume: bool) -> WolnutConfig:
"""Builds a config whose wake_on thresholds never delay a WOL attempt."""
return WolnutConfig(
nut=NutConfig(ups="ups@localhost"),
status_file=status_file,
poll_interval=1,
wake_on=WakeOnConfig(
restore_delay_sec=0,
min_battery_percent=0,
client_timeout_sec=600,
reattempt_delay=0,
),
clients=[CLIENT],
reset_state_on_resume=reset_state_on_resume,
)


def _write_post_outage_state(status_file: str):
"""
Writes the state file exactly as wolnut would have left it if it was killed by
a power outage: the client was online, got snapshotted when the UPS went to
battery, and then went offline.
"""
tracker = ClientStateTracker([CLIENT], status_file=status_file)
tracker.update(CLIENT.name, True)
tracker.mark_all_online_clients()
tracker.set_ups_on_battery(True, 55)
tracker.update(CLIENT.name, False)
tracker.save_state()

# Sanity check: the state we just persisted is what a fresh process would load.
reloaded = ClientStateTracker([CLIENT], status_file=status_file)
assert reloaded.was_ups_on_battery()
assert reloaded.was_online_before_shutdown(CLIENT.name)


def _run_main_once(mocker, tmp_path, reset_state_on_resume: bool):
"""
Simulates wolnut restarting on a freshly-booted host after an outage, with the
UPS already back on line power, and returns the send_wol_packet mock.
"""
status_file = str(tmp_path / "wolnut_state.json")
_write_post_outage_state(status_file)

mocker.patch(
"wolnut.cli.load_config",
return_value=_make_config(status_file, reset_state_on_resume),
)
mocker.patch(
"wolnut.cli.get_ups_status",
return_value={"ups.status": "OL", "battery.charge": "100"},
)
mocker.patch("wolnut.cli.is_client_online", return_value=False)
mock_send_wol = mocker.patch("wolnut.cli.send_wol_packet", return_value=True)
# Break out of the main loop at the end of the first iteration.
mocker.patch("wolnut.cli.time.sleep", side_effect=LoopBreaker)

with pytest.raises(LoopBreaker):
main("dummy.yaml", status_file, False)

return mock_send_wol


def test_resume_with_reset_state_on_resume_true_sends_no_wol(mocker, tmp_path):
"""
Default behaviour (unchanged): the saved state is discarded on resume, so no
client is considered to have been online before the outage and nothing is woken.
"""
mock_send_wol = _run_main_once(mocker, tmp_path, reset_state_on_resume=True)

mock_send_wol.assert_not_called()


def test_resume_with_reset_state_on_resume_false_sends_wol(mocker, tmp_path):
"""
Regression test: with reset_state_on_resume disabled, a client that was online
before the outage is still eligible for WOL after wolnut restarts.
"""
mock_send_wol = _run_main_once(mocker, tmp_path, reset_state_on_resume=False)

mock_send_wol.assert_called_once_with(CLIENT.mac)


def test_resume_with_reset_state_on_resume_false_does_not_reset_tracker(
mocker, tmp_path
):
"""The loaded state must survive startup untouched when the option is disabled."""
status_file = str(tmp_path / "wolnut_state.json")
_write_post_outage_state(status_file)

mocker.patch(
"wolnut.cli.load_config",
return_value=_make_config(status_file, reset_state_on_resume=False),
)
mocker.patch(
"wolnut.cli.get_ups_status",
return_value={"ups.status": "OL", "battery.charge": "100"},
)
mocker.patch("wolnut.cli.is_client_online", return_value=False)
mocker.patch("wolnut.cli.send_wol_packet", return_value=True)
mock_reset = mocker.patch.object(ClientStateTracker, "reset")
mocker.patch("wolnut.cli.time.sleep", side_effect=LoopBreaker)

with pytest.raises(LoopBreaker):
main("dummy.yaml", status_file, False)

mock_reset.assert_not_called()


def test_resume_while_still_on_battery_does_not_resnapshot(mocker, tmp_path):
"""
If the host boots back up while the UPS is still on battery, the preserved
'was online before the outage' flags must not be overwritten with the current
(all offline) state.
"""
status_file = str(tmp_path / "wolnut_state.json")
_write_post_outage_state(status_file)

mocker.patch(
"wolnut.cli.load_config",
return_value=_make_config(status_file, reset_state_on_resume=False),
)
mocker.patch(
"wolnut.cli.get_ups_status",
return_value={"ups.status": "OB DISCHRG", "battery.charge": "40"},
)
mocker.patch("wolnut.cli.is_client_online", return_value=False)
mock_send_wol = mocker.patch("wolnut.cli.send_wol_packet", return_value=True)
mock_snapshot = mocker.patch.object(ClientStateTracker, "mark_all_online_clients")
mocker.patch("wolnut.cli.time.sleep", side_effect=LoopBreaker)

with pytest.raises(LoopBreaker):
main("dummy.yaml", status_file, False)

mock_snapshot.assert_not_called()
mock_send_wol.assert_not_called()

# The persisted state still remembers the client was online before the outage.
reloaded = ClientStateTracker([CLIENT], status_file=status_file)
assert reloaded.was_online_before_shutdown(CLIENT.name)


def test_get_battery_percent():
"""Tests the battery percentage parsing function."""
assert get_battery_percent({"battery.charge": "95.5"}) == 96
Expand Down
38 changes: 38 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,44 @@ def test_load_config_full(mocker, full_config_dict):
mock_resolve_mac.assert_called_once_with("server.local")


def test_reset_state_on_resume_defaults_to_true(mocker, minimal_config_dict):
"""An absent 'reset_state_on_resume' key must preserve the historical behaviour."""
assert "reset_state_on_resume" not in minimal_config_dict
mocker.patch(
"builtins.open", mocker.mock_open(read_data=yaml.dump(minimal_config_dict))
)
mocker.patch("wolnut.config.validate_config")
mocker.patch("wolnut.config.resolve_mac_from_host")

cfg = config.load_config("dummy.yaml", None, False)

assert cfg.reset_state_on_resume is True


@pytest.mark.parametrize("value", [True, False])
def test_reset_state_on_resume_explicit(mocker, minimal_config_dict, value):
"""An explicit 'reset_state_on_resume' value is carried through to the config."""
minimal_config_dict["reset_state_on_resume"] = value
mocker.patch(
"builtins.open", mocker.mock_open(read_data=yaml.dump(minimal_config_dict))
)
mocker.patch("wolnut.config.validate_config")
mocker.patch("wolnut.config.resolve_mac_from_host")

cfg = config.load_config("dummy.yaml", None, False)

assert cfg.reset_state_on_resume is value


def test_validate_config_rejects_non_boolean_reset_state_on_resume(
minimal_config_dict,
):
"""'reset_state_on_resume' must be a boolean when present."""
minimal_config_dict["reset_state_on_resume"] = "yes please"
with pytest.raises(ValueError, match="'reset_state_on_resume' must be a boolean"):
config.validate_config(minimal_config_dict)


def test_load_config_file_not_found(mocker):
"""Tests that None is returned when the config file is not found."""
mocker.patch("builtins.open", side_effect=FileNotFoundError)
Expand Down
15 changes: 13 additions & 2 deletions wolnut/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,19 @@ def main(config_file: str, status_file: str, verbose: bool = False) -> int:
state_tracker = ClientStateTracker(config.clients, status_file=config.status_file)
if state_tracker.was_ups_on_battery():
logger.info("WOLNUT is resuming from a UPS battery event")
restoration_event = True
state_tracker.reset()
if config.reset_state_on_resume:
restoration_event = True
state_tracker.reset()
else:
# Keep the persisted state so we still know which clients were online
# before the outage. Pretending we are still on battery makes the main
# loop take the normal power restoration branch as soon as the UPS
# reports "OL", without re-snapshotting an offline fleet while it is
# still reporting "OB".
logger.info(
"Preserving saved client state (reset_state_on_resume is disabled)"
)
on_battery = True

ups_status = get_ups_status(config.nut.ups)
battery_percent = get_battery_percent(ups_status)
Expand Down
12 changes: 12 additions & 0 deletions wolnut/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

DEFAULT_CONFIG_FILEPATHS = ["/config/config.yaml", "./config.yaml"]
DEFAULT_LOG_LEVEL = "INFO"
# Historical behaviour: discard the persisted client state when resuming from a
# UPS battery event. See docs/configuration.md for why you may want to disable it.
DEFAULT_RESET_STATE_ON_RESUME = True


@dataclass
Expand Down Expand Up @@ -46,6 +49,7 @@ class WolnutConfig:
wake_on: WakeOnConfig = field(default_factory=WakeOnConfig)
clients: list[ClientConfig] = field(default_factory=list)
log_level: str = "INFO"
reset_state_on_resume: bool = DEFAULT_RESET_STATE_ON_RESUME


def find_state_file(state_file: Optional[str] = None) -> str:
Expand Down Expand Up @@ -118,6 +122,9 @@ def load_config(
clients=clients,
log_level=raw.get("log_level", DEFAULT_LOG_LEVEL).upper(),
status_file=final_status_path,
reset_state_on_resume=raw.get(
"reset_state_on_resume", DEFAULT_RESET_STATE_ON_RESUME
),
)
logger.info("Config Imported Successfully")
for client in wolnut_config.clients:
Expand All @@ -136,6 +143,11 @@ def validate_config(raw: dict):
if "status_file" not in raw:
logger.warning("No 'status_file' specified in config, using default.")

if "reset_state_on_resume" in raw and not isinstance(
raw["reset_state_on_resume"], bool
):
raise ValueError("'reset_state_on_resume' must be a boolean")

for i, client in enumerate(raw["clients"]):
if "name" not in client:
raise ValueError(f"Client #{i} is missing required field: 'name'")
Expand Down