31b01c5c0c
Co-authored-by: Copilot <copilot@github.com>
358 lines
11 KiB
Python
358 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import configparser
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
import redfish
|
|
import urllib3
|
|
|
|
try:
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
except ImportError:
|
|
sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography")
|
|
|
|
DEFAULT_CREDENTIALS_FILE = "/root/cron_serverlists/credentials.ini"
|
|
DEFAULT_KEYFILE_NAME = "/root/cron_serverlists/secret.key"
|
|
DEFAULT_SERVER_LIST_FILE = "/root/cron_serverlists/serverlist.ini"
|
|
|
|
# Examples:
|
|
# ./multi_powercycle.py on
|
|
# ./multi_powercycle.py on --keyfile /root/cron_serverlists/secret.key
|
|
# ./multi_powercycle.py off -f /root/cron_serverlists/serverlist.ini --keyfile /root/cron_serverlists/secret.key
|
|
|
|
# ./multi_powercycle.py on
|
|
# ./multi_powercycle.py off
|
|
# ./multi_powercycle.py on -f server_groups.txt
|
|
#
|
|
# File format:
|
|
# [ipmi]
|
|
# 192.168.129.240
|
|
# 192.168.129.241
|
|
#
|
|
# [ilo]
|
|
# 192.168.129.242
|
|
|
|
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
|
|
|
|
|
def load_fernet(keyfile: str):
|
|
"""Load Fernet instance from the key file created by set_password.py."""
|
|
if not os.path.exists(keyfile):
|
|
log(f"Error: key file not found: {keyfile}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
with open(keyfile, "rb") as fh:
|
|
key = fh.read()
|
|
|
|
try:
|
|
return Fernet(key)
|
|
except ValueError:
|
|
log(f"Error: invalid key format in key file: {keyfile}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
|
|
def resolve_keyfile(cli_keyfile, ipmi_credentials_path, ilo_credentials_path):
|
|
"""Resolve keyfile path. Prefer explicit --keyfile, else search next to credentials files."""
|
|
if cli_keyfile:
|
|
return cli_keyfile if os.path.exists(cli_keyfile) else None
|
|
|
|
candidates = []
|
|
for cred_path in (ipmi_credentials_path, ilo_credentials_path):
|
|
key_candidate = os.path.join(os.path.dirname(cred_path), DEFAULT_KEYFILE_NAME)
|
|
if key_candidate not in candidates:
|
|
candidates.append(key_candidate)
|
|
|
|
# Backward-compatible fallback
|
|
legacy_default = os.path.join(os.path.dirname(DEFAULT_CREDENTIALS_FILE), DEFAULT_KEYFILE_NAME)
|
|
if legacy_default not in candidates:
|
|
candidates.append(legacy_default)
|
|
|
|
for candidate in candidates:
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
|
|
return None
|
|
|
|
|
|
def resolve_credentials_path(cli_path, server_file_path):
|
|
"""Resolve credentials path. Prefer explicit CLI path, then search common locations."""
|
|
if cli_path and os.path.exists(cli_path):
|
|
return cli_path
|
|
|
|
candidates = []
|
|
|
|
if cli_path:
|
|
candidates.append(cli_path)
|
|
|
|
# Credentials next to server list file is a common deployment pattern
|
|
candidates.append(os.path.join(os.path.dirname(server_file_path), "credentials.ini"))
|
|
|
|
# Current working directory fallback
|
|
candidates.append(os.path.join(os.getcwd(), "credentials.ini"))
|
|
|
|
# Legacy default fallback
|
|
if DEFAULT_CREDENTIALS_FILE not in candidates:
|
|
candidates.append(DEFAULT_CREDENTIALS_FILE)
|
|
|
|
for candidate in candidates:
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
|
|
log(
|
|
"Error: credentials file not found. Looked in: " + ", ".join(candidates),
|
|
is_error=True,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def is_fernet_token(value: str) -> bool:
|
|
"""Best-effort check for Fernet token format from set_password.py."""
|
|
return isinstance(value, str) and value.startswith("gAAAA")
|
|
|
|
|
|
def decrypt_password(encrypted_password: str, fernet):
|
|
"""Decrypt password encrypted by set_password.py, or pass through plaintext."""
|
|
if not is_fernet_token(encrypted_password):
|
|
return encrypted_password
|
|
|
|
if fernet is None:
|
|
log(
|
|
"Error: encrypted password detected but key file was not found. "
|
|
"Use --keyfile /path/to/secret.key.",
|
|
is_error=True,
|
|
)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
return fernet.decrypt(encrypted_password.encode()).decode()
|
|
except InvalidToken:
|
|
log("Error: failed to decrypt password. Check that --keyfile matches the credentials file.", is_error=True)
|
|
sys.exit(1)
|
|
|
|
|
|
def log(message, is_error=False):
|
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
stream = sys.stderr if is_error else sys.stdout
|
|
print(f"[{timestamp}] {message}", file=stream)
|
|
|
|
|
|
def parse_server_groups(path):
|
|
if not os.path.exists(path):
|
|
log(f"Error: server list file not found: {path}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
groups = {"ipmi": [], "ilo": []}
|
|
current_group = None
|
|
aliases = {
|
|
"ipmi": "ipmi",
|
|
"dell": "ipmi",
|
|
"ilo": "ilo",
|
|
}
|
|
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
for raw_line in handle:
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
|
|
if line.startswith("[") and line.endswith("]"):
|
|
section_name = line[1:-1].strip().lower()
|
|
current_group = aliases.get(section_name)
|
|
if current_group is None:
|
|
log(f"Error: unsupported server group [{section_name}] in {path}", is_error=True)
|
|
sys.exit(1)
|
|
continue
|
|
|
|
if current_group is None:
|
|
log(f"Error: host entry found before any section header in {path}: {line}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
groups[current_group].append(line)
|
|
except OSError as exc:
|
|
log(f"Error reading server list file: {exc}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
return groups
|
|
|
|
|
|
def load_ipmi_credentials(path, fernet):
|
|
if not os.path.exists(path):
|
|
log(f"Error: IPMI credentials file not found: {path}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
config = configparser.ConfigParser()
|
|
config.read(path)
|
|
|
|
section = "ipmi_credentials"
|
|
if not config.has_section(section):
|
|
log(f"Error reading IPMI credentials file: missing [{section}] section in {path}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
username = config.get(section, "username", fallback="root")
|
|
encrypted_password = config.get(section, "password", fallback=None)
|
|
encryption_key = config.get(
|
|
section,
|
|
"ipmiek",
|
|
fallback=config.get(section, "encryption_key", fallback=DEFAULT_EK),
|
|
)
|
|
|
|
if not encrypted_password:
|
|
log(f"Error reading IPMI credentials file: missing password in [{section}] section of {path}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
password = decrypt_password(encrypted_password, fernet)
|
|
return username, password, encryption_key
|
|
|
|
|
|
def load_ilo_credentials(path, fernet):
|
|
if not os.path.exists(path):
|
|
log(f"Error: iLO credentials file not found: {path}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
config = configparser.ConfigParser()
|
|
config.read(path)
|
|
|
|
section = "ilo_credentials"
|
|
if not config.has_section(section):
|
|
log(f"Error reading iLO credentials file: missing [{section}] section in {path}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
username = config.get(section, "username", fallback="root")
|
|
encrypted_password = config.get(section, "password", fallback=None)
|
|
if not encrypted_password:
|
|
log(f"Error reading iLO credentials file: missing password in [{section}] section of {path}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
password = decrypt_password(encrypted_password, fernet)
|
|
return username, password
|
|
|
|
|
|
def ipmi_power(host, action, username, password, encryption_key):
|
|
cmd = [
|
|
"ipmitool",
|
|
"-I",
|
|
"lanplus",
|
|
"-H",
|
|
host,
|
|
"-U",
|
|
username,
|
|
"-P",
|
|
password,
|
|
"-y",
|
|
encryption_key,
|
|
"power",
|
|
action,
|
|
]
|
|
|
|
log(f"Sending IPMI power {action} to {host}")
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
if proc.returncode != 0:
|
|
detail = proc.stderr.strip() or proc.stdout.strip() or "unknown error"
|
|
log(f"IPMI {action} failed for {host}: {detail}", is_error=True)
|
|
return False
|
|
|
|
detail = proc.stdout.strip() or f"power {action} request accepted"
|
|
log(f"IPMI {action} succeeded for {host}: {detail}")
|
|
return True
|
|
|
|
|
|
def ilo_powercycle(host, username, password):
|
|
base_url = f"https://{host}"
|
|
client = redfish.RedfishClient(base_url=base_url, username=username, password=password)
|
|
|
|
try:
|
|
client.login(auth="session")
|
|
sys_response = client.get("/redfish/v1/Systems/1/")
|
|
reset_uri = sys_response.dict["Actions"]["#ComputerSystem.Reset"]["target"]
|
|
response = client.post(reset_uri, body={"ResetType": "PushPowerButton"})
|
|
|
|
log(f"iLO powercycle status for {host}: {response.status}")
|
|
if response.status >= 400:
|
|
log(f"iLO powercycle failed for {host}: {response.read}", is_error=True)
|
|
return False
|
|
return True
|
|
except Exception as exc:
|
|
log(f"iLO powercycle failed for {host}: {exc}", is_error=True)
|
|
return False
|
|
finally:
|
|
try:
|
|
client.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main():
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Dispatch power operations to IPMI hosts and iLO hosts from a grouped server list"
|
|
)
|
|
parser.add_argument("action", choices=("on", "off"), help="Power action for IPMI hosts")
|
|
parser.add_argument(
|
|
"-f",
|
|
"--file",
|
|
dest="server_file",
|
|
default=DEFAULT_SERVER_LIST_FILE,
|
|
help=f"Path to server list file (default: {DEFAULT_SERVER_LIST_FILE})",
|
|
)
|
|
parser.add_argument(
|
|
"--ipmi-credentials",
|
|
default=DEFAULT_CREDENTIALS_FILE,
|
|
help=f"Path to IPMI credentials file (default: {DEFAULT_CREDENTIALS_FILE})",
|
|
)
|
|
parser.add_argument(
|
|
"--ilo-credentials",
|
|
default=DEFAULT_CREDENTIALS_FILE,
|
|
help=f"Path to iLO credentials file (default: {DEFAULT_CREDENTIALS_FILE})",
|
|
)
|
|
parser.add_argument(
|
|
"--keyfile",
|
|
default=None,
|
|
help="Path to Fernet key file created by set_password.py (default: auto-detect next to credentials file)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
args.ipmi_credentials = resolve_credentials_path(args.ipmi_credentials, args.server_file)
|
|
args.ilo_credentials = resolve_credentials_path(args.ilo_credentials, args.server_file)
|
|
|
|
keyfile = resolve_keyfile(args.keyfile, args.ipmi_credentials, args.ilo_credentials)
|
|
fernet = load_fernet(keyfile) if keyfile else None
|
|
|
|
groups = parse_server_groups(args.server_file)
|
|
total_hosts = len(groups["ipmi"]) + len(groups["ilo"])
|
|
if total_hosts == 0:
|
|
log(f"Error: no servers found in {args.server_file}", is_error=True)
|
|
sys.exit(1)
|
|
|
|
log(
|
|
f"Starting run for {total_hosts} host(s): {len(groups['ipmi'])} IPMI host(s), {len(groups['ilo'])} iLO host(s)"
|
|
)
|
|
|
|
ipmi_creds = None
|
|
ilo_creds = None
|
|
failures = 0
|
|
|
|
if groups["ipmi"]:
|
|
ipmi_creds = load_ipmi_credentials(args.ipmi_credentials, fernet)
|
|
for host in groups["ipmi"]:
|
|
if not ipmi_power(host, args.action, *ipmi_creds):
|
|
failures += 1
|
|
|
|
if groups["ilo"]:
|
|
ilo_creds = load_ilo_credentials(args.ilo_credentials, fernet)
|
|
for host in groups["ilo"]:
|
|
if not ilo_powercycle(host, *ilo_creds):
|
|
failures += 1
|
|
|
|
successes = total_hosts - failures
|
|
log(f"Completed run: {successes} successful, {failures} failed")
|
|
if failures:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |