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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
name: Build & Publish to PyPI

on:
push:
branches: [main, DEV]
pull_request:
branches: [main]
release:
types: [published]

Expand All @@ -9,7 +13,29 @@ permissions:
id-token: write

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.12", "3.14"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -e ".[dev]"

- name: Run tests
run: pytest -v

build:
needs: test
if: github.event_name == 'release'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
.venv
dist
src/ipmi_menu.egg-info
build
build
__pycache__
*.pyc
.pytest_cache
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ classifiers = [
]
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=7.0"]

[project.scripts]
ipmi-menu = "ipmi_menu.cli:main"

Expand All @@ -31,3 +34,7 @@ include = ["ipmi_menu*"]
# Inclure tes fichiers de conf / i18n dans le package
[tool.setuptools.package-data]
ipmi_menu = ["config/*.json", "i18n/*.json", "conf/*.json", "conf/*.toml"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
75 changes: 58 additions & 17 deletions src/ipmi_menu/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import getpass
import ipaddress
import logging
import re
import sys
from typing import Optional

Expand All @@ -17,8 +22,10 @@
DEFAULT_INTERFACE,
DEFAULT_PASSWORD,
DEFAULT_PORT,
DEFAULT_TIMEOUT,
DEFAULT_USER,
TIMEOUT_FAST,
TIMEOUT_NORMAL,
TIMEOUT_SLOW,
)
from ipmi_menu.core.detect import detect
from ipmi_menu.core.ipmi import (
Expand All @@ -33,11 +40,27 @@
)
from ipmi_menu.core.updater import is_update_available, run_upgrade

logger = logging.getLogger("ipmi_menu")

# ANSI color codes
RED = "\033[91m"
RESET = "\033[0m"
from ipmi_menu.ui.prompts import confirm_critical, menu, yesno

_HOSTNAME_RE = re.compile(
r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})*$"
)


def _is_valid_bmc_address(addr: str) -> bool:
"""Validate BMC address as IPv4, IPv6, or hostname."""
try:
ipaddress.ip_address(addr)
return True
except ValueError:
pass
return bool(_HOSTNAME_RE.match(addr))


def die(msg: str, code: int = 2) -> None:
if msg:
Expand All @@ -52,9 +75,8 @@ def require_ipmi_ok(
password: Optional[str],
interface: str,
port: int,
timeout: int,
) -> None:
rc, out, err = ipmi(host, user, password, interface, port, timeout, ["mc", "info"])
rc, out, err = ipmi(host, user, password, interface, port, TIMEOUT_FAST, ["mc", "info"])
if rc == 0 and out:
return

Expand All @@ -65,29 +87,42 @@ def require_ipmi_ok(


def main() -> None:
parser = argparse.ArgumentParser(description="Interactive ipmitool menu")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose/debug output")
args = parser.parse_args()

logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.WARNING,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)

msg = load_messages(get_preferred_language())

# Check for updates at startup (silent if fails)
update_info = (False, "", None)
try:
update_info = is_update_available()
except Exception:
pass
available, cur, lat = update_info
logger.debug("Update check: available=%s, current=%s, latest=%s", available, cur, lat)
except Exception as exc:
logger.debug("Update check failed: %s", exc)

if not has_ipmitool():
die(msg.t("errors.ipmitool_missing"))

host = input(msg.t("prompts.bmc_ip")).strip()
if not host:
die(msg.t("errors.bmc_ip_required"))
if not _is_valid_bmc_address(host):
die(msg.t("errors.bmc_ip_invalid"))

saved_user = get_preferred_username()
default_user = saved_user if saved_user else DEFAULT_USER
user_input = input(msg.t("prompts.user", default=default_user)).strip()
user = user_input if user_input else default_user

saved_password = get_preferred_password()
password_in = input(msg.t("prompts.password"))
password_in = getpass.getpass(msg.t("prompts.password"))
if password_in == "":
password: Optional[str] = saved_password if saved_password is not None else DEFAULT_PASSWORD
pw_mode = "default"
Expand All @@ -100,12 +135,11 @@ def main() -> None:

interface = DEFAULT_INTERFACE
port = DEFAULT_PORT
timeout = DEFAULT_TIMEOUT

print(msg.t("info.connect_detect"))
require_ipmi_ok(msg, host, user, password, interface, port, timeout)
require_ipmi_ok(msg, host, user, password, interface, port)

