diff --git a/Dockerfile b/Dockerfile index 4d1c821..94759d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ iputils-ping \ nut-client \ net-tools \ + ipmitool \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/example.config.yaml b/example.config.yaml index 89df0c6..8fb228d 100644 --- a/example.config.yaml +++ b/example.config.yaml @@ -15,9 +15,40 @@ wake_on: reattempt_delay: 30 # Minimum time (in seconds) between WOL attempts per client clients: - - name: "client 1" # Human-readable name - host: 192.168.0.100 # IP address preferred for reliability - mac: 38:f7:cd:c5:87:6b - - name: "client 2" - host: hostname.local # You can use a hostname if it resolves to a reachable interface - mac: auto # MAC will be resolved using ARP at runtime \ No newline at end of file + - name: "Server A" # Human-readable name + type: wol # Choose a client type, wol, idrac, ilo, sm_ipmi(supermicro) + host: 192.168.0.100 # IP address preferred for reliability + mac: aa:bb:cc:dd:ee:ff + + - name: "Server B" # Human-readable name + type: wol # Choose a client type, wol, idrac, ilo, sm_ipmi(supermicro) + host: 192.168.0.101 # IP address preferred for reliability + mac: auto # MAC will be resolved using ARP at runtime + + # if no type is provided, it will default to wol for backwards compatibility + - name: "Server C" + host: 192.168.0.102 + mac: aa:bb:cc:dd:ee:ff + + - name: Dell R740 + type: idrac + host: server.local # You can use a hostname if it resolves to a reachable interface + ipmi_host: 192.168.0.200 + username: root + password: calvin + verify_ssl: true + + - name: Supermicro Server + type: sm_ipmi + host: 192.168.0.104 + ipmi_host: 192.168.0.204 + username: admin + password: admin + + - name: HP iLO Server + type: ilo + host: 192.168.0.105 + ipmi_host: 192.168.0.205 + username: admin + password: admin + verify_ssl: false diff --git a/requirements.txt b/requirements.txt index 2dfd1d4..1aaa62b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ Pygments==2.19.1 pytest==8.4.0 PyYAML==6.0.2 wakeonlan==3.1.0 +requests==2.31.0 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2959d9a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for wolnut diff --git a/tests/test_all_client_types.py b/tests/test_all_client_types.py new file mode 100644 index 0000000..71d7b2c --- /dev/null +++ b/tests/test_all_client_types.py @@ -0,0 +1,71 @@ +""" +Comprehensive test for all client types in the tagged union +""" + +from wolnut.client import ( + IdracClientConfig, + IloClientConfig, + IpmiClientConfig, + WolClientConfig, +) +from wolnut.config import create_client_config + + +def test_wol_client(): + # Test WOL client + wol_data = { + "name": "WOL Server", + "type": "wol", + "host": "192.168.1.100", + "mac": "aa:bb:cc:dd:ee:ff", + } + wol_client = create_client_config(wol_data) + assert isinstance(wol_client, WolClientConfig) + assert wol_client.type == "wol" + + +def test_idrac_client(): + # Test iDRAC client + idrac_data = { + "name": "Dell iDRAC Server", + "type": "idrac", + "host": "192.168.1.101", + "ipmi_host": "192.168.1.102", + "username": "admin", + "password": "secret", + "verify_ssl": False, + } + idrac_client = create_client_config(idrac_data) + assert isinstance(idrac_client, IdracClientConfig) + assert idrac_client.type == "idrac" + + +def test_ilo_client(): + # Test iLO client + ilo_data = { + "name": "HP iLO Server", + "type": "ilo", + "host": "192.168.1.103", + "ipmi_host": "192.168.1.104", + "username": "admin", + "password": "secret", + "verify_ssl": True, + } + ilo_client = create_client_config(ilo_data) + assert isinstance(ilo_client, IloClientConfig) + assert ilo_client.type == "ilo" + + +def test_ipmi_client(): + # Test IPMI client + ipmi_data = { + "name": "IPMI Server", + "type": "ipmi", + "host": "192.168.1.105", + "ipmi_host": "192.168.1.106", + "username": "admin", + "password": "secret", + } + ipmi_client = create_client_config(ipmi_data) + assert isinstance(ipmi_client, IpmiClientConfig) + assert ipmi_client.type == "ipmi" diff --git a/tests/test_config_loading.py b/tests/test_config_loading.py new file mode 100644 index 0000000..207ebdc --- /dev/null +++ b/tests/test_config_loading.py @@ -0,0 +1,132 @@ +""" +Test script to verify the configuration can be loaded from YAML +""" + +import os +import tempfile + +import yaml + +from wolnut.client import ( + IdracClientConfig, + IloClientConfig, + IpmiClientConfig, + WolClientConfig, +) +from wolnut.config import NutConfig, WakeOnConfig, WolnutConfig, load_config + + +def test_config_loading(): + # Create a temporary config file + test_config = { + "log_level": "INFO", + "nut": {"ups": "ups@localhost", "username": "upsmon", "password": "password"}, + "poll_interval": 15, + "wake_on": { + "restore_delay_sec": 30, + "min_battery_percent": 25, + "client_timeout_sec": 600, + "reattempt_delay": 30, + }, + "clients": [ + { + "name": "Server A", + "type": "wol", + "host": "192.168.0.100", + "mac": "aa:bb:cc:dd:ee:ff", + }, + # { # can't resolve MAC during tests + # "name": "Server B", + # "type": "wol", + # "host": "192.168.0.101", + # "mac": "auto", + # }, + { + "name": "Server C", + "host": "192.168.0.102", + "mac": "aa:bb:cc:dd:ee:ff", + }, + { + "name": "Dell iDRAC Server", + "type": "idrac", + "host": "server.local", + "ipmi_host": "192.168.0.203", + "username": "root", + "password": "calvin", + "verify_ssl": True, + }, + { + "name": "Supermicro Server", + "type": "ipmi", + "host": "192.168.0.104", + "ipmi_host": "192.168.0.204", + "username": "admin", + "password": "admin", + }, + { + "name": "HP iLO Server", + "type": "ilo", + "host": "192.168.0.105", + "ipmi_host": "192.168.0.205", + "username": "admin", + "password": "admin", + "verify_ssl": False, + }, + ], + } + + expected = WolnutConfig( + nut=NutConfig(ups="ups@localhost", username="upsmon", password="password"), + poll_interval=15, + wake_on=WakeOnConfig( + restore_delay_sec=30, + min_battery_percent=25, + client_timeout_sec=600, + reattempt_delay=30, + ), + clients=[ + WolClientConfig( + name="Server A", host="192.168.0.100", mac="aa:bb:cc:dd:ee:ff" + ), + # WolClientConfig(name="Server B", host="192.168.0.101", mac="auto"), # can't resolve MAC during tests + WolClientConfig( + name="Server C", host="192.168.0.102", mac="aa:bb:cc:dd:ee:ff" + ), + IdracClientConfig( + name="Dell iDRAC Server", + host="server.local", + ipmi_host="192.168.0.203", + username="root", + password="calvin", + verify_ssl=True, + ), + IpmiClientConfig( + name="Supermicro Server", + host="192.168.0.104", + ipmi_host="192.168.0.204", + username="admin", + password="admin", + ), + IloClientConfig( + name="HP iLO Server", + host="192.168.0.105", + ipmi_host="192.168.0.205", + username="admin", + password="admin", + verify_ssl=False, + ), + ], + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(test_config, f) + temp_config_path = f.name + + try: + # Load the config + config = load_config(temp_config_path) + + assert config == expected, f"Config does not match expected structure: {config}" + finally: + # Clean up + os.unlink(temp_config_path) diff --git a/tests/test_tagged_union.py b/tests/test_tagged_union.py new file mode 100644 index 0000000..fd500ac --- /dev/null +++ b/tests/test_tagged_union.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Test script to verify the tagged union configuration works correctly +""" + +from wolnut.client import IdracClientConfig, WolClientConfig, create_client_config + + +def test_tagged_union(): + # Test WOL client + wol_data = { + "name": "Test WOL", + "type": "wol", + "host": "192.168.1.100", + "mac": "aa:bb:cc:dd:ee:ff", + } + + wol_client = create_client_config(wol_data) + print(f"WOL Client: {wol_client}") + assert isinstance(wol_client, WolClientConfig) + assert wol_client.name == "Test WOL" + assert wol_client.type == "wol" + assert wol_client.mac == "aa:bb:cc:dd:ee:ff" + + # Test iDRAC client + idrac_data = { + "name": "Test iDRAC", + "type": "idrac", + "host": "192.168.1.101", + "ipmi_host": "192.168.1.102", + "username": "admin", + "password": "secret", + "verify_ssl": False, + } + + idrac_client = create_client_config(idrac_data) + print(f"iDRAC Client: {idrac_client}") + assert isinstance(idrac_client, IdracClientConfig) + assert idrac_client.name == "Test iDRAC" + assert idrac_client.host == "192.168.1.101" + assert idrac_client.ipmi_host == "192.168.1.102" + assert idrac_client.username == "admin" + assert idrac_client.password == "secret" + assert idrac_client.verify_ssl == False + + # Test backward compatibility - clients can still be accessed uniformly + clients = [wol_client, idrac_client] + + for client in clients: + print(f"Client: {client.name}, Type: {client.type}, Host: {client.host}") + # Type-specific access + if client.type == "wol": + print(f" WOL MAC: {client.mac}") + elif client.type == "idrac": + print(f" iDRAC IPMI: {client.host}") diff --git a/wolnut/client/__init__.py b/wolnut/client/__init__.py new file mode 100644 index 0000000..e05c89a --- /dev/null +++ b/wolnut/client/__init__.py @@ -0,0 +1,309 @@ +""" +A module defining various client configurations for different types of clients +such as WOL, iDRAC, iLO, and IPMI Host. +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Union, override + +from wolnut.client.idrac import power_on_idrac_client +from wolnut.client.ilo import power_on_ilo_client +from wolnut.client.ipmi import power_on_ipmi_client +from wolnut.client.wol import send_wol_packet +from wolnut.utils import resolve_mac_from_host, validate_mac_format + +logger = logging.getLogger("wolnut") + + +# Base client config for common fields +@dataclass +class BaseClientConfig(ABC): + name: str + + @property + @abstractmethod + def type(self) -> str: + """Return the type of client""" + ... + + @classmethod + def validate_raw(cls, raw: dict, i: int | None = None) -> None: + """Validate a raw client configuration""" + cls.__validate_shared_fields(raw, i) + # determine what kind of client this is + match raw.get("type"): + case "wol" | None: + WolClientConfig._validate_subclass_fields(raw) + case "idrac": + IdracClientConfig._validate_subclass_fields(raw) + case "ilo": + IloClientConfig._validate_subclass_fields(raw) + case "ipmi": + IpmiClientConfig._validate_subclass_fields(raw) + case unsupported_type: + raise ValueError( + f"Client {raw['name']} has an unknown or unsupported type: {unsupported_type}" + ) + + @classmethod + def __validate_shared_fields(cls, raw: dict, i: int | None = None) -> None: + """Validate shared fields across all client types""" + if i is not None: + prefix = f"Client #{i} is missing required field: " + else: + prefix = "Client is missing required field: " + + if "name" not in raw or not raw["name"]: + raise ValueError(f"{prefix} name") + + @classmethod + @abstractmethod + def _validate_subclass_fields(cls, raw: dict) -> None: + """Validate a specific raw client configuration - implemented by subclasses""" + ... + + def post_process(self) -> None: + """Post-process the client configuration after validation""" + return + + def description(self) -> str: + """Return a human-readable description of the client configuration""" + return f"{self.type} Client: {self.name}" + + @abstractmethod + def send_power_on_signal(self) -> bool: + """Send the power-on signal to the client""" + ... + + +@dataclass +class WolClientConfig(BaseClientConfig): + host: str + mac: str + + @property + def type(self) -> str: + return "wol" + + @override + @classmethod + def _validate_subclass_fields(cls, raw: dict) -> None: + # Validate WOL specific fields + if "host" not in raw or not raw["host"]: + raise ValueError( + f"WOL client '{raw['name']}' is missing required field: host" + ) + if "mac" not in raw or not raw["mac"]: + raise ValueError( + f"WOL client '{raw['name']}' is missing required field: mac" + ) + validate_mac_format(raw["mac"]) + + @override + def description(self) -> str: + """Return a human-readable description of the WOL client configuration""" + return ( + f"WOL Client: {self.name} at {self.host} with MAC {self.mac}" + if self.mac + else f"WOL Client: {self.name} at {self.host} (MAC not set)" + ) + + @override + def post_process(self) -> None: + """ + Post-process the WOL client configuration after validation. + - if the MAC address is set to "auto", resolve it from the host. + - validate the MAC address format if it is not "auto". + """ + # Resolve MAC address if set to "auto" + if self.mac == "auto": + logger.info( + f"Resolving MAC for {self.name} at {self.host}...", + ) + resolved_mac = resolve_mac_from_host(self.host) + if not resolved_mac: + raise ValueError( + f"Could not resolve MAC for {self.name} at {self.host}" + ) + self.mac = resolved_mac + logger.info(f"Resolved MAC for {self.name}: {self.mac}") + # Validate MAC address format otherwise + elif not validate_mac_format(self.mac): + raise ValueError(f"Invalid MAC format for {self.name}: {self.mac}") + + @override + def send_power_on_signal(self) -> bool: + """ + Send a Wake-on-LAN packet to the client. + + Returns: + bool: True if the packet was sent successfully, False otherwise. + """ + return send_wol_packet(self.mac, self.host) + + +@dataclass +class IdracClientConfig(BaseClientConfig): + host: str + ipmi_host: str + username: str + password: str + verify_ssl: bool = False + + @property + def type(self) -> str: + return "idrac" + + @override + @classmethod + def _validate_subclass_fields(cls, raw: dict) -> None: + # Validate iDRAC specific fields + if "host" not in raw or not raw["host"]: + raise ValueError( + f"iDRAC client '{raw['name']}' is missing required field: host" + ) + if "ipmi_host" not in raw or not raw["ipmi_host"]: + raise ValueError( + f"iDRAC client '{raw['name']}' is missing required field: ipmi_host" + ) + if "username" not in raw or not raw["username"]: + raise ValueError( + f"iDRAC client '{raw['name']}' is missing required field: username" + ) + if "password" not in raw or not raw["password"]: + raise ValueError( + f"iDRAC client '{raw['name']}' is missing required field: password" + ) + + @override + def send_power_on_signal(self) -> bool: + # Send power on signal via iDRAC + return power_on_idrac_client( + self.ipmi_host, self.username, self.password, self.verify_ssl + ) + + +@dataclass +class IloClientConfig(BaseClientConfig): + host: str + ipmi_host: str + username: str + password: str + verify_ssl: bool = False + + @property + def type(self) -> str: + return "ilo" + + @override + @classmethod + def _validate_subclass_fields(cls, raw: dict) -> None: + # Validate iLO specific fields + if "host" not in raw or not raw["host"]: + raise ValueError( + f"iLO client '{raw['name']}' is missing required field: host" + ) + if "ipmi_host" not in raw or not raw["ipmi_host"]: + raise ValueError( + f"iLO client '{raw['name']}' is missing required field: ipmi_host" + ) + if "username" not in raw or not raw["username"]: + raise ValueError( + f"iLO client '{raw['name']}' is missing required field: username" + ) + if "password" not in raw or not raw["password"]: + raise ValueError( + f"iLO client '{raw['name']}' is missing required field: password" + ) + + @override + def send_power_on_signal(self) -> bool: + """ + Send a power-on signal to the iLO client. + + Returns: + bool: True if the power-on command was sent successfully, False otherwise. + """ + return power_on_ilo_client( + self.ipmi_host, + self.username, + self.password, + self.verify_ssl, + ) + + +@dataclass +class IpmiClientConfig(BaseClientConfig): + host: str + ipmi_host: str + username: str + password: str + + @property + def type(self) -> str: + return "ipmi" + + @override + @classmethod + def _validate_subclass_fields(cls, raw: dict) -> None: + # Validate IPMI Host specific fields + if "host" not in raw or not raw["host"]: + raise ValueError( + f"IPMI Host client '{raw['name']}' is missing required field: host" + ) + if "ipmi_host" not in raw or not raw["ipmi_host"]: + raise ValueError( + f"IPMI Host client '{raw['name']}' is missing required field: ipmi_host" + ) + if "username" not in raw or not raw["username"]: + raise ValueError( + f"IPMI Host client '{raw['name']}' is missing required field: username" + ) + if "password" not in raw or not raw["password"]: + raise ValueError( + f"IPMI Host client '{raw['name']}' is missing required field: password" + ) + + @override + def send_power_on_signal(self) -> bool: + """ + Send a power-on signal to the IPMI Host client. + + Returns: + bool: True if the power-on command was sent successfully, False otherwise. + """ + return power_on_ipmi_client( + self.ipmi_host, + self.username, + self.password, + ) + + +# Tagged union type for all client configurations +ClientConfig = Union[ + WolClientConfig, IdracClientConfig, IloClientConfig, IpmiClientConfig +] + + +def create_client_config(raw: dict) -> ClientConfig: + """Create a client configuration based on the raw data provided.""" + # determine the type of client from the raw data + client_type = raw.get("type") + # Copy the raw data to avoid modifying the original + raw_copy = raw.copy() + # remove the type field from raw to avoid passing it to the dataclass + raw_copy.pop("type", None) + BaseClientConfig.validate_raw(raw) + match client_type: + case "wol" | None: + return WolClientConfig(**raw_copy) + case "idrac": + return IdracClientConfig(**raw_copy) + case "ilo": + return IloClientConfig(**raw_copy) + case "ipmi": + return IpmiClientConfig(**raw_copy) + case _: + raise ValueError(f"Unknown client type: {client_type}") diff --git a/wolnut/client/idrac.py b/wolnut/client/idrac.py new file mode 100644 index 0000000..6cf4399 --- /dev/null +++ b/wolnut/client/idrac.py @@ -0,0 +1,71 @@ +import requests +from requests.auth import HTTPBasicAuth +import logging +import time + +logger = logging.getLogger("wolnut") + +POWER_ACTION_URI = "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset" +POWER_STATE_URI = "/redfish/v1/Systems/System.Embedded.1" + + +def get_idrac_power_state( + ipmi_host: str, username: str, password: str, verify_ssl: bool +) -> str | None: + url = f"https://{ipmi_host}{POWER_STATE_URI}" + + try: + response = requests.get( + url, auth=HTTPBasicAuth(username, password), verify=verify_ssl, timeout=10 + ) + if response.status_code == 200: + return response.json().get("PowerState", None) + except Exception as e: + logger.warning(f"Could not get iDRAC power state for {ipmi_host}: {e}") + return None + + +def power_on_idrac_client( + ipmi_host: str, + username: str, + password: str, + verify_ssl: bool, + retries: int = 3, + delay: int = 5, +): + for attempt in range(1, retries + 1): + state = get_idrac_power_state(ipmi_host, username, password, verify_ssl) + if state == "On": + logger.info(f"iDRAC {ipmi_host} already powered on") + return True + + url = f"https://{ipmi_host}{POWER_ACTION_URI}" + payload = {"ResetType": "On"} + + try: + response = requests.post( + url, + json=payload, + auth=HTTPBasicAuth(username, password), + verify=verify_ssl, + timeout=10, + ) + + if response.status_code in [200, 202, 204]: + logger.info( + f"Power ON command sent to iDRAC {ipmi_host} (attempt {attempt})" + ) + return True + else: + logger.warning( + f"Failed to power on iDRAC {ipmi_host} (attempt {attempt}): {response.text}" + ) + except Exception as e: + logger.warning(f"iDRAC error on attempt {attempt} for {ipmi_host}: {e}") + + if attempt < retries: + logger.info(f"Retrying iDRAC {ipmi_host} in {delay} seconds...") + time.sleep(delay) + + logger.error(f"All attempts failed to power on iDRAC client {ipmi_host}") + return False diff --git a/wolnut/client/ilo.py b/wolnut/client/ilo.py new file mode 100644 index 0000000..e931cfc --- /dev/null +++ b/wolnut/client/ilo.py @@ -0,0 +1,70 @@ +import logging +import time + +import requests +from requests.auth import HTTPBasicAuth + +logger = logging.getLogger("wolnut") + +POWER_ACTION_URI = "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" +POWER_STATE_URI = "/redfish/v1/Systems/1" + + +def get_ilo_power_state( + ipmi_host: str, username: str, password: str, verify_ssl: bool +) -> str | None: + url = f"https://{ipmi_host}{POWER_STATE_URI}" + try: + response = requests.get( + url, auth=HTTPBasicAuth(username, password), verify=verify_ssl, timeout=10 + ) + if response.status_code == 200: + return response.json().get("PowerState", None) + except Exception as e: + logger.warning(f"Could not get ILO power state for {ipmi_host}: {e}") + return None + + +def power_on_ilo_client( + ipmi_host: str, + username: str, + password: str, + verify_ssl: bool, + retries: int = 3, + delay: int = 5, +) -> bool: + for attempt in range(1, retries + 1): + state = get_ilo_power_state(ipmi_host, username, password, verify_ssl) + if state == "On": + logger.info(f"ILO {ipmi_host} already powered on") + return True + + url = f"https://{ipmi_host}{POWER_ACTION_URI}" + payload = {"ResetType": "On"} + + try: + response = requests.post( + url, + json=payload, + auth=HTTPBasicAuth(username, password), + verify=False, + timeout=10, + ) + if response.status_code in [200, 202, 204]: + logger.info( + f"Power ON command sent to ILO {ipmi_host} (attempt {attempt})" + ) + return True + else: + logger.warning( + f"Failed to power on ILO {ipmi_host} (attempt {attempt}): {response.text}" + ) + except Exception as e: + logger.warning(f"ILO error on attempt {attempt} for {ipmi_host}: {e}") + + if attempt < retries: + logger.info(f"Retrying ILO {ipmi_host} in {delay} seconds...") + time.sleep(delay) + + logger.error(f"All attempts failed to power on ILO client {ipmi_host}") + return False diff --git a/wolnut/client/ipmi.py b/wolnut/client/ipmi.py new file mode 100644 index 0000000..bf979cc --- /dev/null +++ b/wolnut/client/ipmi.py @@ -0,0 +1,39 @@ +import subprocess +import logging + +logger = logging.getLogger("wolnut") + + +def power_on_ipmi_client(ipmi_host: str, username: str, password: str): + try: + result = subprocess.run( + [ + "ipmitool", + "-I", + "lanplus", + "-H", + ipmi_host, + "-U", + username, + "-P", + password, + "chassis", + "power", + "on", + ], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + logger.info(f"IPMI Host: Powered on {ipmi_host}") + return True + else: + logger.error( + f"IPMI Host: Failed to power on {ipmi_host}: {result.stderr.strip()}" + ) + return False + except Exception as e: + logger.error(f"IPMI Host error for {ipmi_host}: {e}") + return False diff --git a/wolnut/wol.py b/wolnut/client/wol.py similarity index 89% rename from wolnut/wol.py rename to wolnut/client/wol.py index f7ce606..9b99935 100644 --- a/wolnut/wol.py +++ b/wolnut/client/wol.py @@ -19,5 +19,5 @@ def send_wol_packet(mac_address: str, broadcast_ip: str = "255.255.255.255") -> send_magic_packet(mac_address, ip_address=broadcast_ip) return True except Exception as e: - logger.error("Failed to send WOL packet to %s: %s", mac_address, e) + logger.error(f"Failed to send WOL packet to {mac_address}: {e}") return False diff --git a/wolnut/config.py b/wolnut/config.py index d148200..9c94a2c 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -1,10 +1,19 @@ from dataclasses import dataclass, field +from typing import Union import yaml import logging import os import sys -from wolnut.utils import validate_mac_format, resolve_mac_from_host +from wolnut.client import ( + BaseClientConfig, + ClientConfig, + create_client_config, +) +from wolnut.client import WolClientConfig +from wolnut.client import IdracClientConfig +from wolnut.client import IloClientConfig +from wolnut.client import IpmiClientConfig logger = logging.getLogger("wolnut") @@ -24,13 +33,6 @@ class WakeOnConfig: reattempt_delay: int = 30 -@dataclass -class ClientConfig: - name: str - host: str - mac: str # "auto" supported - - @dataclass class WolnutConfig: nut: NutConfig @@ -40,8 +42,7 @@ class WolnutConfig: log_level: str = "INFO" -def load_config(path: str = None) -> WolnutConfig: - +def load_config(path: str | None = None) -> WolnutConfig: if path is None: # Prefer /config/config.yaml if it exists default_path = "/config/config.yaml" @@ -69,35 +70,28 @@ def load_config(path: str = None) -> WolnutConfig: # LOGGING... - clients = [] + clients: list[ClientConfig] = [] for raw_client in raw["clients"]: try: - mac = raw_client["mac"] - if mac == "auto": - logger.info("Resolving MAC for %s at %s...", - raw_client['name'], raw_client['host']) - resolved_mac = resolve_mac_from_host(raw_client["host"]) - if not resolved_mac: - raise ValueError( - f"Could not resolve MAC address for {raw_client['name']} ({raw_client['host']})") - raw_client["mac"] = resolved_mac - logger.info("MAC for %s: %s", raw_client['name'], resolved_mac) - - clients.append(ClientConfig(**raw_client)) - except ValueError as e: - logger.error("Failed to load client %s: %s", - raw_client.get("name", "?"), e) + client = create_client_config(raw_client) + client.post_process() # Post-process client config + clients.append(client) + except Exception as e: + logger.error("Failed to load client %s: %s", raw_client.get("name", "?"), e) wolnut_config = WolnutConfig( nut=nut, poll_interval=raw.get("poll_interval", 10), wake_on=wake_on, clients=clients, - log_level=raw.get("log_level", "INFO").upper() + log_level=raw.get("log_level", "INFO").upper(), ) + logger.info("Config Imported Successfully") for client in wolnut_config.clients: - logger.info("Client: %s at MAC: %s", client.name, client.mac) + logger.info(client.description()) + + logger.info("WOLnut is ready to go!") return wolnut_config @@ -109,20 +103,5 @@ def validate_config(raw: dict): if "nut" not in raw or "ups" not in raw["nut"]: raise ValueError("Missing required field: 'nut.ups'") - for i, client in enumerate(raw["clients"]): - if "name" not in client: - raise ValueError(f"Client #{i} is missing required field: 'name'") - if "host" not in client: - raise ValueError( - f"Client '{client.get('name', '?')}' is missing required field: 'host'") - if "mac" not in client: - raise ValueError( - f"Client '{client['name']}' is missing required field: 'mac'") - - mac = client["mac"] - if not isinstance(mac, str): - raise ValueError( - f"Client '{client['name']}' has invalid mac format (should be string or 'auto')") - if mac != "auto" and not validate_mac_format(mac): - raise ValueError( - f"Client '{client['name']}' has invalid MAC address format: {mac}") + for i, client in enumerate(raw.get("clients", [])): + BaseClientConfig.validate_raw(client) diff --git a/wolnut/main.py b/wolnut/main.py index 51e14c5..5324031 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -3,7 +3,7 @@ from wolnut.config import load_config from wolnut.state import ClientStateTracker from wolnut.monitor import get_ups_status, is_client_online -from wolnut.wol import send_wol_packet + logger = logging.getLogger("wolnut") @@ -27,21 +27,21 @@ def main(): if state_tracker.was_ups_on_battery(): logger.info("WOLNUT is resuming from a UPS battery event") restoration_event = True - state_tracker.reset() + # state_tracker.reset() ups_status = get_ups_status(config.nut.ups) battery_percent = int(ups_status.get("battery.charge", 100)) power_status = ups_status.get("ups.status", "OL") - logger.info("UPS power status: %s, Battery: %s%%", - power_status, battery_percent) + logger.info("UPS power status: %s, Battery: %s%%", power_status, battery_percent) while True: ups_status = get_ups_status(config.nut.ups) battery_percent = int(ups_status.get("battery.charge", 100)) power_status = ups_status.get("ups.status", "OL") - logger.debug("UPS power status: %s, Battery: %s%%", - power_status, battery_percent) + logger.debug( + "UPS power status: %s, Battery: %s%%", power_status, battery_percent + ) # Check each client for client in config.clients: @@ -68,17 +68,26 @@ def main(): """Power restored, but battery still below minimum percentage (%s%%/%s%%). Waiting...""", battery_percent, - config.wake_on.min_battery_percent) + config.wake_on.min_battery_percent, + ) - elif time.time() - restoration_event_start < config.wake_on.restore_delay_sec: + elif ( + time.time() - restoration_event_start < config.wake_on.restore_delay_sec + ): logger.info( "Power restored, waiting %s seconds before waking clients...", - int(config.wake_on.restore_delay_sec-(time.time() - restoration_event_start))) + int( + config.wake_on.restore_delay_sec + - (time.time() - restoration_event_start) + ), + ) else: if not wol_being_sent: - logger.info("Power restored and battery >= %s%%. Preparing to send WOL...", - config.wake_on.min_battery_percent) + logger.info( + "Power restored and battery >= %s%%. Preparing to send WOL...", + config.wake_on.min_battery_percent, + ) wol_being_sent = True for client in config.clients: @@ -88,7 +97,9 @@ def main(): if not state_tracker.was_online_before_shutdown(client.name): logger.info( - "Skipping WOL for %s: was not online before power loss", client.name) + "Skipping power on for %s: was not online before power loss", + client.name, + ) state_tracker.mark_skip(client.name) continue @@ -102,31 +113,40 @@ def main(): else: recorded_down_clients.update({client.name}) if state_tracker.should_attempt_wol( - client.name, - config.wake_on.reattempt_delay + client.name, config.wake_on.reattempt_delay ): logger.info( - "Sending WOL packet to %s at %s", client.name, client.mac) - if send_wol_packet(client.mac): + "Attempting to power on %s via %s...", + client.name, + client.type, + ) + if client.send_power_on(): state_tracker.mark_wol_sent(client.name) else: logger.debug( - "Waiting to retry WOL for %s (delay not reached)", client.name) + "Waiting to retry power on attemt for %s (delay not reached)", + client.name, + ) if len(recorded_down_clients) == 0: - logger.info( - "Power Restored and all clients are back online!") + logger.info("Power Restored and all clients are back online!") restoration_event = False restoration_event_start = None state_tracker.reset() wol_being_sent = False else: - if time.time() - restoration_event_start > config.wake_on.client_timeout_sec: + if ( + time.time() - restoration_event_start + > config.wake_on.client_timeout_sec + ): logger.warning( - "Some devices failed to come back online within the timeout period.") + "Some devices failed to come back online within the timeout period." + ) for client in recorded_down_clients: logger.warning( - "%s failed to come back online within timeout period.", client) + "%s failed to come back online within timeout period.", + client, + ) restoration_event = False restoration_event_start = None wol_being_sent = False diff --git a/wolnut/monitor.py b/wolnut/monitor.py index 99345e2..13ae335 100644 --- a/wolnut/monitor.py +++ b/wolnut/monitor.py @@ -1,4 +1,5 @@ import subprocess +import os import logging import platform from typing import Optional @@ -6,15 +7,13 @@ logger = logging.getLogger("wolnut") -def get_ups_status(ups_name: str, username: Optional[str] = None, password: Optional[str] = None) -> dict: +def get_ups_status( + ups_name: str, username: Optional[str] = None, password: Optional[str] = None +) -> dict: env = None if username and password: - env = { - **subprocess.os.environ, - "USERNAME": username, - "PASSWORD": password - } + env = {**os.environ, "USERNAME": username, "PASSWORD": password} try: result = subprocess.run( @@ -23,7 +22,7 @@ def get_ups_status(ups_name: str, username: Optional[str] = None, password: Opti text=True, env=env, timeout=5, - check=False + check=False, ) if result.returncode != 0: @@ -50,7 +49,7 @@ def is_client_online(host: str) -> bool: ["ping", count_flag, "1", host], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - check=False + check=False, ) logger.debug("Host: %s Online: %s", host, result.returncode == 0) return result.returncode == 0