From 0d955c4224497e4c22ef4e423f0b3ad1059f6248 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:23:33 -0500 Subject: [PATCH 01/25] Create idrac.py --- wolnut/idrac.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 wolnut/idrac.py diff --git a/wolnut/idrac.py b/wolnut/idrac.py new file mode 100644 index 0000000..19718cb --- /dev/null +++ b/wolnut/idrac.py @@ -0,0 +1,37 @@ +import requests +from requests.auth import HTTPBasicAuth + +# === Configuration === +IDRAC_HOST = "https://10.20.20.8" # Replace with your iDRAC IP +USERNAME = "root" # Default iDRAC username +PASSWORD = "yourpassword" # Default iDRAC password +VERIFY_SSL = False # Set to True if using valid cert + +# === Redfish endpoint to power on system === +POWER_ACTION_URI = "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset" +POWER_PAYLOAD = { + "ResetType": "On" +} + +def power_on_server(): + url = f"{IDRAC_HOST}{POWER_ACTION_URI}" + + try: + response = requests.post( + url, + json=POWER_PAYLOAD, + auth=HTTPBasicAuth(USERNAME, PASSWORD), + verify=VERIFY_SSL, + timeout=10 + ) + + if response.status_code in [200, 202, 204]: + print("✅ Power on command sent successfully.") + else: + print(f"❌ Failed to power on. Status: {response.status_code}") + print(f"Response: {response.text}") + except Exception as e: + print(f"❌ Error: {e}") + +if __name__ == "__main__": + power_on_server() From f8ed623dd91dfb799c6518913966f848a873d535 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:38:00 -0500 Subject: [PATCH 02/25] Update config.py --- wolnut/config.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/wolnut/config.py b/wolnut/config.py index d148200..fda030d 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -87,7 +87,16 @@ def load_config(path: str = None) -> WolnutConfig: except ValueError as e: logger.error("Failed to load client %s: %s", raw_client.get("name", "?"), e) - + idrac_clients = [] + for raw_idracclient in raw["idrac_clients"]: + try: + raw_idracclient['name'] + raw_idracclient['idrac_host'] + raw_idracclient['username'] + raw_idracclient['password'] + raw_idracclient['verify_ssl'] + +--------------------------------------------------------------------------- wolnut_config = WolnutConfig( nut=nut, poll_interval=raw.get("poll_interval", 10), From f0391c86818ca7f17caf2a01969e78651d3afb1c Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:52:46 -0500 Subject: [PATCH 03/25] Update config.py --- wolnut/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wolnut/config.py b/wolnut/config.py index fda030d..3f35643 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -30,6 +30,14 @@ class ClientConfig: host: str mac: str # "auto" supported +@dataclass +class idracClientConfig: + name: str + host: str + username: str | None = None + password: str | None = None + verify_ssl: bol = "false" + @dataclass class WolnutConfig: From b399c280e72022ded96711215c2d0bd553dcd0bc Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:04:26 -0500 Subject: [PATCH 04/25] Update config.py --- wolnut/config.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/wolnut/config.py b/wolnut/config.py index 3f35643..05a2986 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -31,12 +31,13 @@ class ClientConfig: mac: str # "auto" supported @dataclass -class idracClientConfig: +class IDRACClientConfig: name: str host: str - username: str | None = None - password: str | None = None - verify_ssl: bol = "false" + username: str + password: str + verify_ssl: bool = False # New field + @dataclass @@ -45,9 +46,11 @@ class WolnutConfig: poll_interval: int = 10 wake_on: WakeOnConfig = field(default_factory=WakeOnConfig) clients: list[ClientConfig] = field(default_factory=list) + idrac_clients: list[IDRACClientConfig] = field(default_factory=list) # NEW log_level: str = "INFO" + def load_config(path: str = None) -> WolnutConfig: if path is None: @@ -96,13 +99,13 @@ def load_config(path: str = None) -> WolnutConfig: logger.error("Failed to load client %s: %s", raw_client.get("name", "?"), e) idrac_clients = [] - for raw_idracclient in raw["idrac_clients"]: + for raw_idrac in raw.get("idrac_clients", []): try: - raw_idracclient['name'] - raw_idracclient['idrac_host'] - raw_idracclient['username'] - raw_idracclient['password'] - raw_idracclient['verify_ssl'] + idrac_clients.append(IDRACClientConfig(**raw_idrac)) + except Exception as e: + logger.error("Failed to load iDRAC client %s: %s", + raw_idrac.get("name", "?"), e) + --------------------------------------------------------------------------- wolnut_config = WolnutConfig( @@ -110,8 +113,10 @@ def load_config(path: str = None) -> WolnutConfig: poll_interval=raw.get("poll_interval", 10), wake_on=wake_on, clients=clients, + idrac_clients=idrac_clients, 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) @@ -143,3 +148,9 @@ def validate_config(raw: dict): if mac != "auto" and not validate_mac_format(mac): raise ValueError( f"Client '{client['name']}' has invalid MAC address format: {mac}") + + for i, idrac in enumerate(raw.get("idrac_clients", [])): + if "name" not in idrac or "host" not in idrac or "username" not in idrac or "password" not in idrac: + raise ValueError( + f"iDRAC client #{i} is missing one of the required fields: name, host, username, password" + ) From 58557fc1b6395ba8d45f4e04f313bbb53ac17d3f Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:08:56 -0500 Subject: [PATCH 05/25] Update idrac.py --- wolnut/idrac.py | 53 +++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/wolnut/idrac.py b/wolnut/idrac.py index 19718cb..72f244a 100644 --- a/wolnut/idrac.py +++ b/wolnut/idrac.py @@ -1,37 +1,48 @@ +# idrac.py import requests from requests.auth import HTTPBasicAuth +import logging -# === Configuration === -IDRAC_HOST = "https://10.20.20.8" # Replace with your iDRAC IP -USERNAME = "root" # Default iDRAC username -PASSWORD = "yourpassword" # Default iDRAC password -VERIFY_SSL = False # Set to True if using valid cert +logger = logging.getLogger("wolnut") -# === Redfish endpoint to power on system === POWER_ACTION_URI = "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset" -POWER_PAYLOAD = { - "ResetType": "On" -} +POWER_STATE_URI = "/redfish/v1/Systems/System.Embedded.1" -def power_on_server(): - url = f"{IDRAC_HOST}{POWER_ACTION_URI}" +def power_on_idrac_client(host, username, password, verify_ssl=False): + url = f"https://{host}{POWER_ACTION_URI}" + payload = {"ResetType": "On"} try: response = requests.post( url, - json=POWER_PAYLOAD, - auth=HTTPBasicAuth(USERNAME, PASSWORD), - verify=VERIFY_SSL, + json=payload, + auth=HTTPBasicAuth(username, password), + verify=verify_ssl, timeout=10 ) - if response.status_code in [200, 202, 204]: - print("✅ Power on command sent successfully.") + logger.info("iDRAC Power ON command sent to %s", host) + return True else: - print(f"❌ Failed to power on. Status: {response.status_code}") - print(f"Response: {response.text}") + logger.error("Failed to power on iDRAC client %s: %s", host, response.text) + return False + except Exception as e: + logger.error("iDRAC error for %s: %s", host, e) + return False + +def get_idrac_power_state(host, username, password, verify_ssl=False): + url = f"https://{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: - print(f"❌ Error: {e}") + logger.error("Could not get iDRAC power state for %s: %s", host, e) -if __name__ == "__main__": - power_on_server() + return None From ed692ecedc841f2312b1958784f0d84f958f48d6 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:13:28 -0500 Subject: [PATCH 06/25] Update main.py --- wolnut/main.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/wolnut/main.py b/wolnut/main.py index 51e14c5..b12254b 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -4,6 +4,8 @@ from wolnut.state import ClientStateTracker from wolnut.monitor import get_ups_status, is_client_online from wolnut.wol import send_wol_packet +from wolnut.idrac import power_on_idrac_client, get_idrac_power_state + logger = logging.getLogger("wolnut") @@ -80,6 +82,27 @@ def main(): logger.info("Power restored and battery >= %s%%. Preparing to send WOL...", config.wake_on.min_battery_percent) wol_being_sent = True + + # Power on iDRAC clients + for idrac_client in config.idrac_clients: + state = get_idrac_power_state( + idrac_client.host, + idrac_client.username, + idrac_client.password, + idrac_client.verify_ssl + ) + + if state == "On": + logger.info("iDRAC client %s already powered on", idrac_client.name) + continue + + logger.info("Sending iDRAC power on to %s at %s", idrac_client.name, idrac_client.host) + power_on_idrac_client( + idrac_client.host, + idrac_client.username, + idrac_client.password, + idrac_client.verify_ssl + ) for client in config.clients: From 51862fd14bdcbe4a4308cb1fcd2feb0b8d1ee5d8 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:13:47 -0500 Subject: [PATCH 07/25] Update idrac.py --- wolnut/idrac.py | 63 +++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/wolnut/idrac.py b/wolnut/idrac.py index 72f244a..d227f1e 100644 --- a/wolnut/idrac.py +++ b/wolnut/idrac.py @@ -1,35 +1,13 @@ -# idrac.py 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 power_on_idrac_client(host, username, password, verify_ssl=False): - url = f"https://{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("iDRAC Power ON command sent to %s", host) - return True - else: - logger.error("Failed to power on iDRAC client %s: %s", host, response.text) - return False - except Exception as e: - logger.error("iDRAC error for %s: %s", host, e) - return False - def get_idrac_power_state(host, username, password, verify_ssl=False): url = f"https://{host}{POWER_STATE_URI}" @@ -43,6 +21,41 @@ def get_idrac_power_state(host, username, password, verify_ssl=False): if response.status_code == 200: return response.json().get("PowerState", None) except Exception as e: - logger.error("Could not get iDRAC power state for %s: %s", host, e) - + logger.warning("Could not get iDRAC power state for %s: %s", host, e) return None + + +def power_on_idrac_client(host, username, password, verify_ssl=False, retries=3, delay=5): + for attempt in range(1, retries + 1): + state = get_idrac_power_state(host, username, password, verify_ssl) + if state == "On": + logger.info("iDRAC %s already powered on", host) + return True + + url = f"https://{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("Power ON command sent to iDRAC %s (attempt %d)", host, attempt) + return True + else: + logger.warning("Failed to power on iDRAC %s (attempt %d): %s", + host, attempt, response.text) + except Exception as e: + logger.warning("iDRAC error on attempt %d for %s: %s", attempt, host, e) + + if attempt < retries: + logger.info("Retrying iDRAC %s in %s seconds...", host, delay) + time.sleep(delay) + + logger.error("All attempts failed to power on iDRAC client %s", host) + return False From a4444bf2596be1863b35480ad475c30fe8a5f687 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:18:24 -0500 Subject: [PATCH 08/25] Update main.py --- wolnut/main.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/wolnut/main.py b/wolnut/main.py index b12254b..c02f595 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -4,7 +4,7 @@ from wolnut.state import ClientStateTracker from wolnut.monitor import get_ups_status, is_client_online from wolnut.wol import send_wol_packet -from wolnut.idrac import power_on_idrac_client, get_idrac_power_state +from wolnut.idrac import power_on_idrac_client logger = logging.getLogger("wolnut") @@ -84,19 +84,8 @@ def main(): wol_being_sent = True # Power on iDRAC clients + # Power on iDRAC clients for idrac_client in config.idrac_clients: - state = get_idrac_power_state( - idrac_client.host, - idrac_client.username, - idrac_client.password, - idrac_client.verify_ssl - ) - - if state == "On": - logger.info("iDRAC client %s already powered on", idrac_client.name) - continue - - logger.info("Sending iDRAC power on to %s at %s", idrac_client.name, idrac_client.host) power_on_idrac_client( idrac_client.host, idrac_client.username, @@ -104,6 +93,7 @@ def main(): idrac_client.verify_ssl ) + for client in config.clients: if state_tracker.should_skip(client.name): From 15dcac4eea7dfb2dacbeb4a397c5dc1808d03273 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:20:07 -0500 Subject: [PATCH 09/25] Update idrac.py --- wolnut/idrac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wolnut/idrac.py b/wolnut/idrac.py index d227f1e..d8c542a 100644 --- a/wolnut/idrac.py +++ b/wolnut/idrac.py @@ -8,7 +8,7 @@ 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(host, username, password, verify_ssl=False): +def get_idrac_power_state(host, username, password, verify_ssl): url = f"https://{host}{POWER_STATE_URI}" try: From 5b267a6404b320f437dc3e74645021fa7d6d3f82 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:20:46 -0500 Subject: [PATCH 10/25] Update idrac.py --- wolnut/idrac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wolnut/idrac.py b/wolnut/idrac.py index d8c542a..7216edf 100644 --- a/wolnut/idrac.py +++ b/wolnut/idrac.py @@ -25,7 +25,7 @@ def get_idrac_power_state(host, username, password, verify_ssl): return None -def power_on_idrac_client(host, username, password, verify_ssl=False, retries=3, delay=5): +def power_on_idrac_client(host, username, password, verify_ssl, retries=3, delay=5): for attempt in range(1, retries + 1): state = get_idrac_power_state(host, username, password, verify_ssl) if state == "On": From 9ffa42f13d9e6f0c94281ad47db6d82f10bd122d Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:32:31 -0500 Subject: [PATCH 11/25] Update example.config.yaml --- example.config.yaml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/example.config.yaml b/example.config.yaml index 89df0c6..517c5d6 100644 --- a/example.config.yaml +++ b/example.config.yaml @@ -20,4 +20,17 @@ clients: 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 + mac: auto # MAC will be resolved using ARP at runtime + +idrac_clients: + - name: "Dell R740" + host: 192.168.0.200 + username: root + password: calvin + verify_ssl: false + - name: "Dell T640" + host: idrac.local + username: admin + password: secretpass + verify_ssl: true + From a42ee5b67c6755aa37cdde8860c4bdac4566d803 Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:35:34 -0500 Subject: [PATCH 12/25] Update main.py --- wolnut/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/wolnut/main.py b/wolnut/main.py index c02f595..5f15293 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -83,7 +83,6 @@ def main(): config.wake_on.min_battery_percent) wol_being_sent = True - # Power on iDRAC clients # Power on iDRAC clients for idrac_client in config.idrac_clients: power_on_idrac_client( From 80ec1e88e56a8fc084c07b974412cfcd4c4a67cd Mon Sep 17 00:00:00 2001 From: Connor P Bourque <47765857+CPBPILOT@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:36:06 -0500 Subject: [PATCH 13/25] Update config.py --- wolnut/config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/wolnut/config.py b/wolnut/config.py index 05a2986..a4e1471 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -106,8 +106,6 @@ def load_config(path: str = None) -> WolnutConfig: logger.error("Failed to load iDRAC client %s: %s", raw_idrac.get("name", "?"), e) - ---------------------------------------------------------------------------- wolnut_config = WolnutConfig( nut=nut, poll_interval=raw.get("poll_interval", 10), From d6890be974f05fade5a428070ab8cc30c261f81e Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Tue, 24 Jun 2025 14:41:47 -0500 Subject: [PATCH 14/25] Added support for idrac,ilo, and supermicro ipmi --- example.config.yaml | 26 ++++----- wolnut/config.py | 85 +++++++++++------------------ wolnut/ilo.py | 58 ++++++++++++++++++++ wolnut/main.py | 130 +++++++++++++++++++++++++------------------- wolnut/sm_ipmi.py | 22 ++++++++ 5 files changed, 197 insertions(+), 124 deletions(-) create mode 100644 wolnut/ilo.py create mode 100644 wolnut/sm_ipmi.py diff --git a/example.config.yaml b/example.config.yaml index 517c5d6..648aa21 100644 --- a/example.config.yaml +++ b/example.config.yaml @@ -15,22 +15,20 @@ 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 + - 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 -idrac_clients: - - name: "Dell R740" - host: 192.168.0.200 + - 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 + + - name: Dell R740 + type: idrac + host: server.local # You can use a hostname if it resolves to a reachable interface username: root password: calvin verify_ssl: false - - name: "Dell T640" - host: idrac.local - username: admin - password: secretpass - verify_ssl: true diff --git a/wolnut/config.py b/wolnut/config.py index a4e1471..e04b806 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -27,16 +27,12 @@ class WakeOnConfig: @dataclass class ClientConfig: name: str + type: str # e.g., "wol", "idrac", "ilo", "sm_ipmi" host: str - mac: str # "auto" supported - -@dataclass -class IDRACClientConfig: - name: str - host: str - username: str - password: str - verify_ssl: bool = False # New field + mac: str | None = None # Only for WOL + username: str | None = None # Only for idrac/ilo + password: str | None = None + verify_ssl: bool = False # Optional for idrac/ilo @@ -46,7 +42,6 @@ class WolnutConfig: poll_interval: int = 10 wake_on: WakeOnConfig = field(default_factory=WakeOnConfig) clients: list[ClientConfig] = field(default_factory=list) - idrac_clients: list[IDRACClientConfig] = field(default_factory=list) # NEW log_level: str = "INFO" @@ -83,35 +78,28 @@ def load_config(path: str = None) -> WolnutConfig: clients = [] 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) + client_type = raw_client.get("type") + if client_type == "wol": + mac = raw_client.get("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 for {raw_client['name']}") + raw_client["mac"] = resolved_mac + logger.info("MAC for %s: %s", raw_client['name'], resolved_mac) + elif not validate_mac_format(mac): + raise ValueError(f"Invalid MAC format for {raw_client['name']}: {mac}") clients.append(ClientConfig(**raw_client)) - except ValueError as e: - logger.error("Failed to load client %s: %s", - raw_client.get("name", "?"), e) - idrac_clients = [] - for raw_idrac in raw.get("idrac_clients", []): - try: - idrac_clients.append(IDRACClientConfig(**raw_idrac)) except Exception as e: - logger.error("Failed to load iDRAC client %s: %s", - raw_idrac.get("name", "?"), 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, - idrac_clients=idrac_clients, log_level=raw.get("log_level", "INFO").upper() ) @@ -129,26 +117,17 @@ 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, idrac in enumerate(raw.get("idrac_clients", [])): - if "name" not in idrac or "host" not in idrac or "username" not in idrac or "password" not in idrac: - raise ValueError( - f"iDRAC client #{i} is missing one of the required fields: name, host, username, password" - ) + for i, client in enumerate(raw.get("clients", [])): + if "name" not in client or "host" not in client or "type" not in client: + raise ValueError(f"Client #{i} is missing 'name', 'host', or 'type'") + + if client["type"] == "wol": + if "mac" not in client: + raise ValueError(f"WOL client '{client['name']}' is missing 'mac'") + elif client["type"] in ["idrac", "ilo", "sm_ipmi"]: + for key in ["username", "password"]: + if key not in client: + raise ValueError(f"{client['type']} client '{client['name']}' is missing '{key}'") + else: + raise ValueError(f"Unsupported client type: {client['type']}") + diff --git a/wolnut/ilo.py b/wolnut/ilo.py new file mode 100644 index 0000000..12e7fcf --- /dev/null +++ b/wolnut/ilo.py @@ -0,0 +1,58 @@ + +import requests +from requests.auth import HTTPBasicAuth +import logging +import time + +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(host, username, password): + url = f"https://{host}{POWER_STATE_URI}" + try: + response = requests.get( + url, + auth=HTTPBasicAuth(username, password), + verify=False, + timeout=10 + ) + if response.status_code == 200: + return response.json().get("PowerState", None) + except Exception as e: + logger.warning("Could not get ILO power state for %s: %s", host, e) + return None + +def power_on_ilo_client(host, username, password, retries=3, delay=5): + for attempt in range(1, retries + 1): + state = get_ilo_power_state(host, username, password) + if state == "On": + logger.info("ILO %s already powered on", host) + return True + + url = f"https://{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("Power ON command sent to ILO %s (attempt %d)", host, attempt) + return True + else: + logger.warning("Failed to power on ILO %s (attempt %d): %s", host, attempt, response.text) + except Exception as e: + logger.warning("ILO error on attempt %d for %s: %s", attempt, host, e) + + if attempt < retries: + logger.info("Retrying ILO %s in %s seconds...", host, delay) + time.sleep(delay) + + logger.error("All attempts failed to power on ILO client %s", host) + return False diff --git a/wolnut/main.py b/wolnut/main.py index 5f15293..e639303 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -5,6 +5,9 @@ from wolnut.monitor import get_ups_status, is_client_online from wolnut.wol import send_wol_packet from wolnut.idrac import power_on_idrac_client +from wolnut.ilo_old import power_on_ilo_client +from wolnut.sm_ipmi import power_on_sm_ipmi_client + logger = logging.getLogger("wolnut") @@ -83,67 +86,50 @@ def main(): config.wake_on.min_battery_percent) wol_being_sent = True - # Power on iDRAC clients - for idrac_client in config.idrac_clients: - power_on_idrac_client( - idrac_client.host, - idrac_client.username, - idrac_client.password, - idrac_client.verify_ssl - ) - - - for client in config.clients: - - if state_tracker.should_skip(client.name): - continue - - if not state_tracker.was_online_before_shutdown(client.name): - logger.info( - "Skipping WOL for %s: was not online before power loss", client.name) - state_tracker.mark_skip(client.name) - continue - - if state_tracker.is_online(client.name): - if client.name not in recorded_up_clients: - logger.info("%s is online.", client.name) - recorded_down_clients.discard(client.name) - recorded_up_clients.update({client.name}) - continue - - else: - recorded_down_clients.update({client.name}) - if state_tracker.should_attempt_wol( - 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): - state_tracker.mark_wol_sent(client.name) - else: - logger.debug( - "Waiting to retry WOL for %s (delay not reached)", client.name) - - if len(recorded_down_clients) == 0: - logger.info( - "Power Restored and all clients are back online!") + + # Power on all applicable clients + for client in config.clients: + if state_tracker.should_skip(client.name): + continue + if not state_tracker.was_online_before_shutdown(client.name): + logger.info("Skipping power on for %s: was not online before power loss", client.name) + state_tracker.mark_skip(client.name) + continue + if state_tracker.is_online(client.name): + logger.info("%s is already online.", client.name) + recorded_up_clients.add(client.name) + continue + + logger.info("Attempting to power on %s via %s...", client.name, client.type) + if power_on_client(client): + state_tracker.mark_wol_sent(client.name) + else: + logger.warning("Power on failed for %s", client.name) + + recorded_down_clients.clear() + for client in config.clients: + if not state_tracker.is_online(client.name) and not state_tracker.should_skip(client.name): + recorded_down_clients.add(client.name) + + if len(recorded_down_clients) == 0: + 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: + logger.warning( + "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) 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: - logger.warning( - "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) - restoration_event = False - restoration_event_start = None - wol_being_sent = False - else: - pass + pass elif not on_battery and not restoration_event: state_tracker.reset() @@ -157,5 +143,35 @@ def main(): time.sleep(2) + +def power_on_client(client): + if client.type == "wol": + return send_wol_packet(client.mac) + elif client.type == "idrac": + return power_on_idrac_client( + host=client.host, + username=client.username, + password=client.password, + verify_ssl=client.verify_ssl + ) + elif client.type == "ilo": + return power_on_ilo_client( + host=client.host, + username=client.username, + password=client.password, + verify_ssl=client.verify_ssl + ) + elif client.type == "sm_ipmi": + return power_on_sm_ipmi_client( + host=client.host, + username=client.username, + password=client.password, + ) + else: + logger.warning("Unknown client type for %s: %s", client.name, client.type) + return False + + + if __name__ == "__main__": main() diff --git a/wolnut/sm_ipmi.py b/wolnut/sm_ipmi.py new file mode 100644 index 0000000..4b6b0d2 --- /dev/null +++ b/wolnut/sm_ipmi.py @@ -0,0 +1,22 @@ +import subprocess +import logging + +logger = logging.getLogger("wolnut") + +def power_on_sm_ipmi_client(host, username, password): + try: + result = subprocess.run([ + "ipmitool", "-I", "lanplus", "-H", host, + "-U", username, "-P", password, + "chassis", "power", "on" + ], capture_output=True, text=True, timeout=10) + + if result.returncode == 0: + logger.info("Supermicro IPMI: Powered on %s", host) + return True + else: + logger.error("Supermicro IPMI: Failed to power on %s: %s", host, result.stderr.strip()) + return False + except Exception as e: + logger.error("Supermicro IPMI error for %s: %s", host, e) + return False \ No newline at end of file From c2246d616b3f7cca1adf11fb83d5c64f879c32a9 Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Tue, 24 Jun 2025 15:13:45 -0500 Subject: [PATCH 15/25] updated dockerfile and requirements --- Dockerfile | 1 + requirements.txt | 1 + 2 files changed, 2 insertions(+) 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/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 From 081b3ed9a32f865c33302b8cb6ac83873326111e Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Tue, 24 Jun 2025 16:10:11 -0500 Subject: [PATCH 16/25] Update main.py --- wolnut/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wolnut/main.py b/wolnut/main.py index e639303..a88d110 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -5,7 +5,7 @@ from wolnut.monitor import get_ups_status, is_client_online from wolnut.wol import send_wol_packet from wolnut.idrac import power_on_idrac_client -from wolnut.ilo_old import power_on_ilo_client +from wolnut.ilo import power_on_ilo_client from wolnut.sm_ipmi import power_on_sm_ipmi_client From 3dde9cfd018208dfb29c0556f9709350017c486f Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Tue, 24 Jun 2025 22:12:42 -0500 Subject: [PATCH 17/25] host differentiation add config parameter to differentiate between the host ip and the management interface ip --- example.config.yaml | 1 + wolnut/config.py | 2 ++ wolnut/idrac.py | 24 ++++++++++++------------ wolnut/ilo.py | 24 ++++++++++++------------ wolnut/main.py | 6 +++--- wolnut/sm_ipmi.py | 10 +++++----- 6 files changed, 35 insertions(+), 32 deletions(-) diff --git a/example.config.yaml b/example.config.yaml index 648aa21..8dc0a93 100644 --- a/example.config.yaml +++ b/example.config.yaml @@ -28,6 +28,7 @@ clients: - 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.10 username: root password: calvin verify_ssl: false diff --git a/wolnut/config.py b/wolnut/config.py index e04b806..17a657f 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -128,6 +128,8 @@ def validate_config(raw: dict): for key in ["username", "password"]: if key not in client: raise ValueError(f"{client['type']} client '{client['name']}' is missing '{key}'") + if "ipmi_host" not in raw: + raise ValueError(f"Client '{raw.get('name', '')}' of type '{client_type}' must have 'ipmi_host'") else: raise ValueError(f"Unsupported client type: {client['type']}") diff --git a/wolnut/idrac.py b/wolnut/idrac.py index 7216edf..52867f5 100644 --- a/wolnut/idrac.py +++ b/wolnut/idrac.py @@ -8,8 +8,8 @@ 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(host, username, password, verify_ssl): - url = f"https://{host}{POWER_STATE_URI}" +def get_idrac_power_state(ipmi_host, username, password, verify_ssl): + url = f"https://{ipmi_host}{POWER_STATE_URI}" try: response = requests.get( @@ -21,18 +21,18 @@ def get_idrac_power_state(host, username, password, verify_ssl): if response.status_code == 200: return response.json().get("PowerState", None) except Exception as e: - logger.warning("Could not get iDRAC power state for %s: %s", host, e) + logger.warning("Could not get iDRAC power state for %s: %s", ipmi_host, e) return None -def power_on_idrac_client(host, username, password, verify_ssl, retries=3, delay=5): +def power_on_idrac_client(ipmi_host, username, password, verify_ssl, retries=3, delay=5): for attempt in range(1, retries + 1): - state = get_idrac_power_state(host, username, password, verify_ssl) + state = get_idrac_power_state(ipmi_host, username, password, verify_ssl) if state == "On": - logger.info("iDRAC %s already powered on", host) + logger.info("iDRAC %s already powered on", ipmi_host) return True - url = f"https://{host}{POWER_ACTION_URI}" + url = f"https://{ipmi_host}{POWER_ACTION_URI}" payload = {"ResetType": "On"} try: @@ -45,17 +45,17 @@ def power_on_idrac_client(host, username, password, verify_ssl, retries=3, delay ) if response.status_code in [200, 202, 204]: - logger.info("Power ON command sent to iDRAC %s (attempt %d)", host, attempt) + logger.info("Power ON command sent to iDRAC %s (attempt %d)", ipmi_host, attempt) return True else: logger.warning("Failed to power on iDRAC %s (attempt %d): %s", - host, attempt, response.text) + ipmi_host, attempt, response.text) except Exception as e: - logger.warning("iDRAC error on attempt %d for %s: %s", attempt, host, e) + logger.warning("iDRAC error on attempt %d for %s: %s", attempt, ipmi_host, e) if attempt < retries: - logger.info("Retrying iDRAC %s in %s seconds...", host, delay) + logger.info("Retrying iDRAC %s in %s seconds...", ipmi_host, delay) time.sleep(delay) - logger.error("All attempts failed to power on iDRAC client %s", host) + logger.error("All attempts failed to power on iDRAC client %s", ipmi_host) return False diff --git a/wolnut/ilo.py b/wolnut/ilo.py index 12e7fcf..a2a5cdf 100644 --- a/wolnut/ilo.py +++ b/wolnut/ilo.py @@ -9,8 +9,8 @@ POWER_ACTION_URI = "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" POWER_STATE_URI = "/redfish/v1/Systems/1" -def get_ilo_power_state(host, username, password): - url = f"https://{host}{POWER_STATE_URI}" +def get_ilo_power_state(ipmi_host, username, password): + url = f"https://{ipmi_host}{POWER_STATE_URI}" try: response = requests.get( url, @@ -21,17 +21,17 @@ def get_ilo_power_state(host, username, password): if response.status_code == 200: return response.json().get("PowerState", None) except Exception as e: - logger.warning("Could not get ILO power state for %s: %s", host, e) + logger.warning("Could not get ILO power state for %s: %s", ipmi_host, e) return None -def power_on_ilo_client(host, username, password, retries=3, delay=5): +def power_on_ilo_client(ipmi_host, username, password, retries=3, delay=5): for attempt in range(1, retries + 1): - state = get_ilo_power_state(host, username, password) + state = get_ilo_power_state(ipmi_host, username, password) if state == "On": - logger.info("ILO %s already powered on", host) + logger.info("ILO %s already powered on", ipmi_host) return True - url = f"https://{host}{POWER_ACTION_URI}" + url = f"https://{ipmi_host}{POWER_ACTION_URI}" payload = {"ResetType": "On"} try: @@ -43,16 +43,16 @@ def power_on_ilo_client(host, username, password, retries=3, delay=5): timeout=10 ) if response.status_code in [200, 202, 204]: - logger.info("Power ON command sent to ILO %s (attempt %d)", host, attempt) + logger.info("Power ON command sent to ILO %s (attempt %d)", ipmi_host, attempt) return True else: - logger.warning("Failed to power on ILO %s (attempt %d): %s", host, attempt, response.text) + logger.warning("Failed to power on ILO %s (attempt %d): %s", ipmi_host, attempt, response.text) except Exception as e: - logger.warning("ILO error on attempt %d for %s: %s", attempt, host, e) + logger.warning("ILO error on attempt %d for %s: %s", attempt, ipmi_host, e) if attempt < retries: - logger.info("Retrying ILO %s in %s seconds...", host, delay) + logger.info("Retrying ILO %s in %s seconds...", ipmi_host, delay) time.sleep(delay) - logger.error("All attempts failed to power on ILO client %s", host) + logger.error("All attempts failed to power on ILO client %s", ipmi_host) return False diff --git a/wolnut/main.py b/wolnut/main.py index a88d110..c53ad9c 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -149,21 +149,21 @@ def power_on_client(client): return send_wol_packet(client.mac) elif client.type == "idrac": return power_on_idrac_client( - host=client.host, + ipmi_host=client.ipmi_host, username=client.username, password=client.password, verify_ssl=client.verify_ssl ) elif client.type == "ilo": return power_on_ilo_client( - host=client.host, + ipmi_host=client.ipmi_host, username=client.username, password=client.password, verify_ssl=client.verify_ssl ) elif client.type == "sm_ipmi": return power_on_sm_ipmi_client( - host=client.host, + ipmi_host=client.ipmi_host, username=client.username, password=client.password, ) diff --git a/wolnut/sm_ipmi.py b/wolnut/sm_ipmi.py index 4b6b0d2..01b5862 100644 --- a/wolnut/sm_ipmi.py +++ b/wolnut/sm_ipmi.py @@ -3,20 +3,20 @@ logger = logging.getLogger("wolnut") -def power_on_sm_ipmi_client(host, username, password): +def power_on_sm_ipmi_client(ipmi_host, username, password): try: result = subprocess.run([ - "ipmitool", "-I", "lanplus", "-H", host, + "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("Supermicro IPMI: Powered on %s", host) + logger.info("Supermicro IPMI: Powered on %s", ipmi_host) return True else: - logger.error("Supermicro IPMI: Failed to power on %s: %s", host, result.stderr.strip()) + logger.error("Supermicro IPMI: Failed to power on %s: %s", ipmi_host, result.stderr.strip()) return False except Exception as e: - logger.error("Supermicro IPMI error for %s: %s", host, e) + logger.error("Supermicro IPMI error for %s: %s", ipmi_host, e) return False \ No newline at end of file From 3835d70b72831b4b9ac954fa3b06aa47b34fda1a Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Wed, 25 Jun 2025 12:09:21 -0500 Subject: [PATCH 18/25] Update main.py reverted power on logic --- wolnut/main.py | 89 +++++++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/wolnut/main.py b/wolnut/main.py index c53ad9c..b1bdd4b 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -85,51 +85,60 @@ def main(): logger.info("Power restored and battery >= %s%%. Preparing to send WOL...", config.wake_on.min_battery_percent) wol_being_sent = True - - - # Power on all applicable clients - for client in config.clients: - if state_tracker.should_skip(client.name): - continue - if not state_tracker.was_online_before_shutdown(client.name): - logger.info("Skipping power on for %s: was not online before power loss", client.name) - state_tracker.mark_skip(client.name) - continue - if state_tracker.is_online(client.name): - logger.info("%s is already online.", client.name) - recorded_up_clients.add(client.name) - continue - - logger.info("Attempting to power on %s via %s...", client.name, client.type) - if power_on_client(client): - state_tracker.mark_wol_sent(client.name) - else: - logger.warning("Power on failed for %s", client.name) - - recorded_down_clients.clear() - for client in config.clients: - if not state_tracker.is_online(client.name) and not state_tracker.should_skip(client.name): - recorded_down_clients.add(client.name) - if len(recorded_down_clients) == 0: - 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: - logger.warning( - "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) + for client in config.clients: + + if state_tracker.should_skip(client.name): + continue + + if not state_tracker.was_online_before_shutdown(client.name): + logger.info( + "Skipping power on for %s: was not online before power loss", client.name) + state_tracker.mark_skip(client.name) + continue + + if state_tracker.is_online(client.name): + if client.name not in recorded_up_clients: + logger.info("%s is online.", client.name) + recorded_down_clients.discard(client.name) + recorded_up_clients.update({client.name}) + continue + + else: + recorded_down_clients.update({client.name}) + if state_tracker.should_attempt_wol( + client.name, + config.wake_on.reattempt_delay + ): + logger.info( + "Attempting to power on %s via %s...", client.name, client.type) + if power_on_client(client): + state_tracker.mark_wol_sent(client.name) + else: + logger.debug( + "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!") restoration_event = False restoration_event_start = None + state_tracker.reset() wol_being_sent = False else: - pass + 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.") + for client in recorded_down_clients: + logger.warning( + "%s failed to come back online within timeout period.", client) + restoration_event = False + restoration_event_start = None + wol_being_sent = False + else: + pass elif not on_battery and not restoration_event: state_tracker.reset() From 3c3fd55621bff011415c8206f26fe016f3e360bc Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Wed, 25 Jun 2025 12:33:25 -0500 Subject: [PATCH 19/25] change ilo and config Fixed some validation bugs in the config.py and added the verify ssl to the ilo.py --- wolnut/config.py | 5 +++-- wolnut/ilo.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/wolnut/config.py b/wolnut/config.py index 17a657f..cc5bf8a 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -29,6 +29,7 @@ class ClientConfig: name: str type: str # e.g., "wol", "idrac", "ilo", "sm_ipmi" host: str + ipmi_host: str | None = None mac: str | None = None # Only for WOL username: str | None = None # Only for idrac/ilo password: str | None = None @@ -128,8 +129,8 @@ def validate_config(raw: dict): for key in ["username", "password"]: if key not in client: raise ValueError(f"{client['type']} client '{client['name']}' is missing '{key}'") - if "ipmi_host" not in raw: - raise ValueError(f"Client '{raw.get('name', '')}' of type '{client_type}' must have 'ipmi_host'") + if "ipmi_host" not in client: + raise ValueError(f"Client '{client['name']}' of type {client['type']} must have 'ipmi_host'") else: raise ValueError(f"Unsupported client type: {client['type']}") diff --git a/wolnut/ilo.py b/wolnut/ilo.py index a2a5cdf..a1dfc36 100644 --- a/wolnut/ilo.py +++ b/wolnut/ilo.py @@ -9,13 +9,13 @@ POWER_ACTION_URI = "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" POWER_STATE_URI = "/redfish/v1/Systems/1" -def get_ilo_power_state(ipmi_host, username, password): +def get_ilo_power_state(ipmi_host, username, password,verify_ssl): url = f"https://{ipmi_host}{POWER_STATE_URI}" try: response = requests.get( url, auth=HTTPBasicAuth(username, password), - verify=False, + verify=verify_ssl, timeout=10 ) if response.status_code == 200: From aea6235ad01609acdbffca8468ddc7831fee0719 Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Thu, 26 Jun 2025 09:03:45 -0500 Subject: [PATCH 20/25] Update config.py --- wolnut/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/wolnut/config.py b/wolnut/config.py index cc5bf8a..64e222a 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -121,7 +121,6 @@ def validate_config(raw: dict): for i, client in enumerate(raw.get("clients", [])): if "name" not in client or "host" not in client or "type" not in client: raise ValueError(f"Client #{i} is missing 'name', 'host', or 'type'") - if client["type"] == "wol": if "mac" not in client: raise ValueError(f"WOL client '{client['name']}' is missing 'mac'") From a3b005fa084d412083b075af96ea4f89b16b38f8 Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Fri, 27 Jun 2025 08:34:21 -0500 Subject: [PATCH 21/25] Update main.py --- wolnut/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wolnut/main.py b/wolnut/main.py index b1bdd4b..9aaee39 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -32,7 +32,7 @@ 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)) From fec3b9b1c65a010a81a94fd2b8a43fd04ac94966 Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:57:01 -0700 Subject: [PATCH 22/25] refactor alternate client support to maintain backwards compatibility --- example.config.yaml | 23 +- wolnut/client/__init__.py | 303 ++++++++++++++++++++++++++ wolnut/{ => client}/idrac.py | 40 ++-- wolnut/{ => client}/ilo.py | 46 ++-- wolnut/client/sm_ipmi.py | 39 ++++ wolnut/{ => client}/wol.py | 2 +- wolnut/config.py | 67 ++---- wolnut/main.py | 99 ++++----- wolnut/sm_ipmi.py | 22 -- wolnut/tests/__init__.py | 1 + wolnut/tests/test_all_client_types.py | 71 ++++++ wolnut/tests/test_config_loading.py | 132 +++++++++++ wolnut/tests/test_tagged_union.py | 55 +++++ 13 files changed, 740 insertions(+), 160 deletions(-) create mode 100644 wolnut/client/__init__.py rename wolnut/{ => client}/idrac.py (53%) rename wolnut/{ => client}/ilo.py (52%) create mode 100644 wolnut/client/sm_ipmi.py rename wolnut/{ => client}/wol.py (89%) delete mode 100644 wolnut/sm_ipmi.py create mode 100644 wolnut/tests/__init__.py create mode 100644 wolnut/tests/test_all_client_types.py create mode 100644 wolnut/tests/test_config_loading.py create mode 100644 wolnut/tests/test_tagged_union.py diff --git a/example.config.yaml b/example.config.yaml index 8dc0a93..8fb228d 100644 --- a/example.config.yaml +++ b/example.config.yaml @@ -25,11 +25,30 @@ clients: 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.10 + ipmi_host: 192.168.0.200 username: root password: calvin - verify_ssl: false + 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/wolnut/client/__init__.py b/wolnut/client/__init__.py new file mode 100644 index 0000000..4d7bada --- /dev/null +++ b/wolnut/client/__init__.py @@ -0,0 +1,303 @@ +""" +A module defining various client configurations for different types of clients +such as WOL, iDRAC, iLO, and Supermicro IPMI. +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import override + +from wolnut.client.idrac import power_on_idrac_client +from wolnut.client.ilo import power_on_ilo_client +from wolnut.client.sm_ipmi import power_on_sm_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 "sm_ipmi": + SmIpmiClientConfig._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 SmIpmiClientConfig(BaseClientConfig): + host: str + ipmi_host: str + username: str + password: str + + @property + def type(self) -> str: + return "sm_ipmi" + + @override + @classmethod + def _validate_subclass_fields(cls, raw: dict) -> None: + # Validate Supermicro IPMI specific fields + if "host" not in raw or not raw["host"]: + raise ValueError( + f"Supermicro IPMI client '{raw['name']}' is missing required field: host" + ) + if "ipmi_host" not in raw or not raw["ipmi_host"]: + raise ValueError( + f"Supermicro IPMI client '{raw['name']}' is missing required field: ipmi_host" + ) + if "username" not in raw or not raw["username"]: + raise ValueError( + f"Supermicro IPMI client '{raw['name']}' is missing required field: username" + ) + if "password" not in raw or not raw["password"]: + raise ValueError( + f"Supermicro IPMI client '{raw['name']}' is missing required field: password" + ) + + @override + def send_power_on_signal(self) -> bool: + """ + Send a power-on signal to the Supermicro IPMI client. + + Returns: + bool: True if the power-on command was sent successfully, False otherwise. + """ + return power_on_sm_ipmi_client( + self.ipmi_host, + self.username, + self.password, + ) + + +def create_client_config(raw: dict) -> BaseClientConfig: + """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 "sm_ipmi": + return SmIpmiClientConfig(**raw_copy) + case _: + raise ValueError(f"Unknown client type: {client_type}") diff --git a/wolnut/idrac.py b/wolnut/client/idrac.py similarity index 53% rename from wolnut/idrac.py rename to wolnut/client/idrac.py index 52867f5..6cf4399 100644 --- a/wolnut/idrac.py +++ b/wolnut/client/idrac.py @@ -8,28 +8,35 @@ 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, username, password, verify_ssl): + +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 + 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("Could not get iDRAC power state for %s: %s", ipmi_host, e) + logger.warning(f"Could not get iDRAC power state for {ipmi_host}: {e}") return None -def power_on_idrac_client(ipmi_host, username, password, verify_ssl, retries=3, delay=5): +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("iDRAC %s already powered on", ipmi_host) + logger.info(f"iDRAC {ipmi_host} already powered on") return True url = f"https://{ipmi_host}{POWER_ACTION_URI}" @@ -41,21 +48,24 @@ def power_on_idrac_client(ipmi_host, username, password, verify_ssl, retries=3, json=payload, auth=HTTPBasicAuth(username, password), verify=verify_ssl, - timeout=10 + timeout=10, ) if response.status_code in [200, 202, 204]: - logger.info("Power ON command sent to iDRAC %s (attempt %d)", ipmi_host, attempt) + logger.info( + f"Power ON command sent to iDRAC {ipmi_host} (attempt {attempt})" + ) return True else: - logger.warning("Failed to power on iDRAC %s (attempt %d): %s", - ipmi_host, attempt, response.text) + logger.warning( + f"Failed to power on iDRAC {ipmi_host} (attempt {attempt}): {response.text}" + ) except Exception as e: - logger.warning("iDRAC error on attempt %d for %s: %s", attempt, ipmi_host, e) + logger.warning(f"iDRAC error on attempt {attempt} for {ipmi_host}: {e}") if attempt < retries: - logger.info("Retrying iDRAC %s in %s seconds...", ipmi_host, delay) + logger.info(f"Retrying iDRAC {ipmi_host} in {delay} seconds...") time.sleep(delay) - logger.error("All attempts failed to power on iDRAC client %s", ipmi_host) + logger.error(f"All attempts failed to power on iDRAC client {ipmi_host}") return False diff --git a/wolnut/ilo.py b/wolnut/client/ilo.py similarity index 52% rename from wolnut/ilo.py rename to wolnut/client/ilo.py index a1dfc36..e931cfc 100644 --- a/wolnut/ilo.py +++ b/wolnut/client/ilo.py @@ -1,34 +1,42 @@ +import logging +import time import requests from requests.auth import HTTPBasicAuth -import logging -import time 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, username, password,verify_ssl): + +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 + 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("Could not get ILO power state for %s: %s", ipmi_host, e) + logger.warning(f"Could not get ILO power state for {ipmi_host}: {e}") return None -def power_on_ilo_client(ipmi_host, username, password, retries=3, delay=5): + +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) + state = get_ilo_power_state(ipmi_host, username, password, verify_ssl) if state == "On": - logger.info("ILO %s already powered on", ipmi_host) + logger.info(f"ILO {ipmi_host} already powered on") return True url = f"https://{ipmi_host}{POWER_ACTION_URI}" @@ -40,19 +48,23 @@ def power_on_ilo_client(ipmi_host, username, password, retries=3, delay=5): json=payload, auth=HTTPBasicAuth(username, password), verify=False, - timeout=10 + timeout=10, ) if response.status_code in [200, 202, 204]: - logger.info("Power ON command sent to ILO %s (attempt %d)", ipmi_host, attempt) + logger.info( + f"Power ON command sent to ILO {ipmi_host} (attempt {attempt})" + ) return True else: - logger.warning("Failed to power on ILO %s (attempt %d): %s", ipmi_host, attempt, response.text) + logger.warning( + f"Failed to power on ILO {ipmi_host} (attempt {attempt}): {response.text}" + ) except Exception as e: - logger.warning("ILO error on attempt %d for %s: %s", attempt, ipmi_host, e) + logger.warning(f"ILO error on attempt {attempt} for {ipmi_host}: {e}") if attempt < retries: - logger.info("Retrying ILO %s in %s seconds...", ipmi_host, delay) + logger.info(f"Retrying ILO {ipmi_host} in {delay} seconds...") time.sleep(delay) - logger.error("All attempts failed to power on ILO client %s", ipmi_host) + logger.error(f"All attempts failed to power on ILO client {ipmi_host}") return False diff --git a/wolnut/client/sm_ipmi.py b/wolnut/client/sm_ipmi.py new file mode 100644 index 0000000..f2832a1 --- /dev/null +++ b/wolnut/client/sm_ipmi.py @@ -0,0 +1,39 @@ +import subprocess +import logging + +logger = logging.getLogger("wolnut") + + +def power_on_sm_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"Supermicro IPMI: Powered on {ipmi_host}") + return True + else: + logger.error( + f"Supermicro IPMI: Failed to power on {ipmi_host}: {result.stderr.strip()}" + ) + return False + except Exception as e: + logger.error(f"Supermicro IPMI 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 64e222a..37919e9 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -1,10 +1,18 @@ 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, + create_client_config, +) +from wolnut.client import WolClientConfig +from wolnut.client import IdracClientConfig +from wolnut.client import IloClientConfig +from wolnut.client import SmIpmiClientConfig logger = logging.getLogger("wolnut") @@ -24,17 +32,10 @@ class WakeOnConfig: reattempt_delay: int = 30 -@dataclass -class ClientConfig: - name: str - type: str # e.g., "wol", "idrac", "ilo", "sm_ipmi" - host: str - ipmi_host: str | None = None - mac: str | None = None # Only for WOL - username: str | None = None # Only for idrac/ilo - password: str | None = None - verify_ssl: bool = False # Optional for idrac/ilo - +# Tagged union type for all client configurations +ClientConfig = Union[ + WolClientConfig, IdracClientConfig, IloClientConfig, SmIpmiClientConfig +] @dataclass @@ -46,9 +47,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" @@ -79,20 +78,9 @@ def load_config(path: str = None) -> WolnutConfig: clients = [] for raw_client in raw["clients"]: try: - client_type = raw_client.get("type") - if client_type == "wol": - mac = raw_client.get("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 for {raw_client['name']}") - raw_client["mac"] = resolved_mac - logger.info("MAC for %s: %s", raw_client['name'], resolved_mac) - elif not validate_mac_format(mac): - raise ValueError(f"Invalid MAC format for {raw_client['name']}: {mac}") - - clients.append(ClientConfig(**raw_client)) + 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) @@ -101,12 +89,14 @@ def load_config(path: str = None) -> WolnutConfig: 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 @@ -119,17 +109,4 @@ def validate_config(raw: dict): raise ValueError("Missing required field: 'nut.ups'") for i, client in enumerate(raw.get("clients", [])): - if "name" not in client or "host" not in client or "type" not in client: - raise ValueError(f"Client #{i} is missing 'name', 'host', or 'type'") - if client["type"] == "wol": - if "mac" not in client: - raise ValueError(f"WOL client '{client['name']}' is missing 'mac'") - elif client["type"] in ["idrac", "ilo", "sm_ipmi"]: - for key in ["username", "password"]: - if key not in client: - raise ValueError(f"{client['type']} client '{client['name']}' is missing '{key}'") - if "ipmi_host" not in client: - raise ValueError(f"Client '{client['name']}' of type {client['type']} must have 'ipmi_host'") - else: - raise ValueError(f"Unsupported client type: {client['type']}") - + BaseClientConfig.validate_raw(client) diff --git a/wolnut/main.py b/wolnut/main.py index 9aaee39..5324031 100644 --- a/wolnut/main.py +++ b/wolnut/main.py @@ -3,11 +3,6 @@ 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 -from wolnut.idrac import power_on_idrac_client -from wolnut.ilo import power_on_ilo_client -from wolnut.sm_ipmi import power_on_sm_ipmi_client - logger = logging.getLogger("wolnut") @@ -32,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: @@ -73,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: @@ -93,7 +97,9 @@ def main(): if not state_tracker.was_online_before_shutdown(client.name): logger.info( - "Skipping power on 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 @@ -107,33 +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( - "Attempting to power on %s via %s...", client.name, client.type) - if power_on_client(client): + "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 power on attemt 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 @@ -152,35 +165,5 @@ def main(): time.sleep(2) - -def power_on_client(client): - if client.type == "wol": - return send_wol_packet(client.mac) - elif client.type == "idrac": - return power_on_idrac_client( - ipmi_host=client.ipmi_host, - username=client.username, - password=client.password, - verify_ssl=client.verify_ssl - ) - elif client.type == "ilo": - return power_on_ilo_client( - ipmi_host=client.ipmi_host, - username=client.username, - password=client.password, - verify_ssl=client.verify_ssl - ) - elif client.type == "sm_ipmi": - return power_on_sm_ipmi_client( - ipmi_host=client.ipmi_host, - username=client.username, - password=client.password, - ) - else: - logger.warning("Unknown client type for %s: %s", client.name, client.type) - return False - - - if __name__ == "__main__": main() diff --git a/wolnut/sm_ipmi.py b/wolnut/sm_ipmi.py deleted file mode 100644 index 01b5862..0000000 --- a/wolnut/sm_ipmi.py +++ /dev/null @@ -1,22 +0,0 @@ -import subprocess -import logging - -logger = logging.getLogger("wolnut") - -def power_on_sm_ipmi_client(ipmi_host, username, password): - 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("Supermicro IPMI: Powered on %s", ipmi_host) - return True - else: - logger.error("Supermicro IPMI: Failed to power on %s: %s", ipmi_host, result.stderr.strip()) - return False - except Exception as e: - logger.error("Supermicro IPMI error for %s: %s", ipmi_host, e) - return False \ No newline at end of file diff --git a/wolnut/tests/__init__.py b/wolnut/tests/__init__.py new file mode 100644 index 0000000..2959d9a --- /dev/null +++ b/wolnut/tests/__init__.py @@ -0,0 +1 @@ +# Tests for wolnut diff --git a/wolnut/tests/test_all_client_types.py b/wolnut/tests/test_all_client_types.py new file mode 100644 index 0000000..975e3a9 --- /dev/null +++ b/wolnut/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, + SmIpmiClientConfig, + 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_sm_ipmi_client(): + # Test Supermicro IPMI client + sm_data = { + "name": "Supermicro Server", + "type": "sm_ipmi", + "host": "192.168.1.105", + "ipmi_host": "192.168.1.106", + "username": "admin", + "password": "secret", + } + sm_client = create_client_config(sm_data) + assert isinstance(sm_client, SmIpmiClientConfig) + assert sm_client.type == "sm_ipmi" diff --git a/wolnut/tests/test_config_loading.py b/wolnut/tests/test_config_loading.py new file mode 100644 index 0000000..a0c99e8 --- /dev/null +++ b/wolnut/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, + SmIpmiClientConfig, + 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": "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, + }, + ], + } + + 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, + ), + SmIpmiClientConfig( + 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/wolnut/tests/test_tagged_union.py b/wolnut/tests/test_tagged_union.py new file mode 100644 index 0000000..fd500ac --- /dev/null +++ b/wolnut/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}") From fd6bf6982e15bedafb853d875afea05a9002b302 Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:25:07 -0700 Subject: [PATCH 23/25] fix: resolve mypy lints --- wolnut/client/__init__.py | 10 ++++++++-- wolnut/config.py | 9 ++------- wolnut/monitor.py | 15 +++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/wolnut/client/__init__.py b/wolnut/client/__init__.py index 4d7bada..cf2ff41 100644 --- a/wolnut/client/__init__.py +++ b/wolnut/client/__init__.py @@ -6,7 +6,7 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import override +from typing import Union, override from wolnut.client.idrac import power_on_idrac_client from wolnut.client.ilo import power_on_ilo_client @@ -281,7 +281,13 @@ def send_power_on_signal(self) -> bool: ) -def create_client_config(raw: dict) -> BaseClientConfig: +# Tagged union type for all client configurations +ClientConfig = Union[ + WolClientConfig, IdracClientConfig, IloClientConfig, SmIpmiClientConfig +] + + +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") diff --git a/wolnut/config.py b/wolnut/config.py index 37919e9..bdef6dd 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -7,6 +7,7 @@ from wolnut.client import ( BaseClientConfig, + ClientConfig, create_client_config, ) from wolnut.client import WolClientConfig @@ -32,12 +33,6 @@ class WakeOnConfig: reattempt_delay: int = 30 -# Tagged union type for all client configurations -ClientConfig = Union[ - WolClientConfig, IdracClientConfig, IloClientConfig, SmIpmiClientConfig -] - - @dataclass class WolnutConfig: nut: NutConfig @@ -75,7 +70,7 @@ def load_config(path: str | None = None) -> WolnutConfig: # LOGGING... - clients = [] + clients: list[ClientConfig] = [] for raw_client in raw["clients"]: try: client = create_client_config(raw_client) 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 From 7b272fb0f8ee625bc5304e00c04312357e3134bb Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:25:40 -0700 Subject: [PATCH 24/25] refactor: move the tests --- {wolnut/tests => tests}/__init__.py | 0 {wolnut/tests => tests}/test_all_client_types.py | 0 {wolnut/tests => tests}/test_config_loading.py | 0 {wolnut/tests => tests}/test_tagged_union.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {wolnut/tests => tests}/__init__.py (100%) rename {wolnut/tests => tests}/test_all_client_types.py (100%) rename {wolnut/tests => tests}/test_config_loading.py (100%) rename {wolnut/tests => tests}/test_tagged_union.py (100%) diff --git a/wolnut/tests/__init__.py b/tests/__init__.py similarity index 100% rename from wolnut/tests/__init__.py rename to tests/__init__.py diff --git a/wolnut/tests/test_all_client_types.py b/tests/test_all_client_types.py similarity index 100% rename from wolnut/tests/test_all_client_types.py rename to tests/test_all_client_types.py diff --git a/wolnut/tests/test_config_loading.py b/tests/test_config_loading.py similarity index 100% rename from wolnut/tests/test_config_loading.py rename to tests/test_config_loading.py diff --git a/wolnut/tests/test_tagged_union.py b/tests/test_tagged_union.py similarity index 100% rename from wolnut/tests/test_tagged_union.py rename to tests/test_tagged_union.py From 28f1bf42e94d771471d0c777d873990f42700366 Mon Sep 17 00:00:00 2001 From: Connor P Bourque Date: Tue, 8 Jul 2025 19:45:10 -0500 Subject: [PATCH 25/25] Refactoerd to make sm_ipmi just ipmi since this will work woth more than just supermicro servers --- tests/test_all_client_types.py | 18 +++++++-------- tests/test_config_loading.py | 6 ++--- wolnut/client/__init__.py | 32 +++++++++++++-------------- wolnut/client/{sm_ipmi.py => ipmi.py} | 8 +++---- wolnut/config.py | 2 +- 5 files changed, 33 insertions(+), 33 deletions(-) rename wolnut/client/{sm_ipmi.py => ipmi.py} (70%) diff --git a/tests/test_all_client_types.py b/tests/test_all_client_types.py index 975e3a9..71d7b2c 100644 --- a/tests/test_all_client_types.py +++ b/tests/test_all_client_types.py @@ -5,7 +5,7 @@ from wolnut.client import ( IdracClientConfig, IloClientConfig, - SmIpmiClientConfig, + IpmiClientConfig, WolClientConfig, ) from wolnut.config import create_client_config @@ -56,16 +56,16 @@ def test_ilo_client(): assert ilo_client.type == "ilo" -def test_sm_ipmi_client(): - # Test Supermicro IPMI client - sm_data = { - "name": "Supermicro Server", - "type": "sm_ipmi", +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", } - sm_client = create_client_config(sm_data) - assert isinstance(sm_client, SmIpmiClientConfig) - assert sm_client.type == "sm_ipmi" + 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 index a0c99e8..207ebdc 100644 --- a/tests/test_config_loading.py +++ b/tests/test_config_loading.py @@ -10,7 +10,7 @@ from wolnut.client import ( IdracClientConfig, IloClientConfig, - SmIpmiClientConfig, + IpmiClientConfig, WolClientConfig, ) from wolnut.config import NutConfig, WakeOnConfig, WolnutConfig, load_config @@ -57,7 +57,7 @@ def test_config_loading(): }, { "name": "Supermicro Server", - "type": "sm_ipmi", + "type": "ipmi", "host": "192.168.0.104", "ipmi_host": "192.168.0.204", "username": "admin", @@ -100,7 +100,7 @@ def test_config_loading(): password="calvin", verify_ssl=True, ), - SmIpmiClientConfig( + IpmiClientConfig( name="Supermicro Server", host="192.168.0.104", ipmi_host="192.168.0.204", diff --git a/wolnut/client/__init__.py b/wolnut/client/__init__.py index cf2ff41..e05c89a 100644 --- a/wolnut/client/__init__.py +++ b/wolnut/client/__init__.py @@ -1,6 +1,6 @@ """ A module defining various client configurations for different types of clients -such as WOL, iDRAC, iLO, and Supermicro IPMI. +such as WOL, iDRAC, iLO, and IPMI Host. """ import logging @@ -10,7 +10,7 @@ from wolnut.client.idrac import power_on_idrac_client from wolnut.client.ilo import power_on_ilo_client -from wolnut.client.sm_ipmi import power_on_sm_ipmi_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 @@ -40,8 +40,8 @@ def validate_raw(cls, raw: dict, i: int | None = None) -> None: IdracClientConfig._validate_subclass_fields(raw) case "ilo": IloClientConfig._validate_subclass_fields(raw) - case "sm_ipmi": - SmIpmiClientConfig._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}" @@ -235,7 +235,7 @@ def send_power_on_signal(self) -> bool: @dataclass -class SmIpmiClientConfig(BaseClientConfig): +class IpmiClientConfig(BaseClientConfig): host: str ipmi_host: str username: str @@ -243,38 +243,38 @@ class SmIpmiClientConfig(BaseClientConfig): @property def type(self) -> str: - return "sm_ipmi" + return "ipmi" @override @classmethod def _validate_subclass_fields(cls, raw: dict) -> None: - # Validate Supermicro IPMI specific fields + # Validate IPMI Host specific fields if "host" not in raw or not raw["host"]: raise ValueError( - f"Supermicro IPMI client '{raw['name']}' is missing required field: host" + 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"Supermicro IPMI client '{raw['name']}' is missing required field: ipmi_host" + f"IPMI Host client '{raw['name']}' is missing required field: ipmi_host" ) if "username" not in raw or not raw["username"]: raise ValueError( - f"Supermicro IPMI client '{raw['name']}' is missing required field: username" + f"IPMI Host client '{raw['name']}' is missing required field: username" ) if "password" not in raw or not raw["password"]: raise ValueError( - f"Supermicro IPMI client '{raw['name']}' is missing required field: password" + 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 Supermicro IPMI client. + 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_sm_ipmi_client( + return power_on_ipmi_client( self.ipmi_host, self.username, self.password, @@ -283,7 +283,7 @@ def send_power_on_signal(self) -> bool: # Tagged union type for all client configurations ClientConfig = Union[ - WolClientConfig, IdracClientConfig, IloClientConfig, SmIpmiClientConfig + WolClientConfig, IdracClientConfig, IloClientConfig, IpmiClientConfig ] @@ -303,7 +303,7 @@ def create_client_config(raw: dict) -> ClientConfig: return IdracClientConfig(**raw_copy) case "ilo": return IloClientConfig(**raw_copy) - case "sm_ipmi": - return SmIpmiClientConfig(**raw_copy) + case "ipmi": + return IpmiClientConfig(**raw_copy) case _: raise ValueError(f"Unknown client type: {client_type}") diff --git a/wolnut/client/sm_ipmi.py b/wolnut/client/ipmi.py similarity index 70% rename from wolnut/client/sm_ipmi.py rename to wolnut/client/ipmi.py index f2832a1..bf979cc 100644 --- a/wolnut/client/sm_ipmi.py +++ b/wolnut/client/ipmi.py @@ -4,7 +4,7 @@ logger = logging.getLogger("wolnut") -def power_on_sm_ipmi_client(ipmi_host: str, username: str, password: str): +def power_on_ipmi_client(ipmi_host: str, username: str, password: str): try: result = subprocess.run( [ @@ -27,13 +27,13 @@ def power_on_sm_ipmi_client(ipmi_host: str, username: str, password: str): ) if result.returncode == 0: - logger.info(f"Supermicro IPMI: Powered on {ipmi_host}") + logger.info(f"IPMI Host: Powered on {ipmi_host}") return True else: logger.error( - f"Supermicro IPMI: Failed to power on {ipmi_host}: {result.stderr.strip()}" + f"IPMI Host: Failed to power on {ipmi_host}: {result.stderr.strip()}" ) return False except Exception as e: - logger.error(f"Supermicro IPMI error for {ipmi_host}: {e}") + logger.error(f"IPMI Host error for {ipmi_host}: {e}") return False diff --git a/wolnut/config.py b/wolnut/config.py index bdef6dd..9c94a2c 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -13,7 +13,7 @@ from wolnut.client import WolClientConfig from wolnut.client import IdracClientConfig from wolnut.client import IloClientConfig -from wolnut.client import SmIpmiClientConfig +from wolnut.client import IpmiClientConfig logger = logging.getLogger("wolnut")