di = detect(host, user, password, interface, port, timeout)
di = detect(host, user, password, interface, port, TIMEOUT_NORMAL)
print(
msg.t(
"info.hw_detected",
Expand Down Expand Up @@ -189,7 +223,7 @@ def main() -> None:
set_preferred_username(new_user)
print(msg.t("info.settings.username_saved", username=new_user))
elif setting == "password":
new_pw = input(msg.t("prompts.settings.password"))
new_pw = getpass.getpass(msg.t("prompts.settings.password"))
if new_pw:
set_preferred_password(new_pw)
print(msg.t("info.settings.password_saved"))
Expand All @@ -203,21 +237,24 @@ def main() -> None:

if action == "info":
print(msg.t("labels.info.sensors"))
rc_s, out_s, err_s = ipmi_sdr_list(host, user, password, interface, port, timeout)
rc_s, out_s, err_s = ipmi_sdr_list(host, user, password, interface, port, TIMEOUT_SLOW)
if out_s:
print(out_s)
if rc_s != 0 and err_s:
print(err_s, file=sys.stderr)

print(msg.t("labels.info.misc"))
for args in (["mc", "info"], ["fru", "print"]):
rc, out, err = ipmi(host, user, password, interface, port, timeout, list(args))
for sub_args, sub_timeout in (
(["mc", "info"], TIMEOUT_FAST),
(["fru", "print"], TIMEOUT_SLOW),
):
rc, out, err = ipmi(host, user, password, interface, port, sub_timeout, list(sub_args))
if out:
print(out)
if rc != 0 and err:
print(err, file=sys.stderr)

rc, out, err = ipmi_lan_print(host, user, password, interface, port, timeout)
rc, out, err = ipmi_lan_print(host, user, password, interface, port, TIMEOUT_NORMAL)
if out:
print(out)
if rc != 0 and err:
Expand Down Expand Up @@ -255,7 +292,8 @@ def main() -> None:
print(msg.t("errors.cancelled"))
continue

rc, out, err = power(host, user, password, interface, port, timeout, mode)
timeout_power = TIMEOUT_FAST if mode == "status" else TIMEOUT_NORMAL
rc, out, err = power(host, user, password, interface, port, timeout_power, mode)
if out:
print(out)
if rc != 0 and err:
Expand Down Expand Up @@ -302,7 +340,7 @@ def main() -> None:
password,
interface,
port,
timeout,
TIMEOUT_NORMAL,
device,
uefi=(boot_mode == "uefi"),
persistent=persistent,
Expand All @@ -329,17 +367,20 @@ def main() -> None:
)

if reboot_mode in {"quit", "home"}:
print(msg.t("info.boot.set_no_reboot"))
continue

if not confirm_critical(msg, msg.t("labels.critical.reboot")):
print(msg.t("errors.cancelled"))
continue

rc2, out2, err2 = power(host, user, password, interface, port, timeout, reboot_mode)
rc2, out2, err2 = power(host, user, password, interface, port, TIMEOUT_NORMAL, reboot_mode)
if out2:
print(out2)
if rc2 != 0 and err2:
print(err2, file=sys.stderr)
else:
print(msg.t("info.boot.set_no_reboot"))
continue


Expand Down
5 changes: 4 additions & 1 deletion src/ipmi_menu/config/messages.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"errors.ipmi_auth": "IPMI connection failure (authentication or access issue).\nPlease verify credentials (case-sensitive), IP address, port, and network connectivity.\n{details}",
"errors.ipmi_generic": "An IPMI error has occurred.\n{details}",
"errors.unknown": "An unknown error has occurred.",
"errors.bmc_ip_invalid": "Invalid BMC address. Please enter a valid IPv4 or IPv6 address, or hostname.",
"errors.cancelled": "Operation cancelled.",
"errors.interrupted": "\nOperation interrupted by the user.",

"prompts.bmc_ip": "BMC IP address: ",
"prompts.user": "Username (default: {default}): ",
"prompts.password": "Password (Enter = default, '-' = empty): ",
"prompts.password": "Password (Enter = saved/default, '-' = no password): ",

"info.connect_detect": "\nEstablishing connection and detecting hardware…",
"info.hw_detected": "\nHardware detected: vendor={vendor}, manufacturer={mfg}, product={product}",
Expand Down Expand Up @@ -65,6 +66,8 @@
"labels.info.sensors": "\n===== HARDWARE SENSORS (SDR LIST) =====",
"labels.info.misc": "\n===== BMC / FRU / NETWORK CONFIGURATION =====",

"info.boot.set_no_reboot": "Boot device set successfully. System was NOT rebooted.",

"labels.boot.persistent": "Apply persistently (otherwise one-time only)?",
"labels.boot.reboot_after": "Reboot the system after applying this setting?",

Expand Down
5 changes: 4 additions & 1 deletion src/ipmi_menu/config/messages.fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"errors.ipmi_auth": "Échec de la connexion IPMI (authentification ou accès).\nVeuillez vérifier les identifiants (respect de la casse), l’adresse IP, le port configuré ainsi que la connectivité réseau.\n{details}",
"errors.ipmi_generic": "Une erreur IPMI est survenue.\n{details}",
"errors.unknown": "Une erreur inconnue est survenue.",
"errors.bmc_ip_invalid": "Adresse BMC invalide. Veuillez saisir une adresse IPv4, IPv6 ou un nom d'hôte valide.",
"errors.cancelled": "Opération annulée.",
"errors.interrupted": "\nOpération interrompue par l’utilisateur.",

"prompts.bmc_ip": "Adresse IP du BMC : ",
"prompts.user": "Nom d’utilisateur (défaut : {default}) : ",
"prompts.password": "Mot de passe (Entrée = valeur par défaut, '-' = vide) : ",
"prompts.password": "Mot de passe (Entrée = sauvegardé/défaut, '-' = sans mot de passe) : ",

"info.connect_detect": "\nConnexion en cours et détection du matériel…",
"info.hw_detected": "\nMatériel détecté : vendor={vendor}, fabricant={mfg}, produit={product}",
Expand Down Expand Up @@ -65,6 +66,8 @@
"labels.info.sensors": "\n===== CAPTEURS MATÉRIELS (SDR LIST) =====",
"labels.info.misc": "\n===== INFORMATIONS BMC / FRU / CONFIGURATION RÉSEAU =====",

"info.boot.set_no_reboot": "Périphérique de démarrage configuré avec succès. Le système n'a PAS été redémarré.",

"labels.boot.persistent": "Appliquer de manière persistante (sinon temporaire) ?",
"labels.boot.reboot_after": "Redémarrer le système après l'application du paramètre ?",

Expand Down
Loading