diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ebb3352..f398216 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -10,24 +10,25 @@ jobs: run: runs-on: ubuntu-latest env: - PYTHON: '3.9' + PYTHON: '3.12' steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@master + uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: '3.12' + cache: 'pip' - name: Generate coverage report run: | pip install -r tests/test-requirements.txt PYTHONPATH=src pytest --cov=./ --cov-report=xml --cov-config=.coveragerc -p no:warnings - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml env_vars: PYTHON flags: unittests name: codecov-umbrella - fail_ci_if_error: false + fail_ci_if_error: true verbose: true diff --git a/README.md b/README.md index e285298..bed2db9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ * [Power State Control](#power-state-control) * [Check Power State](#check-power-state) * [Get Power Consumed](#get-power-consumed) - * [Resetting iDRAC](#resetting-idrac) + * [Resetting Dell iDRAC](#resetting-dell-idrac) * [Resetting BMC](#resetting-bmc) * [BIOS factory reset](#bios-factory-reset) * [Check current boot order](#check-current-boot-order) @@ -104,14 +104,14 @@ We're mostly concentrated on programmatically enforcing interface/device boot or * Check current boot order * Display current power consumption in watts * Reboot host -* Reset iDRAC -* View, check and clear iDRAC jobs +* Reset Dell iDRAC +* View, check and clear Dell iDRAC jobs * Revert to factory settings * Check/set SRIOV * Take a remote screenshot of server KVM console activity (Dell only). * Support tokenized authentication * Check and set BIOS attributes (e.g. setting UEFI or BIOS mode) -* Get firmware inventory of installed devices supported by iDRAC +* Get firmware inventory of installed devices supported by the BMC * Check/ummount virtual media en-masse across a set of systems (SuperMicro only) * Obtain limited hardware information (CPU, Memory, Interfaces) * Bulk actions via plain text file with list of hosts for parallel execution @@ -122,7 +122,7 @@ We're mostly concentrated on programmatically enforcing interface/device boot or * (Dell) iDRAC7,8,9 or newer * (Dell) Firmware version ```2.60.60.60``` or higher * Any Redfish IPMI 2.0 support on non-Dell systems -* iDRAC administrative account +* BMC administrative account * Python >= ```3.8``` or [podman](https://podman.io/getting-started/installation) as a container. * python3-devel >= ```3.8``` (If using standalone or RPM package below). @@ -326,7 +326,7 @@ badfish -H mgmt-your-server.example.com --boot-to-mac A9:BB:4B:50:CA:54 ``` ### Forcing a one time boot to a specific type -To force systems to perform a one-time boot to a specific type on the next subsequent reboot you can use the ```--boot-to-type``` option and pass as an argument the device type, as defined on the iDRAC interfaces yaml, that you want the one-time boot to be set to. For this action you must also include the path to your interfaces yaml. This will change the one time boot BIOS attributes OneTimeBootMode and OneTimeBootSeqDev and on the next reboot it will attempt to PXE boot or boot from the first interface defined for that host type on the interfaces yaml file. +To force systems to perform a one-time boot to a specific type on the next subsequent reboot you can use the ```--boot-to-type``` option and pass as an argument the device type, as defined on the interfaces yaml, that you want the one-time boot to be set to. For this action you must also include the path to your interfaces yaml. This will change the one time boot BIOS attributes OneTimeBootMode and OneTimeBootSeqDev and on the next reboot it will attempt to PXE boot or boot from the first interface defined for that host type on the interfaces yaml file. ```bash badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml --boot-to-type foreman ``` @@ -379,7 +379,7 @@ Partial Output: - INFO - Current watts consumed: 213 ``` -### Resetting iDRAC +### Resetting Dell iDRAC For the replacement of `racadm racreset`, the optional argument `--racreset` was added. When this argument is passed to ```badfish```, a graceful restart is triggered on the iDRAC itself. ```bash badfish -H mgmt-your-server.example.com --racreset @@ -408,7 +408,7 @@ badfish -H mgmt-your-server.example.com --factory-reset ``` ### Check current boot order -To check the current boot order of a specific host you can use the ```--check-boot``` option which will return an ordered list of boot devices. Additionally you can pass the ```-i``` option which will in turn print on screen what type of host does the current boot order match as those defined on the iDRAC interfaces yaml. +To check the current boot order of a specific host you can use the ```--check-boot``` option which will return an ordered list of boot devices. Additionally you can pass the ```-i``` option which will in turn print on screen what type of host does the current boot order match as those defined on the interfaces yaml. ```bash badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml --check-boot ``` @@ -426,7 +426,7 @@ badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml -t forem ``` ### Firmware inventory -If you would like to get a detailed list of all the devices supported by iDRAC you can run ```badfish``` with the ```--firware-inventory``` option which will return a list of devices with additional device info. +If you would like to get a detailed list of all the devices supported by the BMC you can run ```badfish``` with the ```--firware-inventory``` option which will return a list of devices with additional device info. ```bash badfish -H mgmt-your-server.example.com --firmware-inventory ``` @@ -438,7 +438,7 @@ badfish -H mgmt-your-server.example.com --firmware-inventory --delta mgmt-your-o ``` ### Clear Job Queue -If you would like to clear all the jobs that are queued on the remote iDRAC you can run ```badfish``` with the ```--clear-jobs``` option which query for all active jobs in the iDRAC queue and will post a request to clear the queue. +If you would like to clear all the jobs that are queued on the remote BMC you can run ```badfish``` with the ```--clear-jobs``` option which query for all active jobs in the job queue and will post a request to clear the queue. ```bash badfish -H mgmt-your-server.example.com --clear-jobs ``` @@ -450,7 +450,7 @@ badfish -H mgmt-your-server.example.com --clear-jobs --force ``` ### List Job Queue -If you would like to list all active jobs that are queued on the remote iDRAC you can run ```badfish``` with the ```--ls-jobs``` option which query for all active jobs in the iDRAC queue and will return a list with all active items. +If you would like to list all active jobs that are queued on the remote BMC you can run ```badfish``` with the ```--ls-jobs``` option which query for all active jobs in the job queue and will return a list with all active items. ```bash badfish -H mgmt-your-server.example.com --ls-jobs ``` diff --git a/pytest.ini b/pytest.ini index 3d3bcb9..17cdf94 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ -[tool.pytest.ini_options] -pythonpath = [".", "src"] +[pytest] +pythonpath = . src +asyncio_mode = auto diff --git a/src/badfish/config.py b/src/badfish/config.py index f16520f..5f7c818 100644 --- a/src/badfish/config.py +++ b/src/badfish/config.py @@ -1 +1,2 @@ RETRIES = 30 +TIMEOUT = 120 diff --git a/src/badfish/helpers/http_client.py b/src/badfish/helpers/http_client.py index 4ef0e65..d6ad6b8 100644 --- a/src/badfish/helpers/http_client.py +++ b/src/badfish/helpers/http_client.py @@ -11,13 +11,23 @@ class HTTPClient: - def __init__(self, host: str, username: str, password: str, logger, retries: int = 15, insecure: bool = False): + def __init__( + self, + host: str, + username: str, + password: str, + logger, + retries: int = 15, + insecure: bool = False, + timeout: int = 120, + ): self.host = host self.username = username self.password = password self.logger = logger self.retries = retries self.insecure = insecure + self.timeout = aiohttp.ClientTimeout(total=timeout) self.host_uri = f"https://{host}" self.redfish_uri = "/redfish/v1" self.root_uri = f"{self.host_uri}{self.redfish_uri}" @@ -93,7 +103,7 @@ async def get_raw(self, uri: str, _continue: bool = False, _get_token: bool = Fa uri, headers={"X-Auth-Token": self.token} if self.token else {}, ssl=False if self.insecure else True, - timeout=60, + timeout=self.timeout, ) as _response: await _response.read() else: @@ -101,7 +111,7 @@ async def get_raw(self, uri: str, _continue: bool = False, _get_token: bool = Fa uri, auth=aiohttp.BasicAuth(self.username, self.password), ssl=False if self.insecure else True, - timeout=60, + timeout=self.timeout, ) as _response: await _response.read() except (ssl.SSLError, aiohttp.ClientConnectorCertificateError, aiohttp.ClientSSLError) as ex: @@ -135,6 +145,7 @@ async def post_request( data=json.dumps(payload), headers=headers, ssl=False if self.insecure else True, + timeout=self.timeout, ) as _response: if _response.status != 204: await _response.read() @@ -157,6 +168,7 @@ async def patch_request(self, uri: str, payload: Dict[str, Any], headers: Dict[s data=json.dumps(payload), headers=headers, ssl=False if self.insecure else True, + timeout=self.timeout, ) as _response: raw_data = await _response.read() self.logger.debug(raw_data) @@ -182,6 +194,7 @@ async def delete_request(self, uri: str, headers: Dict[str, str]): uri, headers=headers, ssl=False if self.insecure else True, + timeout=self.timeout, ) as _response: raw_data = await _response.read() self.logger.debug(raw_data) diff --git a/src/badfish/helpers/parser.py b/src/badfish/helpers/parser.py index 24c390a..1e842dc 100644 --- a/src/badfish/helpers/parser.py +++ b/src/badfish/helpers/parser.py @@ -1,7 +1,17 @@ import argparse from badfish import __version__ -from badfish.config import RETRIES +from badfish.config import RETRIES, TIMEOUT + + +def _positive_int(value): + try: + ivalue = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"{value!r} is not an integer") + if ivalue <= 0: + raise argparse.ArgumentTypeError(f"{value} must be a positive integer") + return ivalue def create_parser(): @@ -11,11 +21,11 @@ def create_parser(): allow_abbrev=False, ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") - parser.add_argument("-H", "--host", help="iDRAC host address") - parser.add_argument("-u", help="iDRAC username") - parser.add_argument("-p", help="iDRAC password") - parser.add_argument("-i", help="Path to iDRAC interfaces yaml", default=None) - parser.add_argument("-t", help="Type of host as defined on iDRAC interfaces yaml") + parser.add_argument("-H", "--host", help="BMC host address") + parser.add_argument("-u", help="BMC username") + parser.add_argument("-p", help="BMC password") + parser.add_argument("-i", help="Path to interfaces yaml", default=None) + parser.add_argument("-t", help="Type of host as defined on interfaces yaml") parser.add_argument("-l", "--log", help="Optional argument for logging results to a file") parser.add_argument( "-o", @@ -39,7 +49,7 @@ def create_parser(): parser.add_argument("--boot-to", help="Set next boot to one-shot boot to a specific device") parser.add_argument( "--boot-to-type", - help="Set next boot to one-shot boot to a specific type as defined on iDRAC interfaces yaml", + help="Set next boot to one-shot boot to a specific type as defined on interfaces yaml", ) parser.add_argument( "--boot-to-mac", @@ -71,8 +81,16 @@ def create_parser(): help="Get current consumed watts on host(s)", action="store_true", ) - parser.add_argument("--racreset", help="Flag for iDRAC reset", action="store_true") - parser.add_argument("--wait", help="Wait for iDRAC to be responsive after reset", action="store_true") + parser.add_argument( + "--racreset", + help="(Dell iDRAC) Flag for Dell iDRAC reset", + action="store_true", + ) + parser.add_argument( + "--wait", + help="(Dell iDRAC) Wait for Dell iDRAC to be responsive after reset", + action="store_true", + ) parser.add_argument("--bmc-reset", help="Flag for BMC reset", action="store_true") parser.add_argument( "--factory-reset", @@ -240,6 +258,12 @@ def create_parser(): help="Number of retries for executing actions.", default=RETRIES, ) + parser.add_argument( + "--timeout", + help="Timeout in seconds for each REST API call (must be > 0).", + default=TIMEOUT, + type=_positive_int, + ) parser.add_argument( "--insecure", help="Disable SSL/TLS certificate verification (insecure, use only for testing)", @@ -247,29 +271,30 @@ def create_parser(): ) parser.add_argument( "--get-scp-targets", - help="Get allowable target values to export or import with iDRAC SCP. Choices=['Export', 'Import']", + help="(Dell iDRAC) Get allowable target values to export or import with iDRAC SCP." + " Choices=['Export', 'Import']", choices=["Export", "Import"], default="", ) parser.add_argument( "--scp-targets", - help="Comma separated targets which configs should be exported with iDRAC SCP.", + help="(Dell iDRAC) Comma separated targets which configs should be exported with Dell iDRAC SCP.", default="ALL", ) parser.add_argument( "--scp-include-read-only", - help="Flag for including read only attributes in SCP export.", + help="(Dell iDRAC) Flag for including read only attributes in Dell iDRAC SCP export.", action="store_true", ) parser.add_argument( "--export-scp", - help="Export system config using iDRAC SCP, argument specifies where file should be saved.", + help="(Dell iDRAC) Export system config using Dell iDRAC SCP, argument specifies where file should be saved.", default="", ) parser.add_argument( "--import-scp", - help="Import system config using iDRAC SCP, argument specifies which JSON file contains config that should be " - "imported.", + help="(Dell iDRAC) Import system config using iDRAC SCP, argument specifies which JSON file" + " contains config that should be imported.", default="", ) parser.add_argument( diff --git a/src/badfish/main.py b/src/badfish/main.py index 6968f33..a71af44 100755 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -18,6 +18,7 @@ from rich.console import Console from rich.table import Table +from badfish.config import TIMEOUT from badfish.helpers import get_now from badfish.helpers.parser import parse_arguments from badfish.helpers.logger import BadfishLogger @@ -46,6 +47,7 @@ async def badfish_factory( _insecure=False, _console=None, _progress_disabled=False, + _timeout=TIMEOUT, ): if not _logger: bfl = BadfishLogger() @@ -61,6 +63,7 @@ async def badfish_factory( _insecure, _console, _progress_disabled, + _timeout, ) await badfish.init() return badfish @@ -78,6 +81,7 @@ def __init__( _insecure=False, _console=None, _progress_disabled=False, + _timeout=TIMEOUT, ): self.host = _host self.username = _username @@ -91,7 +95,7 @@ def __init__( self.loop = _loop if not self.loop: self.loop = asyncio.get_event_loop() - self.http_client = HTTPClient(_host, _username, _password, _logger, _retries, _insecure) + self.http_client = HTTPClient(_host, _username, _password, _logger, _retries, _insecure, _timeout) self.system_resource = None self.manager_resource = None self.bios_uri = None @@ -529,7 +533,15 @@ async def find_managers_resource(self): raw = await response.text("utf-8", "ignore") data = json.loads(raw.strip()) - self.vendor = "Dell" if data.get("Oem") and "Dell" in data["Oem"] else "Supermicro" + oem = data.get("Oem") or {} + if "Dell" in oem: + self.vendor = "Dell" + elif "Supermicro" in oem: + self.vendor = "Supermicro" + elif "Hpe" in oem: + self.vendor = "HPE" + else: + self.vendor = "Unknown" if "Managers" not in data: raise BadfishException("Managers resource not found") @@ -1117,7 +1129,9 @@ async def reboot_server(self, graceful=True): async def reset_idrac(self, wait=False): if self.vendor != "Dell": - self.logger.warning("Vendor isn't a Dell, if you are trying this on a Supermicro, use --bmc-reset instead.") + self.logger.warning( + "Vendor isn't a Dell, if you are trying this on a Supermicro or HPE, use --bmc-reset instead." + ) return False self.logger.debug("Running reset iDRAC.") _reset_types = await self.get_reset_types(manager=True) @@ -1154,8 +1168,10 @@ async def reset_idrac(self, wait=False): return True async def reset_bmc(self): - if self.vendor != "Supermicro": - self.logger.warning("Vendor isn't a Supermicro, if you are trying this on a Dell, use --racreset instead.") + if self.vendor not in ("Supermicro", "HPE"): + self.logger.warning( + "Vendor isn't a Supermicro or HPE, if you are trying this on a Dell, use --racreset instead." + ) return False self.logger.debug("Running reset BMC.") _reset_types = await self.get_reset_types(manager=True, bmc=True) @@ -1630,7 +1646,7 @@ async def boot_to_virtual_media(self): _uri = "%s%s" % (self.host_uri, self.system_resource) _headers = {"Content-Type": "application/json"} - if self.vendor == "Supermicro": + if self.vendor in ("Supermicro", "HPE"): _payload = {"Boot": {"BootSourceOverrideEnabled": "Once"}} _response = await self.get_request(_uri) @@ -2933,6 +2949,7 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non old_password = _args["old_password"] screenshot = _args["screenshot"] retries = int(_args["retries"]) + timeout = int(_args.get("timeout", TIMEOUT)) output = _args["output"] get_scp_targets = _args["get_scp_targets"] scp_targets = _args["scp_targets"] @@ -2956,6 +2973,7 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non _insecure=insecure, _console=console, _progress_disabled=progress_disabled, + _timeout=timeout, ) if _args["host_list"] and not _args["output"]: diff --git a/tests/config.py b/tests/config.py index f9c72e6..fc0a286 100644 --- a/tests/config.py +++ b/tests/config.py @@ -271,6 +271,18 @@ def render_device_dict(index, device): ROOT_RESP_SUPERMICRO, MAN_RESP, ] +ROOT_RESP_HPE = ( + '{"Managers":{"@odata.id":"/redfish/v1/Managers"},"Systems":{"@odata.id":"/redfish/v1/Systems"}, ' + '"RedfishVersion": "1.0.2","Oem":{"Hpe":{"Manager":[{}]}}}' +) +MAN_RESP_HPE = '{"Members":[{"@odata.id":"/redfish/v1/Managers/1"}]}' +INIT_RESP_HPE = [ + ROOT_RESP_HPE, + ROOT_RESP_HPE, + SYS_RESP, + ROOT_RESP_HPE, + MAN_RESP_HPE, +] RESPONSE_INIT_CREDENTIALS_UNAUTHORIZED = ( f"- ERROR - Failed to authenticate. Verify your credentials for {MOCK_HOST}\n" diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py index 10e4e7d..d68bbc3 100644 --- a/tests/test_context_manager.py +++ b/tests/test_context_manager.py @@ -467,6 +467,7 @@ async def test_execute_badfish_session_cleanup_success(self): "get_nic_attribute": None, "set_nic_attribute": None, "insecure": False, + "timeout": 120, } with patch("badfish.main.badfish_factory") as mock_factory: @@ -552,6 +553,7 @@ async def test_execute_badfish_session_cleanup_failure(self): "get_nic_attribute": None, "set_nic_attribute": None, "insecure": False, + "timeout": 120, } with patch("badfish.main.badfish_factory") as mock_factory: @@ -641,6 +643,7 @@ async def test_execute_badfish_no_session_cleanup(self): "get_nic_attribute": None, "set_nic_attribute": None, "insecure": False, + "timeout": 120, } with patch("badfish.main.badfish_factory") as mock_factory: @@ -726,6 +729,7 @@ async def test_execute_badfish_no_badfish_instance(self): "get_nic_attribute": None, "set_nic_attribute": None, "insecure": False, + "timeout": 120, } with patch("badfish.main.badfish_factory") as mock_factory: diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 5ac1722..cb6d628 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -467,3 +467,68 @@ async def test_secure_flag_sets_ssl_true(mock_get): called_with_ssl_true = True break assert called_with_ssl_true, "insecure=False should set ssl=True" + + +# --timeout tests + + +def test_timeout_default_stored_as_client_timeout(): + logger = DummyLogger() + client = HTTPClient("host", "u", "p", logger) + assert isinstance(client.timeout, aiohttp.ClientTimeout) + assert client.timeout.total == 120 + + +def test_timeout_custom_stored_as_client_timeout(): + logger = DummyLogger() + client = HTTPClient("host", "u", "p", logger, timeout=10) + assert isinstance(client.timeout, aiohttp.ClientTimeout) + assert client.timeout.total == 10 + + +@pytest.mark.asyncio +@patch("aiohttp.ClientSession.get") +async def test_get_raw_passes_timeout(mock_get): + logger = DummyLogger() + client = HTTPClient("host", "u", "p", logger, timeout=5) + set_mock_response(mock_get, 200, "{}") + await client.get_raw("https://x") + _, kwargs = mock_get.call_args + assert isinstance(kwargs.get("timeout"), aiohttp.ClientTimeout) + assert kwargs["timeout"].total == 5 + + +@pytest.mark.asyncio +@patch("aiohttp.ClientSession.post") +async def test_post_request_passes_timeout(mock_post): + logger = DummyLogger() + client = HTTPClient("host", "u", "p", logger, timeout=7) + set_mock_response(mock_post, 200, "{}", post=True) + await client.post_request("https://x", {}, {}) + _, kwargs = mock_post.call_args + assert isinstance(kwargs.get("timeout"), aiohttp.ClientTimeout) + assert kwargs["timeout"].total == 7 + + +@pytest.mark.asyncio +@patch("aiohttp.ClientSession.patch") +async def test_patch_request_passes_timeout(mock_patch): + logger = DummyLogger() + client = HTTPClient("host", "u", "p", logger, timeout=8) + set_mock_response(mock_patch, 200, "{}") + await client.patch_request("https://x", {}, {}) + _, kwargs = mock_patch.call_args + assert isinstance(kwargs.get("timeout"), aiohttp.ClientTimeout) + assert kwargs["timeout"].total == 8 + + +@pytest.mark.asyncio +@patch("aiohttp.ClientSession.delete") +async def test_delete_request_passes_timeout(mock_delete): + logger = DummyLogger() + client = HTTPClient("host", "u", "p", logger, timeout=9) + set_mock_response(mock_delete, 200, "{}") + await client.delete_request("https://x", {}) + _, kwargs = mock_delete.call_args + assert isinstance(kwargs.get("timeout"), aiohttp.ClientTimeout) + assert kwargs["timeout"].total == 9 diff --git a/tests/test_main_coverage.py b/tests/test_main_coverage.py index 0a2c9de..e9ddc7a 100644 --- a/tests/test_main_coverage.py +++ b/tests/test_main_coverage.py @@ -170,3 +170,60 @@ def test_version_flag(capsys): assert exc_info.value.code == 0 captured = capsys.readouterr() assert f"badfish {__version__}" in captured.out + + +# --timeout argument tests + + +def test_timeout_argument_default(): + from badfish.helpers.parser import parse_arguments + from badfish.config import TIMEOUT + + args = parse_arguments(["-H", "host", "-u", "user", "-p", "pass", "--power-state"]) + assert args["timeout"] == TIMEOUT + + +def test_timeout_argument_custom(): + from badfish.helpers.parser import parse_arguments + + args = parse_arguments(["-H", "host", "-u", "user", "-p", "pass", "--power-state", "--timeout", "15"]) + assert args["timeout"] == 15 + + +def test_timeout_argument_invalid_type(): + from badfish.helpers.parser import parse_arguments + + with pytest.raises(SystemExit): + parse_arguments(["-H", "host", "-u", "user", "-p", "pass", "--timeout", "notanint"]) + + +def test_timeout_argument_rejects_zero(): + from badfish.helpers.parser import parse_arguments + + with pytest.raises(SystemExit): + parse_arguments(["-H", "host", "-u", "user", "-p", "pass", "--timeout", "0"]) + + +def test_timeout_argument_rejects_negative(): + from badfish.helpers.parser import parse_arguments + + with pytest.raises(SystemExit): + parse_arguments(["-H", "host", "-u", "user", "-p", "pass", "--timeout", "-5"]) + + +@pytest.mark.asyncio +async def test_execute_badfish_passes_timeout_to_factory(mock_args): + host = "test_host" + mock_args.update({"u": "user", "p": "pass", "retries": 1, "timeout": 15}) + logger = MagicMock(spec=logging.Logger) + + with patch("badfish.main.badfish_factory", new_callable=AsyncMock) as mock_factory: + mock_badfish = MagicMock() + mock_badfish.logger = logger + mock_badfish.session_id = None + mock_factory.return_value = mock_badfish + + await execute_badfish(host, mock_args, logger, None) + + _, kwargs = mock_factory.call_args + assert kwargs.get("_timeout") == 15 diff --git a/tests/test_reset_bmc.py b/tests/test_reset_bmc.py index 17c9b2c..faeff0a 100644 --- a/tests/test_reset_bmc.py +++ b/tests/test_reset_bmc.py @@ -3,6 +3,7 @@ from tests.config import ( BOOT_SEQ_RESPONSE_DIRECTOR, INIT_RESP, + INIT_RESP_HPE, INIT_RESP_SUPERMICRO, RESET_TYPE_RESP, RESET_TYPE_RESP_NO_ALLOWABLE_VALUES, @@ -66,4 +67,17 @@ def test_reset_bmc_wrong_vendor(self, mock_get, mock_post, mock_delete): self.boot_seq = BOOT_SEQ_RESPONSE_DIRECTOR self.args = [self.option_arg] _, err = self.badfish_call() - assert err == RESPONSE_RESET_WRONG_VENDOR % ("Supermicro", "Dell", "--racreset") + assert err == RESPONSE_RESET_WRONG_VENDOR % ("Supermicro or HPE", "Dell", "--racreset") + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.get") + def test_reset_bmc_hpe(self, mock_get, mock_post, mock_delete): + responses = INIT_RESP_HPE + [RESET_TYPE_RESP] + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_post, [200, 200], "OK", True) + self.set_mock_response(mock_delete, 200, "OK") + self.boot_seq = BOOT_SEQ_RESPONSE_DIRECTOR + self.args = [self.option_arg] + _, err = self.badfish_call() + assert err == RESPONSE_RESET % ("200", "BMC", "BMC") diff --git a/tests/test_reset_idrac.py b/tests/test_reset_idrac.py index edc28c4..927094a 100644 --- a/tests/test_reset_idrac.py +++ b/tests/test_reset_idrac.py @@ -53,7 +53,7 @@ def test_reset_idrac_wrong_vendor(self, mock_get, mock_post, mock_delete): self.boot_seq = BOOT_SEQ_RESPONSE_DIRECTOR self.args = [self.option_arg] _, err = self.badfish_call() - assert err == RESPONSE_RESET_WRONG_VENDOR % ("Dell", "Supermicro", "--bmc-reset") + assert err == RESPONSE_RESET_WRONG_VENDOR % ("Dell", "Supermicro or HPE", "--bmc-reset") @patch("badfish.main.Badfish.wait_for_idrac_ready", new_callable=AsyncMock) @patch("aiohttp.ClientSession.delete") diff --git a/tests/test_scp.py b/tests/test_scp.py index 4de772d..b729b40 100644 --- a/tests/test_scp.py +++ b/tests/test_scp.py @@ -31,12 +31,15 @@ from tests.test_base import TestBase +FIXED_BASE_TIME = datetime(2024, 6, 15, 10, 30, 0) + + def fixed_datetime(): fixed_datetime.counter = getattr(fixed_datetime, "counter", 0) + 1 if fixed_datetime.counter < 5: - return datetime.now() + return FIXED_BASE_TIME else: - return datetime.now() + timedelta(minutes=15) + return FIXED_BASE_TIME + timedelta(minutes=15) def export_dir_check(): @@ -114,11 +117,13 @@ class TestExportSCP(TestBase): tests_dir = os.path.dirname(__file__) example_path = os.path.join(tests_dir, "fixtures/example_scp.json") - @patch("badfish.helpers.get_now", fixed_datetime) + @patch("badfish.main.get_now", fixed_datetime) @patch("aiohttp.ClientSession.delete") @patch("aiohttp.ClientSession.post") @patch("aiohttp.ClientSession.get") def test_pass(self, mock_get, mock_post, mock_delete): + if hasattr(fixed_datetime, "counter"): + setattr(fixed_datetime, "counter", 0) export_dir_check() scp_file = open(self.example_path, "r").read() # Wait-then-fetch approach: wait 45 seconds, then fetch from Jobs, fallback to Tasks diff --git a/tests/test_vendor_detection.py b/tests/test_vendor_detection.py new file mode 100644 index 0000000..cf71115 --- /dev/null +++ b/tests/test_vendor_detection.py @@ -0,0 +1,62 @@ +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from badfish.main import Badfish + + +ROOT_RESP_UNKNOWN_OEM = ( + '{"Managers":{"@odata.id":"/redfish/v1/Managers"},' '"RedfishVersion":"1.0.2","Oem":{"Lenovo":{}}}' +) +MAN_RESP = '{"Members":[{"@odata.id":"/redfish/v1/Managers/1"}]}' + + +def _make_resp(body: str) -> MagicMock: + resp = MagicMock() + resp.status = 200 + resp.text = AsyncMock(return_value=body) + return resp + + +async def _init_vendor(root_body: str) -> str: + logger = MagicMock(spec=logging.Logger) + bf = Badfish("test_host", "user", "pass", logger, 1) + bf.http_client = MagicMock() + bf.http_client.get_request = AsyncMock(side_effect=[_make_resp(root_body), _make_resp(MAN_RESP)]) + await bf.find_managers_resource() + return bf.vendor + + +@pytest.mark.asyncio +async def test_vendor_dell(): + root = ( + '{"Managers":{"@odata.id":"/redfish/v1/Managers"},' + '"RedfishVersion":"1.0.2","Oem":{"Dell":{"ServiceTag":"T35T7A6"}}}' + ) + assert await _init_vendor(root) == "Dell" + + +@pytest.mark.asyncio +async def test_vendor_supermicro(): + root = '{"Managers":{"@odata.id":"/redfish/v1/Managers"},' '"RedfishVersion":"1.0.2","Oem":{"Supermicro":{}}}' + assert await _init_vendor(root) == "Supermicro" + + +@pytest.mark.asyncio +async def test_vendor_hpe(): + root = ( + '{"Managers":{"@odata.id":"/redfish/v1/Managers"},' '"RedfishVersion":"1.0.2","Oem":{"Hpe":{"Manager":[{}]}}}' + ) + assert await _init_vendor(root) == "HPE" + + +@pytest.mark.asyncio +async def test_vendor_unknown_oem(): + assert await _init_vendor(ROOT_RESP_UNKNOWN_OEM) == "Unknown" + + +@pytest.mark.asyncio +async def test_vendor_no_oem(): + root = '{"Managers":{"@odata.id":"/redfish/v1/Managers"},' '"RedfishVersion":"1.0.2"}' + assert await _init_vendor(root) == "Unknown" diff --git a/tests/test_virtual_media.py b/tests/test_virtual_media.py index 41d5713..f89726c 100644 --- a/tests/test_virtual_media.py +++ b/tests/test_virtual_media.py @@ -9,6 +9,7 @@ BOOT_SOURCE_OVERRIDE_TARGET_CD, BOOT_SOURCE_OVERRIDE_TARGET_USBCD, INIT_RESP, + INIT_RESP_HPE, INIT_RESP_SUPERMICRO, JOB_OK_RESP, RESPONSE_BOOT_TO, @@ -431,6 +432,26 @@ def test_fail_sm(self, mock_get, mock_post, mock_delete, mock_patch): _, err = self.badfish_call() assert err == VMEDIA_BOOT_TO_SM_FAIL + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.get") + def test_good_hpe(self, mock_get, mock_post, mock_delete, mock_patch): + responses_get = [ + VMEDIA_GET_VM_CONFIG_RESP_DELL, + VMEDIA_MEMBER_RM_DISK_RESP, + VMEDIA_MEMBER_CD_RESP, + BOOT_SOURCE_OVERRIDE_TARGET_USBCD, + ] + responses = INIT_RESP_HPE + responses_get + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_post, 200, "OK") + self.set_mock_response(mock_delete, 200, "OK") + self.set_mock_response(mock_patch, 200, "OK") + self.args = [self.option_arg] + _, err = self.badfish_call() + assert err == VMEDIA_BOOT_TO_SM_PASS + class TestCheckRemoteImage(TestBase): option_arg = "--check-remote-image"