From 30d6b5f088248974f6120e681f1c717063485945 Mon Sep 17 00:00:00 2001 From: Marek Mahut Date: Mon, 25 May 2026 10:46:15 +0200 Subject: [PATCH 01/36] feat: add --rack, --uloc, --blade CLI arguments for explicit host location fixes #416 --- src/badfish/helpers/parser.py | 3 ++ src/badfish/main.py | 38 ++++++++++++++++---- tests/test_custom_interfaces.py | 63 +++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/badfish/helpers/parser.py b/src/badfish/helpers/parser.py index 1e842dc..cbcb2bc 100644 --- a/src/badfish/helpers/parser.py +++ b/src/badfish/helpers/parser.py @@ -26,6 +26,9 @@ def create_parser(): 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("--rack", help="Rack name of the host", default=None) + parser.add_argument("--uloc", help="U-location name of the host", default=None) + parser.add_argument("--blade", help="Blade name of the host", default=None) parser.add_argument("-l", "--log", help="Optional argument for logging results to a file") parser.add_argument( "-o", diff --git a/src/badfish/main.py b/src/badfish/main.py index a71af44..971dd0d 100755 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -48,6 +48,9 @@ async def badfish_factory( _console=None, _progress_disabled=False, _timeout=TIMEOUT, + _rack=None, + _uloc=None, + _blade=None, ): if not _logger: bfl = BadfishLogger() @@ -64,6 +67,9 @@ async def badfish_factory( _console, _progress_disabled, _timeout, + _rack, + _uloc, + _blade, ) await badfish.init() return badfish @@ -82,11 +88,17 @@ def __init__( _console=None, _progress_disabled=False, _timeout=TIMEOUT, + _rack=None, + _uloc=None, + _blade=None, ): self.host = _host self.username = _username self.password = _password self.retries = _retries + self.rack = _rack + self.uloc = _uloc + self.blade = _blade self.host_uri = "https://%s" % _host self.redfish_uri = "/redfish/v1" self.root_uri = "%s%s" % (self.host_uri, self.redfish_uri) @@ -164,19 +176,25 @@ async def error_handler(self, _response, message=None): async def get_interfaces_by_type(self, host_type, _interfaces_path): definitions = await self.read_yaml(_interfaces_path) - host_name_split = self.host.split(".")[0].split("-") + host_name_split = self.host.split(":")[0].split(".")[0].split("-") host_model = None - rack = None - uloc = None host_blade = None prefix = [host_type] + if len(host_name_split) > 1: host_model = host_name_split[-1] - rack = host_name_split[1] - uloc = host_name_split[2] - prefix.extend([rack, uloc]) + rack = self.rack if self.rack is not None else host_name_split[1] + uloc = self.uloc if self.uloc is not None else ( + host_name_split[2] if len(host_name_split) > 2 else None + ) + for val in [rack, uloc]: + if val is not None: + prefix.append(val) - if len(host_name_split) > 4: + if self.blade is not None: + host_blade = self.blade + prefix.append(host_blade) + elif len(host_name_split) > 4: host_blade = host_name_split[3] prefix.append(host_blade) @@ -2904,6 +2922,9 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non host_type = _args["t"] interfaces_path = _args["i"] force = _args["force"] + rack = _args.get("rack") + uloc = _args.get("uloc") + blade = _args.get("blade") pxe = _args["pxe"] device = _args["boot_to"] boot_to_type = _args["boot_to_type"] @@ -2974,6 +2995,9 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non _console=console, _progress_disabled=progress_disabled, _timeout=timeout, + _rack=rack, + _uloc=uloc, + _blade=blade, ) if _args["host_list"] and not _args["output"]: diff --git a/tests/test_custom_interfaces.py b/tests/test_custom_interfaces.py index 6035e5a..743987b 100644 --- a/tests/test_custom_interfaces.py +++ b/tests/test_custom_interfaces.py @@ -51,3 +51,66 @@ async def test_get_interface_by_type(self): f"{host_type} interfaces for host: {host} : " f"{interfaces} does not match expected: {expected_interfaces}" ) + + +class TestGetInterfaceByTypeOverride(TestBase): + @pytest.mark.asyncio + async def test_explicit_rack_uloc(self): + # Non-standard hostname; rack and uloc provided explicitly to match e27 interfaces + badfish = Badfish( + _host="server-r750.example.com", + _username="", + _password="", + _logger="", + _retries="", + _rack="e27", + _uloc="h01", + ) + for host_type, expected_str in E27_EXPECTED_INTERFACES.items(): + expected_interfaces = expected_str.split(",") + with self.subTest(host_type=host_type): + interfaces = await badfish.get_interfaces_by_type(host_type, _interfaces_path=INTERFACES_PATH) + assert interfaces == expected_interfaces, ( + f"{host_type} interfaces with explicit rack/uloc: " + f"{interfaces} does not match expected: {expected_interfaces}" + ) + + @pytest.mark.asyncio + async def test_explicit_blade(self): + # Non-standard hostname; rack, uloc, and blade provided explicitly for blade b01 + badfish = Badfish( + _host="server-fc640.example.com", + _username="", + _password="", + _logger="", + _retries="", + _rack="e99", + _uloc="h01", + _blade="b01", + ) + expected_interfaces = FC640_B01_INTERFACES["uefi"].split(",") + interfaces = await badfish.get_interfaces_by_type("uefi", _interfaces_path=INTERFACES_PATH) + assert interfaces == expected_interfaces, ( + f"uefi interfaces with explicit blade b01: " + f"{interfaces} does not match expected: {expected_interfaces}" + ) + + @pytest.mark.asyncio + async def test_explicit_blade_b02(self): + # Non-standard hostname; rack, uloc, and blade provided explicitly for blade b02 + badfish = Badfish( + _host="server-fc640.example.com", + _username="", + _password="", + _logger="", + _retries="", + _rack="e99", + _uloc="h01", + _blade="b02", + ) + expected_interfaces = FC640_B02_INTERFACES["uefi"].split(",") + interfaces = await badfish.get_interfaces_by_type("uefi", _interfaces_path=INTERFACES_PATH) + assert interfaces == expected_interfaces, ( + f"uefi interfaces with explicit blade b02: " + f"{interfaces} does not match expected: {expected_interfaces}" + ) From 41f06850f026c3525a825620892497ab77771df7 Mon Sep 17 00:00:00 2001 From: Marek Mahut Date: Fri, 15 May 2026 12:04:16 +0200 Subject: [PATCH 02/36] feat: add AUR package and CI publishing --- .github/workflows/aur.yml | 54 +++++++++++++++++++++++ .github/workflows/production-release.yml | 55 ++++++++++++++++++++++++ aur/.SRCINFO | 21 +++++++++ aur/PKGBUILD | 35 +++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 .github/workflows/aur.yml create mode 100644 aur/.SRCINFO create mode 100644 aur/PKGBUILD diff --git a/.github/workflows/aur.yml b/.github/workflows/aur.yml new file mode 100644 index 0000000..8536aa3 --- /dev/null +++ b/.github/workflows/aur.yml @@ -0,0 +1,54 @@ +name: AUR + +on: + workflow_dispatch: + + pull_request: + types: [opened, edited] + branches: [development, master] + paths: + - 'aur/**' + - '.github/workflows/aur.yml' + + push: + branches: [master] + paths: + - 'aur/**' + - '.github/workflows/aur.yml' + +jobs: + build: + name: Build and Lint AUR Package + runs-on: ubuntu-latest + container: + image: archlinux:latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + pacman -Sy --noconfirm base-devel namcap python python-build \ + python-installer python-wheel python-setuptools \ + python-yaml python-aiohttp python-async-lru python-rich + + - name: Create build user + run: | + useradd -m builder + chown -R builder /github/workspace || chown -R builder $GITHUB_WORKSPACE + + - name: Lint with namcap + run: namcap aur/PKGBUILD + + - name: Build package + run: | + cp -r aur /home/builder/aur + chown -R builder /home/builder/aur + su builder -c 'cd /home/builder/aur && makepkg --noconfirm' + + - name: Verify package installs + run: | + pkg=$(ls /home/builder/aur/*.pkg.tar.zst) + pacman -U --noconfirm "$pkg" + badfish --help diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml index b4a11e4..ed4b437 100644 --- a/.github/workflows/production-release.yml +++ b/.github/workflows/production-release.yml @@ -140,3 +140,58 @@ jobs: podman tag quay.io/quads/badfish:master quay.io/quads/badfish:latest podman push quay.io/quads/badfish:master podman push quay.io/quads/badfish:latest + + # ------------------------------------------------------------------ + # JOB 4: AUR PUBLISH + # ------------------------------------------------------------------ + aur_publish: + name: Publish to AUR + needs: release + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout Tagged Release + uses: actions/checkout@v4 + with: + ref: ${{ needs.release.outputs.tag }} + + - name: Update PKGBUILD and .SRCINFO + env: + VERSION: ${{ needs.release.outputs.version }} + TAG: ${{ needs.release.outputs.tag }} + run: | + SHA256=$(curl -sL "https://github.com/quadsproject/badfish/archive/refs/tags/${TAG}.tar.gz" | sha256sum | cut -d' ' -f1) + + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" aur/PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" aur/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA256}')/" aur/PKGBUILD + + sed -i "s/^\tpkgver = .*/\tpkgver = ${VERSION}/" aur/.SRCINFO + sed -i "s/^\tpkgrel = .*/\tpkgrel = 1/" aur/.SRCINFO + sed -i "s|badfish-.*\.tar\.gz::|badfish-${VERSION}.tar.gz::|" aur/.SRCINFO + sed -i "s|/tags/v[^/]*/|/tags/${TAG}/|" aur/.SRCINFO + sed -i "s/^\tsha256sums = .*/\tsha256sums = ${SHA256}/" aur/.SRCINFO + + - name: Set up SSH for AUR + env: + AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$AUR_SSH_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + printf 'Host aur.archlinux.org\n IdentityFile ~/.ssh/aur\n User aur\n' >> ~/.ssh/config + ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts + + - name: Push to AUR + env: + VERSION: ${{ needs.release.outputs.version }} + run: | + git config --global user.name "GitHub CI" + git config --global user.email "ci@github.com" + git clone ssh://aur@aur.archlinux.org/badfish.git /tmp/aur-badfish + cp aur/PKGBUILD aur/.SRCINFO /tmp/aur-badfish/ + cd /tmp/aur-badfish + git add PKGBUILD .SRCINFO + git commit -m "Update to ${VERSION}" + git push diff --git a/aur/.SRCINFO b/aur/.SRCINFO new file mode 100644 index 0000000..eec66e1 --- /dev/null +++ b/aur/.SRCINFO @@ -0,0 +1,21 @@ +pkgbase = badfish + pkgdesc = Redfish-based API tool for managing bare-metal systems via out-of-band management + pkgver = 1.5.0 + pkgrel = 1 + url = https://github.com/quadsproject/badfish + arch = any + license = GPL-3.0-or-later + makedepends = python-build + makedepends = python-installer + makedepends = python-wheel + makedepends = python-setuptools + depends = python + depends = python-yaml + depends = python-aiohttp + depends = python-setuptools + depends = python-async-lru + depends = python-rich + source = badfish-1.5.0.tar.gz::https://github.com/quadsproject/badfish/archive/refs/tags/v1.5.0.tar.gz + sha256sums = 70ffb8e5980e8f0177ce6dec1e78550156a425b63d5ee41854df37a0ffb4b47d + +pkgname = badfish diff --git a/aur/PKGBUILD b/aur/PKGBUILD new file mode 100644 index 0000000..e1ac665 --- /dev/null +++ b/aur/PKGBUILD @@ -0,0 +1,35 @@ +# Maintainer: Marek Mahut + +pkgname=badfish +pkgver=1.5.0 +pkgrel=1 +pkgdesc="Redfish-based API tool for managing bare-metal systems via out-of-band management" +arch=('any') +url="https://github.com/quadsproject/badfish" +license=('GPL-3.0-or-later') +depends=( + 'python' + 'python-yaml' + 'python-aiohttp' + 'python-setuptools' + 'python-async-lru' + 'python-rich' +) +makedepends=( + 'python-build' + 'python-installer' + 'python-wheel' + 'python-setuptools' +) +source=("${pkgname}-${pkgver}.tar.gz::https://github.com/quadsproject/badfish/archive/refs/tags/v${pkgver}.tar.gz") +sha256sums=('70ffb8e5980e8f0177ce6dec1e78550156a425b63d5ee41854df37a0ffb4b47d') + +build() { + cd "${pkgname}-${pkgver}" + python -m build --wheel --no-isolation +} + +package() { + cd "${pkgname}-${pkgver}" + python -m installer --destdir="${pkgdir}" dist/*.whl +} From c43f4d0450f9902f726f16abe8d1d8dfbc61c28a Mon Sep 17 00:00:00 2001 From: Will Foster Date: Fri, 4 Sep 2026 15:05:21 +0100 Subject: [PATCH 03/36] chore: bump CI Python to a support version. --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 224e4a9..a318364 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v1 with: - python-version: 3.13.13 + python-version: 3.13.15 - name: Install Python dependencies run: pip install black flake8 From 1f07abead37393e6dafd0d362972021b42529168 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Fri, 4 Sep 2026 15:51:52 +0200 Subject: [PATCH 04/36] feat: support setting multiple BIOS attributes in one invocation The --set-bios-attribute path now accepts a repeatable --attribute-value option taking attribute=value pairs, so multiple BIOS attributes are staged and applied in a single operation and one reboot. Existing --attribute/--value usage is unchanged. The accepted-value check is reset per attribute so an invalid value is not masked by an earlier accepted one, and accepted values are sent using the registry's canonical casing. fixes: https://github.com/quadsproject/badfish/issues/393 --- README.md | 6 +++ src/badfish/helpers/parser.py | 6 +++ src/badfish/main.py | 16 ++++++-- tests/config.py | 39 ++++++++++++++++++ tests/test_bios_attributes.py | 75 +++++++++++++++++++++++++++++++++++ tests/test_context_manager.py | 4 ++ 6 files changed, 143 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bed2db9..5c60115 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ * [Get BIOS attributes](#get-bios-attributes) * [Get specific BIOS attribute](#get-specific-bios-attribute) * [Set BIOS attribute](#set-bios-attribute) + * [Set multiple BIOS attributes](#set-multiple-bios-attributes) * [Change between BIOS and UEFI modes](#change-between-bios-and-uefi-modes) * [Querying bootmode](#querying-bootmode) * [Setting UEFI mode](#setting-uefi-mode) @@ -604,6 +605,11 @@ To change the value of a bios attribute you can use ```--set-bios-attribute``` p ```bash badfish -H mgmt-your-server.example.com --set-bios-attribute --attribute ProcC1E --value Enabled ``` +#### Set multiple BIOS attributes +To set multiple BIOS attributes in one call, repeat ```--attribute-value``` with an ```attribute=value``` pair each time. All attributes are applied in a single operation and one reboot. +```bash +badfish -H mgmt-your-server.example.com --set-bios-attribute --attribute-value ProcC1E=Enabled --attribute-value BootMode=Uefi +``` > [!NOTE] > You can get the list of allowed values you can pass for that attribute by looking at the attribute details via ```--get-bios-attribute``` for that specific one. diff --git a/src/badfish/helpers/parser.py b/src/badfish/helpers/parser.py index cbcb2bc..60fd3ee 100644 --- a/src/badfish/helpers/parser.py +++ b/src/badfish/helpers/parser.py @@ -229,6 +229,12 @@ def create_parser(): help="BIOS attribute value", default="", ) + parser.add_argument( + "--attribute-value", + help="BIOS attribute/value pair, e.g. ProcC1E=Enabled. May be repeated to set multiple BIOS attributes in one call.", + action="append", + default=[], + ) parser.add_argument( "--set-bios-password", help="Set the BIOS password", diff --git a/src/badfish/main.py b/src/badfish/main.py index 971dd0d..1509518 100755 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -289,13 +289,13 @@ async def get_bios_attribute(self, attribute): async def set_bios_attribute(self, attributes): data = await self.get_bios_attributes_registry() - accepted = False for entry in data["RegistryEntries"]["Attributes"]: entries = [low_entry.lower() for low_entry in entry.values() if isinstance(low_entry, str)] _warnings = [] _not_found = [] _remove = [] for attribute, value in attributes.items(): + accepted = False if attribute.lower() in entries: for values in entry.items(): if values[0] == "Value": @@ -303,6 +303,7 @@ async def set_bios_attribute(self, attributes): for accepted_value in accepted_values: if value.lower() == accepted_value.lower(): value = accepted_value + attributes[attribute] = accepted_value accepted = True if not accepted: _warnings.append(f"List of accepted values for '{attribute}': {accepted_values}") @@ -2964,6 +2965,7 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non get_bios_attribute = _args["get_bios_attribute"] attribute = _args["attribute"] value = _args["value"] + attribute_value = _args["attribute_value"] set_bios_password = _args["set_bios_password"] remove_bios_password = _args["remove_bios_password"] new_password = _args["new_password"] @@ -3083,8 +3085,16 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non for attribute, value in data["Attributes"].items(): logger.info(f"{attribute}: {value}") elif set_bios_attribute: - payload = {attribute: value} - await badfish.set_bios_attribute(payload) + attributes = {} + if attribute_value: + for pair in attribute_value: + name, sep, attr_value = pair.partition("=") + if not sep or not name.strip() or not attr_value.strip(): + raise BadfishException(f"Invalid attribute/value pair supplied: {pair}") + attributes[name.strip()] = attr_value.strip() + else: + attributes = {attribute: value} + await badfish.set_bios_attribute(attributes) elif set_bios_password: await badfish.set_bios_password(old_password, new_password) elif remove_bios_password: diff --git a/tests/config.py b/tests/config.py index fc0a286..968433e 100644 --- a/tests/config.py +++ b/tests/config.py @@ -974,6 +974,9 @@ def render_device_dict(index, device): ATTR_VALUE_OK = "Enabled" ATTR_VALUE_BAD = "NotAllowed" ATTR_VALUE_DIS = "Disabled" +ATTRIBUTE_OK_2 = "BootMode" +ATTR_VALUE_OK_2 = "Uefi" +ATTR_VALUE_DIS_2 = "Bios" BIOS_RESPONSE_OK = '{"Attributes":{"%s": "%s"}}' % (ATTRIBUTE_OK, ATTR_VALUE_OK) BIOS_RESPONSE_DIS = '{"Attributes":{"%s": "%s"}}' % (ATTRIBUTE_OK, ATTR_VALUE_DIS) @@ -1016,6 +1019,32 @@ def render_device_dict(index, device): "WriteOnly": "False", } BIOS_REGISTRY_OK = BIOS_REGISTRY_BASE % str([BIOS_REGISTRY_1, BIOS_REGISTRY_2]) +BIOS_REGISTRY_3 = { + "AttributeName": "BootMode", + "CurrentValue": "None", + "DisplayName": "Boot Mode", + "DisplayOrder": 9001, + "HelpText": "Select the boot mode.", + "Hidden": "False", + "Immutable": "False", + "MenuPath": "./BootSettingsRef", + "ReadOnly": "False", + "ResetRequired": "True", + "Type": "Enumeration", + "Value": [ + {"ValueDisplayName": "Uefi", "ValueName": "Uefi"}, + {"ValueDisplayName": "Bios", "ValueName": "Bios"}, + ], + "WarningText": "None", + "WriteOnly": "False", +} +BIOS_REGISTRY_MULTI = BIOS_REGISTRY_BASE % str([BIOS_REGISTRY_2, BIOS_REGISTRY_3]) +BIOS_RESPONSE_MULTI = '{"Attributes":{"%s": "%s", "%s": "%s"}}' % ( + ATTRIBUTE_OK, + ATTR_VALUE_DIS, + ATTRIBUTE_OK_2, + ATTR_VALUE_DIS_2, +) BIOS_SET_OK = """\ - INFO - Command passed to set BIOS attribute pending values. - INFO - Command passed to GracefulRestart server, code return is 200. @@ -1034,6 +1063,16 @@ def render_device_dict(index, device): - ERROR - NotThere not found. Please check attribute name. - ERROR - Attribute not found """ +BIOS_SET_MULTI_BAD_VALUE = ( + """\ +- WARNING - List of accepted values for '%s': ['Uefi', 'Bios'] +- ERROR - Value not accepted +""" + % ATTRIBUTE_OK_2 +) +BIOS_SET_MULTI_BAD_PAIR = """\ +- ERROR - Invalid attribute/value pair supplied: ProcC1E +""" BIOS_GET_ALL_OK = f"""- INFO - {ATTRIBUTE_OK}: {ATTR_VALUE_OK}\n""" BIOS_GET_ONE_OK = """\ - INFO - AttributeName: ProcC1E diff --git a/tests/test_bios_attributes.py b/tests/test_bios_attributes.py index 0a93640..824d025 100644 --- a/tests/test_bios_attributes.py +++ b/tests/test_bios_attributes.py @@ -1,18 +1,25 @@ +import json from unittest.mock import patch from tests.config import ( ATTR_VALUE_BAD, ATTR_VALUE_OK, + ATTR_VALUE_OK_2, ATTRIBUTE_BAD, ATTRIBUTE_OK, + ATTRIBUTE_OK_2, BIOS_GET_ALL_OK, BIOS_GET_ONE_BAD, BIOS_GET_ONE_OK, + BIOS_REGISTRY_MULTI, BIOS_REGISTRY_OK, BIOS_RESPONSE_DIS, + BIOS_RESPONSE_MULTI, BIOS_RESPONSE_OK, BIOS_SET_BAD_ATTR, BIOS_SET_BAD_VALUE, + BIOS_SET_MULTI_BAD_PAIR, + BIOS_SET_MULTI_BAD_VALUE, BIOS_SET_OK, INIT_RESP, JOB_OK_RESP, @@ -101,6 +108,74 @@ def test_set_bios_attribute_bad_attr(self, mock_get, mock_patch, mock_post, mock _, err = self.badfish_call() assert err == BIOS_SET_BAD_ATTR + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_multi_ok(self, mock_get, mock_patch, mock_post, mock_delete): + get_resp = [ + BIOS_REGISTRY_MULTI.replace("'", '"'), + BIOS_RESPONSE_MULTI, + RESET_TYPE_RESP, + STATE_ON_RESP, + STATE_ON_RESP, + ] + responses = INIT_RESP + get_resp + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = [ + self.option_arg, + "--attribute-value", + f"{ATTRIBUTE_OK}={ATTR_VALUE_OK}", + "--attribute-value", + f"{ATTRIBUTE_OK_2}={ATTR_VALUE_OK_2}", + ] + _, err = self.badfish_call() + assert err == BIOS_SET_OK + assert mock_patch.call_args is not None + payload = json.loads(mock_patch.call_args.kwargs["data"])["Attributes"] + assert payload == {ATTRIBUTE_OK: ATTR_VALUE_OK, ATTRIBUTE_OK_2: ATTR_VALUE_OK_2} + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_multi_bad_value(self, mock_get, mock_patch, mock_post, mock_delete): + get_resp = [ + BIOS_REGISTRY_MULTI.replace("'", '"'), + BIOS_RESPONSE_MULTI, + ] + responses = INIT_RESP + get_resp + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = [ + self.option_arg, + "--attribute-value", + f"{ATTRIBUTE_OK}={ATTR_VALUE_OK}", + "--attribute-value", + f"{ATTRIBUTE_OK_2}={ATTR_VALUE_BAD}", + ] + _, err = self.badfish_call() + assert err == BIOS_SET_MULTI_BAD_VALUE + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_bad_pair(self, mock_get, mock_patch, mock_post, mock_delete): + responses = INIT_RESP + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = [self.option_arg, "--attribute-value", ATTRIBUTE_OK] + _, err = self.badfish_call() + assert err == BIOS_SET_MULTI_BAD_PAIR + class TestGetBiosAttribute(TestBase): option_arg = "--get-bios-attribute" diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py index d68bbc3..b48c58d 100644 --- a/tests/test_context_manager.py +++ b/tests/test_context_manager.py @@ -452,6 +452,7 @@ async def test_execute_badfish_session_cleanup_success(self): "get_bios_attribute": False, "attribute": "", "value": "", + "attribute_value": [], "set_bios_password": False, "remove_bios_password": False, "new_password": "", @@ -538,6 +539,7 @@ async def test_execute_badfish_session_cleanup_failure(self): "get_bios_attribute": False, "attribute": "", "value": "", + "attribute_value": [], "set_bios_password": False, "remove_bios_password": False, "new_password": "", @@ -628,6 +630,7 @@ async def test_execute_badfish_no_session_cleanup(self): "get_bios_attribute": False, "attribute": "", "value": "", + "attribute_value": [], "set_bios_password": False, "remove_bios_password": False, "new_password": "", @@ -714,6 +717,7 @@ async def test_execute_badfish_no_badfish_instance(self): "get_bios_attribute": False, "attribute": "", "value": "", + "attribute_value": [], "set_bios_password": False, "remove_bios_password": False, "new_password": "", From dfe08d38f328ac236702c081a2ce5b637460630d Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 20:52:53 +0200 Subject: [PATCH 05/36] fix: harden multi-attribute BIOS parsing and skip no-op PATCH Follow-up from independent and adversarial review of the PR: - reject duplicate --attribute-value names instead of silently last-wins - error when --attribute-value is mixed with legacy --attribute/--value - require --set-bios-attribute when --attribute-value is used (no silent no-op) - validate pairs before any session/network work so bad syntax fails fast - resolve attributes to the registry canonical AttributeName before the current-value lookup and PATCH (odd casing no longer misreported) - skip the PATCH and reboot entirely when every value is already in the desired state - pin single-PATCH / no-PATCH counts and add edge-case tests --- src/badfish/helpers/parser.py | 2 +- src/badfish/main.py | 51 +++++++++++--- tests/test_bios_attributes.py | 122 +++++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 13 deletions(-) diff --git a/src/badfish/helpers/parser.py b/src/badfish/helpers/parser.py index 60fd3ee..4bb90fc 100644 --- a/src/badfish/helpers/parser.py +++ b/src/badfish/helpers/parser.py @@ -233,7 +233,7 @@ def create_parser(): "--attribute-value", help="BIOS attribute/value pair, e.g. ProcC1E=Enabled. May be repeated to set multiple BIOS attributes in one call.", action="append", - default=[], + default=None, ) parser.add_argument( "--set-bios-password", diff --git a/src/badfish/main.py b/src/badfish/main.py index 1509518..5562612 100755 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -289,6 +289,22 @@ async def get_bios_attribute(self, attribute): async def set_bios_attribute(self, attributes): data = await self.get_bios_attributes_registry() + # Resolve user-supplied attribute names to the registry's canonical + # AttributeName up front, so oddly-cased NAME=value pairs (e.g. + # bootmode=Uefi) read current values and PATCH with the BMC's exact + # attribute naming instead of failing with a misleading + # "attribute not found". + canonical_map = {} + for entry in data["RegistryEntries"]["Attributes"]: + entries = [low_entry.lower() for low_entry in entry.values() if isinstance(low_entry, str)] + for attribute in list(attributes): + if attribute.lower() in entries and attribute not in canonical_map: + canonical_map[attribute] = entry.get("AttributeName") or attribute + for attribute in list(attributes): + canonical = canonical_map.get(attribute) + if canonical and canonical != attribute: + attributes[canonical] = attributes.pop(attribute) + for entry in data["RegistryEntries"]["Attributes"]: entries = [low_entry.lower() for low_entry in entry.values() if isinstance(low_entry, str)] _warnings = [] @@ -302,12 +318,10 @@ async def set_bios_attribute(self, attributes): accepted_values = [value["ValueName"] for value in values[1]] for accepted_value in accepted_values: if value.lower() == accepted_value.lower(): - value = accepted_value attributes[attribute] = accepted_value accepted = True if not accepted: _warnings.append(f"List of accepted values for '{attribute}': {accepted_values}") - attribute_value = await self.get_bios_attribute(attribute) if attribute_value: if value.lower() == attribute_value.lower(): @@ -327,6 +341,10 @@ async def set_bios_attribute(self, attributes): for attribute in _remove: attributes.pop(attribute) + if not attributes: + self.logger.info("All attributes are already in the desired state; skipping PATCH and reboot.") + return + _payload = {"Attributes": attributes} await self.patch_bios(_payload, insist=False) @@ -2987,6 +3005,25 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non badfish = None try: + # Parse and validate --attribute-value pairs before any session or + # network work: syntax errors fail fast with zero BMC round-trips, and + # duplicate names / mixed flags are rejected instead of silently + # applying a partial set. + bios_attributes = {} + if attribute_value: + if not set_bios_attribute: + raise BadfishException("--attribute-value requires --set-bios-attribute") + if attribute or value: + raise BadfishException("Use either --attribute/--value or --attribute-value, not both") + for pair in attribute_value: + name, _sep, attr_value = pair.partition("=") + if not _sep or not name.strip() or not attr_value.strip(): + raise BadfishException(f"Invalid attribute/value pair supplied: {pair}") + name = name.strip() + if name in bios_attributes: + raise BadfishException(f"Duplicate BIOS attribute supplied: {name}") + bios_attributes[name] = attr_value.strip() + badfish = await badfish_factory( _host=_host, _username=_username, @@ -3085,15 +3122,7 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non for attribute, value in data["Attributes"].items(): logger.info(f"{attribute}: {value}") elif set_bios_attribute: - attributes = {} - if attribute_value: - for pair in attribute_value: - name, sep, attr_value = pair.partition("=") - if not sep or not name.strip() or not attr_value.strip(): - raise BadfishException(f"Invalid attribute/value pair supplied: {pair}") - attributes[name.strip()] = attr_value.strip() - else: - attributes = {attribute: value} + attributes = bios_attributes if attribute_value else {attribute: value} await badfish.set_bios_attribute(attributes) elif set_bios_password: await badfish.set_bios_password(old_password, new_password) diff --git a/tests/test_bios_attributes.py b/tests/test_bios_attributes.py index 824d025..384442e 100644 --- a/tests/test_bios_attributes.py +++ b/tests/test_bios_attributes.py @@ -3,6 +3,8 @@ from tests.config import ( ATTR_VALUE_BAD, + ATTR_VALUE_DIS, + ATTR_VALUE_DIS_2, ATTR_VALUE_OK, ATTR_VALUE_OK_2, ATTRIBUTE_BAD, @@ -134,7 +136,7 @@ def test_set_bios_attribute_multi_ok(self, mock_get, mock_patch, mock_post, mock ] _, err = self.badfish_call() assert err == BIOS_SET_OK - assert mock_patch.call_args is not None + assert mock_patch.call_count == 1 payload = json.loads(mock_patch.call_args.kwargs["data"])["Attributes"] assert payload == {ATTRIBUTE_OK: ATTR_VALUE_OK, ATTRIBUTE_OK_2: ATTR_VALUE_OK_2} @@ -161,6 +163,7 @@ def test_set_bios_attribute_multi_bad_value(self, mock_get, mock_patch, mock_pos ] _, err = self.badfish_call() assert err == BIOS_SET_MULTI_BAD_VALUE + assert mock_patch.call_count == 0 @patch("aiohttp.ClientSession.delete") @patch("aiohttp.ClientSession.post") @@ -175,6 +178,123 @@ def test_set_bios_attribute_bad_pair(self, mock_get, mock_patch, mock_post, mock self.args = [self.option_arg, "--attribute-value", ATTRIBUTE_OK] _, err = self.badfish_call() assert err == BIOS_SET_MULTI_BAD_PAIR + assert mock_patch.call_count == 0 + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_multi_duplicate(self, mock_get, mock_patch, mock_post, mock_delete): + responses = INIT_RESP + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = [ + self.option_arg, + "--attribute-value", + f"{ATTRIBUTE_OK}={ATTR_VALUE_OK}", + "--attribute-value", + f"{ATTRIBUTE_OK}={ATTR_VALUE_DIS}", + ] + _, err = self.badfish_call() + assert f"Duplicate BIOS attribute supplied: {ATTRIBUTE_OK}" in err + assert mock_patch.call_count == 0 + assert mock_post.call_count == 0 + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_mixed_flags(self, mock_get, mock_patch, mock_post, mock_delete): + responses = INIT_RESP + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = [ + self.option_arg, + "--attribute", + ATTRIBUTE_OK, + "--value", + ATTR_VALUE_OK, + "--attribute-value", + f"{ATTRIBUTE_OK_2}={ATTR_VALUE_OK_2}", + ] + _, err = self.badfish_call() + assert "Use either --attribute/--value or --attribute-value, not both" in err + assert mock_patch.call_count == 0 + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_missing_flag(self, mock_get, mock_patch, mock_post, mock_delete): + responses = INIT_RESP + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = ["--attribute-value", f"{ATTRIBUTE_OK}={ATTR_VALUE_OK}"] + _, err = self.badfish_call() + assert "--attribute-value requires --set-bios-attribute" in err + assert mock_patch.call_count == 0 + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_multi_already_state(self, mock_get, mock_patch, mock_post, mock_delete): + get_resp = [ + BIOS_REGISTRY_MULTI.replace("'", '"'), + BIOS_RESPONSE_MULTI, + ] + responses = INIT_RESP + get_resp + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + # Both values already match the running system: nothing should be + # PATCHed and no reboot should be triggered. + self.args = [ + self.option_arg, + "--attribute-value", + f"{ATTRIBUTE_OK}={ATTR_VALUE_DIS}", + "--attribute-value", + f"{ATTRIBUTE_OK_2}={ATTR_VALUE_DIS_2}", + ] + _, err = self.badfish_call() + assert err.count("already in that state. IGNORING.") == 2 + assert "All attributes are already in the desired state" in err + assert mock_patch.call_count == 0 + # Only the session-login POST happens; no reset/reboot POST is issued. + assert mock_post.call_count == 1 + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_lowercase_name(self, mock_get, mock_patch, mock_post, mock_delete): + get_resp = [ + BIOS_REGISTRY_MULTI.replace("'", '"'), + BIOS_RESPONSE_MULTI, + RESET_TYPE_RESP, + STATE_ON_RESP, + STATE_ON_RESP, + ] + responses = INIT_RESP + get_resp + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + # Oddly-cased name is resolved to the registry's canonical + # AttributeName (BootMode) before the current-value lookup and PATCH. + self.args = [self.option_arg, "--attribute-value", f"bootmode={ATTR_VALUE_OK_2}"] + _, err = self.badfish_call() + assert err == BIOS_SET_OK + assert mock_patch.call_count == 1 + payload = json.loads(mock_patch.call_args.kwargs["data"])["Attributes"] + assert payload == {ATTRIBUTE_OK_2: ATTR_VALUE_OK_2} class TestGetBiosAttribute(TestBase): From b0d82b7fcda1bb72f989da9963085225d29ead0d Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sun, 6 Sep 2026 06:23:22 +0200 Subject: [PATCH 06/36] fix: reject case-insensitive duplicates and guard a missing BIOS registry The multi-attribute path silently dropped a value when two attributes differed only by case (e.g. ProcC1E and procc1e both mapped to the same canonical name). Make the duplicate check case-insensitive, narrow canonical-name resolution to the registry AttributeName field only, and raise a catchable BadfishException when the BMC does not expose a BIOS registry instead of crashing with a TypeError. --- src/badfish/main.py | 12 ++++++---- tests/test_bios_attributes.py | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/badfish/main.py b/src/badfish/main.py index 5562612..53b48a5 100755 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -289,6 +289,8 @@ async def get_bios_attribute(self, attribute): async def set_bios_attribute(self, attributes): data = await self.get_bios_attributes_registry() + if not data: + raise BadfishException("BIOS attribute registry is not available on this system.") # Resolve user-supplied attribute names to the registry's canonical # AttributeName up front, so oddly-cased NAME=value pairs (e.g. # bootmode=Uefi) read current values and PATCH with the BMC's exact @@ -296,10 +298,12 @@ async def set_bios_attribute(self, attributes): # "attribute not found". canonical_map = {} for entry in data["RegistryEntries"]["Attributes"]: - entries = [low_entry.lower() for low_entry in entry.values() if isinstance(low_entry, str)] + canonical_name = entry.get("AttributeName") + if not canonical_name: + continue for attribute in list(attributes): - if attribute.lower() in entries and attribute not in canonical_map: - canonical_map[attribute] = entry.get("AttributeName") or attribute + if attribute.lower() == canonical_name.lower() and attribute not in canonical_map: + canonical_map[attribute] = canonical_name for attribute in list(attributes): canonical = canonical_map.get(attribute) if canonical and canonical != attribute: @@ -3020,7 +3024,7 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non if not _sep or not name.strip() or not attr_value.strip(): raise BadfishException(f"Invalid attribute/value pair supplied: {pair}") name = name.strip() - if name in bios_attributes: + if name.lower() in (k.lower() for k in bios_attributes): raise BadfishException(f"Duplicate BIOS attribute supplied: {name}") bios_attributes[name] = attr_value.strip() diff --git a/tests/test_bios_attributes.py b/tests/test_bios_attributes.py index 384442e..beeadd5 100644 --- a/tests/test_bios_attributes.py +++ b/tests/test_bios_attributes.py @@ -202,6 +202,49 @@ def test_set_bios_attribute_multi_duplicate(self, mock_get, mock_patch, mock_pos assert mock_patch.call_count == 0 assert mock_post.call_count == 0 + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_multi_duplicate_case_insensitive(self, mock_get, mock_patch, mock_post, mock_delete): + responses = INIT_RESP + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = [ + self.option_arg, + "--attribute-value", + f"{ATTRIBUTE_OK}={ATTR_VALUE_OK}", + "--attribute-value", + f"{ATTRIBUTE_OK.lower()}={ATTR_VALUE_DIS}", + ] + _, err = self.badfish_call() + assert f"Duplicate BIOS attribute supplied: {ATTRIBUTE_OK.lower()}" in err + assert mock_patch.call_count == 0 + assert mock_post.call_count == 0 + + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_set_bios_attribute_registry_missing(self, mock_get, mock_patch, mock_post, mock_delete): + responses = INIT_RESP + ["{}"] + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = [ + self.option_arg, + "--attribute", + ATTRIBUTE_OK, + "--value", + ATTR_VALUE_OK, + ] + _, err = self.badfish_call() + assert "BIOS attribute registry is not available on this system." in err + assert mock_patch.call_count == 0 + @patch("aiohttp.ClientSession.delete") @patch("aiohttp.ClientSession.post") @patch("aiohttp.ClientSession.patch") From dfe95c2fa3f4956ada16ce5305817765cebe4b92 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sun, 6 Sep 2026 23:17:36 +0200 Subject: [PATCH 07/36] refactor: canonicalize BIOS attribute names in a single pass Grafuls (review): the nested loop was O(entries x attributes). Build a lowercase->canonical AttributeName map once, then resolve each supplied attribute with one lookup. Behavior unchanged (38 BIOS tests pass). --- src/badfish/main.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/badfish/main.py b/src/badfish/main.py index 53b48a5..d0972ac 100755 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -296,16 +296,13 @@ async def set_bios_attribute(self, attributes): # bootmode=Uefi) read current values and PATCH with the BMC's exact # attribute naming instead of failing with a misleading # "attribute not found". - canonical_map = {} + canonical_by_lower = {} for entry in data["RegistryEntries"]["Attributes"]: canonical_name = entry.get("AttributeName") - if not canonical_name: - continue - for attribute in list(attributes): - if attribute.lower() == canonical_name.lower() and attribute not in canonical_map: - canonical_map[attribute] = canonical_name + if canonical_name: + canonical_by_lower.setdefault(canonical_name.lower(), canonical_name) for attribute in list(attributes): - canonical = canonical_map.get(attribute) + canonical = canonical_by_lower.get(attribute.lower()) if canonical and canonical != attribute: attributes[canonical] = attributes.pop(attribute) From 2178bc19da482d3380b3606449b50c4d9eed3c45 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Fri, 4 Sep 2026 18:48:59 +0200 Subject: [PATCH 08/36] chore: add rpmlint to CI for RPM hygiene Adds an rpmlint make target under rpm/ that builds the SRPM and binary noarch RPM and lints the spec plus both artifacts, and a GHA workflow that runs it in a fedora:latest container on PRs and pushes. Pivots the #312 investigation away from rpminspect, which targets binary packages and deviation analysis that do not apply to a noarch pure Python package. fixes: https://github.com/quadsproject/badfish/issues/312 --- .github/workflows/rpmlint.yml | 35 +++++++++++++++++++++++++++++++++++ rpm/Makefile | 10 ++++++++++ src/badfish/main.py | 1 - 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/rpmlint.yml diff --git a/.github/workflows/rpmlint.yml b/.github/workflows/rpmlint.yml new file mode 100644 index 0000000..e0a3340 --- /dev/null +++ b/.github/workflows/rpmlint.yml @@ -0,0 +1,35 @@ +name: RPM Lint + +on: + pull_request: + types: [opened, edited] + branches: [development, master] + + push: + branches: [development, master] + +jobs: + rpmlint: + name: Run rpmlint + runs-on: ubuntu-latest + container: fedora:latest + + steps: + - name: Install Git + run: dnf -y install git + + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install rpmlint and build dependencies + run: | + dnf -y install make rpmlint python3-devel pyproject-rpm-macros \ + python3-setuptools python3-wheel python3-pip python3-build \ + python3-pyyaml python3-aiohttp python3-async-lru python3-rich \ + python3-pytest python3-pytest-asyncio glibc-langpack-en + + - name: Build RPM and run rpmlint + run: | + # Work around GHA permission/safe.directory issue with the checkout + git config --global --add safe.directory "$GITHUB_WORKSPACE" + make -C rpm rpmlint diff --git a/rpm/Makefile b/rpm/Makefile index d3c697d..a612730 100644 --- a/rpm/Makefile +++ b/rpm/Makefile @@ -37,6 +37,16 @@ srpm: all clean: rm -rf *.src.rpm *.tar.gz *.spec noarch +rpmlint: tarball badfish.spec + rm -rf $(CURDIR)/rpmbuild + mkdir -p $(CURDIR)/rpmbuild/SOURCES $(CURDIR)/rpmbuild/SPECS + cp $(TARBALL) $(CURDIR)/rpmbuild/SOURCES/ + cp badfish.spec $(CURDIR)/rpmbuild/SPECS/ + echo "==> Building RPM from spec" + rpmbuild -ba --define "_topdir $(CURDIR)/rpmbuild" $(CURDIR)/rpmbuild/SPECS/badfish.spec + echo "==> Running rpmlint" + rpmlint $(CURDIR)/badfish.spec $$(find $(CURDIR)/rpmbuild/RPMS $(CURDIR)/rpmbuild/SRPMS -name '*.rpm' -type f 2>/dev/null) + test: tarball badfish.spec @echo "Moving tarball to rpmbuild/SOURCES directory" mkdir -p ~/rpmbuild/SOURCES diff --git a/src/badfish/main.py b/src/badfish/main.py index d0972ac..58b6b55 100755 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 import asyncio import base64 import functools From b66c28ced54abf209cacc97b46db0227ade5f4e0 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 20:55:41 +0200 Subject: [PATCH 09/36] chore: rpmlint CI also re-runs on commits, drop stray exec bit Follow-up from independent review of the PR: - run the rpmlint gate on synchronize and reopened, not just opened/edited, so new commits pushed to the PR are actually linted - install rpm-build explicitly (previously only transitive) - add rpmbuild/ to rpm cleanup and .gitignore - mark the rpmlint target .PHONY - jobs get permissions: contents: read - main.py lost its shebang earlier; clear the stale executable bit (module is not a script; entry point is the badfish console script) --- .github/workflows/rpmlint.yml | 6 ++++-- rpm/.gitignore | 1 + rpm/Makefile | 3 ++- src/badfish/main.py | 0 4 files changed, 7 insertions(+), 3 deletions(-) mode change 100755 => 100644 src/badfish/main.py diff --git a/.github/workflows/rpmlint.yml b/.github/workflows/rpmlint.yml index e0a3340..be208d5 100644 --- a/.github/workflows/rpmlint.yml +++ b/.github/workflows/rpmlint.yml @@ -2,7 +2,7 @@ name: RPM Lint on: pull_request: - types: [opened, edited] + types: [opened, synchronize, reopened] branches: [development, master] push: @@ -13,6 +13,8 @@ jobs: name: Run rpmlint runs-on: ubuntu-latest container: fedora:latest + permissions: + contents: read steps: - name: Install Git @@ -23,7 +25,7 @@ jobs: - name: Install rpmlint and build dependencies run: | - dnf -y install make rpmlint python3-devel pyproject-rpm-macros \ + dnf -y install make rpmlint rpm-build python3-devel pyproject-rpm-macros \ python3-setuptools python3-wheel python3-pip python3-build \ python3-pyyaml python3-aiohttp python3-async-lru python3-rich \ python3-pytest python3-pytest-asyncio glibc-langpack-en diff --git a/rpm/.gitignore b/rpm/.gitignore index 917a6c2..8672479 100644 --- a/rpm/.gitignore +++ b/rpm/.gitignore @@ -4,3 +4,4 @@ noarch/ usr/ badfish.spec/ +rpmbuild/ diff --git a/rpm/Makefile b/rpm/Makefile index a612730..19be8cf 100644 --- a/rpm/Makefile +++ b/rpm/Makefile @@ -35,8 +35,9 @@ srpm: all -bs *.spec clean: - rm -rf *.src.rpm *.tar.gz *.spec noarch + rm -rf *.src.rpm *.tar.gz *.spec noarch rpmbuild +.PHONY: rpmlint rpmlint: tarball badfish.spec rm -rf $(CURDIR)/rpmbuild mkdir -p $(CURDIR)/rpmbuild/SOURCES $(CURDIR)/rpmbuild/SPECS diff --git a/src/badfish/main.py b/src/badfish/main.py old mode 100755 new mode 100644 From 5649dd3d0972712ab0d0a53fe1d25f262ec5a42c Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 21:07:23 +0200 Subject: [PATCH 10/36] ci: skip rpmlint on docs-only PRs and cancel stale runs paths filter keeps the RPM hygiene gate to packaging-related changes; concurrency group cancels superseded runs instead of queueing them. --- .github/workflows/rpmlint.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/rpmlint.yml b/.github/workflows/rpmlint.yml index be208d5..1ee6a65 100644 --- a/.github/workflows/rpmlint.yml +++ b/.github/workflows/rpmlint.yml @@ -4,9 +4,27 @@ on: pull_request: types: [opened, synchronize, reopened] branches: [development, master] + paths: + - 'src/**' + - 'rpm/**' + - 'pyproject.toml' + - 'setup.cfg' + - 'requirements.txt' + - '.github/workflows/rpmlint.yml' push: branches: [development, master] + paths: + - 'src/**' + - 'rpm/**' + - 'pyproject.toml' + - 'setup.cfg' + - 'requirements.txt' + - '.github/workflows/rpmlint.yml' + +concurrency: + group: rpmlint-${{ github.ref }} + cancel-in-progress: true jobs: rpmlint: From 61f0c0ab04a5b09b5b7a9cd8f8831e285a9fd448 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sun, 6 Sep 2026 06:33:19 +0200 Subject: [PATCH 11/36] ci: gate rpmlint on setup.py and fail loudly on an empty package version The rpm build derives VERSION from setup.py, but the paths filter omitted it, so a setup.py-only change would not re-trigger the gate. Also, if setuptools is unimportable the Makefile silently built badfish-.tar.gz with empty @VERSION@ substitutions; the version is now asserted non-empty before building. --- .github/workflows/rpmlint.yml | 2 ++ rpm/Makefile | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/rpmlint.yml b/.github/workflows/rpmlint.yml index 1ee6a65..1b87ac9 100644 --- a/.github/workflows/rpmlint.yml +++ b/.github/workflows/rpmlint.yml @@ -10,6 +10,7 @@ on: - 'pyproject.toml' - 'setup.cfg' - 'requirements.txt' + - 'setup.py' - '.github/workflows/rpmlint.yml' push: @@ -20,6 +21,7 @@ on: - 'pyproject.toml' - 'setup.cfg' - 'requirements.txt' + - 'setup.py' - '.github/workflows/rpmlint.yml' concurrency: diff --git a/rpm/Makefile b/rpm/Makefile index 19be8cf..7f901f9 100644 --- a/rpm/Makefile +++ b/rpm/Makefile @@ -2,6 +2,11 @@ PYTHON := python3 BUILD_HELPER := ./build-helper VERSION = $(shell $(BUILD_HELPER) --version) +# Fail loudly if VERSION resolves empty (e.g. setuptools missing) instead of +# silently building badfish-.tar.gz and empty @VERSION@ substitutions. +ifeq ($(strip $(VERSION)),) +$(error Could not determine package version; is setuptools installed and importable?) +endif RELEASE = $(shell $(BUILD_HELPER) --release) DATE = $(shell date +'%a %b %d %Y') TARBALL = badfish-$(VERSION).tar.gz From e6ab7fc56fa8346c4a4fed99f6044065d88e5a06 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 12:07:48 +0200 Subject: [PATCH 12/36] feat(emulator): add built-in Redfish emulator Adds a mock iDRAC server so badfish can be developed and tested without bare metal. Run with "badfish --redfish-emulator --port 8443"; it always runs as a persistent server until interrupted. Design: static resource shapes live as JSON templates under src/badfish/emulator/templates, served over HTTPS by a small aiohttp app with an in-memory fake driver holding mutable system state. Architecture is inspired by the sushy-tools emulator (OpenStack, Apache-2.0), written independently for badfish under GPL-3.0-or-later. Covers the surface badfish actually talks to: session/token auth, power and reset, one-shot boot and boot order, BIOS registry and attributes, jobs queue, virtual media, firmware inventory, processor/memory/network inventory, the Dell OS deployment service and SCP targets, and the Dell network attribute registry. Screenshot and network ISO actions report "not supported" so badfish degrades gracefully. Default credentials are quads/quads (override with BADFISH_EMULATOR_USER and BADFISH_EMULATOR_PASSWORD). TLS uses a bundled self-signed test certificate, so clients should pass --insecure. Tests: unit coverage of the API surface plus an end-to-end test that runs the real badfish client against a live emulator over TLS. --- README.md | 28 + src/badfish/emulator.py | 641 ++++++++++++++++++ src/badfish/emulator/certs/README.md | 19 + src/badfish/emulator/certs/emulator.crt | 19 + src/badfish/emulator/certs/emulator.key | 28 + src/badfish/emulator/templates/bios.json | 16 + .../emulator/templates/bios_registry.json | 55 ++ .../emulator/templates/bios_settings.json | 6 + .../emulator/templates/boot_sources.json | 40 ++ src/badfish/emulator/templates/chassis.json | 18 + .../emulator/templates/chassis_power.json | 22 + .../emulator/templates/dell_job_service.json | 10 + .../templates/dell_network_attributes.json | 11 + .../templates/dell_os_deployment_service.json | 9 + .../templates/ethernet_interface.json | 13 + src/badfish/emulator/templates/job.json | 12 + src/badfish/emulator/templates/manager.json | 55 ++ src/badfish/emulator/templates/managers.json | 10 + src/badfish/emulator/templates/memory.json | 13 + .../emulator/templates/network_adapter.json | 11 + .../emulator/templates/network_adapters.json | 10 + .../network_attributes_registry.json | 50 ++ .../templates/network_device_function.json | 16 + .../emulator/templates/network_port.json | 15 + src/badfish/emulator/templates/processor.json | 15 + .../emulator/templates/service_root.json | 34 + src/badfish/emulator/templates/session.json | 6 + .../emulator/templates/session_service.json | 12 + .../templates/software_inventory.json | 13 + src/badfish/emulator/templates/system.json | 57 ++ src/badfish/emulator/templates/systems.json | 10 + src/badfish/emulator/templates/task.json | 15 + .../emulator/templates/update_service.json | 11 + .../emulator/templates/virtual_media_cd.json | 20 + src/badfish/helpers/parser.py | 16 + src/badfish/main.py | 5 + tests/test_emulator.py | 221 ++++++ 37 files changed, 1562 insertions(+) create mode 100644 src/badfish/emulator.py create mode 100644 src/badfish/emulator/certs/README.md create mode 100644 src/badfish/emulator/certs/emulator.crt create mode 100644 src/badfish/emulator/certs/emulator.key create mode 100644 src/badfish/emulator/templates/bios.json create mode 100644 src/badfish/emulator/templates/bios_registry.json create mode 100644 src/badfish/emulator/templates/bios_settings.json create mode 100644 src/badfish/emulator/templates/boot_sources.json create mode 100644 src/badfish/emulator/templates/chassis.json create mode 100644 src/badfish/emulator/templates/chassis_power.json create mode 100644 src/badfish/emulator/templates/dell_job_service.json create mode 100644 src/badfish/emulator/templates/dell_network_attributes.json create mode 100644 src/badfish/emulator/templates/dell_os_deployment_service.json create mode 100644 src/badfish/emulator/templates/ethernet_interface.json create mode 100644 src/badfish/emulator/templates/job.json create mode 100644 src/badfish/emulator/templates/manager.json create mode 100644 src/badfish/emulator/templates/managers.json create mode 100644 src/badfish/emulator/templates/memory.json create mode 100644 src/badfish/emulator/templates/network_adapter.json create mode 100644 src/badfish/emulator/templates/network_adapters.json create mode 100644 src/badfish/emulator/templates/network_attributes_registry.json create mode 100644 src/badfish/emulator/templates/network_device_function.json create mode 100644 src/badfish/emulator/templates/network_port.json create mode 100644 src/badfish/emulator/templates/processor.json create mode 100644 src/badfish/emulator/templates/service_root.json create mode 100644 src/badfish/emulator/templates/session.json create mode 100644 src/badfish/emulator/templates/session_service.json create mode 100644 src/badfish/emulator/templates/software_inventory.json create mode 100644 src/badfish/emulator/templates/system.json create mode 100644 src/badfish/emulator/templates/systems.json create mode 100644 src/badfish/emulator/templates/task.json create mode 100644 src/badfish/emulator/templates/update_service.json create mode 100644 src/badfish/emulator/templates/virtual_media_cd.json create mode 100644 tests/test_emulator.py diff --git a/README.md b/README.md index 5c60115..4759c4e 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ * [Verbose Output](#verbose-output) * [Log to File](#log-to-file) * [Formatted output](#formatted-output) + * [Redfish emulator (mock iDRAC)](#redfish-emulator-mock-idrac) * [iDRAC and Data Format](#idrac-and-data-format) * [Dell Foreman and PXE Interface](#dell-foreman-and-pxe-interface) * [Host type overrides](#host-type-overrides) @@ -698,6 +699,33 @@ If you would like to easier query some information listed by badfish, you can te badfish -H mgmt-your-server.example.com --output json/yaml --firmware-inventory ``` +### Redfish emulator (mock iDRAC) +Badfish ships a built-in Redfish emulator that acts like a Dell-flavored iDRAC for development and testing, so most badfish commands can be exercised without bare metal. + +Run the emulator as a persistent server: + +```bash +badfish --redfish-emulator --port 8443 +``` + +It serves HTTPS on `127.0.0.1:8443` using a bundled self-signed test certificate. Point a second badfish instance at it like any BMC, and pass `--insecure` to skip certificate verification, the self-signed cert will not validate otherwise: + +```bash +badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --power-state +badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --ls-serial +badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --firmware-inventory +badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --ls-jobs +``` + +Default credentials are `quads` / `quads`, the same convention quads uses for its IPMI user. Set `BADFISH_EMULATOR_USER` and `BADFISH_EMULATOR_PASSWORD` to override. `--bind` and `--port` control the listen address. + +Currently covered: session/token auth, power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, the jobs queue (create/check/delete), virtual media mount/eject, firmware inventory, system/processor/memory/interface inventory, and SCP import/export targets. Screenshot and OS-deployment network ISO actions return "not supported" so badfish degrades gracefully. + +> [!NOTE] +> This is a development tool, not a security boundary. The bundled certificate and default credentials exist so CI and laptops can spin up a mock iDRAC with zero setup. + +Resource templates live in `src/badfish/emulator/templates/` and are served by URI path, the same store that vendor mockup bundles (for example DMTF DSP2043) can feed as fetch support for Dell and SuperMicro trees lands. + ## iDRAC and Data Format ### Dell Foreman and PXE Interface diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py new file mode 100644 index 0000000..f5893a3 --- /dev/null +++ b/src/badfish/emulator.py @@ -0,0 +1,641 @@ +# -*- coding: utf-8 -*- +"""Badfish Redfish emulator: a mock iDRAC served over HTTP(S). + +Architecture is inspired by the sushy-tools emulator (OpenStack project, +Apache License 2.0): JSON template resources on top of a fake driver that +holds mutable per-system state. This is an independent implementation written +for badfish under the GPL-3.0-or-later license; no sushy-tools source is used. + +Static resource shapes live as JSON documents in emulator/templates/ so a +vendor mockup bundle can feed the same store later. Run it with: + + badfish --redfish-emulator --port 8443 + +The emulator always runs as a persistent server until interrupted; there is no +separate daemon flag because that is its only mode. +""" + +import asyncio +import base64 +import copy +import json +import os +import secrets +import ssl +from pathlib import Path + +from aiohttp import web + +ROOT = "/redfish/v1" + +_BASE = Path(__file__).parent +_TEMPLATES = _BASE / "emulator" / "templates" +_CERTS = _BASE / "emulator" / "certs" + +# Default credentials are intentionally generic (quads/quads mirrors the +# quads project's IPMI user convention). Override per run with env vars. +USERNAME = os.environ.get("BADFISH_EMULATOR_USER", "quads") +PASSWORD = os.environ.get("BADFISH_EMULATOR_PASSWORD", "quads") + +# Single source of truth for the fake host's hardware identity. Changing a +# value here changes every resource that reports it; no template editing needed. +SYSCONF = { + "system_id": "System.Embedded.1", + "manager_id": "iDRAC.Embedded.1", + "chassis_id": "System.Embedded.1", + "model": "PowerEdge R740", + "manufacturer": "Dell Inc.", + "serial": "EMUL8V1", + "uuid": "27946b59-9e44-4fa7-8e91-f3527a1ef094", + "bios_version": "2.60.60.60", + "boot_devices": [ + {"index": 0, "name": "NIC.Integrated.1-1-1", "enabled": True}, + {"index": 1, "name": "Optical.iDRACVirtual.1-1", "enabled": True}, + {"index": 2, "name": "Disk.SATAEmbedded.0-1", "enabled": True}, + ], + "nics": [ + {"id": "NIC.Integrated.1-1-1", "mac": "00:5c:52:31:3a:9c", "speed": 25000, "link": "Up"}, + {"id": "NIC.Integrated.1-2-1", "mac": "00:5c:52:31:3a:9d", "speed": 25000, "link": "Up"}, + ], + "cpus": [ + { + "id": "CPU.Socket.1", + "model": "Intel(R) Xeon(R) Gold 6230R CPU @ 2.10GHz", + "manufacturer": "Intel", + "cores": 26, + "threads": 52, + "max": 2100, + }, + { + "id": "CPU.Socket.2", + "model": "Intel(R) Xeon(R) Gold 6230R CPU @ 2.10GHz", + "manufacturer": "Intel", + "cores": 26, + "threads": 52, + "max": 2100, + }, + ], + "dimms": [ + {"id": "DIMM.Socket.A1", "cap": 32768, "manufacturer": "Micron", "type": "DDR4", "speed": 2933}, + {"id": "DIMM.Socket.B1", "cap": 32768, "manufacturer": "Micron", "type": "DDR4", "speed": 2933}, + ], + "firmware": [ + { + "id": "iDRAC-with-LCC", + "name": "Integrated Dell Remote Access Controller", + "version": "6.00.00.00", + "manufacturer": "Dell Inc.", + }, + {"id": "BIOS", "name": "BIOS", "version": "2.60.60.60", "manufacturer": "Dell Inc."}, + ], +} + +SYSTEM = f"{ROOT}/Systems/{SYSCONF['system_id']}" +MANAGER = f"{ROOT}/Managers/{SYSCONF['manager_id']}" +CHASSIS = f"{ROOT}/Chassis/{SYSCONF['chassis_id']}" +UPDATESERVICE = f"{ROOT}/UpdateService" +FIRMWARE = f"{UPDATESERVICE}/FirmwareInventory" + +_RESTART_TYPES = {"GracefulRestart", "ForceRestart"} # rebooted: off then on +_RESET_STATES = { # one-shot power targets + "On": "On", + "ForceOn": "On", + "PowerCycle": "On", + "ForceOff": "Off", + "GracefulShutdown": "Off", +} + + +class _StateKey(web.AppKey): + pass + + +_STATE_KEY = _StateKey("state", object) + + +class State: + """Mutable fake-driver state shared by the mock BMC's resources.""" + + def __init__(self): + self.power = "Off" + self.boot_target = "None" + self.boot_enabled = "Disabled" + self.vmedia_image = None + self.jobs = {} + self.sessions = {} + self._job_n = 0 + self._restart_task = None + + +# --- template store --------------------------------------------------------- + +_STATIC = {} + + +def _load_templates(): + for path in _TEMPLATES.glob("*.json"): + with open(path) as fh: + _STATIC[path.stem] = json.load(fh) + + +_load_templates() + + +def _tmpl(key): + return copy.deepcopy(_STATIC[key]) + + +def _static_doc(key, uri): + if key is None: + return None + data = copy.deepcopy(_STATIC[key]) + data["@odata.id"] = uri + return data + + +def _collection(resource_name, base, members): + return { + "@odata.type": f"#{resource_name}Collection.{resource_name}Collection", + "Name": f"{resource_name} Collection", + "Members@odata.count": len(members), + "Members": [{"@odata.id": f"{base}/{m}"} for m in members], + } + + +# Static leaf resources, keyed by full URI. Anything not listed here is built +# from SYSCONF/state (collections and members below) so identity stays single-sourced. +_STATIC_URI = { + ROOT: "service_root", + f"{ROOT}/SessionService": "session_service", + f"{ROOT}/Systems": "systems", + f"{SYSTEM}/Bios": "bios", + f"{SYSTEM}/Bios/Settings": "bios_settings", + f"{SYSTEM}/Bios/BiosRegistry": "bios_registry", + f"{SYSTEM}/BootSources": "boot_sources", + f"{SYSTEM}/NetworkAdapters": "network_adapters", + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1": "network_adapter", + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts": "network_ports", + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts/Port0": "network_port", + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions": "network_device_functions", + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions/NIC.Integrated.1-1-1": "network_device_function", + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions/NIC.Integrated.1-1-1/Oem/Dell/DellNetworkAttributes/NIC.Integrated.1-1-1": "dell_network_attributes", + f"{ROOT}/Managers": "managers", + MANAGER: "manager", + f"{CHASSIS}": "chassis", + UPDATESERVICE: "update_service", + f"{ROOT}/Dell/Managers/{SYSCONF['manager_id']}/DellJobService": "dell_job_service", + f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService": "dell_os_deployment_service", + f"{CHASSIS}/Power": "chassis_power", +} + + +def _collection_uri(uri, state): + if uri == f"{SYSTEM}/EthernetInterfaces": + return _collection("EthernetInterface", uri, [n["id"] for n in SYSCONF["nics"]]) + if uri == f"{SYSTEM}/Processors": + return _collection("Processor", uri, [c["id"] for c in SYSCONF["cpus"]]) + if uri == f"{SYSTEM}/Memory": + return _collection("Memory", uri, [d["id"] for d in SYSCONF["dimms"]]) + if uri == FIRMWARE: + return _collection("SoftwareInventory", uri, [f"{f['id']}-{f['id']}-Installed" for f in SYSCONF["firmware"]]) + if uri == f"{MANAGER}/Jobs": + return _collection("Job", uri, list(state.jobs.keys())) + if uri == f"{ROOT}/SessionService/Sessions": + return _collection("Session", uri, [s["id"] for s in state.sessions.values()]) + if uri == f"{MANAGER}/VirtualMedia": + return _collection("VirtualMedia", uri, ["CD"]) + return None + + +def _member(tmpl_key, items, member_id, uri, fill=None): + item = next((i for i in items if i["id"] == member_id), None) + if item is None: + return None + data = _tmpl(tmpl_key) + data["@odata.id"] = uri + if fill: + fill(data, item) + return data + + +def _m_nic(uri, state): + member_id = uri.rsplit("/", 1)[-1] + + def fill(data, nic): + data["MACAddress"] = nic["mac"] + data["SpeedMbps"] = nic["speed"] + data["LinkStatus"] = nic["link"] + + return _member("ethernet_interface", SYSCONF["nics"], member_id, uri, fill) + + +def _m_processor(uri, state): + member_id = uri.rsplit("/", 1)[-1] + + def fill(data, cpu): + data["Manufacturer"] = cpu["manufacturer"] + data["Model"] = cpu["model"] + data["TotalCores"] = cpu["cores"] + data["TotalThreads"] = cpu["threads"] + data["MaxSpeedMHz"] = cpu["max"] + + return _member("processor", SYSCONF["cpus"], member_id, uri, fill) + + +def _m_dimm(uri, state): + member_id = uri.rsplit("/", 1)[-1] + + def fill(data, dimm): + data["CapacityMiB"] = dimm["cap"] + data["Manufacturer"] = dimm["manufacturer"] + data["MemoryDeviceType"] = dimm["type"] + data["OperatingSpeedMhz"] = dimm["speed"] + + return _member("memory", SYSCONF["dimms"], member_id, uri, fill) + + +def _m_firmware(uri, state): + member_id = uri.rsplit("/", 1)[-1] + + def fill(data, fw): + data["Id"] = member_id + data["Name"] = fw["name"] + data["Version"] = fw["version"] + data["Manufacturer"] = fw["manufacturer"] + + item = next((f for f in SYSCONF["firmware"] if f["id"] in member_id), None) + if item is None: + return None + data = _tmpl("software_inventory") + data["@odata.id"] = uri + fill(data, item) + return data + + +def _m_job(uri, state): + job_id = uri.rsplit("/", 1)[-1] + job = state.jobs.get(job_id) + if job is None: + return None + data = _tmpl("job") + data["@odata.id"] = uri + data["Id"] = job_id + data["Name"] = job.get("Name", "Configure: BIOS.Setup.1-1") + data["Message"] = "Job completed successfully." + data["PercentComplete"] = 100 + data["JobState"] = "Completed" + if "SystemConfiguration" in job: + data["SystemConfiguration"] = {"ComponentResults": [], "Id": "SystemConfiguration"} + return data + + +def _m_session(uri, state): + session_id = uri.rsplit("/", 1)[-1] + for token, info in state.sessions.items(): + if str(info["id"]) == session_id: + data = _tmpl("session") + data["@odata.id"] = uri + data["Id"] = session_id + data["UserName"] = info["username"] + return data + return None + + +def _m_vmedia(uri, state): + data = _tmpl("virtual_media_cd") + data["@odata.id"] = uri + data["ImageName"] = state.vmedia_image or "" + data["Inserted"] = bool(state.vmedia_image) + return data + + +def _m_task(uri, state): + data = _tmpl("task") + data["@odata.id"] = uri + data["Id"] = uri.rsplit("/", 1)[-1] + return data + + +def _m_registry_file(uri, state): + return _static_doc("network_attributes_registry", uri) + + +_MEMBER_PREFIXES = ( + (f"{SYSTEM}/EthernetInterfaces/", _m_nic), + (f"{SYSTEM}/Processors/", _m_processor), + (f"{SYSTEM}/Memory/", _m_dimm), + (f"{FIRMWARE}/", _m_firmware), + (f"{MANAGER}/Jobs/", _m_job), + (f"{ROOT}/SessionService/Sessions/", _m_session), + (f"{MANAGER}/VirtualMedia/", _m_vmedia), + (f"{ROOT}/TaskService/Tasks/", _m_task), + (f"{ROOT}/Registries/NetworkAttributesRegistry_", _m_registry_file), +) + + +def _get_system(uri, state): + data = _tmpl("system") + data["@odata.id"] = uri + s = SYSCONF + data["Model"] = s["model"] + data["Manufacturer"] = s["manufacturer"] + data["SerialNumber"] = s["serial"] + data["UUID"] = s["uuid"] + data["PowerState"] = state.power + data["Boot"]["BootSourceOverrideTarget"] = state.boot_target + data["Boot"]["BootSourceOverrideEnabled"] = state.boot_enabled + data["ProcessorSummary"] = { + "Model": s["cpus"][0]["model"], + "Count": len(s["cpus"]), + "LogicalProcessorCount": sum(c["threads"] for c in s["cpus"]), + } + data["MemorySummary"] = {"TotalSystemMemoryGiB": sum(d["cap"] for d in s["dimms"]) // 1024} + return data + + +def _resource(uri, state): + """Resolve a Redfish resource for a URI against the fake system.""" + uri = uri.rstrip("/") or "/" + data = _static_doc(_STATIC_URI.get(uri), uri) + if data is not None: + return data + if uri == SYSTEM: + return _get_system(uri, state) + if uri == f"{CHASSIS}/Power": + data = _static_doc("chassis_power", uri) + data["PowerControl"][0]["PowerConsumedWatts"] = 320 if state.power == "On" else 120 + return data + data = _collection_uri(uri, state) + if data is not None: + return data + for prefix, handler in _MEMBER_PREFIXES: + if uri.startswith(prefix): + return handler(uri, state) + return None + + +# --- responses and helpers -------------------------------------------------- + + +def _error(message, resolution=None): + payload = { + "error": { + "code": "Base.1.0.GeneralError", + "message": "A general error has occurred. See ExtendedInfo for more information.", + "@Message.ExtendedInfo": [ + { + "MessageId": "Base.1.0.GeneralError", + "Message": message, + "Resolution": resolution or "Retry the operation.", + } + ], + } + } + return payload + + +def _json(data, status=200, headers=None): + return web.json_response(data, status=status, headers=headers) + + +def _bad_request(message): + return _json(_error(message), status=400) + + +def _unauthorized(message): + return _json(_error(message), status=401) + + +def _not_found(uri): + return _json(_error(f"Resource at {uri} was not found.", "Verify the URI."), status=404) + + +def _method_not_allowed(uri, method): + return _json(_error(f"{method} is not supported on {uri}."), status=405) + + +def _delete_session(state, session_id): + for token in list(state.sessions): + if str(state.sessions[token]["id"]) == session_id: + del state.sessions[token] + return True + return False + + +def _basic_ok(auth_header): + if not auth_header.startswith("Basic "): + return False + try: + user, _, password = base64.b64decode(auth_header[6:]).decode().partition(":") + except (ValueError, UnicodeDecodeError): + return False + return (user, password) == (USERNAME, PASSWORD) + + +async def _read_json(request): + try: + return await request.json() + except (ValueError, json.JSONDecodeError): + return None + + +def _make_session(state, username): + token = secrets.token_hex(16) + state._job_n += 1 + session_id = str(state._job_n) + state.sessions[token] = {"id": session_id, "username": username} + uri = f"{ROOT}/SessionService/Sessions/{session_id}" + body = _static_doc("session", uri) + body["Id"] = session_id + body["UserName"] = username + return body, token, uri + + +# --- auth middleware -------------------------------------------------------- + + +@web.middleware +async def _auth(request, handler): + state = request.app[_STATE_KEY] + path = request.path.rstrip("/") + if path in ("", "/") or not path.startswith(ROOT): + return await handler(request) + if path == ROOT: # the service root is public in Redfish + return await handler(request) + if request.method == "POST" and path in (f"{ROOT}/SessionService/Sessions", f"{ROOT}/Sessions"): + return await handler(request) + if request.headers.get("X-Auth-Token") in state.sessions: + return await handler(request) + if _basic_ok(request.headers.get("Authorization", "")): + return await handler(request) + return _unauthorized("Authentication required. Create a session via POST /redfish/v1/SessionService/Sessions.") + + +# --- HTTP handlers ---------------------------------------------------------- + + +async def _get(_request): + state = _request.app[_STATE_KEY] + uri = _request.path + data = _resource(uri, state) + if data is None: + return _not_found(uri) + return _json(data) + + +async def _post(_request): + state = _request.app[_STATE_KEY] + path = _request.path.rstrip("/") + + if path in (f"{ROOT}/SessionService/Sessions", f"{ROOT}/Sessions"): + body = await _read_json(_request) + if body is None or body.get("UserName") != USERNAME or body.get("Password") != PASSWORD: + return _unauthorized("Authentication failed. Verify your credentials.") + resource, token, location = _make_session(state, body.get("UserName")) + return _json(resource, status=201, headers={"X-Auth-Token": token, "Location": location}) + + if path == f"{SYSTEM}/Actions/ComputerSystem.Reset": + body = await _read_json(_request) + if body is None: + return _bad_request("Malformed JSON body.") + reset_type = body.get("ResetType") + if reset_type in _RESTART_TYPES: + state.power = "Off" + + async def _power_back_on(): + try: + await asyncio.sleep(2.0) + state.power = "On" + except asyncio.CancelledError: + pass + + state._restart_task = asyncio.get_running_loop().create_task(_power_back_on()) + elif reset_type in _RESET_STATES: + state.power = _RESET_STATES[reset_type] + else: + return _bad_request(f"Unsupported ResetType '{reset_type}'.") + return web.Response(status=204) + + if path == f"{SYSTEM}/Bios/Actions/Bios.ResetBios": + return _json({"Settings": f"{SYSTEM}/Bios/Settings"}) + if path == f"{SYSTEM}/Bios/Actions/Bios.ChangePassword": + return web.Response(status=204) + if path == f"{MANAGER}/Actions/Manager.Reset": + return web.Response(status=204) + + if path == f"{MANAGER}/Jobs": + body = await _read_json(_request) + if body is None: + return _bad_request("Malformed JSON body.") + state._job_n += 1 + job_id = f"JID_{state._job_n:016d}" + state.jobs[job_id] = {"TargetSettingsURI": body.get("TargetSettingsURI")} + return _json( + _m_job(f"{MANAGER}/Jobs/{job_id}", state), status=200, headers={"Location": f"{MANAGER}/Jobs/{job_id}"} + ) + + if path == f"{MANAGER}/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia": + body = await _read_json(_request) + if body is None: + return _bad_request("Malformed JSON body.") + state.vmedia_image = body.get("Image") + return web.Response(status=204) + if path == f"{MANAGER}/VirtualMedia/CD/Actions/VirtualMedia.EjectMedia": + state.vmedia_image = None + return web.Response(status=204) + + if path == f"{ROOT}/Dell/Managers/{SYSCONF['manager_id']}/DellJobService/Actions/DellJobService.DeleteJobQueue": + state.jobs.clear() + return _json({"Message": "Job queue cleared."}) + + if path == ( + f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService/" + "Actions/DellOSDeploymentService.GetAttachStatus" + ): + return _json({"ISOAttachStatus": "Detached"}) + if path == ( + f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService/" + "Actions/DellOSDeploymentService.BootToNetworkISO" + ): + return _json( + {"Message": "Successfully Requested"}, status=202, headers={"Location": f"{ROOT}/TaskService/Tasks/1"} + ) + if path == ( + f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService/" + "Actions/DellOSDeploymentService.DetachISOImage" + ): + return web.Response(status=204) + + if path.endswith("/Actions/Oem/EID_674_Manager.ExportSystemConfiguration"): + state._job_n += 1 + job_id = f"JID_{state._job_n:016d}" + state.jobs[job_id] = {"Export": True} + return web.Response(status=202, headers={"Location": f"{MANAGER}/Jobs/{job_id}"}) + if path.endswith("/Actions/Oem/EID_674_Manager.ImportSystemConfiguration"): + state._job_n += 1 + job_id = f"JID_{state._job_n:016d}" + return web.Response(status=202, headers={"Location": f"{ROOT}/TaskService/Tasks/{job_id}"}) + + if "DellLCService" in path and "ExportServerScreenShot" in path: + return _not_found(path) + return _method_not_allowed(path, "POST") + + +async def _patch(_request): + state = _request.app[_STATE_KEY] + path = _request.path.rstrip("/") + + if path in (SYSTEM,): + body = await _read_json(_request) + if body is None: + return _bad_request("Malformed JSON body.") + boot = body.get("Boot", {}) + state.boot_target = boot.get("BootSourceOverrideTarget", state.boot_target) + state.boot_enabled = boot.get("BootSourceOverrideEnabled", state.boot_enabled) + return web.Response(status=200) + + if path == f"{SYSTEM}/Bios/Settings": + return web.Response(status=200) + if path == f"{SYSTEM}/BootSources/Settings": + return web.Response(status=200) + if "DellNetworkAttributes" in path and path.endswith("/Settings"): + return web.Response(status=204) + return _method_not_allowed(path, "PATCH") + + +async def _delete(_request): + state = _request.app[_STATE_KEY] + path = _request.path.rstrip("/") + + if path.startswith(f"{ROOT}/SessionService/Sessions/"): + session_id = path.rsplit("/", 1)[-1] + if _delete_session(state, session_id): + return web.Response(status=200) + return _not_found(path) + if path.startswith(f"{MANAGER}/Jobs/"): + job_id = path.rsplit("/", 1)[-1] + if job_id == "JID_CLEARALL_FORCE": + state.jobs.clear() + else: + state.jobs.pop(job_id, None) + return web.Response(status=200) + return _method_not_allowed(path, "DELETE") + + +def create_app(): + app = web.Application(middlewares=[_auth]) + app[_STATE_KEY] = State() + app.router.add_get("/{path:.*}", _get) + app.router.add_post("/{path:.*}", _post) + app.router.add_patch("/{path:.*}", _patch) + app.router.add_delete("/{path:.*}", _delete) + return app + + +def run_daemon(args): + app = create_app() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(str(_CERTS / "emulator.crt"), str(_CERTS / "emulator.key")) + host = args.get("bind") or "127.0.0.1" + port = int(args.get("port") or 8443) + web.run_app(app, host=host, port=port, ssl_context=context) diff --git a/src/badfish/emulator/certs/README.md b/src/badfish/emulator/certs/README.md new file mode 100644 index 0000000..77aab03 --- /dev/null +++ b/src/badfish/emulator/certs/README.md @@ -0,0 +1,19 @@ +# Emulator TLS certificate + +`emulator.crt` and `emulator.key` are a self-signed test certificate for the +built-in Redfish emulator. They are bundled so anyone can spin up a mock iDRAC +with zero setup, not for production use. The private key is public and must +never be used anywhere real. + +badfish clients that talk to the emulator should pass `--insecure` to skip +verification of this self-signed certificate. + +Regenerate (1 year, SAN for localhost and loopback): + +``` +openssl req -x509 -newkey rsa:2048 \ + -keyout emulator.key -out emulator.crt -days 365 -nodes \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +chmod 600 emulator.key +``` diff --git a/src/badfish/emulator/certs/emulator.crt b/src/badfish/emulator/certs/emulator.crt new file mode 100644 index 0000000..df62b68 --- /dev/null +++ b/src/badfish/emulator/certs/emulator.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUHRCKRQfdG38QfhmPPjICuNFhIrAwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkwNTEwMDQwOVoXDTI3MDkw +NTEwMDQwOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA0cUb1vCLY4ssdOsfFMPPF0QrzUWoPXekWukl0RBx496m +I32mQWjQSu663A/uNeHtaemkGAfVqN2+WDp9gZJwyAilq21afWep3EZM1uVvHvXQ +DaBB9GuD7lqIJHBnnJnd6/9Oj2wCV5Qk2PVQXQQFijw5nDYow5vfGOkRAXXLeke0 +PH8rFZGWIFXVbJRjY0zV2kSX9L7wWXLbbrE2tw5E+z4ba99rgDNWvaUxniQ1DfgU ++SgCKGX1UBW9Na01kotKMxCoxAJ/Qg8nWG7h0x+GZpUBSYZ25cFpHkW4WIAfwKYS +uOU0FvI+U+P+fSOwxNS/sRoUs8ciWcTaYNUPgvnxQQIDAQABo28wbTAdBgNVHQ4E +FgQUQnxn9oYkM24ruLhvniRm2R+BobcwHwYDVR0jBBgwFoAUQnxn9oYkM24ruLhv +niRm2R+BobcwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIiVbLONPOXa6+woNV1KNSiYPzx4ig6T +ffdq6KBWh7CMgAlqAK5meIxB4EnRqB3f+8F4mYsuaBm5hoQrj/L7aB+J0jkk4/Rb +a4/DgInlFo3aroGdSCTkTtGzcVCSKW6Xf08Ezgp5FKy0t8l80FpbvteTN5bZDkod +8KW26oZAnrZn9DSqkkk4gxbehs8fUqg/6OYeOnF1DxIdOkLyriqzXl6pmD/de4ss +nQXaiUvGNjS9abg9dork4Wjue0oZVJG47Ge4Nlb01ccaMYOhEunKr0rQyA50elV6 +37f0Ll06BNezHL0Tm2FnkzLkVqX4eac/12eqvfnwY0ZRQnxecuxfpvM= +-----END CERTIFICATE----- diff --git a/src/badfish/emulator/certs/emulator.key b/src/badfish/emulator/certs/emulator.key new file mode 100644 index 0000000..2a40e6b --- /dev/null +++ b/src/badfish/emulator/certs/emulator.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRxRvW8Itjiyx0 +6x8Uw88XRCvNRag9d6Ra6SXREHHj3qYjfaZBaNBK7rrcD+414e1p6aQYB9Wo3b5Y +On2BknDICKWrbVp9Z6ncRkzW5W8e9dANoEH0a4PuWogkcGecmd3r/06PbAJXlCTY +9VBdBAWKPDmcNijDm98Y6REBdct6R7Q8fysVkZYgVdVslGNjTNXaRJf0vvBZcttu +sTa3DkT7Phtr32uAM1a9pTGeJDUN+BT5KAIoZfVQFb01rTWSi0ozEKjEAn9CDydY +buHTH4ZmlQFJhnblwWkeRbhYgB/AphK45TQW8j5T4/59I7DE1L+xGhSzxyJZxNpg +1Q+C+fFBAgMBAAECggEACxwS7Uyy5oF9Mq8DfSR2sBSDSQfuply9sWE7iGmZd+L3 +mRXDQPUVt6uSQsE8PDx8Mq8LVV4lhcqYABEO9cEP+eQirrl9VfrFb1NbfUkhnA89 +kYSCjGOc/+S/D5uoEnjqsjWaD0n+Nhhe5Y0D0H1RSXYxleafZ5FhMi72ddrrfSJn +n7PP9luImwvEv9+9pj9AnbCXQxzxMhECcFZqGRbuaNQt8Cset+crW8JJLpPuF+ns +jmiEBo8SAr1OnW6pGkpByZMq7AfTi9tIs3SX2QFBoF1aOqiLZDTM+gAtPYXiOsvC +pFXJZAAZCBK6FgzXbaLQqz+AjBk4lT5jVCPZATEXbQKBgQD2LOZijX42Sx1QAPP6 +fPHi+PKWVkBmFCO70F7qhWfHvpPHNS430OL5JV0Y9ATUfAKPvia4D5pmDdrTnRJx +bpcodCXtJsRckHPk/zMuznkdW0xTzZkvkgJ/0wCYSksgUneERNLlDqyv7T2ffzId +AZZ9hDbDuRXzyt0gNO5bzFwGXQKBgQDaJEPyKQeIeo20qBas5CZb1SqGpaoNOsPr +5W7NhjpCRlCMbT4Ykl3vkaIKKDGOw9qA4pdYHgMvSrzeV87qQE+PAkPvPBmzXt8H +wa4roEqi3zdmTYhjr6fi0wZKydH2SjVPR93KT/HXVTGZDEtjgOTuG4rw+o/LRGVF +yGqRC5UgNQKBgQDG6fLicUgpYLp3ub1qimj9KIED/v+cO+u/x5faUh9QY+qOzabh +zPSJsqouDoaUlvuO4Gvy0BDHI6zMzp9nbp/PPUKkBG4oCUTMJXVa/dUZZnsfQALm +UEmatYlGhMl9fYU7KE1sblYU9VKUvTdl/rF2DE4gCj71tdbFPl/XZyJ4tQKBgQCD +q3wvwUBAyuiZ8ROuzA+zQpnmqDxau+viiZw2Bh1IP7UC7jWbE04L+vW598TiDano +Pd1oXMVDWHNkKdBFaQgcpBtpXfeNY2hwACInRxuF8AI6h/YZZb+KlCGqJuPLK8O9 +1P00zsiFV3EWlmsy5mxIpOtaxYLiCKiwVGauojUjOQKBgD5KQ5ST2/kgDVLg0R+l +U4l5H2A9SNmFNL1w+GCYqDB5q/bEUsxZvUo201kn9iuCOIiQznIXYuAeXQQPxdNk +VShX+SCtHc4bNaEsOQGUXxvRIGPrdgThl8QDY9puE6EkbBb+UyRrloPk/DCkzYsN +T97TgWEdVuGWeCbJUoc5R6rn +-----END PRIVATE KEY----- diff --git a/src/badfish/emulator/templates/bios.json b/src/badfish/emulator/templates/bios.json new file mode 100644 index 0000000..236afc3 --- /dev/null +++ b/src/badfish/emulator/templates/bios.json @@ -0,0 +1,16 @@ +{ + "@odata.type": "#Bios.v1_1_0.Bios", + "Id": "BIOS", + "Name": "BIOS", + "AttributeRegistry": "BiosAttributeRegistryUefi.v1_0_0", + "Attributes": { + "BootMode": "Bios", + "SriovGlobalEnable": "Disabled", + "OneTimeBootMode": "Disabled", + "OneTimeBootSeqDev": "", + "PxeDev1Interface": "NIC.Integrated.1-1-1", + "PxeDev1EnDis": "Enabled", + "PxeDev2Interface": "NIC.Integrated.1-2-1", + "PxeDev2EnDis": "Enabled" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/bios_registry.json b/src/badfish/emulator/templates/bios_registry.json new file mode 100644 index 0000000..9522efa --- /dev/null +++ b/src/badfish/emulator/templates/bios_registry.json @@ -0,0 +1,55 @@ +{ + "@odata.type": "#BiosAttributeRegistry.v1_2_0.BiosAttributeRegistry", + "Id": "BiosAttributeRegistryUefi", + "RegistryEntries": { + "Attributes": [ + { + "AttributeName": "BootMode", + "Type": "Enumeration", + "Value": [ + { + "ValueName": "Bios" + }, + { + "ValueName": "Uefi" + } + ], + "CurrentValue": "Bios" + }, + { + "AttributeName": "SriovGlobalEnable", + "Type": "Enumeration", + "Value": [ + { + "ValueName": "Enabled" + }, + { + "ValueName": "Disabled" + } + ], + "CurrentValue": "Disabled" + }, + { + "AttributeName": "PxeDev1Interface", + "Type": "String", + "MinLength": 0, + "MaxLength": 64, + "CurrentValue": "NIC.Integrated.1-1-1" + }, + { + "AttributeName": "PxeDev2Interface", + "Type": "String", + "MinLength": 0, + "MaxLength": 64, + "CurrentValue": "NIC.Integrated.1-2-1" + }, + { + "AttributeName": "OneTimeBootMode", + "Type": "String", + "MinLength": 0, + "MaxLength": 64, + "CurrentValue": "Disabled" + } + ] + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/bios_settings.json b/src/badfish/emulator/templates/bios_settings.json new file mode 100644 index 0000000..43b77c9 --- /dev/null +++ b/src/badfish/emulator/templates/bios_settings.json @@ -0,0 +1,6 @@ +{ + "@odata.type": "#Bios.v1_1_0.Bios", + "Id": "BIOS.Settings", + "Name": "BIOS Settings Pending", + "Attributes": {} +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/boot_sources.json b/src/badfish/emulator/templates/boot_sources.json new file mode 100644 index 0000000..5f0e8db --- /dev/null +++ b/src/badfish/emulator/templates/boot_sources.json @@ -0,0 +1,40 @@ +{ + "@odata.type": "#Registry.v1_3_0.Registry", + "Id": "BootSources", + "Attributes": { + "BootSeq": [ + { + "Index": 0, + "Name": "NIC.Integrated.1-1-1", + "Enabled": true + }, + { + "Index": 1, + "Name": "Optical.iDRACVirtual.1-1", + "Enabled": true + }, + { + "Index": 2, + "Name": "Disk.SATAEmbedded.0-1", + "Enabled": true + } + ], + "UefiBootSeq": [ + { + "Index": 0, + "Name": "NIC.Integrated.1-1-1", + "Enabled": true + }, + { + "Index": 1, + "Name": "Optical.iDRACVirtual.1-1", + "Enabled": true + }, + { + "Index": 2, + "Name": "Disk.SATAEmbedded.0-1", + "Enabled": true + } + ] + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/chassis.json b/src/badfish/emulator/templates/chassis.json new file mode 100644 index 0000000..580810e --- /dev/null +++ b/src/badfish/emulator/templates/chassis.json @@ -0,0 +1,18 @@ +{ + "@odata.type": "#Chassis.v1_10_0.Chassis", + "Id": "System.Embedded.1", + "Name": "Chassis", + "ChassisType": "RackMount", + "Model": "PowerEdge R740", + "SerialNumber": "EMUL8V1", + "PowerState": "Off", + "Status": { + "State": "Enabled", + "Health": "OK" + }, + "Links": { + "Power": { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power" + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/chassis_power.json b/src/badfish/emulator/templates/chassis_power.json new file mode 100644 index 0000000..e772aab --- /dev/null +++ b/src/badfish/emulator/templates/chassis_power.json @@ -0,0 +1,22 @@ +{ + "@odata.type": "#Power.v1_5_0.Power", + "Id": "Power", + "PowerControl": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power/PowerControl", + "Name": "Chassis Power Control", + "PowerConsumedWatts": 320, + "PowerCapacityWatts": 1600 + } + ], + "PowerSupplies": [ + { + "Name": "PSU.Slot.1", + "PowerOutputWatts": 800, + "Status": { + "State": "Enabled", + "Health": "OK" + } + } + ] +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/dell_job_service.json b/src/badfish/emulator/templates/dell_job_service.json new file mode 100644 index 0000000..98d1b33 --- /dev/null +++ b/src/badfish/emulator/templates/dell_job_service.json @@ -0,0 +1,10 @@ +{ + "@odata.type": "#DellJobService.v1_0_0.DellJobService", + "Id": "DellJobService", + "Name": "Dell Job Service", + "Actions": { + "#DellJobService.DeleteJobQueue": { + "target": "/redfish/v1/Dell/Managers/iDRAC.Embedded.1/DellJobService/Actions/DellJobService.DeleteJobQueue" + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/dell_network_attributes.json b/src/badfish/emulator/templates/dell_network_attributes.json new file mode 100644 index 0000000..8706e88 --- /dev/null +++ b/src/badfish/emulator/templates/dell_network_attributes.json @@ -0,0 +1,11 @@ +{ + "@odata.type": "#DellNetworkAttributes.v1_0_0.DellNetworkAttributes", + "Id": "", + "Attributes": { + "VirtualizationMode": "NONE", + "WakeOnLan": "Disabled", + "NumberVFAdvertised": 0, + "DeviceName": "Intel(R) XXV710", + "FCoEOffloadMode": "Disabled" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/dell_os_deployment_service.json b/src/badfish/emulator/templates/dell_os_deployment_service.json new file mode 100644 index 0000000..ec1d91c --- /dev/null +++ b/src/badfish/emulator/templates/dell_os_deployment_service.json @@ -0,0 +1,9 @@ +{ + "@odata.type": "#DellOSDeploymentService.v1_0_0.DellOSDeploymentService", + "Id": "DellOSDeploymentService", + "Name": "Dell OS Deployment Service", + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/ethernet_interface.json b/src/badfish/emulator/templates/ethernet_interface.json new file mode 100644 index 0000000..2a100c1 --- /dev/null +++ b/src/badfish/emulator/templates/ethernet_interface.json @@ -0,0 +1,13 @@ +{ + "@odata.type": "#EthernetInterface.v1_5_0.EthernetInterface", + "Id": "", + "Name": "", + "MACAddress": "", + "SpeedMbps": 0, + "LinkStatus": "Up", + "IPv4Addresses": [], + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/job.json b/src/badfish/emulator/templates/job.json new file mode 100644 index 0000000..e6c1e5d --- /dev/null +++ b/src/badfish/emulator/templates/job.json @@ -0,0 +1,12 @@ +{ + "@odata.type": "#Job.v1_0_2.Job", + "Id": "", + "Name": "", + "Message": "", + "PercentComplete": 0, + "JobState": "New", + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/manager.json b/src/badfish/emulator/templates/manager.json new file mode 100644 index 0000000..f5c7564 --- /dev/null +++ b/src/badfish/emulator/templates/manager.json @@ -0,0 +1,55 @@ +{ + "@odata.type": "#Manager.v1_9_0.Manager", + "Id": "iDRAC.Embedded.1", + "Name": "iDRAC", + "ManagerType": "BMC", + "FirmwareVersion": "6.00.00.00", + "Model": "iDRAC9", + "UUID": "4c4c4544-0046-3410-8043-b7c04f584c31", + "Status": { + "State": "Enabled", + "Health": "OK" + }, + "Actions": { + "#Manager.Reset": { + "target": "/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Manager.Reset", + "ResetType@Redfish.AllowableValues": [ + "GracefulRestart", + "ForceRestart" + ] + }, + "Oem": { + "EID_674_Manager.ExportSystemConfiguration": { + "target": "/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/EID_674_Manager.ExportSystemConfiguration", + "ShareParameters": { + "Target@Redfish.AllowableValues": [ + "ALL", + "IDRAC", + "BIOS", + "NIC", + "RAID" + ] + } + }, + "EID_674_Manager.ImportSystemConfiguration": { + "target": "/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/EID_674_Manager.ImportSystemConfiguration", + "ShareParameters": { + "Target@Redfish.AllowableValues": [ + "ALL", + "IDRAC", + "BIOS", + "NIC" + ] + } + } + } + }, + "Links": { + "Jobs": { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/Jobs" + }, + "VirtualMedia": { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/VirtualMedia" + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/managers.json b/src/badfish/emulator/templates/managers.json new file mode 100644 index 0000000..c84851e --- /dev/null +++ b/src/badfish/emulator/templates/managers.json @@ -0,0 +1,10 @@ +{ + "@odata.type": "#ManagerCollection.ManagerCollection", + "Name": "Manager Collection", + "Members@odata.count": 1, + "Members": [ + { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1" + } + ] +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/memory.json b/src/badfish/emulator/templates/memory.json new file mode 100644 index 0000000..2b35d78 --- /dev/null +++ b/src/badfish/emulator/templates/memory.json @@ -0,0 +1,13 @@ +{ + "@odata.type": "#Memory.v1_10_0.Memory", + "Id": "", + "Name": "", + "CapacityMiB": 0, + "Manufacturer": "", + "MemoryDeviceType": "", + "OperatingSpeedMhz": 0, + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/network_adapter.json b/src/badfish/emulator/templates/network_adapter.json new file mode 100644 index 0000000..d06f011 --- /dev/null +++ b/src/badfish/emulator/templates/network_adapter.json @@ -0,0 +1,11 @@ +{ + "@odata.type": "#NetworkAdapter.v1_4_0.NetworkAdapter", + "Id": "NIC.Integrated.1", + "Name": "NIC.Integrated.1", + "Manufacturer": "Intel", + "Model": "XXV710", + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/network_adapters.json b/src/badfish/emulator/templates/network_adapters.json new file mode 100644 index 0000000..e238844 --- /dev/null +++ b/src/badfish/emulator/templates/network_adapters.json @@ -0,0 +1,10 @@ +{ + "@odata.type": "#NetworkAdapterCollection.NetworkAdapterCollection", + "Name": "Network Adapter Collection", + "Members@odata.count": 1, + "Members": [ + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/NetworkAdapters/NIC.Integrated.1" + } + ] +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/network_attributes_registry.json b/src/badfish/emulator/templates/network_attributes_registry.json new file mode 100644 index 0000000..1bab469 --- /dev/null +++ b/src/badfish/emulator/templates/network_attributes_registry.json @@ -0,0 +1,50 @@ +{ + "@odata.type": "#DellNetworkAttributesRegistry.v1_0_0.DellNetworkAttributesRegistry", + "RegistryEntries": { + "Attributes": [ + { + "AttributeName": "WakeOnLan", + "Type": "Enumeration", + "Value": [ + { + "ValueName": "Enabled" + }, + { + "ValueName": "Disabled" + } + ], + "CurrentValue": "Disabled" + }, + { + "AttributeName": "VirtualizationMode", + "Type": "Enumeration", + "Value": [ + { + "ValueName": "NONE" + }, + { + "ValueName": "SRIOV" + }, + { + "ValueName": "NPARSRIOV" + } + ], + "CurrentValue": "NONE" + }, + { + "AttributeName": "NumberVFAdvertised", + "Type": "Integer", + "LowerBound": 0, + "UpperBound": 128, + "CurrentValue": 0 + }, + { + "AttributeName": "DeviceName", + "Type": "String", + "MinLength": 0, + "MaxLength": 64, + "CurrentValue": "Intel(R) XXV710" + } + ] + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/network_device_function.json b/src/badfish/emulator/templates/network_device_function.json new file mode 100644 index 0000000..7602c9e --- /dev/null +++ b/src/badfish/emulator/templates/network_device_function.json @@ -0,0 +1,16 @@ +{ + "@odata.type": "#NetworkDeviceFunction.v1_3_0.NetworkDeviceFunction", + "Id": "NIC.Integrated.1-1-1", + "Name": "NIC.Integrated.1-1-1", + "NetDevFuncType": "Ethernet", + "Ethernet": { + "MACAddress": "00:5c:52:31:3a:9c" + }, + "Oem": { + "Dell": { + "DellNIC": { + "VendorName": "Intel" + } + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/network_port.json b/src/badfish/emulator/templates/network_port.json new file mode 100644 index 0000000..d97d7f9 --- /dev/null +++ b/src/badfish/emulator/templates/network_port.json @@ -0,0 +1,15 @@ +{ + "@odata.type": "#NetworkPort.v1_2_0.NetworkPort", + "Id": "Port0", + "Name": "Port0", + "LinkStatus": "Up", + "SupportedLinkCapabilities": [ + { + "LinkSpeedMbps": 25000 + } + ], + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/processor.json b/src/badfish/emulator/templates/processor.json new file mode 100644 index 0000000..93b096f --- /dev/null +++ b/src/badfish/emulator/templates/processor.json @@ -0,0 +1,15 @@ +{ + "@odata.type": "#Processor.v1_8_0.Processor", + "Id": "", + "Name": "Processor", + "Manufacturer": "", + "Model": "", + "InstructionSet": "x86-64", + "MaxSpeedMHz": 0, + "TotalCores": 0, + "TotalThreads": 0, + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/service_root.json b/src/badfish/emulator/templates/service_root.json new file mode 100644 index 0000000..ba42d4d --- /dev/null +++ b/src/badfish/emulator/templates/service_root.json @@ -0,0 +1,34 @@ +{ + "@odata.type": "#ServiceRoot.v1_6_0.ServiceRoot", + "Id": "RootService", + "Name": "Root Service", + "RedfishVersion": "1.16.0", + "UUID": "27946b59-9e44-4fa7-8e91-f3527a1ef094", + "Systems": { + "@odata.id": "/redfish/v1/Systems" + }, + "Managers": { + "@odata.id": "/redfish/v1/Managers" + }, + "Chassis": { + "@odata.id": "/redfish/v1/Chassis" + }, + "SessionService": { + "@odata.id": "/redfish/v1/SessionService" + }, + "UpdateService": { + "@odata.id": "/redfish/v1/UpdateService" + }, + "TaskService": { + "@odata.id": "/redfish/v1/TaskService" + }, + "Registries": { + "@odata.id": "/redfish/v1/Registries" + }, + "Oem": { + "Dell": { + "ServiceTag": "EMUL8V1", + "IsUsingRedfish": true + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/session.json b/src/badfish/emulator/templates/session.json new file mode 100644 index 0000000..400d807 --- /dev/null +++ b/src/badfish/emulator/templates/session.json @@ -0,0 +1,6 @@ +{ + "@odata.type": "#Session.v1_2_1.Session", + "Id": "", + "Name": "Session", + "UserName": "" +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/session_service.json b/src/badfish/emulator/templates/session_service.json new file mode 100644 index 0000000..ea388f8 --- /dev/null +++ b/src/badfish/emulator/templates/session_service.json @@ -0,0 +1,12 @@ +{ + "@odata.type": "#SessionService.v1_1_6.SessionService", + "Id": "SessionService", + "Name": "Session Service", + "ServiceEnabled": true, + "SessionTimeout": 1800, + "Links": { + "Sessions": { + "@odata.id": "/redfish/v1/SessionService/Sessions" + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/software_inventory.json b/src/badfish/emulator/templates/software_inventory.json new file mode 100644 index 0000000..27c7ebb --- /dev/null +++ b/src/badfish/emulator/templates/software_inventory.json @@ -0,0 +1,13 @@ +{ + "@odata.type": "#SoftwareInventory.v1_2_0.SoftwareInventory", + "Id": "", + "Name": "", + "Version": "", + "Manufacturer": "", + "Description": "Firmware inventory entry", + "Oem": {}, + "Status": { + "State": "Enabled", + "Health": "OK" + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/system.json b/src/badfish/emulator/templates/system.json new file mode 100644 index 0000000..cd8adcf --- /dev/null +++ b/src/badfish/emulator/templates/system.json @@ -0,0 +1,57 @@ +{ + "@odata.type": "#ComputerSystem.v1_8_0.ComputerSystem", + "Id": "System.Embedded.1", + "Name": "System.Embedded.1", + "SystemType": "Physical", + "Boot": { + "BootSourceOverrideEnabled": "Disabled", + "BootSourceOverrideTarget": "None", + "BootSourceOverrideTarget@Redfish.AllowableValues": [ + "None", + "Pxe", + "Cd", + "Usb", + "Hdd", + "BiosSetup", + "UefiTarget" + ] + }, + "Actions": { + "#ComputerSystem.Reset": { + "target": "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset", + "ResetType@Redfish.AllowableValues": [ + "On", + "ForceOff", + "GracefulShutdown", + "GracefulRestart", + "ForceRestart", + "PowerCycle", + "Nmi", + "ForceOn" + ] + } + }, + "Status": { + "State": "Enabled", + "Health": "OK" + }, + "Links": { + "EthernetInterfaces": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/EthernetInterfaces" + }, + "Processors": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Processors" + }, + "Memory": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Memory" + }, + "Bios": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Bios" + }, + "Chassis": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1" + } + ] + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/systems.json b/src/badfish/emulator/templates/systems.json new file mode 100644 index 0000000..7cd06d3 --- /dev/null +++ b/src/badfish/emulator/templates/systems.json @@ -0,0 +1,10 @@ +{ + "@odata.type": "#ComputerSystemCollection.ComputerSystemCollection", + "Name": "Computer System Collection", + "Members@odata.count": 1, + "Members": [ + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1" + } + ] +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/task.json b/src/badfish/emulator/templates/task.json new file mode 100644 index 0000000..348ee51 --- /dev/null +++ b/src/badfish/emulator/templates/task.json @@ -0,0 +1,15 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "Id": "", + "Name": "Task", + "TaskState": "Completed", + "TaskStatus": "OK", + "PercentComplete": 100, + "Messages": [], + "Oem": { + "Dell": { + "PercentComplete": 100, + "JobState": "Completed" + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/update_service.json b/src/badfish/emulator/templates/update_service.json new file mode 100644 index 0000000..0c4012b --- /dev/null +++ b/src/badfish/emulator/templates/update_service.json @@ -0,0 +1,11 @@ +{ + "@odata.type": "#UpdateService.v1_8_0.UpdateService", + "Id": "UpdateService", + "Name": "Update Service", + "ServiceEnabled": true, + "Links": { + "FirmwareInventory": { + "@odata.id": "/redfish/v1/UpdateService/FirmwareInventory" + } + } +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/virtual_media_cd.json b/src/badfish/emulator/templates/virtual_media_cd.json new file mode 100644 index 0000000..497cd50 --- /dev/null +++ b/src/badfish/emulator/templates/virtual_media_cd.json @@ -0,0 +1,20 @@ +{ + "@odata.type": "#VirtualMedia.v1_3_0.VirtualMedia", + "Id": "CD", + "Name": "Virtual CD", + "ImageName": "", + "Image": "", + "Inserted": false, + "WriteProtected": true, + "MediaTypes": [ + "CD" + ], + "Actions": { + "#VirtualMedia.InsertMedia": { + "target": "/redfish/v1/Managers/iDRAC.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia" + }, + "#VirtualMedia.EjectMedia": { + "target": "/redfish/v1/Managers/iDRAC.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.EjectMedia" + } + } +} \ No newline at end of file diff --git a/src/badfish/helpers/parser.py b/src/badfish/helpers/parser.py index 4bb90fc..a1a6ab8 100644 --- a/src/badfish/helpers/parser.py +++ b/src/badfish/helpers/parser.py @@ -321,6 +321,22 @@ def create_parser(): help="Set a NIC attribute value", default="", ) + parser.add_argument( + "--redfish-emulator", + help="Run the built-in Redfish emulator (mock iDRAC) as a persistent server instead of querying a host", + action="store_true", + ) + parser.add_argument( + "--bind", + help="(Redfish emulator) Address the emulator should bind to", + default="127.0.0.1", + ) + parser.add_argument( + "--port", + help="(Redfish emulator) Port the emulator should listen on", + default=8443, + type=_positive_int, + ) return parser diff --git a/src/badfish/main.py b/src/badfish/main.py index 58b6b55..52a412c 100644 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -3175,6 +3175,11 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non def main(argv=None): _args = parse_arguments(argv) + if _args.get("redfish_emulator"): + from badfish import emulator + + return emulator.run_daemon(_args) + log_level = DEBUG if _args["verbose"] else INFO host = _args["host"] diff --git a/tests/test_emulator.py b/tests/test_emulator.py new file mode 100644 index 0000000..6b5b8da --- /dev/null +++ b/tests/test_emulator.py @@ -0,0 +1,221 @@ +import ssl +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from badfish import emulator +from badfish.main import badfish_factory + +_CERTS = Path(__file__).parent.parent / "src" / "badfish" / "emulator" / "certs" +_ROOT = "/redfish/v1" + + +async def _client(app): + client = TestClient(TestServer(app)) + await client.start_server() + return client + + +async def _login(client, user="quads", password="quads"): + resp = await client.post(f"{_ROOT}/SessionService/Sessions", json={"UserName": user, "Password": password}) + assert resp.status == 201 + return resp.headers["X-Auth-Token"] + + +@pytest.fixture +async def client(): + app = emulator.create_app() + c = await _client(app) + yield c + await c.close() + + +async def test_bad_credentials_rejected(client): + resp = await client.post(f"{_ROOT}/SessionService/Sessions", json={"UserName": "quads", "Password": "wrong"}) + assert resp.status == 401 + body = await resp.json() + assert "error" in body + + +async def test_auth_required_for_resources(client): + token = await _login(client) + resp = await client.get(f"{_ROOT}/Systems") + assert resp.status == 401 + resp = await client.get(f"{_ROOT}/Systems", headers={"X-Auth-Token": token}) + assert resp.status == 200 + body = await resp.json() + assert body["Members"][0]["@odata.id"] == f"{_ROOT}/Systems/System.Embedded.1" + + +async def test_session_discovery_without_token(client): + resp = await client.get(f"{_ROOT}") + assert resp.status == 200 + body = await resp.json() + assert body["RedfishVersion"] == "1.16.0" + assert body["Oem"]["Dell"]["ServiceTag"] == "EMUL8V1" + + +async def test_system_power_roundtrip(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + + resp = await client.get(f"{_ROOT}/Systems/System.Embedded.1", headers=headers) + body = await resp.json() + assert body["PowerState"] == "Off" + assert body["Model"] == "PowerEdge R740" + + reset = await client.post( + f"{_ROOT}/Systems/System.Embedded.1/Actions/ComputerSystem.Reset", json={"ResetType": "On"}, headers=headers + ) + assert reset.status == 204 + + resp = await client.get(f"{_ROOT}/Systems/System.Embedded.1", headers=headers) + assert (await resp.json())["PowerState"] == "On" + + +async def test_boot_override_patch(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + resp = await client.patch( + f"{_ROOT}/Systems/System.Embedded.1", + json={"Boot": {"BootSourceOverrideTarget": "Pxe", "BootSourceOverrideEnabled": "Once"}}, + headers=headers, + ) + assert resp.status == 200 + body = await (await client.get(f"{_ROOT}/Systems/System.Embedded.1", headers=headers)).json() + assert body["Boot"]["BootSourceOverrideTarget"] == "Pxe" + assert body["Boot"]["BootSourceOverrideEnabled"] == "Once" + + +async def test_jobs_lifecycle(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + manager = f"{_ROOT}/Managers/iDRAC.Embedded.1" + resp = await client.post( + f"{manager}/Jobs", + json={"TargetSettingsURI": "/redfish/v1/Systems/System.Embedded.1/Bios/Settings"}, + headers=headers, + ) + assert resp.status == 200 + job_id = resp.headers["Location"].split("/")[-1] + assert job_id.startswith("JID_") + + job = await (await client.get(f"{manager}/Jobs/{job_id}", headers=headers)).json() + assert job["JobState"] == "Completed" + assert job["PercentComplete"] == 100 + + jobs = await (await client.get(f"{manager}/Jobs", headers=headers)).json() + assert [m["@odata.id"] for m in jobs["Members"]] == [f"{manager}/Jobs/{job_id}"] + + resp = await client.delete(f"{manager}/Jobs/{job_id}", headers=headers) + assert resp.status == 200 + jobs = await (await client.get(f"{manager}/Jobs", headers=headers)).json() + assert jobs["Members@odata.count"] == 0 + + +async def test_virtual_media(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + vmedia = f"{_ROOT}/Managers/iDRAC.Embedded.1/VirtualMedia/CD" + + resp = await client.post( + f"{vmedia}/Actions/VirtualMedia.InsertMedia", json={"Image": "/tmp/fake.iso"}, headers=headers + ) + assert resp.status == 204 + body = await (await client.get(vmedia, headers=headers)).json() + assert body["Inserted"] is True + assert body["ImageName"] == "/tmp/fake.iso" + + resp = await client.post(f"{vmedia}/Actions/VirtualMedia.EjectMedia", json={}, headers=headers) + assert resp.status == 204 + body = await (await client.get(vmedia, headers=headers)).json() + assert body["Inserted"] is False + + +async def test_firmware_and_dell_endpoints(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + inv = await (await client.get(f"{_ROOT}/UpdateService/FirmwareInventory", headers=headers)).json() + assert inv["Members@odata.count"] == 2 + member = inv["Members"][0]["@odata.id"] + fw = await (await client.get(member, headers=headers)).json() + assert fw["Version"] + + dell = await (await client.get(f"{_ROOT}/Dell/Managers/iDRAC.Embedded.1/DellJobService", headers=headers)).json() + assert dell["Id"] == "DellJobService" + + +async def test_not_found_and_tasks(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + resp = await client.get(f"{_ROOT}/Systems/DoesNotExist", headers=headers) + assert resp.status == 404 + body = await resp.json() + assert body["error"]["@Message.ExtendedInfo"][0]["Message"] + + task = await (await client.get(f"{_ROOT}/TaskService/Tasks/1", headers=headers)).json() + assert task["Oem"]["Dell"]["PercentComplete"] == 100 + + +async def test_emulator_end_to_end(monkeypatch): + """Drive the real badfish client against a live HTTPS emulator.""" + + async def _noop(*_args, **_kwargs): + return None + + monkeypatch.setattr("badfish.main.asyncio.sleep", _noop) + + app = emulator.create_app() + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(str(_CERTS / "emulator.crt"), str(_CERTS / "emulator.key")) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ctx) + await site.start() + host = f"127.0.0.1:{runner.addresses[0][1]}" + + bf = None + try: + bf = await badfish_factory(host, "quads", "quads", _insecure=True, _retries=3) + assert bf.vendor == "Dell" + + assert await bf.get_power_state() == "Off" + assert await bf.send_reset("On") is True + bf.http_client.get_json.cache_clear() + assert await bf.get_power_state() == "On" + assert await bf.send_reset("ForceOff") is True + bf.http_client.get_json.cache_clear() + assert await bf.get_power_state() == "Off" + + assert await bf.get_boot_seq() == "BootSeq" + await bf.get_boot_devices() + assert bf.boot_devices is not None + assert bf.boot_devices[0]["Name"] == "NIC.Integrated.1-1-1" + + job_id = await bf.create_bios_config_job(bf.bios_uri) + assert job_id and job_id.startswith("JID_") + assert (await bf.check_schedule_job_status(job_id)) is None + + # get_firmware_inventory logs results and returns None on success. + assert (await bf.get_firmware_inventory()) is None + assert await bf.mount_virtual_media("/tmp/fake.iso") + assert await bf.check_virtual_media() + assert await bf.unmount_virtual_media() + + await bf.get_power_consumed_watts() + assert await bf.get_bios_boot_mode() == "Bios" + finally: + if bf: + await bf.delete_session() + await runner.cleanup() + + +def test_parser_has_emulator_flags(): + from badfish.helpers.parser import parse_arguments + + args = parse_arguments(["--redfish-emulator", "--port", "9000", "--bind", "localhost"]) + assert args["redfish_emulator"] is True + assert args["port"] == 9000 + assert args["bind"] == "localhost" From 4759852ce4bfe60cb959499503e2925afc6f5f0c Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 12:47:24 +0200 Subject: [PATCH 13/36] feat(emulator): AccountService user management with role-based RBAC Adds the Redfish AccountService surface to the mock iDRAC: a JSON-backed user store (created at runtime, /tmp by default, BADFISH_EMULATOR_USERS to relocate), roles ReadOnly/Operator/Administrator, per-role authorization on mutating requests, and the last-enabled-Administrator guard. Users can be created, edited, removed and have passwords changed over the API, so future badfish user/RBAC client work has a stable target. The store stays a flat JSON file rather than sqlite on purpose: the emulator is a temporal testing fixture, not a persistent service, and JSON keeps the state human-inspectable and aligned with the DMTF/sushy mockup convention. sqlite would be stdlib-safe too, but its durability/concurrency advantages are precisely what this throwaway scope does not need. Also ships the account_service and account templates (previously missing), warts the e2e test off the shared /tmp default store, and declares template and cert package_data so the emulator actually runs from an installed wheel or RPM (templates were previously omitted from the build entirely). --- README.md | 4 +- setup.cfg | 5 + src/badfish/emulator.py | 195 ++++++++++++++++-- src/badfish/emulator/templates/account.json | 10 + .../emulator/templates/account_service.json | 16 ++ tests/test_emulator.py | 139 ++++++++++++- 6 files changed, 351 insertions(+), 18 deletions(-) create mode 100644 src/badfish/emulator/templates/account.json create mode 100644 src/badfish/emulator/templates/account_service.json diff --git a/README.md b/README.md index 4759c4e..fc98e11 100644 --- a/README.md +++ b/README.md @@ -719,7 +719,9 @@ badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --ls-jobs Default credentials are `quads` / `quads`, the same convention quads uses for its IPMI user. Set `BADFISH_EMULATOR_USER` and `BADFISH_EMULATOR_PASSWORD` to override. `--bind` and `--port` control the listen address. -Currently covered: session/token auth, power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, the jobs queue (create/check/delete), virtual media mount/eject, firmware inventory, system/processor/memory/interface inventory, and SCP import/export targets. Screenshot and OS-deployment network ISO actions return "not supported" so badfish degrades gracefully. +Accounts run through the Redfish AccountService: each user carries a role (`Administrator`, `Operator`, `ReadOnly`), users can be created, edited and removed over the API, passwords change via the `ChangePassword` action, and the last enabled Administrator cannot be deleted or disabled. Accounts live in a small JSON store created at runtime (default `/tmp/badfish_emulator_users.json`, `BADFISH_EMULATOR_USERS` to relocate). The emulator is a temporal testing fixture, not a persistent service: state is throwaway by default and dies with the box. + +Currently covered: session/token auth, user and account management with role-based authorization, power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, the jobs queue (create/check/delete), virtual media mount/eject, firmware inventory, system/processor/memory/interface inventory, and SCP import/export targets. Screenshot and OS-deployment network ISO actions return "not supported" so badfish degrades gracefully. > [!NOTE] > This is a development tool, not a security boundary. The bundled certificate and default credentials exist so CI and laptops can spin up a mock iDRAC with zero setup. diff --git a/setup.cfg b/setup.cfg index 9a31f1c..61c625e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,11 @@ zip_safe = True [options.packages.find] where = src +[options.package_data] +badfish = + emulator/templates/*.json + emulator/certs/* + [sdist] formats = gztar diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index f5893a3..898b22a 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -37,6 +37,11 @@ USERNAME = os.environ.get("BADFISH_EMULATOR_USER", "quads") PASSWORD = os.environ.get("BADFISH_EMULATOR_PASSWORD", "quads") +# Flat JSON user store, created at runtime. Throwaway by default; point +# BADFISH_EMULATOR_USERS elsewhere to persist between emulator runs. +USERS_PATH = os.environ.get("BADFISH_EMULATOR_USERS", "/tmp/badfish_emulator_users.json") +ROLES = ("ReadOnly", "Operator", "Administrator") + # Single source of truth for the fake host's hardware identity. Changing a # value here changes every resource that reports it; no template editing needed. SYSCONF = { @@ -95,6 +100,8 @@ CHASSIS = f"{ROOT}/Chassis/{SYSCONF['chassis_id']}" UPDATESERVICE = f"{ROOT}/UpdateService" FIRMWARE = f"{UPDATESERVICE}/FirmwareInventory" +ACCOUNTSERVICE = f"{ROOT}/AccountService" +ACCOUNTS_URI = f"{ACCOUNTSERVICE}/Accounts" _RESTART_TYPES = {"GracefulRestart", "ForceRestart"} # rebooted: off then on _RESET_STATES = { # one-shot power targets @@ -116,7 +123,8 @@ class _StateKey(web.AppKey): class State: """Mutable fake-driver state shared by the mock BMC's resources.""" - def __init__(self): + def __init__(self, store=None): + self.store = store self.power = "Off" self.boot_target = "None" self.boot_enabled = "Disabled" @@ -127,6 +135,49 @@ def __init__(self): self._restart_task = None +class _UserStore: + """Flat JSON user store: username -> {password, role, enabled}.""" + + def __init__(self, path, seed_user, seed_password): + self.path = path + self.users = {} + self._load(seed_user, seed_password) + + def _load(self, seed_user, seed_password): + if self.path and os.path.exists(self.path): + try: + with open(self.path) as fh: + data = json.load(fh) + self.users = data.get("users", {}) or {} + return + except (ValueError, OSError): + pass + self.users = {seed_user: {"password": seed_password, "role": "Administrator", "enabled": True}} + self._save() + + def _save(self): + os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) + tmp = f"{self.path}.tmp" + with open(tmp, "w") as fh: + json.dump({"users": self.users}, fh, indent=2) + os.replace(tmp, self.path) + + def authenticate(self, username, password): + user = self.users.get(username) + return bool(user and user["enabled"] and user["password"] == password) + + def role(self, username): + return self.users.get(username, {}).get("role", "ReadOnly") + + def set_user(self, username, password, role, enabled=True): + self.users[username] = {"password": password, "role": role, "enabled": enabled} + self._save() + + def delete_user(self, username): + self.users.pop(username, None) + self._save() + + # --- template store --------------------------------------------------------- _STATIC = {} @@ -182,6 +233,7 @@ def _collection(resource_name, base, members): f"{ROOT}/Managers": "managers", MANAGER: "manager", f"{CHASSIS}": "chassis", + ACCOUNTSERVICE: "account_service", UPDATESERVICE: "update_service", f"{ROOT}/Dell/Managers/{SYSCONF['manager_id']}/DellJobService": "dell_job_service", f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService": "dell_os_deployment_service", @@ -202,6 +254,8 @@ def _collection_uri(uri, state): return _collection("Job", uri, list(state.jobs.keys())) if uri == f"{ROOT}/SessionService/Sessions": return _collection("Session", uri, [s["id"] for s in state.sessions.values()]) + if uri == ACCOUNTS_URI: + return _collection("ManagerAccount", uri, sorted(state.store.users)) if uri == f"{MANAGER}/VirtualMedia": return _collection("VirtualMedia", uri, ["CD"]) return None @@ -320,6 +374,21 @@ def _m_registry_file(uri, state): return _static_doc("network_attributes_registry", uri) +def _m_account(uri, state): + username = uri.rsplit("/", 1)[-1] + user = state.store.users.get(username) + if user is None: + return None + data = _tmpl("account") + data["@odata.id"] = uri + data["Id"] = username + data["Name"] = username + data["UserName"] = username + data["RoleId"] = user["role"] + data["Enabled"] = user["enabled"] + return data + + _MEMBER_PREFIXES = ( (f"{SYSTEM}/EthernetInterfaces/", _m_nic), (f"{SYSTEM}/Processors/", _m_processor), @@ -327,6 +396,7 @@ def _m_registry_file(uri, state): (f"{FIRMWARE}/", _m_firmware), (f"{MANAGER}/Jobs/", _m_job), (f"{ROOT}/SessionService/Sessions/", _m_session), + (f"{ACCOUNTS_URI}/", _m_account), (f"{MANAGER}/VirtualMedia/", _m_vmedia), (f"{ROOT}/TaskService/Tasks/", _m_task), (f"{ROOT}/Registries/NetworkAttributesRegistry_", _m_registry_file), @@ -422,14 +492,26 @@ def _delete_session(state, session_id): return False -def _basic_ok(auth_header): +def _basic_creds(auth_header): if not auth_header.startswith("Basic "): - return False + return None try: user, _, password = base64.b64decode(auth_header[6:]).decode().partition(":") except (ValueError, UnicodeDecodeError): - return False - return (user, password) == (USERNAME, PASSWORD) + return None + return user, password + + +def _forbidden(message): + return _json( + _error(message, "Ask an administrator to grant the required privileges."), + status=403, + ) + + +def _enabled_administrators(state): + users = state.store.users if state.store else {} + return sum(1 for u in users.values() if u["role"] == "Administrator" and u["enabled"]) async def _read_json(request): @@ -443,7 +525,8 @@ def _make_session(state, username): token = secrets.token_hex(16) state._job_n += 1 session_id = str(state._job_n) - state.sessions[token] = {"id": session_id, "username": username} + role = state.store.role(username) if state.store else "Administrator" + state.sessions[token] = {"id": session_id, "username": username, "role": role} uri = f"{ROOT}/SessionService/Sessions/{session_id}" body = _static_doc("session", uri) body["Id"] = session_id @@ -464,13 +547,29 @@ async def _auth(request, handler): return await handler(request) if request.method == "POST" and path in (f"{ROOT}/SessionService/Sessions", f"{ROOT}/Sessions"): return await handler(request) - if request.headers.get("X-Auth-Token") in state.sessions: - return await handler(request) - if _basic_ok(request.headers.get("Authorization", "")): - return await handler(request) + + token = request.headers.get("X-Auth-Token") + if token in state.sessions: + return await _authorize(request, handler, state.sessions[token]["role"]) + creds = _basic_creds(request.headers.get("Authorization", "")) + if creds is not None and state.store is not None and state.store.authenticate(*creds): + return await _authorize(request, handler, state.store.role(creds[0])) return _unauthorized("Authentication required. Create a session via POST /redfish/v1/SessionService/Sessions.") +async def _authorize(request, handler, role): + """Coarse RBAC: ReadOnly sees, Operator and up mutate, Administrator manages users.""" + method = request.method + path = request.path.rstrip("/") + if method in ("POST", "PATCH", "DELETE"): + if path.startswith(ACCOUNTSERVICE) and not path.endswith("/AccountService.ChangePassword"): + if role != "Administrator": + return _forbidden("Account management requires the Administrator role.") + elif role == "ReadOnly": + return _forbidden("This operation requires Operator or Administrator privileges.") + return await handler(request) + + # --- HTTP handlers ---------------------------------------------------------- @@ -489,11 +588,47 @@ async def _post(_request): if path in (f"{ROOT}/SessionService/Sessions", f"{ROOT}/Sessions"): body = await _read_json(_request) - if body is None or body.get("UserName") != USERNAME or body.get("Password") != PASSWORD: + if body is None: + return _bad_request("Malformed JSON body.") + if not state.store.authenticate(body.get("UserName"), body.get("Password")): return _unauthorized("Authentication failed. Verify your credentials.") resource, token, location = _make_session(state, body.get("UserName")) return _json(resource, status=201, headers={"X-Auth-Token": token, "Location": location}) + if path == ACCOUNTS_URI: + body = await _read_json(_request) + if body is None: + return _bad_request("Malformed JSON body.") + username = body.get("UserName") + password = body.get("Password") + role = body.get("RoleId") or "ReadOnly" + if not username or not password: + return _bad_request("UserName and Password are required.") + if role not in ROLES: + return _bad_request(f"RoleId must be one of {', '.join(ROLES)}.") + if username in state.store.users: + return _bad_request(f"User {username} already exists.") + state.store.set_user(username, password, role) + uri = f"{ACCOUNTS_URI}/{username}" + return _json(_m_account(uri, state), status=201, headers={"Location": uri}) + + if path == f"{ACCOUNTSERVICE}/Actions/AccountService.ChangePassword": + body = await _read_json(_request) + if body is None: + return _bad_request("Malformed JSON body.") + if not state.store.authenticate(body.get("UserName"), body.get("OldPassword")): + return _unauthorized("Old password is incorrect.") + new_password = body.get("NewPassword") + if not new_password: + return _bad_request("NewPassword is required.") + state.store.set_user( + body["UserName"], + new_password, + state.store.role(body["UserName"]), + state.store.users[body["UserName"]]["enabled"], + ) + return web.Response(status=204) + if path == f"{SYSTEM}/Actions/ComputerSystem.Reset": body = await _read_json(_request) if body is None: @@ -594,6 +729,30 @@ async def _patch(_request): state.boot_enabled = boot.get("BootSourceOverrideEnabled", state.boot_enabled) return web.Response(status=200) + if path.startswith(f"{ACCOUNTS_URI}/"): + username = path.rsplit("/", 1)[-1] + user = state.store.users.get(username) + if user is None: + return _not_found(path) + body = await _read_json(_request) + if body is None: + return _bad_request("Malformed JSON body.") + if "Password" in body: + if not body["Password"]: + return _bad_request("Password cannot be empty.") + user["password"] = body["Password"] + if "RoleId" in body: + if body["RoleId"] not in ROLES: + return _bad_request(f"RoleId must be one of {', '.join(ROLES)}.") + user["role"] = body["RoleId"] + if "Enabled" in body: + enabled = bool(body["Enabled"]) + if not enabled and user["role"] == "Administrator" and _enabled_administrators(state) <= 1: + return _bad_request("Cannot disable the last enabled Administrator.") + user["enabled"] = enabled + state.store._save() + return web.Response(status=200) + if path == f"{SYSTEM}/Bios/Settings": return web.Response(status=200) if path == f"{SYSTEM}/BootSources/Settings": @@ -612,6 +771,15 @@ async def _delete(_request): if _delete_session(state, session_id): return web.Response(status=200) return _not_found(path) + if path.startswith(f"{ACCOUNTS_URI}/"): + username = path.rsplit("/", 1)[-1] + user = state.store.users.get(username) + if user is None: + return _not_found(path) + if user["role"] == "Administrator" and _enabled_administrators(state) <= 1: + return _bad_request("Cannot remove the last Administrator.") + state.store.delete_user(username) + return web.Response(status=200) if path.startswith(f"{MANAGER}/Jobs/"): job_id = path.rsplit("/", 1)[-1] if job_id == "JID_CLEARALL_FORCE": @@ -622,9 +790,10 @@ async def _delete(_request): return _method_not_allowed(path, "DELETE") -def create_app(): +def create_app(users_path=None): app = web.Application(middlewares=[_auth]) - app[_STATE_KEY] = State() + store = _UserStore(users_path or USERS_PATH, USERNAME, PASSWORD) + app[_STATE_KEY] = State(store) app.router.add_get("/{path:.*}", _get) app.router.add_post("/{path:.*}", _post) app.router.add_patch("/{path:.*}", _patch) diff --git a/src/badfish/emulator/templates/account.json b/src/badfish/emulator/templates/account.json new file mode 100644 index 0000000..e2f3dc4 --- /dev/null +++ b/src/badfish/emulator/templates/account.json @@ -0,0 +1,10 @@ +{ + "@odata.type": "#ManagerAccount.v1_5_0.ManagerAccount", + "Id": "", + "Name": "", + "UserName": "", + "RoleId": "", + "Enabled": false, + "Locked": false, + "Links": {} +} \ No newline at end of file diff --git a/src/badfish/emulator/templates/account_service.json b/src/badfish/emulator/templates/account_service.json new file mode 100644 index 0000000..e7889c9 --- /dev/null +++ b/src/badfish/emulator/templates/account_service.json @@ -0,0 +1,16 @@ +{ + "@odata.type": "#AccountService.v1_7_0.AccountService", + "Id": "AccountService", + "Name": "Account Service", + "ServiceEnabled": true, + "MinPasswordLength": 4, + "MaxPasswordLength": 20, + "Accounts": { + "@odata.id": "/redfish/v1/AccountService/Accounts" + }, + "Actions": { + "#AccountService.ChangePassword": { + "target": "/redfish/v1/AccountService/Actions/AccountService.ChangePassword" + } + } +} \ No newline at end of file diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 6b5b8da..8d03df9 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -10,6 +10,7 @@ _CERTS = Path(__file__).parent.parent / "src" / "badfish" / "emulator" / "certs" _ROOT = "/redfish/v1" +ACCOUNTS = f"{_ROOT}/AccountService/Accounts" async def _client(app): @@ -25,8 +26,8 @@ async def _login(client, user="quads", password="quads"): @pytest.fixture -async def client(): - app = emulator.create_app() +async def client(tmp_path): + app = emulator.create_app(str(tmp_path / "users.json")) c = await _client(app) yield c await c.close() @@ -159,7 +160,137 @@ async def test_not_found_and_tasks(client): assert task["Oem"]["Dell"]["PercentComplete"] == 100 -async def test_emulator_end_to_end(monkeypatch): +async def test_account_service_and_quads_admin(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + svc = await (await client.get(f"{_ROOT}/AccountService", headers=headers)).json() + assert svc["Id"] == "AccountService" + assert svc["Accounts"]["@odata.id"] == ACCOUNTS + coll = await (await client.get(ACCOUNTS, headers=headers)).json() + assert [m["@odata.id"] for m in coll["Members"]] == [f"{ACCOUNTS}/quads"] + acct = await (await client.get(f"{ACCOUNTS}/quads", headers=headers)).json() + assert acct["RoleId"] == "Administrator" + assert acct["Enabled"] is True + # real iDRAC never returns Password on account GET + assert "Password" not in acct + + +async def test_admin_creates_user_and_store_persists(tmp_path): + users_path = str(tmp_path / "users.json") + app = emulator.create_app(users_path) + c = await _client(app) + token = await _login(c) + resp = await c.post( + ACCOUNTS, + json={"UserName": "alice", "Password": "secret", "RoleId": "Operator"}, + headers={"X-Auth-Token": token}, + ) + assert resp.status == 201 + await c.close() + + # a fresh emulator process against the same file still knows alice + app2 = emulator.create_app(users_path) + c2 = await _client(app2) + assert await _login(c2, "alice", "secret") + await c2.close() + + +async def test_account_create_validation(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + dup = await client.post( + ACCOUNTS, json={"UserName": "quads", "Password": "x", "RoleId": "Administrator"}, headers=headers + ) + assert dup.status == 400 + badrole = await client.post( + ACCOUNTS, json={"UserName": "carol", "Password": "x", "RoleId": "Superuser"}, headers=headers + ) + assert badrole.status == 400 + nopass = await client.post(ACCOUNTS, json={"UserName": "carol", "RoleId": "Operator"}, headers=headers) + assert nopass.status == 400 + + +async def test_operator_mutates_but_cannot_manage_accounts(client): + token = await _login(client) + admin = {"X-Auth-Token": token} + assert ( + await client.post( + ACCOUNTS, json={"UserName": "op", "Password": "opsecret", "RoleId": "Operator"}, headers=admin + ) + ).status == 201 + op = {"X-Auth-Token": await _login(client, "op", "opsecret")} + + reset = await client.post( + f"{_ROOT}/Systems/System.Embedded.1/Actions/ComputerSystem.Reset", json={"ResetType": "On"}, headers=op + ) + assert reset.status == 204 + + forbidden = await client.post( + ACCOUNTS, json={"UserName": "mallory", "Password": "x", "RoleId": "ReadOnly"}, headers=op + ) + assert forbidden.status == 403 + forbidden = await client.delete(f"{ACCOUNTS}/op", headers=op) + assert forbidden.status == 403 + + +async def test_readonly_has_no_mutation_rights(client): + token = await _login(client) + admin = {"X-Auth-Token": token} + assert ( + await client.post( + ACCOUNTS, json={"UserName": "ro", "Password": "rosecret", "RoleId": "ReadOnly"}, headers=admin + ) + ).status == 201 + ro = {"X-Auth-Token": await _login(client, "ro", "rosecret")} + + assert (await client.get(f"{_ROOT}/Systems", headers=ro)).status == 200 + reset = await client.post( + f"{_ROOT}/Systems/System.Embedded.1/Actions/ComputerSystem.Reset", json={"ResetType": "On"}, headers=ro + ) + assert reset.status == 403 + boot = await client.patch(f"{_ROOT}/Systems/System.Embedded.1", json={"Boot": {}}, headers=ro) + assert boot.status == 403 + + +async def test_change_password_action(client): + token = await _login(client) + admin = {"X-Auth-Token": token} + assert ( + await client.post( + ACCOUNTS, json={"UserName": "bob", "Password": "oldpass", "RoleId": "Operator"}, headers=admin + ) + ).status == 201 + + resp = await client.post( + f"{_ROOT}/AccountService/Actions/AccountService.ChangePassword", + json={"UserName": "bob", "OldPassword": "oldpass", "NewPassword": "newpass"}, + headers={"X-Auth-Token": await _login(client, "bob", "oldpass")}, + ) + assert resp.status == 204 + + wrong = await client.post(f"{_ROOT}/SessionService/Sessions", json={"UserName": "bob", "Password": "oldpass"}) + assert wrong.status == 401 + assert await _login(client, "bob", "newpass") + + +async def test_account_patch_and_last_admin_guard(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + + patch = await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "ReadOnly"}, headers=headers) + assert patch.status == 200 + acct = await (await client.get(f"{ACCOUNTS}/quads", headers=headers)).json() + assert acct["RoleId"] == "ReadOnly" + + assert (await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "Administrator"}, headers=headers)).status == 200 + + disable = await client.patch(f"{ACCOUNTS}/quads", json={"Enabled": False}, headers=headers) + assert disable.status == 400 + remove = await client.delete(f"{ACCOUNTS}/quads", headers=headers) + assert remove.status == 400 + + +async def test_emulator_end_to_end(monkeypatch, tmp_path): """Drive the real badfish client against a live HTTPS emulator.""" async def _noop(*_args, **_kwargs): @@ -167,7 +298,7 @@ async def _noop(*_args, **_kwargs): monkeypatch.setattr("badfish.main.asyncio.sleep", _noop) - app = emulator.create_app() + app = emulator.create_app(str(tmp_path / "users.json")) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(str(_CERTS / "emulator.crt"), str(_CERTS / "emulator.key")) runner = web.AppRunner(app) From 3908c542a643f5ede5d4ec21979d7bd6ac404134 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 14:03:48 +0200 Subject: [PATCH 14/36] test(emulator): lift patch coverage past the codecov target Codecov patch coverage on the new emulator surface was 74.45% against a 93.43% target, pulling the PR check red. Adds targeted tests for the member collections (NIC/CPU/DIMM), session and registry resources, malformed-JSON 400s on every JSON reader, ChangePassword and account PATCH/DELETE error paths, reset variants and BIOS/Manager actions, the Dell OEM endpoints (job queue, OS deployment, SCP import/export, screenshot), PATCH/DELETE fallthroughs, garbage Basic auth, corrupt-store recovery, and a live run_daemon smoke test plus the main() routing branch. Two small correctness fixes surfaced while chasing coverage: the chassis Power consumption was dead code (shadowed by the static URI map), so it never varied with power state, and run_daemon now returns web.run_app() so callers can drive it. Patch coverage is now ~99%. --- src/badfish/emulator.py | 3 +- tests/test_emulator.py | 257 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 2 deletions(-) diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 898b22a..145ed4d 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -237,7 +237,6 @@ def _collection(resource_name, base, members): UPDATESERVICE: "update_service", f"{ROOT}/Dell/Managers/{SYSCONF['manager_id']}/DellJobService": "dell_job_service", f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService": "dell_os_deployment_service", - f"{CHASSIS}/Power": "chassis_power", } @@ -807,4 +806,4 @@ def run_daemon(args): context.load_cert_chain(str(_CERTS / "emulator.crt"), str(_CERTS / "emulator.key")) host = args.get("bind") or "127.0.0.1" port = int(args.get("port") or 8443) - web.run_app(app, host=host, port=port, ssl_context=context) + return web.run_app(app, host=host, port=port, ssl_context=context) diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 8d03df9..8b8284d 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -1,3 +1,5 @@ +import asyncio +import base64 import ssl from pathlib import Path @@ -11,6 +13,10 @@ _CERTS = Path(__file__).parent.parent / "src" / "badfish" / "emulator" / "certs" _ROOT = "/redfish/v1" ACCOUNTS = f"{_ROOT}/AccountService/Accounts" +SYSTEM = f"{_ROOT}/Systems/System.Embedded.1" +MANAGER = f"{_ROOT}/Managers/iDRAC.Embedded.1" +CHASSIS = f"{_ROOT}/Chassis/System.Embedded.1" +FIRMWARE = f"{_ROOT}/UpdateService/FirmwareInventory" async def _client(app): @@ -173,6 +179,7 @@ async def test_account_service_and_quads_admin(client): assert acct["Enabled"] is True # real iDRAC never returns Password on account GET assert "Password" not in acct + assert (await client.get(f"{ACCOUNTS}/ghost", headers=headers)).status == 404 async def test_admin_creates_user_and_store_persists(tmp_path): @@ -343,6 +350,256 @@ async def _noop(*_args, **_kwargs): await runner.cleanup() +async def test_root_path_public_and_missing(client): + resp = await client.get("/") + assert resp.status == 404 + + +async def test_inventory_collections_and_members(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + + nics = await (await client.get(f"{SYSTEM}/EthernetInterfaces", headers=headers)).json() + assert [m["@odata.id"] for m in nics["Members"]] == [ + f"{SYSTEM}/EthernetInterfaces/NIC.Integrated.1-1-1", + f"{SYSTEM}/EthernetInterfaces/NIC.Integrated.1-2-1", + ] + nic = await (await client.get(f"{SYSTEM}/EthernetInterfaces/NIC.Integrated.1-1-1", headers=headers)).json() + assert nic["MACAddress"] == "00:5c:52:31:3a:9c" + assert nic["LinkStatus"] == "Up" + + procs = await (await client.get(f"{SYSTEM}/Processors", headers=headers)).json() + assert procs["Members@odata.count"] == 2 + cpu = await (await client.get(f"{SYSTEM}/Processors/CPU.Socket.1", headers=headers)).json() + assert cpu["Model"] and cpu["TotalCores"] > 0 + + mem = await (await client.get(f"{SYSTEM}/Memory", headers=headers)).json() + assert mem["Members@odata.count"] == 2 + dimm = await (await client.get(f"{SYSTEM}/Memory/DIMM.Socket.A1", headers=headers)).json() + assert dimm["CapacityMiB"] == 32768 + assert dimm["Manufacturer"] == "Micron" + + assert (await client.get(f"{SYSTEM}/Memory/DIMM.Socket.ZZ", headers=headers)).status == 404 + assert (await client.get(f"{FIRMWARE}/NOPE", headers=headers)).status == 404 + assert (await client.get(f"{MANAGER}/Jobs/NOPE", headers=headers)).status == 404 + + +async def test_sessions_and_registry(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + + login = await client.post(f"{_ROOT}/SessionService/Sessions", json={"UserName": "quads", "Password": "quads"}) + loc = login.headers["Location"] + session = await (await client.get(loc, headers=headers)).json() + assert session["UserName"] == "quads" + assert session["Id"] + + coll = await (await client.get(f"{_ROOT}/SessionService/Sessions", headers=headers)).json() + assert coll["Members@odata.count"] >= 1 + assert (await client.get(f"{_ROOT}/SessionService/Sessions/9999", headers=headers)).status == 404 + + reg = await (await client.get(f"{_ROOT}/Registries/NetworkAttributesRegistry_1.0.0.json", headers=headers)).json() + assert reg["@odata.type"].startswith("#DellNetworkAttributesRegistry") + + +async def test_chassis_power_dynamic(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + power = await (await client.get(f"{CHASSIS}/Power", headers=headers)).json() + assert power["PowerControl"][0]["PowerConsumedWatts"] == 120 + await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "On"}, headers=headers) + power = await (await client.get(f"{CHASSIS}/Power", headers=headers)).json() + assert power["PowerControl"][0]["PowerConsumedWatts"] == 320 + + +async def test_malformed_json_400s(client): + token = await _login(client) + headers = {"X-Auth-Token": token, "Content-Type": "application/json"} + bad = b'{"UserName": ' + bad_public = {"Content-Type": "application/json"} + + checks = [ + ("POST", f"{_ROOT}/SessionService/Sessions", None, bad_public), + ("POST", ACCOUNTS, None, headers), + ("POST", f"{_ROOT}/AccountService/Actions/AccountService.ChangePassword", None, headers), + ("POST", f"{SYSTEM}/Actions/ComputerSystem.Reset", None, headers), + ("POST", f"{MANAGER}/Jobs", None, headers), + ("POST", f"{MANAGER}/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia", None, headers), + ("PATCH", SYSTEM, None, headers), + ("PATCH", f"{ACCOUNTS}/quads", None, headers), + ] + for method, url, _json, hdrs in checks: + resp = await client.request(method, url, data=bad, headers=hdrs) + assert resp.status == 400, f"{method} {url} -> {resp.status}" + + +async def test_change_password_errors(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + action = f"{_ROOT}/AccountService/Actions/AccountService.ChangePassword" + wrong = await client.post( + action, json={"UserName": "quads", "OldPassword": "nope", "NewPassword": "x"}, headers=headers + ) + assert wrong.status == 401 + missing = await client.post(action, json={"UserName": "quads", "OldPassword": "quads"}, headers=headers) + assert missing.status == 400 + + +async def test_account_patch_edges(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + assert (await client.patch(f"{ACCOUNTS}/quads", json={"Password": ""}, headers=headers)).status == 400 + assert (await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "Superuser"}, headers=headers)).status == 400 + assert (await client.patch(f"{ACCOUNTS}/ghost", json={"RoleId": "Operator"}, headers=headers)).status == 404 + + await client.post( + ACCOUNTS, json={"UserName": "admin2", "Password": "pw", "RoleId": "Administrator"}, headers=headers + ) + assert (await client.patch(f"{ACCOUNTS}/admin2", json={"Enabled": False}, headers=headers)).status == 200 + assert (await client.patch(f"{ACCOUNTS}/admin2", json={"Password": "newpw"}, headers=headers)).status == 200 + assert (await client.patch(f"{ACCOUNTS}/admin2", json={"Enabled": True}, headers=headers)).status == 200 + assert await _login(client, "admin2", "newpw") + + +async def test_account_delete(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + await client.post(ACCOUNTS, json={"UserName": "dave", "Password": "pw", "RoleId": "Operator"}, headers=headers) + assert (await client.delete(f"{ACCOUNTS}/dave", headers=headers)).status == 200 + assert (await client.delete(f"{ACCOUNTS}/dave", headers=headers)).status == 404 + + +async def test_reset_variants_and_bios_actions(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + + assert ( + await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "ForceRestart"}, headers=headers) + ).status == 204 + await asyncio.sleep(2.2) # restart task flips power back on + body = await (await client.get(SYSTEM, headers=headers)).json() + assert body["PowerState"] == "On" + + assert ( + await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "NukeIt"}, headers=headers) + ).status == 400 + assert (await client.post(f"{SYSTEM}/Bios/Actions/Bios.ResetBios", json={}, headers=headers)).status == 200 + assert (await client.post(f"{SYSTEM}/Bios/Actions/Bios.ChangePassword", json={}, headers=headers)).status == 204 + assert (await client.post(f"{MANAGER}/Actions/Manager.Reset", json={}, headers=headers)).status == 204 + + +async def test_oem_actions(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + dellsvc = f"{_ROOT}/Dell/Systems/System.Embedded.1/DellOSDeploymentService" + jobsvc = f"{_ROOT}/Dell/Managers/iDRAC.Embedded.1/DellJobService" + + await client.post(f"{MANAGER}/Jobs", json={"TargetSettingsURI": "/redfish/v1/x"}, headers=headers) + resp = await client.post(f"{jobsvc}/Actions/DellJobService.DeleteJobQueue", json={}, headers=headers) + assert resp.status == 200 + jobs = await (await client.get(f"{MANAGER}/Jobs", headers=headers)).json() + assert jobs["Members@odata.count"] == 0 + + attach = await client.post(f"{dellsvc}/Actions/DellOSDeploymentService.GetAttachStatus", json={}, headers=headers) + assert attach.status == 200 + assert (await attach.json())["ISOAttachStatus"] == "Detached" + + boot = await client.post(f"{dellsvc}/Actions/DellOSDeploymentService.BootToNetworkISO", json={}, headers=headers) + assert boot.status == 202 + detach = await client.post(f"{dellsvc}/Actions/DellOSDeploymentService.DetachISOImage", json={}, headers=headers) + assert detach.status == 204 + + exp = await client.post( + f"{MANAGER}/Actions/Oem/EID_674_Manager.ExportSystemConfiguration", json={}, headers=headers + ) + assert exp.status == 202 and exp.headers["Location"].startswith(f"{MANAGER}/Jobs/") + imp = await client.post( + f"{MANAGER}/Actions/Oem/EID_674_Manager.ImportSystemConfiguration", json={}, headers=headers + ) + assert imp.status == 202 and imp.headers["Location"].startswith(f"{_ROOT}/TaskService/Tasks/") + + shot = await client.post(f"{MANAGER}/Actions/Oem/DellLCService.ExportServerScreenShot", json={}, headers=headers) + assert shot.status == 404 + assert (await client.post(f"{_ROOT}/NoSuchAction", json={}, headers=headers)).status == 405 + + +async def test_patch_and_delete_fallthrough(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + + assert (await client.patch(f"{SYSTEM}/Bios/Settings", json={}, headers=headers)).status == 200 + assert (await client.patch(f"{SYSTEM}/BootSources/Settings", json={}, headers=headers)).status == 200 + assert ( + await client.patch(f"{MANAGER}/DellNetworkAttributes/NIC/Attributes/Settings", json={}, headers=headers) + ).status == 204 + assert (await client.patch(f"{_ROOT}/NoSuchThing", json={}, headers=headers)).status == 405 + assert (await client.delete(f"{_ROOT}/NoSuchThing", headers=headers)).status == 405 + + login = await client.post(f"{_ROOT}/SessionService/Sessions", json={"UserName": "quads", "Password": "quads"}) + loc = login.headers["Location"] + assert (await client.delete(loc, headers=headers)).status == 200 + assert (await client.delete(loc, headers=headers)).status == 404 + + await client.post(f"{MANAGER}/Jobs", json={"TargetSettingsURI": "/x"}, headers=headers) + assert (await client.delete(f"{MANAGER}/Jobs/JID_CLEARALL_FORCE", headers=headers)).status == 200 + assert (await client.delete(f"{MANAGER}/Jobs/MISSING", headers=headers)).status == 200 + + +async def test_basic_auth_garbage(client): + junk = base64.b64encode(b"\xff\xfe").decode() + assert (await client.get(f"{_ROOT}/Systems", headers={"Authorization": f"Basic {junk}"})).status == 401 + assert (await client.get(f"{_ROOT}/Systems", headers={"Authorization": "Bearer x"})).status == 401 + + +async def test_store_corrupt_file_recovers(tmp_path): + users_file = tmp_path / "users.json" + users_file.write_text("{not json") + app = emulator.create_app(str(users_file)) + c = await _client(app) + assert await _login(c) + await c.close() + + +async def test_run_daemon_serves_live(monkeypatch, tmp_path): + monkeypatch.setenv("BADFISH_EMULATOR_USERS", str(tmp_path / "users.json")) + captured = {} + + async def _fake_run_app(app, host="127.0.0.1", port=8443, ssl_context=None, **kwargs): + captured["host"], captured["port"], captured["ssl"] = host, port, ssl_context is not None + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, 0, ssl_context=ssl_context) + await site.start() + port = runner.addresses[0][1] + from aiohttp import ClientSession + + async with ClientSession() as sess: + async with sess.get(f"https://127.0.0.1:{port}/redfish/v1", ssl=False) as resp: + assert resp.status == 200 + await runner.cleanup() + + monkeypatch.setattr("badfish.emulator.web.run_app", _fake_run_app) + result = await emulator.run_daemon({"bind": "127.0.0.1", "port": "18445", "redfish_emulator": True}) + assert result is None + assert captured == {"host": "127.0.0.1", "port": 18445, "ssl": True} + + +def test_main_redfish_emulator_routes_to_run_daemon(monkeypatch): + from badfish.main import main + + calls = {} + + def _fake_run_daemon(args): + calls["args"] = args + return "daemon-started" + + monkeypatch.setattr("badfish.emulator.run_daemon", _fake_run_daemon) + rc = main(["--redfish-emulator", "--port", "9000", "--bind", "localhost"]) + assert rc == "daemon-started" + assert calls["args"]["port"] == 9000 + assert calls["args"]["bind"] == "localhost" + + def test_parser_has_emulator_flags(): from badfish.helpers.parser import parse_arguments From 98be33189618e2f4451853d8aa3ca7e8fde44695 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 15:59:10 +0200 Subject: [PATCH 15/36] fix(emulator): generate self-signed TLS cert at runtime, drop shipped private key The emulator bundled a self-signed private key in SCM and in the wheel/RPM (emulator/certs/emulator.key). This removes it from the repo and from package data, and generates a fresh per-install localhost keypair on first run under $XDG_CACHE_HOME/badfish/emulator (overridable via BADFISH_EMULATOR_CERTS), so no private key ever sits in public artifacts. Existing certs are reused; the key is written 0600. Tests cover generation, reuse, missing-openssl, default-dir resolution, and the live daemon now exercises runtime generation. --- .gitignore | 5 +- README.md | 4 +- setup.cfg | 1 - src/badfish/emulator.py | 62 ++++++++++++++++++++++++- src/badfish/emulator/certs/README.md | 26 ++++++++--- src/badfish/emulator/certs/emulator.crt | 19 -------- src/badfish/emulator/certs/emulator.key | 28 ----------- tests/test_emulator.py | 48 ++++++++++++++++++- 8 files changed, 131 insertions(+), 62 deletions(-) delete mode 100644 src/badfish/emulator/certs/emulator.crt delete mode 100644 src/badfish/emulator/certs/emulator.key diff --git a/.gitignore b/.gitignore index 273149b..1456a42 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,7 @@ exports/ .vscode/ CLAUDE.md -.claude/ \ No newline at end of file +.claude/ +# Runtime-generated emulator TLS keypair (see certs/README.md) +src/badfish/emulator/certs/emulator.crt +src/badfish/emulator/certs/emulator.key diff --git a/README.md b/README.md index fc98e11..670d9df 100644 --- a/README.md +++ b/README.md @@ -708,7 +708,7 @@ Run the emulator as a persistent server: badfish --redfish-emulator --port 8443 ``` -It serves HTTPS on `127.0.0.1:8443` using a bundled self-signed test certificate. Point a second badfish instance at it like any BMC, and pass `--insecure` to skip certificate verification, the self-signed cert will not validate otherwise: +It serves HTTPS on `127.0.0.1:8443` using a self-signed certificate generated on first run (never committed to the repo or shipped in the wheel/RPM; see `src/badfish/emulator/certs/README.md`). Point a second badfish instance at it like any BMC, and pass `--insecure` to skip certificate verification, the self-signed cert will not validate otherwise: ```bash badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --power-state @@ -724,7 +724,7 @@ Accounts run through the Redfish AccountService: each user carries a role (`Admi Currently covered: session/token auth, user and account management with role-based authorization, power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, the jobs queue (create/check/delete), virtual media mount/eject, firmware inventory, system/processor/memory/interface inventory, and SCP import/export targets. Screenshot and OS-deployment network ISO actions return "not supported" so badfish degrades gracefully. > [!NOTE] -> This is a development tool, not a security boundary. The bundled certificate and default credentials exist so CI and laptops can spin up a mock iDRAC with zero setup. +> This is a development tool, not a security boundary. The certificate (generated at runtime, unique per install) and default credentials exist so CI and laptops can spin up a mock iDRAC with zero setup. Resource templates live in `src/badfish/emulator/templates/` and are served by URI path, the same store that vendor mockup bundles (for example DMTF DSP2043) can feed as fetch support for Dell and SuperMicro trees lands. diff --git a/setup.cfg b/setup.cfg index 61c625e..50ae084 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,7 +44,6 @@ where = src [options.package_data] badfish = emulator/templates/*.json - emulator/certs/* [sdist] formats = gztar diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 145ed4d..5b5e867 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -22,6 +22,7 @@ import os import secrets import ssl +import subprocess from pathlib import Path from aiohttp import web @@ -30,7 +31,6 @@ _BASE = Path(__file__).parent _TEMPLATES = _BASE / "emulator" / "templates" -_CERTS = _BASE / "emulator" / "certs" # Default credentials are intentionally generic (quads/quads mirrors the # quads project's IPMI user convention). Override per run with env vars. @@ -800,10 +800,68 @@ def create_app(users_path=None): return app +_CERT_STORE_ENV = "BADFISH_EMULATOR_CERTS" + + +def _default_cert_dir() -> Path: + """Per-user writable directory for the emulator TLS keypair. + + Never inside the package: no private key is shipped with badfish. + Overridable with BADFISH_EMULATOR_CERTS, else $XDG_CACHE_HOME/badfish/emulator. + """ + env = os.environ.get(_CERT_STORE_ENV) + if env: + return Path(env) + cache = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")) + return Path(cache) / "badfish" / "emulator" + + +def _ensure_certs(certs_dir=None) -> tuple[str, str]: + """Return (crt, key) paths for the emulator HTTPS listener. + + Generates a fresh self-signed localhost keypair on first run instead of + shipping a private key in SCM/binary artifacts. Reuses existing files and + applies 0600 perms to the key. + """ + certs_dir = Path(certs_dir) if certs_dir else _default_cert_dir() + certs_dir.mkdir(parents=True, exist_ok=True) + crt = certs_dir / "emulator.crt" + key = certs_dir / "emulator.key" + if crt.exists() and key.exists(): + return str(crt), str(key) + cmd = [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key), + "-out", + str(crt), + "-days", + "365", + "-nodes", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + ] + try: + subprocess.run(cmd, check=True, capture_output=True) + except FileNotFoundError as err: # pragma: no cover - depends on env + raise RuntimeError( + "openssl not found: install openssl, or pre-provision " f"emulator.crt/emulator.key in {certs_dir}" + ) from err + key.chmod(0o600) + return str(crt), str(key) + + def run_daemon(args): app = create_app() context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(str(_CERTS / "emulator.crt"), str(_CERTS / "emulator.key")) + crt, key = _ensure_certs() + context.load_cert_chain(crt, key) host = args.get("bind") or "127.0.0.1" port = int(args.get("port") or 8443) return web.run_app(app, host=host, port=port, ssl_context=context) diff --git a/src/badfish/emulator/certs/README.md b/src/badfish/emulator/certs/README.md index 77aab03..6ffc6b4 100644 --- a/src/badfish/emulator/certs/README.md +++ b/src/badfish/emulator/certs/README.md @@ -1,14 +1,26 @@ # Emulator TLS certificate -`emulator.crt` and `emulator.key` are a self-signed test certificate for the -built-in Redfish emulator. They are bundled so anyone can spin up a mock iDRAC -with zero setup, not for production use. The private key is public and must -never be used anywhere real. +No private key is shipped with badfish. On first start, the emulator generates a +fresh self-signed localhost certificate (`emulator.crt` / `emulator.key`) in a +per-user writable directory, so every install has its own keypair and none ever +sits in SCM or in built artifacts (wheel/RPM). -badfish clients that talk to the emulator should pass `--insecure` to skip -verification of this self-signed certificate. +Default location (see `_default_cert_dir` in `src/badfish/emulator.py`): -Regenerate (1 year, SAN for localhost and loopback): +``` +$XDG_CACHE_HOME/badfish/emulator/ (default: ~/.cache/badfish/emulator/) +``` + +Override with the `BADFISH_EMULATOR_CERTS` env var, pointing at a directory that +already contains `emulator.crt` and `emulator.key`, or an empty directory where +they should be created. Requires `openssl` on PATH to generate the keypair. + +The generated certificate is self-signed (CN=localhost, SAN localhost + +127.0.0.1, valid 1 year) for local/test use only, not production. badfish clients +that talk to the emulator should pass `--insecure` to skip verification of this +self-signed certificate. + +The equivalent manual command, for reference: ``` openssl req -x509 -newkey rsa:2048 \ diff --git a/src/badfish/emulator/certs/emulator.crt b/src/badfish/emulator/certs/emulator.crt deleted file mode 100644 index df62b68..0000000 --- a/src/badfish/emulator/certs/emulator.crt +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDJTCCAg2gAwIBAgIUHRCKRQfdG38QfhmPPjICuNFhIrAwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkwNTEwMDQwOVoXDTI3MDkw -NTEwMDQwOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEA0cUb1vCLY4ssdOsfFMPPF0QrzUWoPXekWukl0RBx496m -I32mQWjQSu663A/uNeHtaemkGAfVqN2+WDp9gZJwyAilq21afWep3EZM1uVvHvXQ -DaBB9GuD7lqIJHBnnJnd6/9Oj2wCV5Qk2PVQXQQFijw5nDYow5vfGOkRAXXLeke0 -PH8rFZGWIFXVbJRjY0zV2kSX9L7wWXLbbrE2tw5E+z4ba99rgDNWvaUxniQ1DfgU -+SgCKGX1UBW9Na01kotKMxCoxAJ/Qg8nWG7h0x+GZpUBSYZ25cFpHkW4WIAfwKYS -uOU0FvI+U+P+fSOwxNS/sRoUs8ciWcTaYNUPgvnxQQIDAQABo28wbTAdBgNVHQ4E -FgQUQnxn9oYkM24ruLhvniRm2R+BobcwHwYDVR0jBBgwFoAUQnxn9oYkM24ruLhv -niRm2R+BobcwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH -BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIiVbLONPOXa6+woNV1KNSiYPzx4ig6T -ffdq6KBWh7CMgAlqAK5meIxB4EnRqB3f+8F4mYsuaBm5hoQrj/L7aB+J0jkk4/Rb -a4/DgInlFo3aroGdSCTkTtGzcVCSKW6Xf08Ezgp5FKy0t8l80FpbvteTN5bZDkod -8KW26oZAnrZn9DSqkkk4gxbehs8fUqg/6OYeOnF1DxIdOkLyriqzXl6pmD/de4ss -nQXaiUvGNjS9abg9dork4Wjue0oZVJG47Ge4Nlb01ccaMYOhEunKr0rQyA50elV6 -37f0Ll06BNezHL0Tm2FnkzLkVqX4eac/12eqvfnwY0ZRQnxecuxfpvM= ------END CERTIFICATE----- diff --git a/src/badfish/emulator/certs/emulator.key b/src/badfish/emulator/certs/emulator.key deleted file mode 100644 index 2a40e6b..0000000 --- a/src/badfish/emulator/certs/emulator.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRxRvW8Itjiyx0 -6x8Uw88XRCvNRag9d6Ra6SXREHHj3qYjfaZBaNBK7rrcD+414e1p6aQYB9Wo3b5Y -On2BknDICKWrbVp9Z6ncRkzW5W8e9dANoEH0a4PuWogkcGecmd3r/06PbAJXlCTY -9VBdBAWKPDmcNijDm98Y6REBdct6R7Q8fysVkZYgVdVslGNjTNXaRJf0vvBZcttu -sTa3DkT7Phtr32uAM1a9pTGeJDUN+BT5KAIoZfVQFb01rTWSi0ozEKjEAn9CDydY -buHTH4ZmlQFJhnblwWkeRbhYgB/AphK45TQW8j5T4/59I7DE1L+xGhSzxyJZxNpg -1Q+C+fFBAgMBAAECggEACxwS7Uyy5oF9Mq8DfSR2sBSDSQfuply9sWE7iGmZd+L3 -mRXDQPUVt6uSQsE8PDx8Mq8LVV4lhcqYABEO9cEP+eQirrl9VfrFb1NbfUkhnA89 -kYSCjGOc/+S/D5uoEnjqsjWaD0n+Nhhe5Y0D0H1RSXYxleafZ5FhMi72ddrrfSJn -n7PP9luImwvEv9+9pj9AnbCXQxzxMhECcFZqGRbuaNQt8Cset+crW8JJLpPuF+ns -jmiEBo8SAr1OnW6pGkpByZMq7AfTi9tIs3SX2QFBoF1aOqiLZDTM+gAtPYXiOsvC -pFXJZAAZCBK6FgzXbaLQqz+AjBk4lT5jVCPZATEXbQKBgQD2LOZijX42Sx1QAPP6 -fPHi+PKWVkBmFCO70F7qhWfHvpPHNS430OL5JV0Y9ATUfAKPvia4D5pmDdrTnRJx -bpcodCXtJsRckHPk/zMuznkdW0xTzZkvkgJ/0wCYSksgUneERNLlDqyv7T2ffzId -AZZ9hDbDuRXzyt0gNO5bzFwGXQKBgQDaJEPyKQeIeo20qBas5CZb1SqGpaoNOsPr -5W7NhjpCRlCMbT4Ykl3vkaIKKDGOw9qA4pdYHgMvSrzeV87qQE+PAkPvPBmzXt8H -wa4roEqi3zdmTYhjr6fi0wZKydH2SjVPR93KT/HXVTGZDEtjgOTuG4rw+o/LRGVF -yGqRC5UgNQKBgQDG6fLicUgpYLp3ub1qimj9KIED/v+cO+u/x5faUh9QY+qOzabh -zPSJsqouDoaUlvuO4Gvy0BDHI6zMzp9nbp/PPUKkBG4oCUTMJXVa/dUZZnsfQALm -UEmatYlGhMl9fYU7KE1sblYU9VKUvTdl/rF2DE4gCj71tdbFPl/XZyJ4tQKBgQCD -q3wvwUBAyuiZ8ROuzA+zQpnmqDxau+viiZw2Bh1IP7UC7jWbE04L+vW598TiDano -Pd1oXMVDWHNkKdBFaQgcpBtpXfeNY2hwACInRxuF8AI6h/YZZb+KlCGqJuPLK8O9 -1P00zsiFV3EWlmsy5mxIpOtaxYLiCKiwVGauojUjOQKBgD5KQ5ST2/kgDVLg0R+l -U4l5H2A9SNmFNL1w+GCYqDB5q/bEUsxZvUo201kn9iuCOIiQznIXYuAeXQQPxdNk -VShX+SCtHc4bNaEsOQGUXxvRIGPrdgThl8QDY9puE6EkbBb+UyRrloPk/DCkzYsN -T97TgWEdVuGWeCbJUoc5R6rn ------END PRIVATE KEY----- diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 8b8284d..c741a6f 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -10,7 +10,6 @@ from badfish import emulator from badfish.main import badfish_factory -_CERTS = Path(__file__).parent.parent / "src" / "badfish" / "emulator" / "certs" _ROOT = "/redfish/v1" ACCOUNTS = f"{_ROOT}/AccountService/Accounts" SYSTEM = f"{_ROOT}/Systems/System.Embedded.1" @@ -306,8 +305,9 @@ async def _noop(*_args, **_kwargs): monkeypatch.setattr("badfish.main.asyncio.sleep", _noop) app = emulator.create_app(str(tmp_path / "users.json")) + crt, key = emulator._ensure_certs(str(tmp_path / "certs")) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(_CERTS / "emulator.crt"), str(_CERTS / "emulator.key")) + ctx.load_cert_chain(crt, key) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ctx) @@ -560,8 +560,52 @@ async def test_store_corrupt_file_recovers(tmp_path): await c.close() +def test_default_cert_dir(monkeypatch): + monkeypatch.delenv("BADFISH_EMULATOR_CERTS", raising=False) + monkeypatch.delenv("XDG_CACHE_HOME", raising=False) + assert emulator._default_cert_dir() == Path.home() / ".cache" / "badfish" / "emulator" + monkeypatch.setenv("XDG_CACHE_HOME", "/tmp/xdg") + assert emulator._default_cert_dir() == Path("/tmp/xdg") / "badfish" / "emulator" + monkeypatch.setenv("BADFISH_EMULATOR_CERTS", "/tmp/override") + assert emulator._default_cert_dir() == Path("/tmp/override") + + +def test_ensure_certs_generates_keypair(tmp_path): + crt, key = emulator._ensure_certs(str(tmp_path)) + crt_p, key_p = Path(crt), Path(key) + assert crt_p.exists() and key_p.exists() + assert (crt_p.stat().st_mode & 0o777) == 0o644 # cert is public + assert (key_p.stat().st_mode & 0o777) == 0o600 # key is private + assert "BEGIN CERTIFICATE" in crt_p.read_text() + assert "PRIVATE KEY" in key_p.read_text() + + +def test_ensure_certs_reuses_existing(tmp_path, monkeypatch): + crt_p = tmp_path / "emulator.crt" + key_p = tmp_path / "emulator.key" + crt_p.write_text("BEGIN CERTIFICATE placeholder") + key_p.write_text("BEGIN PRIVATE KEY placeholder") + + def _should_not_call(*_a, **_k): + raise AssertionError("openssl must not run when certs already exist") + + monkeypatch.setattr("badfish.emulator.subprocess.run", _should_not_call) + crt, key = emulator._ensure_certs(str(tmp_path)) + assert crt == str(crt_p) and key == str(key_p) + + +def test_ensure_certs_missing_openssl(tmp_path, monkeypatch): + def _no_openssl(*_a, **_k): + raise FileNotFoundError("openssl") + + monkeypatch.setattr("badfish.emulator.subprocess.run", _no_openssl) + with pytest.raises(RuntimeError, match="openssl not found"): + emulator._ensure_certs(str(tmp_path)) + + async def test_run_daemon_serves_live(monkeypatch, tmp_path): monkeypatch.setenv("BADFISH_EMULATOR_USERS", str(tmp_path / "users.json")) + monkeypatch.setenv("BADFISH_EMULATOR_CERTS", str(tmp_path / "certs")) captured = {} async def _fake_run_app(app, host="127.0.0.1", port=8443, ssl_context=None, **kwargs): From 2045405b510d6a34e7dc7fd0379840e1948e57e3 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 21:05:45 +0200 Subject: [PATCH 16/36] fix(emulator): serve missing network collections, wire SCP export, close RBAC gaps Findings from independent + contrarian review of this PR, all verified live: - NetworkPorts / NetworkDeviceFunctions collections now served (were 500: _STATIC_URI referenced templates that don't exist). badfish walks these in get_network_adapters / get_nic_fqdds. - DellNetworkAttributes also registered on the Chassis tree (client uses /Chassis/... not /Systems/... for get/set_nic_attribute). - SCP export job now carries SystemConfiguration so export_scp completes instead of timing out; task template gains Oem.Dell.Message so import_scp's progress poll reads cleanly. - DetachISOImage returns 200 like real iDRAC (badfish only accepts 200). - Last enabled Administrator cannot be demoted via RoleId (previously only disable/delete were guarded); roles re-resolved live so demotions take effect on existing sessions instead of a login-time snapshot. - Pending restart power-on task is cancelled by any later reset. - NetworkPorts member resources served; firmware member ids singularized; reused certs re-applied 0600. - Tests: network collections + Chassis NIC attribute set, export/import payload asserts, live-RBAC demotion, restart override, end-to-end drives get_network_adapters/get_nic_fqdds/get_nic_attribute/detach/export_scp. --- README.md | 4 +- src/badfish/emulator.py | 46 ++++++++-- src/badfish/emulator/templates/task.json | 1 + tests/test_emulator.py | 105 ++++++++++++++++++++--- 4 files changed, 135 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 670d9df..b2a5a29 100644 --- a/README.md +++ b/README.md @@ -719,9 +719,9 @@ badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --ls-jobs Default credentials are `quads` / `quads`, the same convention quads uses for its IPMI user. Set `BADFISH_EMULATOR_USER` and `BADFISH_EMULATOR_PASSWORD` to override. `--bind` and `--port` control the listen address. -Accounts run through the Redfish AccountService: each user carries a role (`Administrator`, `Operator`, `ReadOnly`), users can be created, edited and removed over the API, passwords change via the `ChangePassword` action, and the last enabled Administrator cannot be deleted or disabled. Accounts live in a small JSON store created at runtime (default `/tmp/badfish_emulator_users.json`, `BADFISH_EMULATOR_USERS` to relocate). The emulator is a temporal testing fixture, not a persistent service: state is throwaway by default and dies with the box. +Accounts run through the Redfish AccountService: each user carries a role (`Administrator`, `Operator`, `ReadOnly`), users can be created, edited and removed over the API, passwords change via the `ChangePassword` action, and the last enabled Administrator cannot be deleted, disabled, or demoted. Accounts live in a small JSON store created at runtime (default `/tmp/badfish_emulator_users.json`, `BADFISH_EMULATOR_USERS` to relocate) with plaintext passwords: the emulator is a temporal testing fixture, not a security boundary, and state dies with the box. -Currently covered: session/token auth, user and account management with role-based authorization, power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, the jobs queue (create/check/delete), virtual media mount/eject, firmware inventory, system/processor/memory/interface inventory, and SCP import/export targets. Screenshot and OS-deployment network ISO actions return "not supported" so badfish degrades gracefully. +Currently covered: session/token auth, user and account management with role-based authorization (roles are re-resolved live, so demotions take effect on existing sessions), power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, NIC attributes (DellNetworkAttributes) under both the System and Chassis trees, the jobs queue (create/check/delete), virtual media mount/eject/detach, firmware inventory, system/processor/memory/network-interface/port/network-device-function inventory, and SCP import/export. Screen capture and network ISO boot are stubbed and degrade gracefully. > [!NOTE] > This is a development tool, not a security boundary. The certificate (generated at runtime, unique per install) and default credentials exist so CI and laptops can spin up a mock iDRAC with zero setup. diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 5b5e867..0c56906 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Badfish Redfish emulator: a mock iDRAC served over HTTP(S). Architecture is inspired by the sushy-tools emulator (OpenStack project, @@ -225,11 +224,10 @@ def _collection(resource_name, base, members): f"{SYSTEM}/BootSources": "boot_sources", f"{SYSTEM}/NetworkAdapters": "network_adapters", f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1": "network_adapter", - f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts": "network_ports", f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts/Port0": "network_port", - f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions": "network_device_functions", f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions/NIC.Integrated.1-1-1": "network_device_function", f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions/NIC.Integrated.1-1-1/Oem/Dell/DellNetworkAttributes/NIC.Integrated.1-1-1": "dell_network_attributes", + f"{CHASSIS}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions/NIC.Integrated.1-1-1/Oem/Dell/DellNetworkAttributes/NIC.Integrated.1-1-1": "dell_network_attributes", f"{ROOT}/Managers": "managers", MANAGER: "manager", f"{CHASSIS}": "chassis", @@ -248,7 +246,11 @@ def _collection_uri(uri, state): if uri == f"{SYSTEM}/Memory": return _collection("Memory", uri, [d["id"] for d in SYSCONF["dimms"]]) if uri == FIRMWARE: - return _collection("SoftwareInventory", uri, [f"{f['id']}-{f['id']}-Installed" for f in SYSCONF["firmware"]]) + return _collection("SoftwareInventory", uri, [f"{f['id']}-Installed" for f in SYSCONF["firmware"]]) + if uri == f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts": + return _collection("NetworkPort", uri, [n["id"] for n in SYSCONF["nics"]]) + if uri == f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions": + return _collection("NetworkDeviceFunction", uri, [n["id"] for n in SYSCONF["nics"]]) if uri == f"{MANAGER}/Jobs": return _collection("Job", uri, list(state.jobs.keys())) if uri == f"{ROOT}/SessionService/Sessions": @@ -344,7 +346,7 @@ def _m_job(uri, state): def _m_session(uri, state): session_id = uri.rsplit("/", 1)[-1] - for token, info in state.sessions.items(): + for info in state.sessions.values(): if str(info["id"]) == session_id: data = _tmpl("session") data["@odata.id"] = uri @@ -362,6 +364,13 @@ def _m_vmedia(uri, state): return data +def _m_port(uri, state): + data = _tmpl("network_port") + data["@odata.id"] = uri + data["Id"] = uri.rsplit("/", 1)[-1] + return data + + def _m_task(uri, state): data = _tmpl("task") data["@odata.id"] = uri @@ -393,6 +402,7 @@ def _m_account(uri, state): (f"{SYSTEM}/Processors/", _m_processor), (f"{SYSTEM}/Memory/", _m_dimm), (f"{FIRMWARE}/", _m_firmware), + (f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts/", _m_port), (f"{MANAGER}/Jobs/", _m_job), (f"{ROOT}/SessionService/Sessions/", _m_session), (f"{ACCOUNTS_URI}/", _m_account), @@ -549,7 +559,10 @@ async def _auth(request, handler): token = request.headers.get("X-Auth-Token") if token in state.sessions: - return await _authorize(request, handler, state.sessions[token]["role"]) + # Resolve role live from the store: RBAC reflects demotions/promotions + # applied after the session was created instead of a login-time snapshot. + role = state.store.role(state.sessions[token]["username"]) if state.store else state.sessions[token]["role"] + return await _authorize(request, handler, role) creds = _basic_creds(request.headers.get("Authorization", "")) if creds is not None and state.store is not None and state.store.authenticate(*creds): return await _authorize(request, handler, state.store.role(creds[0])) @@ -633,6 +646,9 @@ async def _post(_request): if body is None: return _bad_request("Malformed JSON body.") reset_type = body.get("ResetType") + # A later reset supersedes any pending automatic power-on task. + if state._restart_task is not None: + state._restart_task.cancel() if reset_type in _RESTART_TYPES: state.power = "Off" @@ -698,12 +714,14 @@ async def _power_back_on(): f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService/" "Actions/DellOSDeploymentService.DetachISOImage" ): - return web.Response(status=204) + # Real iDRAC answers 200 with a JSON body, not 204; badfish's + # detach_remote_image only accepts 200. + return web.Response(status=200) if path.endswith("/Actions/Oem/EID_674_Manager.ExportSystemConfiguration"): state._job_n += 1 job_id = f"JID_{state._job_n:016d}" - state.jobs[job_id] = {"Export": True} + state.jobs[job_id] = {"Export": True, "SystemConfiguration": {"ComponentResults": [], "Id": "SystemConfiguration"}} return web.Response(status=202, headers={"Location": f"{MANAGER}/Jobs/{job_id}"}) if path.endswith("/Actions/Oem/EID_674_Manager.ImportSystemConfiguration"): state._job_n += 1 @@ -743,6 +761,13 @@ async def _patch(_request): if "RoleId" in body: if body["RoleId"] not in ROLES: return _bad_request(f"RoleId must be one of {', '.join(ROLES)}.") + if ( + body["RoleId"] != user["role"] + and user["role"] == "Administrator" + and user["enabled"] + and _enabled_administrators(state) <= 1 + ): + return _bad_request("Cannot demote the last enabled Administrator.") user["role"] = body["RoleId"] if "Enabled" in body: enabled = bool(body["Enabled"]) @@ -828,6 +853,11 @@ def _ensure_certs(certs_dir=None) -> tuple[str, str]: crt = certs_dir / "emulator.crt" key = certs_dir / "emulator.key" if crt.exists() and key.exists(): + try: + key.chmod(0o600) + crt.chmod(0o600) + except OSError: # pragma: no cover - non-POSIX or read-only fs + pass return str(crt), str(key) cmd = [ "openssl", diff --git a/src/badfish/emulator/templates/task.json b/src/badfish/emulator/templates/task.json index 348ee51..fde02f6 100644 --- a/src/badfish/emulator/templates/task.json +++ b/src/badfish/emulator/templates/task.json @@ -8,6 +8,7 @@ "Messages": [], "Oem": { "Dell": { + "Message": "RAC0580: Job is in progress or completed.", "PercentComplete": 100, "JobState": "Completed" } diff --git a/tests/test_emulator.py b/tests/test_emulator.py index c741a6f..63d1165 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -283,17 +283,28 @@ async def test_account_patch_and_last_admin_guard(client): token = await _login(client) headers = {"X-Auth-Token": token} - patch = await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "ReadOnly"}, headers=headers) - assert patch.status == 200 + # quads is the only enabled Administrator: demoting, disabling, or deleting + # it must all be rejected. + assert (await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "ReadOnly"}, headers=headers)).status == 400 + assert (await client.patch(f"{ACCOUNTS}/quads", json={"Enabled": False}, headers=headers)).status == 400 + assert (await client.delete(f"{ACCOUNTS}/quads", headers=headers)).status == 400 + + # With a second enabled Administrator present, the same demotion succeeds. + assert ( + await client.post(ACCOUNTS, json={"UserName": "admin2", "Password": "pw", "RoleId": "Administrator"}, headers=headers) + ).status == 201 + assert (await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "ReadOnly"}, headers=headers)).status == 200 acct = await (await client.get(f"{ACCOUNTS}/quads", headers=headers)).json() assert acct["RoleId"] == "ReadOnly" - assert (await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "Administrator"}, headers=headers)).status == 200 + # The demoted quads session loses account-management rights immediately; + # admin2 (still Administrator) restores it. + assert (await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "Administrator"}, headers=headers)).status == 403 + admin2 = {"X-Auth-Token": await _login(client, "admin2", "pw")} + assert (await client.patch(f"{ACCOUNTS}/quads", json={"RoleId": "Administrator"}, headers=admin2)).status == 200 - disable = await client.patch(f"{ACCOUNTS}/quads", json={"Enabled": False}, headers=headers) - assert disable.status == 400 - remove = await client.delete(f"{ACCOUNTS}/quads", headers=headers) - assert remove.status == 400 + # A non-last Administrator can still be disabled. + assert (await client.patch(f"{ACCOUNTS}/admin2", json={"Enabled": False}, headers=admin2)).status == 200 async def test_emulator_end_to_end(monkeypatch, tmp_path): @@ -332,6 +343,14 @@ async def _noop(*_args, **_kwargs): assert bf.boot_devices is not None assert bf.boot_devices[0]["Name"] == "NIC.Integrated.1-1-1" + # Network adapter inventory and NIC attributes: the flows that used to + # 500 because NetworkPorts/NDF collections and the Chassis tree were + # missing (get_nic_fqdds / get_nic_attribute). + assert await bf.get_network_adapters() + assert await bf.get_nic_fqdds() + assert await bf.get_nic_attribute("NIC.Integrated.1-1-1") + assert await bf.detach_remote_image() + job_id = await bf.create_bios_config_job(bf.bios_uri) assert job_id and job_id.startswith("JID_") assert (await bf.check_schedule_job_status(job_id)) is None @@ -342,6 +361,9 @@ async def _noop(*_args, **_kwargs): assert await bf.check_virtual_media() assert await bf.unmount_virtual_media() + # SCP export: waits on the job until SystemConfiguration appears. + assert await bf.export_scp(str(tmp_path)) + await bf.get_power_consumed_watts() assert await bf.get_bios_boot_mode() == "Bios" finally: @@ -368,6 +390,35 @@ async def test_inventory_collections_and_members(client): assert nic["MACAddress"] == "00:5c:52:31:3a:9c" assert nic["LinkStatus"] == "Up" + # Network adapter inventory the way badfish walks it: the NetworkPorts and + # NetworkDeviceFunctions collections under each adapter (get_nic_fqdds) and + # NIC attributes served from the Chassis tree (get/set_nic_attribute). + na = await (await client.get(f"{SYSTEM}/NetworkAdapters", headers=headers)).json() + assert [m["@odata.id"] for m in na["Members"]] == [f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1"] + ports = await (await client.get(f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts", headers=headers)).json() + assert ports["Members@odata.count"] == 2 + assert (await client.get(f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts/Port0", headers=headers)).status == 200 + ndf = await ( + await client.get(f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions", headers=headers) + ).json() + assert ndf["Members@odata.count"] == 2 + dnn = await ( + await client.get( + f"{CHASSIS}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions/NIC.Integrated.1-1-1/" + "Oem/Dell/DellNetworkAttributes/NIC.Integrated.1-1-1", + headers=headers, + ) + ).json() + assert dnn["Attributes"] + assert ( + await client.patch( + f"{CHASSIS}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions/NIC.Integrated.1-1-1/" + "Oem/Dell/DellNetworkAttributes/NIC.Integrated.1-1-1/Settings", + json={"Attributes": {"WakeOnLan": "Enabled"}}, + headers=headers, + ) + ).status == 204 + procs = await (await client.get(f"{SYSTEM}/Processors", headers=headers)).json() assert procs["Members@odata.count"] == 2 cpu = await (await client.get(f"{SYSTEM}/Processors/CPU.Socket.1", headers=headers)).json() @@ -469,6 +520,22 @@ async def test_account_delete(client): assert (await client.delete(f"{ACCOUNTS}/dave", headers=headers)).status == 404 +async def test_rbac_role_reevaluated_on_existing_session(client): + """Demoting a user takes effect on their already-issued session token.""" + token = await _login(client) + headers = {"X-Auth-Token": token} + assert ( + await client.post(ACCOUNTS, json={"UserName": "op", "Password": "oppw", "RoleId": "Operator"}, headers=headers) + ).status == 201 + op = {"X-Auth-Token": await _login(client, "op", "oppw")} + assert ( + await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "On"}, headers=op) + ).status == 204 + assert (await client.patch(f"{ACCOUNTS}/op", json={"RoleId": "ReadOnly"}, headers=headers)).status == 200 + kicked = await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "On"}, headers=op) + assert kicked.status == 403 + + async def test_reset_variants_and_bios_actions(client): token = await _login(client) headers = {"X-Auth-Token": token} @@ -480,6 +547,17 @@ async def test_reset_variants_and_bios_actions(client): body = await (await client.get(SYSTEM, headers=headers)).json() assert body["PowerState"] == "On" + # A later reset cancels the pending automatic power-on task. + assert ( + await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "GracefulRestart"}, headers=headers) + ).status == 204 + assert ( + await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "ForceOff"}, headers=headers) + ).status == 204 + await asyncio.sleep(2.2) + body = await (await client.get(SYSTEM, headers=headers)).json() + assert body["PowerState"] == "Off" + assert ( await client.post(f"{SYSTEM}/Actions/ComputerSystem.Reset", json={"ResetType": "NukeIt"}, headers=headers) ).status == 400 @@ -507,16 +585,20 @@ async def test_oem_actions(client): boot = await client.post(f"{dellsvc}/Actions/DellOSDeploymentService.BootToNetworkISO", json={}, headers=headers) assert boot.status == 202 detach = await client.post(f"{dellsvc}/Actions/DellOSDeploymentService.DetachISOImage", json={}, headers=headers) - assert detach.status == 204 + assert detach.status == 200 # real iDRAC answers 200, and badfish only accepts 200 exp = await client.post( f"{MANAGER}/Actions/Oem/EID_674_Manager.ExportSystemConfiguration", json={}, headers=headers ) assert exp.status == 202 and exp.headers["Location"].startswith(f"{MANAGER}/Jobs/") + exp_job = await (await client.get(exp.headers["Location"], headers=headers)).json() + assert "SystemConfiguration" in exp_job # what badfish.export_scp waits for imp = await client.post( f"{MANAGER}/Actions/Oem/EID_674_Manager.ImportSystemConfiguration", json={}, headers=headers ) assert imp.status == 202 and imp.headers["Location"].startswith(f"{_ROOT}/TaskService/Tasks/") + imp_task = await (await client.get(imp.headers["Location"], headers=headers)).json() + assert imp_task["Oem"]["Dell"]["Message"] # badfish.import_scp reads this on every poll shot = await client.post(f"{MANAGER}/Actions/Oem/DellLCService.ExportServerScreenShot", json={}, headers=headers) assert shot.status == 404 @@ -617,9 +699,10 @@ async def _fake_run_app(app, host="127.0.0.1", port=8443, ssl_context=None, **kw port = runner.addresses[0][1] from aiohttp import ClientSession - async with ClientSession() as sess: - async with sess.get(f"https://127.0.0.1:{port}/redfish/v1", ssl=False) as resp: - assert resp.status == 200 + async with ClientSession() as sess, sess.get( + f"https://127.0.0.1:{port}/redfish/v1", ssl=False + ) as resp: + assert resp.status == 200 await runner.cleanup() monkeypatch.setattr("badfish.emulator.web.run_app", _fake_run_app) From daf3549cbf9852e1c4a3dfad215ae13112ebe175 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sat, 5 Sep 2026 21:21:23 +0200 Subject: [PATCH 17/36] feat(emulator): jobs run then complete, unknown tasks 404 Per maintainer direction on review follow-ups: - Jobs report Running/0% on the first read, then Completed/100% on the next, so badfish's poll-and-retry job loops exercise a real lifecycle instead of instant success. The job-creation POST body renders without consuming a read so the first client poll sees the running state. - Import tasks are now tracked; TaskService/Tasks/ 404s like real iDRAC (badfish only polls tasks it created via SCP import). - RBAC intentionally left permissive: Operator may run SCP/job actions. --- README.md | 2 +- src/badfish/emulator.py | 34 ++++++++++++++++++++++++++++------ tests/test_emulator.py | 12 ++++++++++-- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b2a5a29..fb00ff3 100644 --- a/README.md +++ b/README.md @@ -721,7 +721,7 @@ Default credentials are `quads` / `quads`, the same convention quads uses for it Accounts run through the Redfish AccountService: each user carries a role (`Administrator`, `Operator`, `ReadOnly`), users can be created, edited and removed over the API, passwords change via the `ChangePassword` action, and the last enabled Administrator cannot be deleted, disabled, or demoted. Accounts live in a small JSON store created at runtime (default `/tmp/badfish_emulator_users.json`, `BADFISH_EMULATOR_USERS` to relocate) with plaintext passwords: the emulator is a temporal testing fixture, not a security boundary, and state dies with the box. -Currently covered: session/token auth, user and account management with role-based authorization (roles are re-resolved live, so demotions take effect on existing sessions), power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, NIC attributes (DellNetworkAttributes) under both the System and Chassis trees, the jobs queue (create/check/delete), virtual media mount/eject/detach, firmware inventory, system/processor/memory/network-interface/port/network-device-function inventory, and SCP import/export. Screen capture and network ISO boot are stubbed and degrade gracefully. +Currently covered: session/token auth, user and account management with role-based authorization (roles are re-resolved live, so demotions take effect on existing sessions), power state and reset, one-shot boot overrides, boot order reads, BIOS attributes and registry, NIC attributes (DellNetworkAttributes) under both the System and Chassis trees, the jobs queue (create/check/delete) with a Running-then-Completed lifecycle, SCP import tasks that 404 when unknown (like real iDRAC), virtual media mount/eject/detach, firmware inventory, system/processor/memory/network-interface/port/network-device-function inventory, and SCP import/export. Screen capture and network ISO boot are stubbed and degrade gracefully. > [!NOTE] > This is a development tool, not a security boundary. The certificate (generated at runtime, unique per install) and default credentials exist so CI and laptops can spin up a mock iDRAC with zero setup. diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 0c56906..452d95b 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -129,6 +129,7 @@ def __init__(self, store=None): self.boot_enabled = "Disabled" self.vmedia_image = None self.jobs = {} + self.tasks = set() self.sessions = {} self._job_n = 0 self._restart_task = None @@ -327,18 +328,30 @@ def fill(data, fw): return data -def _m_job(uri, state): +def _m_job(uri, state, consume=True): + """Render a job; jobs report `Running` on their first couple of reads to + exercise badfish's poll-and-retry client loops, then `Completed`. + + `consume=False` renders without advancing the read counter (used to build + the response body of the job-creation POST so the first client poll sees + the running state). + """ job_id = uri.rsplit("/", 1)[-1] job = state.jobs.get(job_id) if job is None: return None + reads = job.get("reads", 0) + if consume: + reads += 1 + job["reads"] = reads + running = reads <= 1 data = _tmpl("job") data["@odata.id"] = uri data["Id"] = job_id data["Name"] = job.get("Name", "Configure: BIOS.Setup.1-1") - data["Message"] = "Job completed successfully." - data["PercentComplete"] = 100 - data["JobState"] = "Completed" + data["Message"] = "Job is running." if running else "Job completed successfully." + data["PercentComplete"] = 0 if running else 100 + data["JobState"] = "Running" if running else "Completed" if "SystemConfiguration" in job: data["SystemConfiguration"] = {"ComponentResults": [], "Id": "SystemConfiguration"} return data @@ -372,9 +385,15 @@ def _m_port(uri, state): def _m_task(uri, state): + # Real iDRAC 404s unknown task ids; badfish only polls tasks it created + # via SCP import, so unknown-404 mirrors real usage without breaking the + # client's own poll. + task_id = uri.rsplit("/", 1)[-1] + if task_id not in state.tasks: + return None data = _tmpl("task") data["@odata.id"] = uri - data["Id"] = uri.rsplit("/", 1)[-1] + data["Id"] = task_id return data @@ -681,7 +700,9 @@ async def _power_back_on(): job_id = f"JID_{state._job_n:016d}" state.jobs[job_id] = {"TargetSettingsURI": body.get("TargetSettingsURI")} return _json( - _m_job(f"{MANAGER}/Jobs/{job_id}", state), status=200, headers={"Location": f"{MANAGER}/Jobs/{job_id}"} + _m_job(f"{MANAGER}/Jobs/{job_id}", state, consume=False), + status=200, + headers={"Location": f"{MANAGER}/Jobs/{job_id}"}, ) if path == f"{MANAGER}/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia": @@ -726,6 +747,7 @@ async def _power_back_on(): if path.endswith("/Actions/Oem/EID_674_Manager.ImportSystemConfiguration"): state._job_n += 1 job_id = f"JID_{state._job_n:016d}" + state.tasks.add(job_id) return web.Response(status=202, headers={"Location": f"{ROOT}/TaskService/Tasks/{job_id}"}) if "DellLCService" in path and "ExportServerScreenShot" in path: diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 63d1165..10338f5 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -108,6 +108,11 @@ async def test_jobs_lifecycle(client): job_id = resp.headers["Location"].split("/")[-1] assert job_id.startswith("JID_") + # Jobs report Running on the first poll, then Completed, so client job + # loops see a real lifecycle instead of instant success. + job = await (await client.get(f"{manager}/Jobs/{job_id}", headers=headers)).json() + assert job["JobState"] == "Running" + assert job["PercentComplete"] == 0 job = await (await client.get(f"{manager}/Jobs/{job_id}", headers=headers)).json() assert job["JobState"] == "Completed" assert job["PercentComplete"] == 100 @@ -161,8 +166,9 @@ async def test_not_found_and_tasks(client): body = await resp.json() assert body["error"]["@Message.ExtendedInfo"][0]["Message"] - task = await (await client.get(f"{_ROOT}/TaskService/Tasks/1", headers=headers)).json() - assert task["Oem"]["Dell"]["PercentComplete"] == 100 + # Unknown task ids 404 like real iDRAC; only tasks created by SCP import + # resolve (covered in test_oem_actions). + assert (await client.get(f"{_ROOT}/TaskService/Tasks/1", headers=headers)).status == 404 async def test_account_service_and_quads_admin(client): @@ -599,6 +605,8 @@ async def test_oem_actions(client): assert imp.status == 202 and imp.headers["Location"].startswith(f"{_ROOT}/TaskService/Tasks/") imp_task = await (await client.get(imp.headers["Location"], headers=headers)).json() assert imp_task["Oem"]["Dell"]["Message"] # badfish.import_scp reads this on every poll + # Real iDRAC 404s unknown task ids; only tasks created by import resolve. + assert (await client.get(f"{_ROOT}/TaskService/Tasks/9999", headers=headers)).status == 404 shot = await client.post(f"{MANAGER}/Actions/Oem/DellLCService.ExportServerScreenShot", json={}, headers=headers) assert shot.status == 404 From 751994fb1985e6eb823c176554f56d7d99aa6a92 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sun, 6 Sep 2026 06:29:42 +0200 Subject: [PATCH 18/36] fix(emulator): serve advertised ServiceRoot resources and harden the user store The ServiceRoot advertised Chassis, TaskService and Registries, but all three 404'd, which would break any Redfish client that walks the link tree. Serve a Chassis collection, a TaskService resource with its Tasks collection, and a Registries collection. The user store is now written 0600 (not world-readable under a default umask), uses a unique temp filename so concurrent instances don't race, and the throwaway default /tmp path is no longer trusted: a pre-existing file there (e.g. a locally planted Administrator) is ignored. --- src/badfish/emulator.py | 23 ++++++++-- .../emulator/templates/task_service.json | 17 +++++++ tests/test_emulator.py | 45 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/badfish/emulator/templates/task_service.json diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 452d95b..542c88f 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -38,7 +38,9 @@ # Flat JSON user store, created at runtime. Throwaway by default; point # BADFISH_EMULATOR_USERS elsewhere to persist between emulator runs. -USERS_PATH = os.environ.get("BADFISH_EMULATOR_USERS", "/tmp/badfish_emulator_users.json") +_USERS_ENV = "BADFISH_EMULATOR_USERS" +DEFAULT_USERS_PATH = "/tmp/badfish_emulator_users.json" +USERS_PATH = os.environ.get(_USERS_ENV, DEFAULT_USERS_PATH) ROLES = ("ReadOnly", "Operator", "Administrator") # Single source of truth for the fake host's hardware identity. Changing a @@ -144,7 +146,12 @@ def __init__(self, path, seed_user, seed_password): self._load(seed_user, seed_password) def _load(self, seed_user, seed_password): - if self.path and os.path.exists(self.path): + # Persistence is only honored when the store path is explicitly chosen + # (create_app(users_path) or BADFISH_EMULATOR_USERS). The default + # throwaway /tmp store must never silently adopt a preexisting file: a + # local user could pre-seed an Administrator there, and two emulator + # instances would race on one store. + if self.path and os.path.exists(self.path) and self.path != DEFAULT_USERS_PATH: try: with open(self.path) as fh: data = json.load(fh) @@ -157,9 +164,12 @@ def _load(self, seed_user, seed_password): def _save(self): os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) - tmp = f"{self.path}.tmp" + # Unique temp name so concurrent instances don't race on the same .tmp, + # and 0600 so the plaintext store isn't world-readable under a default umask. + tmp = f"{self.path}.{os.getpid()}.{secrets.token_hex(4)}.tmp" with open(tmp, "w") as fh: json.dump({"users": self.users}, fh, indent=2) + os.chmod(tmp, 0o600) os.replace(tmp, self.path) def authenticate(self, username, password): @@ -236,6 +246,7 @@ def _collection(resource_name, base, members): UPDATESERVICE: "update_service", f"{ROOT}/Dell/Managers/{SYSCONF['manager_id']}/DellJobService": "dell_job_service", f"{ROOT}/Dell/Systems/{SYSCONF['system_id']}/DellOSDeploymentService": "dell_os_deployment_service", + f"{ROOT}/TaskService": "task_service", } @@ -260,6 +271,12 @@ def _collection_uri(uri, state): return _collection("ManagerAccount", uri, sorted(state.store.users)) if uri == f"{MANAGER}/VirtualMedia": return _collection("VirtualMedia", uri, ["CD"]) + if uri == f"{ROOT}/Chassis": + return _collection("Chassis", uri, [SYSCONF["chassis_id"]]) + if uri == f"{ROOT}/TaskService/Tasks": + return _collection("Task", uri, list(state.tasks)) + if uri == f"{ROOT}/Registries": + return _collection("Registry", uri, ["NetworkAttributesRegistry_1.0.0.json"]) return None diff --git a/src/badfish/emulator/templates/task_service.json b/src/badfish/emulator/templates/task_service.json new file mode 100644 index 0000000..9f7c45b --- /dev/null +++ b/src/badfish/emulator/templates/task_service.json @@ -0,0 +1,17 @@ +{ + "@odata.type": "#TaskService.v1_1_0.TaskService", + "Id": "TaskService", + "Name": "Task Service", + "ServiceEnabled": true, + "CompletedTaskOverWritePolicy": "Oldest", + "LifeCycleEventOnTaskStateChange": true, + "Tasks": { + "@odata.id": "/redfish/v1/TaskService/Tasks" + }, + "Oem": { + "Dell": { + "@odata.type": "#DellTaskService.v1_0_0.DellTaskService", + "JobQueryPolicy": "AllJobs" + } + } +} diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 10338f5..37a03c3 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -207,6 +207,51 @@ async def test_admin_creates_user_and_store_persists(tmp_path): await c2.close() +async def test_service_root_links_are_served(client): + token = await _login(client) + headers = {"X-Auth-Token": token} + for uri in ( + f"{_ROOT}/Chassis", + f"{_ROOT}/TaskService", + f"{_ROOT}/TaskService/Tasks", + f"{_ROOT}/Registries", + ): + resp = await client.get(uri, headers=headers) + assert resp.status == 200, uri + body = await (await client.get(f"{_ROOT}/Chassis", headers=headers)).json() + assert body["Members"][0]["@odata.id"] == CHASSIS + + +async def test_user_store_is_0600(tmp_path): + users_path = str(tmp_path / "users.json") + app = emulator.create_app(users_path) + c = await _client(app) + token = await _login(c) + await c.post( + ACCOUNTS, + json={"UserName": "alice", "Password": "secret", "RoleId": "Operator"}, + headers={"X-Auth-Token": token}, + ) + await c.close() + mode = Path(users_path).stat().st_mode & 0o777 + assert oct(mode) == "0o600" + + +async def test_default_store_not_trusted(tmp_path, monkeypatch): + # A local attacker pre-seeds the default throwaway /tmp store with an + # Administrator; the emulator must not adopt it. + plant = str(tmp_path / "plant.json") + monkeypatch.setattr(emulator, "DEFAULT_USERS_PATH", plant) + monkeypatch.setattr(emulator, "USERS_PATH", plant) + Path(plant).write_text('{"users":{"evil":{"password":"pwn","role":"Administrator","enabled":true}}}') + app = emulator.create_app() + c = await _client(app) + resp = await c.post(f"{_ROOT}/SessionService/Sessions", json={"UserName": "evil", "Password": "pwn"}) + assert resp.status != 201 + assert await _login(c) + await c.close() + + async def test_account_create_validation(client): token = await _login(client) headers = {"X-Auth-Token": token} From 98fa6383c68863ee9244cc931ae8e3061d88707a Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sun, 6 Sep 2026 06:36:33 +0200 Subject: [PATCH 19/36] refactor(emulator): data-driven collections, shared body parser, job id helper Make _collection_uri data-driven (open/closed: a new collection is a row in _COLLECTIONS, not a new branch), centralize the malformed-JSON 400 handling in _read_body (was 8 duplicated blocks), and factor the JID_ generation into _next_job_id (was 3 duplicated blocks). The extended flag for splitting the 919-line god-module into multiple files was deliberately deferred: the independent review judged a single file defensible for a dev-only mock, the module's helpers are referenced directly by the tests, and a physical split would churn tests and package_data for little gain. --- src/badfish/emulator.py | 110 ++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 542c88f..406deac 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -250,33 +250,37 @@ def _collection(resource_name, base, members): } +# Data-driven registry of collection endpoints: (uri, resource, members_from_state). +# Adding a collection is a row here, not a new branch in _collection_uri (open/closed). +_COLLECTIONS = ( + (f"{SYSTEM}/EthernetInterfaces", "EthernetInterface", lambda state: [n["id"] for n in SYSCONF["nics"]]), + (f"{SYSTEM}/Processors", "Processor", lambda state: [c["id"] for c in SYSCONF["cpus"]]), + (f"{SYSTEM}/Memory", "Memory", lambda state: [d["id"] for d in SYSCONF["dimms"]]), + (FIRMWARE, "SoftwareInventory", lambda state: [f"{f['id']}-Installed" for f in SYSCONF["firmware"]]), + ( + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts", + "NetworkPort", + lambda state: [n["id"] for n in SYSCONF["nics"]], + ), + ( + f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions", + "NetworkDeviceFunction", + lambda state: [n["id"] for n in SYSCONF["nics"]], + ), + (f"{MANAGER}/Jobs", "Job", lambda state: list(state.jobs.keys())), + (f"{ROOT}/SessionService/Sessions", "Session", lambda state: [s["id"] for s in state.sessions.values()]), + (ACCOUNTS_URI, "ManagerAccount", lambda state: sorted(state.store.users)), + (f"{MANAGER}/VirtualMedia", "VirtualMedia", lambda state: ["CD"]), + (f"{ROOT}/Chassis", "Chassis", lambda state: [SYSCONF["chassis_id"]]), + (f"{ROOT}/TaskService/Tasks", "Task", lambda state: list(state.tasks)), + (f"{ROOT}/Registries", "Registry", lambda state: ["NetworkAttributesRegistry_1.0.0.json"]), +) + + def _collection_uri(uri, state): - if uri == f"{SYSTEM}/EthernetInterfaces": - return _collection("EthernetInterface", uri, [n["id"] for n in SYSCONF["nics"]]) - if uri == f"{SYSTEM}/Processors": - return _collection("Processor", uri, [c["id"] for c in SYSCONF["cpus"]]) - if uri == f"{SYSTEM}/Memory": - return _collection("Memory", uri, [d["id"] for d in SYSCONF["dimms"]]) - if uri == FIRMWARE: - return _collection("SoftwareInventory", uri, [f"{f['id']}-Installed" for f in SYSCONF["firmware"]]) - if uri == f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkPorts": - return _collection("NetworkPort", uri, [n["id"] for n in SYSCONF["nics"]]) - if uri == f"{SYSTEM}/NetworkAdapters/NIC.Integrated.1/NetworkDeviceFunctions": - return _collection("NetworkDeviceFunction", uri, [n["id"] for n in SYSCONF["nics"]]) - if uri == f"{MANAGER}/Jobs": - return _collection("Job", uri, list(state.jobs.keys())) - if uri == f"{ROOT}/SessionService/Sessions": - return _collection("Session", uri, [s["id"] for s in state.sessions.values()]) - if uri == ACCOUNTS_URI: - return _collection("ManagerAccount", uri, sorted(state.store.users)) - if uri == f"{MANAGER}/VirtualMedia": - return _collection("VirtualMedia", uri, ["CD"]) - if uri == f"{ROOT}/Chassis": - return _collection("Chassis", uri, [SYSCONF["chassis_id"]]) - if uri == f"{ROOT}/TaskService/Tasks": - return _collection("Task", uri, list(state.tasks)) - if uri == f"{ROOT}/Registries": - return _collection("Registry", uri, ["NetworkAttributesRegistry_1.0.0.json"]) + for base, resource, members in _COLLECTIONS: + if uri == base: + return _collection(resource, uri, members(state)) return None @@ -566,6 +570,19 @@ async def _read_json(request): return None +async def _read_body(_request): + """Parse a JSON request body, or return a 400 when the body is not valid JSON.""" + _body = await _read_json(_request) + if _body is None: + raise web.HTTPBadRequest(text="Malformed JSON body.") + return _body + + +def _next_job_id(state): + state._job_n += 1 + return f"JID_{state._job_n:016d}" + + def _make_session(state, username): token = secrets.token_hex(16) state._job_n += 1 @@ -635,18 +652,14 @@ async def _post(_request): path = _request.path.rstrip("/") if path in (f"{ROOT}/SessionService/Sessions", f"{ROOT}/Sessions"): - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") + body = await _read_body(_request) if not state.store.authenticate(body.get("UserName"), body.get("Password")): return _unauthorized("Authentication failed. Verify your credentials.") resource, token, location = _make_session(state, body.get("UserName")) return _json(resource, status=201, headers={"X-Auth-Token": token, "Location": location}) if path == ACCOUNTS_URI: - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") + body = await _read_body(_request) username = body.get("UserName") password = body.get("Password") role = body.get("RoleId") or "ReadOnly" @@ -661,9 +674,7 @@ async def _post(_request): return _json(_m_account(uri, state), status=201, headers={"Location": uri}) if path == f"{ACCOUNTSERVICE}/Actions/AccountService.ChangePassword": - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") + body = await _read_body(_request) if not state.store.authenticate(body.get("UserName"), body.get("OldPassword")): return _unauthorized("Old password is incorrect.") new_password = body.get("NewPassword") @@ -678,9 +689,7 @@ async def _post(_request): return web.Response(status=204) if path == f"{SYSTEM}/Actions/ComputerSystem.Reset": - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") + body = await _read_body(_request) reset_type = body.get("ResetType") # A later reset supersedes any pending automatic power-on task. if state._restart_task is not None: @@ -710,11 +719,8 @@ async def _power_back_on(): return web.Response(status=204) if path == f"{MANAGER}/Jobs": - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") - state._job_n += 1 - job_id = f"JID_{state._job_n:016d}" + body = await _read_body(_request) + job_id = _next_job_id(state) state.jobs[job_id] = {"TargetSettingsURI": body.get("TargetSettingsURI")} return _json( _m_job(f"{MANAGER}/Jobs/{job_id}", state, consume=False), @@ -723,9 +729,7 @@ async def _power_back_on(): ) if path == f"{MANAGER}/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia": - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") + body = await _read_body(_request) state.vmedia_image = body.get("Image") return web.Response(status=204) if path == f"{MANAGER}/VirtualMedia/CD/Actions/VirtualMedia.EjectMedia": @@ -757,13 +761,11 @@ async def _power_back_on(): return web.Response(status=200) if path.endswith("/Actions/Oem/EID_674_Manager.ExportSystemConfiguration"): - state._job_n += 1 - job_id = f"JID_{state._job_n:016d}" + job_id = _next_job_id(state) state.jobs[job_id] = {"Export": True, "SystemConfiguration": {"ComponentResults": [], "Id": "SystemConfiguration"}} return web.Response(status=202, headers={"Location": f"{MANAGER}/Jobs/{job_id}"}) if path.endswith("/Actions/Oem/EID_674_Manager.ImportSystemConfiguration"): - state._job_n += 1 - job_id = f"JID_{state._job_n:016d}" + job_id = _next_job_id(state) state.tasks.add(job_id) return web.Response(status=202, headers={"Location": f"{ROOT}/TaskService/Tasks/{job_id}"}) @@ -777,9 +779,7 @@ async def _patch(_request): path = _request.path.rstrip("/") if path in (SYSTEM,): - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") + body = await _read_body(_request) boot = body.get("Boot", {}) state.boot_target = boot.get("BootSourceOverrideTarget", state.boot_target) state.boot_enabled = boot.get("BootSourceOverrideEnabled", state.boot_enabled) @@ -790,9 +790,7 @@ async def _patch(_request): user = state.store.users.get(username) if user is None: return _not_found(path) - body = await _read_json(_request) - if body is None: - return _bad_request("Malformed JSON body.") + body = await _read_body(_request) if "Password" in body: if not body["Password"]: return _bad_request("Password cannot be empty.") From 83e60267f465c0187f7daaba724ed6daefd3e8dd Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Sun, 6 Sep 2026 14:39:53 +0200 Subject: [PATCH 20/36] ci: only run the dev Quay publish from the canonical repo Forks don't have the QUAY_USERNAME/QUAY_API_TOKEN secrets, so the Development Build workflow dies at podman login on every fork push (empty password -> 'inappropriate ioctl for device'). Gate the quay_dev job to this repo so forks skip it and the dev image is still published from quadsproject/badfish exactly as before. --- .github/workflows/development-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/development-build.yml b/.github/workflows/development-build.yml index 3461cc7..47a6698 100644 --- a/.github/workflows/development-build.yml +++ b/.github/workflows/development-build.yml @@ -12,6 +12,8 @@ concurrency: jobs: quay_dev: name: Push Quay (Dev) + # Only publish from the canonical repo; forks don't have the Quay secrets. + if: github.repository == 'quadsproject/badfish' runs-on: ubuntu-latest permissions: contents: read From be5f9b2666f0b473a7ea59feddbc20a77eb8efc5 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Mon, 7 Sep 2026 23:24:39 +0200 Subject: [PATCH 21/36] fix: black-format emulator.py and main.py Run black to fix the two lint errors that fail the repo-wide Black check on development: expand the state.jobs[job_id] dict literal (over 120 chars) and collapse the parenthesized uloc expression to one line. fixes: https://github.com/quadsproject/badfish/issues/556 --- src/badfish/emulator.py | 5 ++++- src/badfish/main.py | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 406deac..17a1995 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -762,7 +762,10 @@ async def _power_back_on(): if path.endswith("/Actions/Oem/EID_674_Manager.ExportSystemConfiguration"): job_id = _next_job_id(state) - state.jobs[job_id] = {"Export": True, "SystemConfiguration": {"ComponentResults": [], "Id": "SystemConfiguration"}} + state.jobs[job_id] = { + "Export": True, + "SystemConfiguration": {"ComponentResults": [], "Id": "SystemConfiguration"}, + } return web.Response(status=202, headers={"Location": f"{MANAGER}/Jobs/{job_id}"}) if path.endswith("/Actions/Oem/EID_674_Manager.ImportSystemConfiguration"): job_id = _next_job_id(state) diff --git a/src/badfish/main.py b/src/badfish/main.py index 52a412c..92848bb 100644 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -183,9 +183,7 @@ async def get_interfaces_by_type(self, host_type, _interfaces_path): if len(host_name_split) > 1: host_model = host_name_split[-1] rack = self.rack if self.rack is not None else host_name_split[1] - uloc = self.uloc if self.uloc is not None else ( - host_name_split[2] if len(host_name_split) > 2 else None - ) + uloc = self.uloc if self.uloc is not None else (host_name_split[2] if len(host_name_split) > 2 else None) for val in [rack, uloc]: if val is not None: prefix.append(val) From 77ba6636ebf341996f3aa2efd590016cbf80b3b9 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Mon, 7 Sep 2026 23:47:19 +0200 Subject: [PATCH 22/36] fix: declare openssl dep so RPM %check can run the emulator tests The emulator subcommand shells out to openssl to generate its self-signed TLS cert on first run. Without openssl in BuildRequires/Requires the rpmbuild %check step (pytest) fails the 3 emulator tests with 'openssl not found'. Add openssl to BuildRequires (for %check) and Requires (runtime), and to the dnf install list in the rpmlint workflow. --- .github/workflows/rpmlint.yml | 2 +- rpm/badfish.spec.tpl | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rpmlint.yml b/.github/workflows/rpmlint.yml index 1b87ac9..279a50e 100644 --- a/.github/workflows/rpmlint.yml +++ b/.github/workflows/rpmlint.yml @@ -48,7 +48,7 @@ jobs: dnf -y install make rpmlint rpm-build python3-devel pyproject-rpm-macros \ python3-setuptools python3-wheel python3-pip python3-build \ python3-pyyaml python3-aiohttp python3-async-lru python3-rich \ - python3-pytest python3-pytest-asyncio glibc-langpack-en + python3-pytest python3-pytest-asyncio glibc-langpack-en openssl - name: Build RPM and run rpmlint run: | diff --git a/rpm/badfish.spec.tpl b/rpm/badfish.spec.tpl index f3b69a6..3dd5dca 100644 --- a/rpm/badfish.spec.tpl +++ b/rpm/badfish.spec.tpl @@ -18,12 +18,17 @@ Source: %{url}/releases/download/v%{version}/badfish-%{version}.tar.gz BuildArch: noarch BuildRequires: python3-devel +# Emulator generates a self-signed TLS cert via the openssl CLI for %check. +BuildRequires: openssl # Test dependencies BuildRequires: python3dist(pytest) BuildRequires: python3dist(pytest-asyncio) BuildRequires: python3dist(pyyaml) BuildRequires: python3dist(aiohttp) Provides: badfish = %{version}-%{release} +# The emulator subcommand (badfish.emulator.run_daemon) shells out to openssl +# to generate its self-signed TLS cert on first run. +Requires: openssl %description %{desc} From e79e2938db6e2b0ad26befafccef068646f726f7 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 12:20:40 +0100 Subject: [PATCH 23/36] docs: document --ls-gpu and correct interface-key naming Add a Common Operations section and TOC entry for the --ls-gpu flag. Correct the interface-key format spec and all examples to match the shipped idrac_interfaces.yml keys, which use no _interfaces suffix. --- README.md | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fb00ff3..7149020 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ * [List Network Interfaces](#list-network-interfaces) * [List Memory](#list-memory) * [List Processors](#list-processors) + * [List GPUs](#list-gpus) * [List Serial Number or Service Tag](#list-serial-number-or-service-tag) * [Check Virtual Media](#check-virtual-media) * [Mount Virtual Media](#mount-virtual-media) @@ -292,20 +293,20 @@ We will use the custom interface order called **ocp5beta** as an example. _Example_ any system you want to boot with a certain custom interface order. ``` -ocp5beta_fc640_interfaces: NIC.Slot.2-4,NIC.Slot.2-1,NIC.Slot.2-2,NIC.Slot.2-3 +ocp5beta_fc640: NIC.Slot.2-4,NIC.Slot.2-1,NIC.Slot.2-2,NIC.Slot.2-3 ``` _Example_ a rack of systems you want to boot with a certain custom interface order. ``` -ocp5beta_f21_fc640_interfaces: NIC.Slot.2-4,NIC.Slot.2-1,NIC.Slot.2-2,NIC.Slot.2-3 +ocp5beta_f21_fc640: NIC.Slot.2-4,NIC.Slot.2-1,NIC.Slot.2-2,NIC.Slot.2-3 ``` _Example_ a specific system you want to boot with a certain custom interface order ``` -ocp5beta_f21_h23_fc640_interfaces: NIC.Slot.2-4,NIC.Slot.2-1,NIC.Slot.2-2,NIC.Slot.2-3 +ocp5beta_f21_h23_fc640: NIC.Slot.2-4,NIC.Slot.2-1,NIC.Slot.2-2,NIC.Slot.2-3 ``` Now you can run Badfish against the custom interface order type you have defined, refer to the [custom overrides](#host-type-overrides) on further usage examples. @@ -493,6 +494,12 @@ For getting a detailed list of processors you can run ```badfish``` with the ``` badfish -H mgmt-your-server.example.com --ls-processors ``` +### List GPUs +For getting a detailed summary and list of GPU's on the host you can run ```badfish``` with the ```--ls-gpu``` option. +```bash +badfish -H mgmt-your-server.example.com --ls-gpu +``` + ### List Serial Number or Service Tag For getting the system's serial number or on Dell servers the service tag (equivalent to `racadm getsvctag`) you can run ```badfish``` with the ```--ls-serial``` option. ```bash @@ -753,11 +760,11 @@ Your usage may vary, this is what our configuration looks like via ```config/idr Every other method that requires passing the `-i` argument, is going to parse the key strings from this and look for the most adequate candidate for the given FQDN. We format the key strings with the following criteria: ``` -{host_type}_[{rack}_[{ULocation}_[{blade}_]]]{model}_interfaces +{host_type}_[{rack}_[{ULocation}_[{blade}_]]]{model} ``` Additionally we can do a blade only override like: ``` -{host_type}_[{blade}_]{model}_interfaces +{host_type}_[{blade}_]{model} ``` With rack, ULocation and blade being optional in a hierarchical fashion otherwise mandatory with the exception of the blade, as we can now use the blade independently from rack and ULocation. host_type and model values are always mandatory. @@ -765,21 +772,21 @@ With rack, ULocation and blade being optional in a hierarchical fashion otherwis | Keys defined on interfaces yaml | FQDN | Use boot order | | :------------------------------ |:----:| --------------:| -| director_r620_interfaces | mgmt-f21-h17-000-r620.domain.com | NO | -| director_f21_r620_interfaces | mgmt-f21-h17-000-r620.domain.com | NO | -| director_f21_h17_r620_interfaces | mgmt-f21-h17-000-r620.domain.com | YES | +| director_r620 | mgmt-f21-h17-000-r620.domain.com | NO | +| director_f21_r620 | mgmt-f21-h17-000-r620.domain.com | NO | +| director_f21_h17_r620 | mgmt-f21-h17-000-r620.domain.com | YES | | Keys defined on interfaces yaml | FQDN | Use boot order | | :------------------------------ |:----:| --------------:| -| director_r620_interfaces | mgmt-f21-h18-000-r620.domain.com | NO | -| director_f21_r620_interfaces | mgmt-f21-h18-000-r620.domain.com | YES | -| director_f21_h17_r620_interfaces | mgmt-f21-h18-000-r620.domain.com | NO | +| director_r620 | mgmt-f21-h18-000-r620.domain.com | NO | +| director_f21_r620 | mgmt-f21-h18-000-r620.domain.com | YES | +| director_f21_h17_r620 | mgmt-f21-h18-000-r620.domain.com | NO | | Keys defined on interfaces yaml | FQDN | Use boot order | | :------------------------------ |:----:| --------------:| -| director_r620_interfaces | mgmt-f22-h17-000-r620.domain.com | YES | -| director_f21_r620_interfaces | mgmt-f22-h17-000-r620.domain.com | NO | -| director_f21_h17_r620_interfaces | mgmt-f22-h17-000-r620.domain.com | NO | +| director_r620 | mgmt-f22-h17-000-r620.domain.com | YES | +| director_f21_r620 | mgmt-f22-h17-000-r620.domain.com | NO | +| director_f21_h17_r620 | mgmt-f22-h17-000-r620.domain.com | NO | ## Contributing From dcee876a1052ae86d2c7eac4be1b4e697d8fe680 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 12:24:56 +0100 Subject: [PATCH 24/36] fix(emulator): set member Id so boot-to-mac resolves devices The EthernetInterface template renders an empty Id, so badfish's boot_to_mac sees device=None and raises 'MAC Address does not match any of the existing'. Populate the member Id from its resource id so collection members (EthernetInterface, Processor, Memory) carry a real identity and boot-to-mac resolves the device. Fixes #564 --- src/badfish/emulator.py | 1 + tests/test_emulator.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/badfish/emulator.py b/src/badfish/emulator.py index 17a1995..57f5375 100644 --- a/src/badfish/emulator.py +++ b/src/badfish/emulator.py @@ -290,6 +290,7 @@ def _member(tmpl_key, items, member_id, uri, fill=None): return None data = _tmpl(tmpl_key) data["@odata.id"] = uri + data["Id"] = member_id if fill: fill(data, item) return data diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 37a03c3..caa5982 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -417,6 +417,12 @@ async def _noop(*_args, **_kwargs): await bf.get_power_consumed_watts() assert await bf.get_bios_boot_mode() == "Bios" + + # F8: the EthernetInterface member Id is now populated, so boot_to_mac + # resolves the MAC to a device and boots to it instead of raising + # "MAC Address does not match any of the existing" (device was None + # because the template rendered an empty Id). + await bf.boot_to_mac("00:5c:52:31:3a:9c") finally: if bf: await bf.delete_session() @@ -440,6 +446,7 @@ async def test_inventory_collections_and_members(client): nic = await (await client.get(f"{SYSTEM}/EthernetInterfaces/NIC.Integrated.1-1-1", headers=headers)).json() assert nic["MACAddress"] == "00:5c:52:31:3a:9c" assert nic["LinkStatus"] == "Up" + assert nic["Id"] == "NIC.Integrated.1-1-1" # Network adapter inventory the way badfish walks it: the NetworkPorts and # NetworkDeviceFunctions collections under each adapter (get_nic_fqdds) and @@ -474,12 +481,14 @@ async def test_inventory_collections_and_members(client): assert procs["Members@odata.count"] == 2 cpu = await (await client.get(f"{SYSTEM}/Processors/CPU.Socket.1", headers=headers)).json() assert cpu["Model"] and cpu["TotalCores"] > 0 + assert cpu["Id"] == "CPU.Socket.1" mem = await (await client.get(f"{SYSTEM}/Memory", headers=headers)).json() assert mem["Members@odata.count"] == 2 dimm = await (await client.get(f"{SYSTEM}/Memory/DIMM.Socket.A1", headers=headers)).json() assert dimm["CapacityMiB"] == 32768 assert dimm["Manufacturer"] == "Micron" + assert dimm["Id"] == "DIMM.Socket.A1" assert (await client.get(f"{SYSTEM}/Memory/DIMM.Socket.ZZ", headers=headers)).status == 404 assert (await client.get(f"{FIRMWARE}/NOPE", headers=headers)).status == 404 From e2f00c61acbf0b8c1b500ca9a24390a0c7b381b0 Mon Sep 17 00:00:00 2001 From: Ted Logan Date: Tue, 8 Sep 2026 22:20:40 +0200 Subject: [PATCH 25/36] fix: iDRAC10 virtual media and OS deployment paths Dell 17G hosts (R670, iDRAC10) fail virtual media cleanup and OS deployment with "Could not unmount virtual media" and "iDRAC version installed doesn't support DellOSDeploymentService". iDRAC10 dropped the legacy /redfish/v1/Dell OEM namespace and no longer serves the VirtualMedia collection under the manager resource. Port of quadsproject/quads#723 (commit 0dd27bb4): - find_virtual_media_resource() resolves VirtualMedia from the manager then system resource, caching the result. Supermicro keeps VM1. - is_optical_media() and get_virtual_media_device() replace the [x for x in vm_config if "CD" in x][0] filter; a miss raises BadfishException instead of IndexError. - find_os_deployment_resource() tries {system}/Oem/Dell/... then the legacy /redfish/v1/Dell path, caching the result. The OS deployment action URIs now use the resolved resource. fixes: https://github.com/quadsproject/quads/issues/723 --- src/badfish/main.py | 109 +++++++++++---- tests/test_badfish_idrac10.py | 256 ++++++++++++++++++++++++++++++++++ tests/test_virtual_media.py | 4 +- 3 files changed, 343 insertions(+), 26 deletions(-) create mode 100644 tests/test_badfish_idrac10.py diff --git a/src/badfish/main.py b/src/badfish/main.py index 92848bb..2af9bad 100644 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -115,6 +115,8 @@ def __init__( self.session_id = None self.token = None self.vendor = None + self.virtual_media_resource = None + self.os_deployment_resource = None self.console = _console if _console is not None else Console() self._progress_disabled = _progress_disabled # Tables are useful only in the same conditions as progress bars: TTY, @@ -1533,14 +1535,60 @@ async def toggle_boot_device(self, device): await self.reboot_server(graceful=False) return True - async def get_virtual_media_config(self): - vm_path = "/" + @staticmethod + def is_optical_media(_id, media_types=None): + """Whether a VirtualMedia member is the virtual optical drive.""" + if "CD" in str(_id): + return True + return bool(set(media_types or []) & {"CD", "DVD"}) + + async def find_virtual_media_resource(self): + """Resolve the virtual media collection. + + iDRAC10 dropped the collection under the manager resource and only + serves it under the system resource. + """ + if self.virtual_media_resource: + return self.virtual_media_resource + if self.vendor == "Supermicro": - vm_path += "VM1" + candidates = ["%s/VM1" % self.manager_resource] else: - vm_path += "VirtualMedia" + candidates = [ + "%s/VirtualMedia" % self.manager_resource, + "%s/VirtualMedia" % self.system_resource, + ] + + for candidate in candidates: + _response = await self.get_request("%s%s" % (self.host_uri, candidate)) + if _response.status == 200: + self.virtual_media_resource = candidate + return candidate + + raise BadfishException("Not able to access virtual media resource.") + + async def get_virtual_media_device(self, vm_config): + """Resolve the virtual optical media device from a media collection.""" + for member in vm_config: + if self.is_optical_media(member.split("/")[-1]): + return member + + for member in vm_config: + _response = await self.get_request("%s%s" % (self.host_uri, member)) + try: + raw = await _response.text("utf-8", "ignore") + data = json.loads(raw.strip()) + except ValueError: + raise BadfishException("There was something wrong getting values for VirtualMedia") + if self.is_optical_media(data.get("Id"), data.get("MediaTypes")): + return member - _uri = "%s%s%s" % (self.host_uri, self.manager_resource, vm_path) + raise BadfishException("No virtual optical media device found.") + + async def get_virtual_media_config(self): + vm_resource = await self.find_virtual_media_resource() + + _uri = "%s%s" % (self.host_uri, vm_resource) _response = await self.get_request(_uri) try: raw = await _response.text("utf-8", "ignore") @@ -1593,7 +1641,9 @@ async def check_virtual_media(self): self.logger.info(f" Name: {_data.get('Name')}") self.logger.info(f" ImageName: {_data.get('ImageName')}") self.logger.info(f" Inserted: {_data.get('Inserted')}") - if str(_data.get("Inserted")).lower() == "true" and "CD" in str(_data.get("Id")): + if str(_data.get("Inserted")).lower() == "true" and self.is_optical_media( + _data.get("Id"), _data.get("MediaTypes") + ): inserted = True except ValueError: raise BadfishException("There was something wrong getting values for VirtualMedia") @@ -1620,7 +1670,7 @@ async def mount_virtual_media(self, path): else: raise BadfishException("There was something wrong trying to mount virtual media.") else: - vcd = [x for x in vm_config if "CD" in x][0] + vcd = await self.get_virtual_media_device(vm_config) _uri = "%s%s/Actions/VirtualMedia.InsertMedia" % (self.host_uri, vcd) _payload = {"Image": path} _response = await self.post_request(_uri, payload=_payload, headers=_headers) @@ -1654,7 +1704,7 @@ async def unmount_virtual_media(self): _uri = "%s%s" % (self.host_uri, vm_config["config"]) _response = await self.patch_request(_uri, payload=_payload, headers=_headers) else: - vcd = [x for x in vm_config if "CD" in x][0] + vcd = await self.get_virtual_media_device(vm_config) _uri = "%s%s/Actions/VirtualMedia.EjectMedia" % (self.host_uri, vcd) _response = await self.post_request(_uri, payload={}, headers=_headers) status = _response.status @@ -1713,11 +1763,31 @@ async def boot_to_virtual_media(self): return False return True + async def find_os_deployment_resource(self): + """Resolve the Dell OS deployment service. + + iDRAC10 dropped the legacy /redfish/v1/Dell OEM namespace and serves + the service under the system resource instead. + """ + if self.os_deployment_resource: + return self.os_deployment_resource + + system_id = self.system_resource.split("/")[-1] + candidates = [ + "%s/Oem/Dell/DellOSDeploymentService" % self.system_resource, + "%s/Dell/Systems/%s/DellOSDeploymentService" % (self.redfish_uri, system_id), + ] + + for candidate in candidates: + _response = await self.get_request("%s%s" % (self.host_uri, candidate)) + if _response.status == 200: + self.os_deployment_resource = candidate + return candidate + + return None + async def check_os_deployment_support(self): - _uri = "%s/redfish/v1/Dell/Systems/System.Embedded.1/DellOSDeploymentService" % self.host_uri - _response = await self.get_request(_uri) - await _response.text("utf-8", "ignore") - if _response.status != 200: + if not await self.find_os_deployment_resource(): self.logger.error( "iDRAC version installed doesn't support DellOSDeploymentService needed for this feature." ) @@ -1727,10 +1797,7 @@ async def check_os_deployment_support(self): async def check_remote_image(self): if not await self.check_os_deployment_support(): return False - _uri = ( - "%s/redfish/v1/Dell/Systems/System.Embedded.1/DellOSDeploymentService/Actions/DellOSDeploymentService." - "GetAttachStatus" % self.host_uri - ) + _uri = "%s%s/Actions/DellOSDeploymentService.GetAttachStatus" % (self.host_uri, self.os_deployment_resource) _headers = {"Content-Type": "application/json"} _response = await self.post_request(_uri, payload={}, headers=_headers) try: @@ -1749,10 +1816,7 @@ async def check_remote_image(self): async def boot_remote_image(self, nfs_path): if not await self.check_os_deployment_support(): return False - _uri = ( - "%s/redfish/v1/Dell/Systems/System.Embedded.1/DellOSDeploymentService/Actions/DellOSDeploymentService" - ".BootToNetworkISO" % self.host_uri - ) + _uri = "%s%s/Actions/DellOSDeploymentService.BootToNetworkISO" % (self.host_uri, self.os_deployment_resource) _headers = {"Content-Type": "application/json"} try: split_path = str(nfs_path).split(":") @@ -1791,10 +1855,7 @@ async def boot_remote_image(self, nfs_path): async def detach_remote_image(self): if not await self.check_os_deployment_support(): return False - _uri = ( - "%s/redfish/v1/Dell/Systems/System.Embedded.1/DellOSDeploymentService/Actions/DellOSDeploymentService" - ".DetachISOImage" % self.host_uri - ) + _uri = "%s%s/Actions/DellOSDeploymentService.DetachISOImage" % (self.host_uri, self.os_deployment_resource) _headers = {"Content-Type": "application/json"} _response = await self.post_request(_uri, payload={}, headers=_headers) if _response.status == 200: diff --git a/tests/test_badfish_idrac10.py b/tests/test_badfish_idrac10.py new file mode 100644 index 0000000..dd9c8cb --- /dev/null +++ b/tests/test_badfish_idrac10.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from badfish.main import Badfish +from badfish.helpers.exceptions import BadfishException + +HOST_URI = "https://mgmt-host.example.com" +REDFISH_URI = "/redfish/v1" +SYSTEM_RESOURCE = "/redfish/v1/Systems/System.Embedded.1" +MANAGER_RESOURCE = "/redfish/v1/Managers/iDRAC.Embedded.1" +MANAGER_VM = "%s/VirtualMedia" % MANAGER_RESOURCE +SYSTEM_VM = "%s/VirtualMedia" % SYSTEM_RESOURCE +MEMBER_CD = "%s/CD" % MANAGER_VM +MEMBER_REMOVABLE = "%s/RemovableDisk" % MANAGER_VM +MEMBER_ONE = "%s/1" % SYSTEM_VM +MEMBER_TWO = "%s/2" % SYSTEM_VM +OEM_OSD = "%s/Oem/Dell/DellOSDeploymentService" % SYSTEM_RESOURCE +LEGACY_OSD = "%s/Dell/Systems/System.Embedded.1/DellOSDeploymentService" % REDFISH_URI +OPTICAL_MEDIA_TYPES = ["CD", "DVD", "USBStick"] + + +def url(path): + return "%s%s" % (HOST_URI, path) + + +def make_response(status=200, payload=None): + response = MagicMock() + response.status = status + response.headers = {} + response.text = AsyncMock(return_value=json.dumps(payload if payload is not None else {})) + return response + + +def router(responses, default_status=404): + """Return a side effect resolving request URIs against a response map.""" + + def _resolve(uri, *args, **kwargs): + if uri in responses: + return responses[uri] + return make_response(default_status, {"error": {"code": "Base.1.18.GeneralError"}}) + + return _resolve + + +def media_collection(*members): + return {"Members": [{"@odata.id": member} for member in members]} + + +@pytest.fixture +def badfish_instance(): + badfish = Badfish("mgmt-host.example.com", "r1", "u1", MagicMock(), 3, _loop=MagicMock()) + badfish.system_resource = SYSTEM_RESOURCE + badfish.manager_resource = MANAGER_RESOURCE + badfish.vendor = "Dell" + return badfish + + +class TestIsOpticalMedia: + def test_matches_by_id(self): + assert Badfish.is_optical_media("CD") is True + + def test_ignores_removable_disk(self): + assert Badfish.is_optical_media("RemovableDisk") is False + + def test_matches_by_media_types(self): + assert Badfish.is_optical_media("1", OPTICAL_MEDIA_TYPES) is True + + def test_ignores_usb_only_media_types(self): + assert Badfish.is_optical_media("2", ["USBStick"]) is False + + def test_ignores_numeric_id_without_media_types(self): + assert Badfish.is_optical_media("1") is False + + +class TestFindVirtualMediaResource: + @pytest.mark.asyncio + async def test_manager_resource(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({url(MANAGER_VM): make_response()})) + + assert await badfish_instance.find_virtual_media_resource() == MANAGER_VM + + @pytest.mark.asyncio + async def test_falls_back_to_system_resource(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({url(SYSTEM_VM): make_response()})) + + assert await badfish_instance.find_virtual_media_resource() == SYSTEM_VM + + @pytest.mark.asyncio + async def test_supermicro_resource(self, badfish_instance): + badfish_instance.vendor = "Supermicro" + vm1 = "%s/VM1" % MANAGER_RESOURCE + badfish_instance.get_request = AsyncMock(side_effect=router({url(vm1): make_response()})) + + assert await badfish_instance.find_virtual_media_resource() == vm1 + + @pytest.mark.asyncio + async def test_cached_resource(self, badfish_instance): + badfish_instance.virtual_media_resource = SYSTEM_VM + badfish_instance.get_request = AsyncMock() + + assert await badfish_instance.find_virtual_media_resource() == SYSTEM_VM + badfish_instance.get_request.assert_not_called() + + @pytest.mark.asyncio + async def test_no_resource_found(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({})) + + with pytest.raises(BadfishException): + await badfish_instance.find_virtual_media_resource() + + +class TestGetVirtualMediaDevice: + @pytest.mark.asyncio + async def test_device_by_name(self, badfish_instance): + badfish_instance.get_request = AsyncMock() + + assert await badfish_instance.get_virtual_media_device([MEMBER_REMOVABLE, MEMBER_CD]) == MEMBER_CD + badfish_instance.get_request.assert_not_called() + + @pytest.mark.asyncio + async def test_device_by_media_types(self, badfish_instance): + member_data = make_response(payload={"Id": "1", "MediaTypes": OPTICAL_MEDIA_TYPES}) + badfish_instance.get_request = AsyncMock(side_effect=router({url(MEMBER_ONE): member_data})) + + assert await badfish_instance.get_virtual_media_device([MEMBER_ONE, MEMBER_TWO]) == MEMBER_ONE + + @pytest.mark.asyncio + async def test_no_optical_device(self, badfish_instance): + member_data = make_response(payload={"Id": "1", "MediaTypes": ["USBStick"]}) + badfish_instance.get_request = AsyncMock(side_effect=router({url(MEMBER_ONE): member_data})) + + with pytest.raises(BadfishException): + await badfish_instance.get_virtual_media_device([MEMBER_ONE]) + + @pytest.mark.asyncio + async def test_device_invalid_json_raises(self, badfish_instance): + member_data = make_response(payload={"Id": "1", "MediaTypes": ["USBStick"]}) + member_data.text = AsyncMock(return_value="{not json") + badfish_instance.get_request = AsyncMock(side_effect=router({url(MEMBER_ONE): member_data})) + + with pytest.raises(BadfishException): + await badfish_instance.get_virtual_media_device([MEMBER_ONE]) + + +class TestVirtualMediaActions: + @pytest.mark.asyncio + async def test_check_virtual_media_numeric_ids(self, badfish_instance): + badfish_instance.virtual_media_resource = SYSTEM_VM + responses = { + url(SYSTEM_VM): make_response(payload=media_collection(MEMBER_ONE, MEMBER_TWO)), + url(MEMBER_ONE): make_response(payload={"Id": "1", "Inserted": True, "MediaTypes": OPTICAL_MEDIA_TYPES}), + url(MEMBER_TWO): make_response(payload={"Id": "2", "Inserted": False, "MediaTypes": OPTICAL_MEDIA_TYPES}), + } + badfish_instance.get_request = AsyncMock(side_effect=router(responses)) + + assert await badfish_instance.check_virtual_media() is True + + @pytest.mark.asyncio + async def test_unmount_virtual_media_numeric_ids(self, badfish_instance): + badfish_instance.virtual_media_resource = SYSTEM_VM + responses = { + url(SYSTEM_VM): make_response(payload=media_collection(MEMBER_ONE, MEMBER_TWO)), + url(MEMBER_ONE): make_response(payload={"Id": "1", "MediaTypes": OPTICAL_MEDIA_TYPES}), + } + badfish_instance.get_request = AsyncMock(side_effect=router(responses)) + badfish_instance.post_request = AsyncMock(return_value=make_response(status=204)) + + assert await badfish_instance.unmount_virtual_media() is True + assert badfish_instance.post_request.call_args[0][0] == url("%s/Actions/VirtualMedia.EjectMedia" % MEMBER_ONE) + + @pytest.mark.asyncio + async def test_mount_virtual_media_numeric_ids(self, badfish_instance): + badfish_instance.virtual_media_resource = SYSTEM_VM + responses = { + url(SYSTEM_VM): make_response(payload=media_collection(MEMBER_ONE, MEMBER_TWO)), + url(MEMBER_ONE): make_response(payload={"Id": "1", "MediaTypes": OPTICAL_MEDIA_TYPES}), + } + badfish_instance.get_request = AsyncMock(side_effect=router(responses)) + badfish_instance.post_request = AsyncMock(return_value=make_response(status=204)) + + assert await badfish_instance.mount_virtual_media("http://example.com/boot.iso") is True + assert badfish_instance.post_request.call_args[0][0] == url("%s/Actions/VirtualMedia.InsertMedia" % MEMBER_ONE) + + +class TestOsDeploymentResource: + @pytest.mark.asyncio + async def test_oem_resource(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({url(OEM_OSD): make_response()})) + + assert await badfish_instance.find_os_deployment_resource() == OEM_OSD + + @pytest.mark.asyncio + async def test_falls_back_to_legacy_resource(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({url(LEGACY_OSD): make_response()})) + + assert await badfish_instance.find_os_deployment_resource() == LEGACY_OSD + + @pytest.mark.asyncio + async def test_resources_follow_discovered_system_id(self, badfish_instance): + badfish_instance.system_resource = "/redfish/v1/Systems/System.Embedded.2" + legacy = "%s/Dell/Systems/System.Embedded.2/DellOSDeploymentService" % REDFISH_URI + badfish_instance.get_request = AsyncMock(side_effect=router({url(legacy): make_response()})) + + assert await badfish_instance.find_os_deployment_resource() == legacy + + @pytest.mark.asyncio + async def test_cached_resource(self, badfish_instance): + badfish_instance.os_deployment_resource = OEM_OSD + badfish_instance.get_request = AsyncMock() + + assert await badfish_instance.find_os_deployment_resource() == OEM_OSD + badfish_instance.get_request.assert_not_called() + + @pytest.mark.asyncio + async def test_unsupported_resource(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({})) + + assert await badfish_instance.find_os_deployment_resource() is None + assert await badfish_instance.check_os_deployment_support() is False + + @pytest.mark.asyncio + async def test_detach_remote_image_uses_resolved_resource(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({url(OEM_OSD): make_response()})) + badfish_instance.post_request = AsyncMock(return_value=make_response()) + + assert await badfish_instance.detach_remote_image() is True + expected = url("%s/Actions/DellOSDeploymentService.DetachISOImage" % OEM_OSD) + assert badfish_instance.post_request.call_args[0][0] == expected + + @pytest.mark.asyncio + async def test_check_remote_image_uses_resolved_resource(self, badfish_instance): + badfish_instance.get_request = AsyncMock(side_effect=router({url(OEM_OSD): make_response()})) + badfish_instance.post_request = AsyncMock(return_value=make_response(payload={"ISOAttachStatus": "Attached"})) + + assert await badfish_instance.check_remote_image() is True + expected = url("%s/Actions/DellOSDeploymentService.GetAttachStatus" % OEM_OSD) + assert badfish_instance.post_request.call_args[0][0] == expected + + @pytest.mark.asyncio + async def test_boot_remote_image_uses_resolved_resource(self, badfish_instance): + task = "%s/TaskService/Tasks/JID_123" % REDFISH_URI + responses = { + url(OEM_OSD): make_response(), + url(task): make_response(payload={"TaskStatus": "OK"}), + } + badfish_instance.get_request = AsyncMock(side_effect=router(responses)) + boot_response = make_response(status=202) + boot_response.headers = {"Location": task} + badfish_instance.post_request = AsyncMock(return_value=boot_response) + + assert await badfish_instance.boot_remote_image("server:/path/to.iso") is True + expected = url("%s/Actions/DellOSDeploymentService.BootToNetworkISO" % OEM_OSD) + assert badfish_instance.post_request.call_args[0][0] == expected diff --git a/tests/test_virtual_media.py b/tests/test_virtual_media.py index f89726c..d987905 100644 --- a/tests/test_virtual_media.py +++ b/tests/test_virtual_media.py @@ -529,7 +529,7 @@ def test_boot_os_deployment_not_supported(self, mock_get, mock_post, mock_delete @patch("aiohttp.ClientSession.post") @patch("aiohttp.ClientSession.get") def test_boot_good(self, mock_get, mock_post, mock_delete): - responses_get = [BLANK_RESP, VMEDIA_REMOTE_BOOT_TASK_RESP] + responses_get = [VMEDIA_REMOTE_BOOT_TASK_RESP] responses = INIT_RESP + responses_get self.set_mock_response(mock_get, 200, responses) self.set_mock_response(mock_post, [200, 202], "OK", True) @@ -568,7 +568,7 @@ def test_boot_command_fail(self, mock_get, mock_post, mock_delete): @patch("aiohttp.ClientSession.post") @patch("aiohttp.ClientSession.get") def test_boot_task_fail(self, mock_get, mock_post, mock_delete): - responses_get = [BLANK_RESP, VMEDIA_REMOTE_BOOT_TASK_FAILED_RESP] + responses_get = [VMEDIA_REMOTE_BOOT_TASK_FAILED_RESP] responses = INIT_RESP + responses_get self.set_mock_response(mock_get, 200, responses) self.set_mock_response(mock_post, [200, 202], "OK", True) From c1298cf1ac8552fbc436ae9fadecb8b8fc345180 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 12:25:01 +0100 Subject: [PATCH 26/36] fix: build container images from checked-out code, not cloned master The default Dockerfile ran 'git clone https://github.com/quadsproject/badfish' with no -b flag, so it pulled the repository default branch (master) regardless of what the CI checkout had. The development-build workflow therefore published a master/v1.6.0 image as quay.io/quads/badfish:development, which lacked development features like the built-in Redfish emulator. Build from the already-checked-out context instead (COPY . /badfish + WORKDIR /badfish). Both development-build and production-release workflows already actions/checkout the target ref, so this removes the default-branch dependence and the clone race. Added a minimal .dockerignore to keep .git and python caches out of the build context. --- .dockerignore | 4 ++++ Dockerfile | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc5e161 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +__pycache__ +*.pyc +.pytest_cache diff --git a/Dockerfile b/Dockerfile index a88bda7..1beef37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,9 @@ USER root RUN dnf install -y git -RUN git clone https://github.com/quadsproject/badfish +COPY . /badfish -WORKDIR badfish +WORKDIR /badfish RUN dnf install -y gcc python3-devel RUN pip install --upgrade pip From ade92f4d8c377fe3dbdb1650e5bfa9f12f47248a Mon Sep 17 00:00:00 2001 From: Will Foster Date: Wed, 9 Sep 2026 21:18:57 +0100 Subject: [PATCH 27/36] fix: drop unused git install and stale Dockerfiles The image now builds from the checked-out context instead of cloning the repo, so git is unused at build and runtime. Dockerfile_dev and Dockerfile_local are not referenced by any workflow or documentation, so remove them. --- Dockerfile | 2 -- Dockerfile_dev | 20 -------------------- Dockerfile_local | 16 ---------------- 3 files changed, 38 deletions(-) delete mode 100644 Dockerfile_dev delete mode 100644 Dockerfile_local diff --git a/Dockerfile b/Dockerfile index 1beef37..152b22c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,6 @@ FROM quay.io/fedora/python-310:latest USER root -RUN dnf install -y git - COPY . /badfish WORKDIR /badfish diff --git a/Dockerfile_dev b/Dockerfile_dev deleted file mode 100644 index 085e547..0000000 --- a/Dockerfile_dev +++ /dev/null @@ -1,20 +0,0 @@ -FROM quay.io/fedora/python-310:latest - -USER root - -RUN dnf install -y git - -RUN git clone https://github.com/quadsproject/badfish - -WORKDIR badfish - -RUN git checkout development - -RUN dnf install -y gcc python3-devel -RUN pip install --upgrade pip -RUN pip install -r requirements.txt -RUN python -m build -RUN python -m pip install dist/badfish-*.tar.gz - -ENTRYPOINT ["badfish"] -CMD ["-v"] diff --git a/Dockerfile_local b/Dockerfile_local deleted file mode 100644 index 9c9fc18..0000000 --- a/Dockerfile_local +++ /dev/null @@ -1,16 +0,0 @@ -FROM quay.io/quads/python39:latest - -RUN apk add git && apk update - -COPY . /badfish - -WORKDIR badfish - -RUN apk add build-base -RUN pip install --upgrade pip -RUN pip install --no-cache-dir -r requirements.txt -RUN python -m build -RUN python -m pip install dist/badfish-*.tar.gz - -ENTRYPOINT ["badfish"] -CMD ["-v"] From a0b0a992f87bffc0011f07c2c05b0c4168eebb30 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 12:28:11 +0100 Subject: [PATCH 28/36] fix: propagate boot-to failures, join export path, guard init JSON parses boot_to_type returns its boot_to result and execute_badfish folds a False result into the exit status. export_scp joins the export filename to the target directory instead of concatenating. find_systems_resource wraps both response JSON parses in the standard BadfishException guard. --- src/badfish/main.py | 22 +++++++++++---- tests/test_boot_to.py | 18 +++++++++++- tests/test_boot_to_type.py | 23 ++++++++++++++- tests/test_execution.py | 57 +++++++++++++++++++++++++++++++++++++- tests/test_scp.py | 38 ++++++++++++++++++++++++- 5 files changed, 148 insertions(+), 10 deletions(-) diff --git a/src/badfish/main.py b/src/badfish/main.py index 2af9bad..92d8d5c 100644 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -543,7 +543,10 @@ async def find_systems_resource(self): raise BadfishException("Failed to communicate with server.") raw = await response.text("utf-8", "ignore") - data = json.loads(raw.strip()) + try: + data = json.loads(raw.strip()) + except ValueError: + raise BadfishException("Error reading response from host.") if "Systems" not in data: raise BadfishException("Systems resource not found") @@ -553,7 +556,10 @@ async def find_systems_resource(self): raise BadfishException("Authorization Error: verify credentials.") raw = await systems_response.text("utf-8", "ignore") - systems_data = json.loads(raw.strip()) + try: + systems_data = json.loads(raw.strip()) + except ValueError: + raise BadfishException("Error reading response from host.") if systems_data.get("Members"): for member in systems_data["Members"]: @@ -1281,7 +1287,7 @@ async def boot_to_type(self, host_type, _interfaces_path): device = await self.get_host_type_boot_device(host_type, _interfaces_path) - await self.boot_to(device, True) + return await self.boot_to(device, True) async def boot_to_mac(self, mac_address): interfaces_endpoints = await self.get_interfaces_endpoints() @@ -2614,7 +2620,9 @@ async def export_scp(self, file_path, targets="ALL", include_read_only=False): # Save the exported configuration if "SystemConfiguration" in data: now = get_now() - filename = file_path + now.strftime(f"%Y-%m-%d_%H%M%S_targets_{targets.replace(',', '-')}_export.json") + filename = os.path.join( + file_path, now.strftime(f"%Y-%m-%d_%H%M%S_targets_{targets.replace(',', '-')}_export.json") + ) with open(filename, "w") as f: f.write(json.dumps(data, indent=4)) self.logger.info("SCP export completed successfully.") @@ -3102,9 +3110,11 @@ async def execute_badfish(_host, _args, logger, format_handler=None, console=Non badfish.logger.info("Executing actions on host: %s" % _host) if device: - await badfish.boot_to(device) + if not await badfish.boot_to(device): + result = False elif boot_to_type: - await badfish.boot_to_type(boot_to_type, interfaces_path) + if not await badfish.boot_to_type(boot_to_type, interfaces_path): + result = False elif boot_to_mac: await badfish.boot_to_mac(boot_to_mac) elif check_boot: diff --git a/tests/test_boot_to.py b/tests/test_boot_to.py index f9b4d33..31d657b 100644 --- a/tests/test_boot_to.py +++ b/tests/test_boot_to.py @@ -1,5 +1,9 @@ -from unittest.mock import patch +import logging +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from badfish.main import Badfish from tests.config import ( BAD_DEVICE_NAME, BLANK_RESP, @@ -159,3 +163,15 @@ def test_boot_to_job_creation_fails(self, mock_get, mock_patch, mock_post, mock_ # The error path executes L805-806, but error_handler raises BadfishException # which gets caught at higher level and logs generic message assert "Failed to communicate" in err + + +@pytest.mark.asyncio +async def test_boot_to_returns_false_on_no_match(): + """boot_to() must return False (not None) when the device does not match.""" + logger = MagicMock(spec=logging.Logger) + bf = Badfish("test_host", "user", "pass", logger, 1) + bf.check_device = AsyncMock(return_value=False) + + result = await bf.boot_to("bad_device") + + assert result is False diff --git a/tests/test_boot_to_type.py b/tests/test_boot_to_type.py index 10d6f1b..1017a39 100644 --- a/tests/test_boot_to_type.py +++ b/tests/test_boot_to_type.py @@ -1,5 +1,9 @@ -from unittest.mock import patch +import logging +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from badfish.main import Badfish from tests.config import ( BLANK_RESP, BOOT_MODE_RESP, @@ -128,3 +132,20 @@ def test_boot_to_no_file(self, mock_get, mock_post, mock_delete): self.set_mock_response(mock_delete, 200, "OK") _, err = self.badfish_call() assert err == RESPONSE_BOOT_TO_NO_FILE + + +@pytest.mark.asyncio +async def test_boot_to_type_returns_false_on_no_device(tmp_path): + """boot_to_type() must propagate False from boot_to() when no device matches.""" + logger = MagicMock(spec=logging.Logger) + bf = Badfish("test_host", "user", "pass", logger, 1) + iface = tmp_path / "idrac_interfaces.yml" + iface.write_text("key: value") + + bf.get_host_types_from_yaml = AsyncMock(return_value=["foreman", "director"]) + bf.get_host_type_boot_device = AsyncMock(return_value=None) + bf.boot_to = AsyncMock(return_value=False) + + result = await bf.boot_to_type("foreman", str(iface)) + + assert result is False diff --git a/tests/test_execution.py b/tests/test_execution.py index 84d70da..a9d0e78 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -1,7 +1,12 @@ +import logging import os -from unittest.mock import patch +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from badfish.helpers.exceptions import BadfishException +from badfish.main import Badfish, execute_badfish from tests.config import ( HOST_LIST_EXTRAS, KEYBOARD_INTERRUPT, @@ -161,3 +166,53 @@ def test_find_systems_resource_not_found(self, mock_get, mock_post, mock_delete) # When Members array is empty or missing, init() catches the exception and logs as WARNING assert "- WARNING - Could not find system resource:" in err assert "Systems resource not found" in err or "ComputerSystem's Members array" in err + + +@pytest.mark.asyncio +async def test_execute_badfish_boot_to_false_sets_result_false(): + """execute_badfish() must propagate a False return from boot_to() to `result`.""" + logger = MagicMock(spec=logging.Logger) + fake_badfish = MagicMock() + fake_badfish.boot_to = AsyncMock(return_value=False) + fake_badfish.session_id = None + mock_args = defaultdict(lambda: None) + mock_args.update({"u": "user", "p": "pass", "retries": 1, "boot_to": "NIC.1"}) + + with patch("badfish.main.badfish_factory", new_callable=AsyncMock) as mock_factory: + mock_factory.return_value = fake_badfish + result = await execute_badfish("test_host", mock_args, logger, None) + + assert result == ("test_host", False) + fake_badfish.boot_to.assert_awaited_once_with("NIC.1") + + +@pytest.mark.asyncio +async def test_find_systems_resource_invalid_root_json_raises_badfish_exception(): + """Invalid JSON from the root resource must surface as BadfishException, not JSONDecodeError.""" + logger = MagicMock(spec=logging.Logger) + bf = Badfish("test_host", "user", "pass", logger, 1) + bf.http_client = MagicMock() + + root_resp = MagicMock() + root_resp.text = AsyncMock(return_value="not json {") + bf.http_client.get_request = AsyncMock(return_value=root_resp) + + with pytest.raises(BadfishException, match="Error reading response from host."): + await bf.find_systems_resource() + + +@pytest.mark.asyncio +async def test_find_systems_resource_invalid_systems_json_raises_badfish_exception(): + """Invalid JSON from the Systems resource must surface as BadfishException, not JSONDecodeError.""" + logger = MagicMock(spec=logging.Logger) + bf = Badfish("test_host", "user", "pass", logger, 1) + bf.http_client = MagicMock() + + root_resp = MagicMock() + root_resp.text = AsyncMock(return_value='{"Systems":{"@odata.id":"/redfish/v1/Systems"}}') + sys_resp = MagicMock() + sys_resp.text = AsyncMock(return_value="bad") + bf.http_client.get_request = AsyncMock(side_effect=[root_resp, sys_resp]) + + with pytest.raises(BadfishException, match="Error reading response from host."): + await bf.find_systems_resource() diff --git a/tests/test_scp.py b/tests/test_scp.py index b729b40..082cb9c 100644 --- a/tests/test_scp.py +++ b/tests/test_scp.py @@ -1,7 +1,11 @@ +import json +import logging import os +import pytest from datetime import datetime, timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch +from badfish.main import Badfish from tests.config import ( BLANK_RESP, INIT_RESP, @@ -47,6 +51,38 @@ def export_dir_check(): os.makedirs("exports") +@pytest.mark.asyncio +async def test_export_scp_saves_file_inside_target_dir(tmp_path): + """The exported file must land INSIDE the given directory (no trailing slash).""" + logger = MagicMock(spec=logging.Logger) + bf = Badfish("test_host", "user", "pass", logger, 1) + bf.http_client = MagicMock() + + config_body = {"SystemConfiguration": {"Settings": {"value": 1}}} + job_resp = MagicMock() + job_resp.text = AsyncMock(return_value=json.dumps(config_body)) + + post_resp = MagicMock() + post_resp.status = 202 + post_resp.headers = {"Location": f"/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/{JOB_ID}"} + + bf.post_request = AsyncMock(return_value=post_resp) + bf._extract_job_id_from_response = MagicMock(return_value=JOB_ID) + bf.get_request = AsyncMock(return_value=job_resp) + + expected_name = f"{FIXED_BASE_TIME.strftime('%Y-%m-%d_%H%M%S')}_targets_ALL_export.json" + + with patch("badfish.main.asyncio.sleep", new=AsyncMock()), patch( + "badfish.main.get_now", return_value=FIXED_BASE_TIME + ): + result = await bf.export_scp(str(tmp_path), "ALL") + + assert result is True + # os.path.join semantics: the file lives under tmp_path, not as a sibling. + assert (tmp_path / expected_name).is_file() + assert not (tmp_path.parent / expected_name).exists() + + class TestGetSCPTargets(TestBase): option_arg = "--get-scp-targets" From 47f0e72c603279ad763331fd8ec757e6f2d30d9a Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 13:05:31 +0100 Subject: [PATCH 29/36] test: cover boot-to-type no-match exit path --- tests/test_boot_to_type.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_boot_to_type.py b/tests/test_boot_to_type.py index 1017a39..f660e9a 100644 --- a/tests/test_boot_to_type.py +++ b/tests/test_boot_to_type.py @@ -8,6 +8,9 @@ BLANK_RESP, BOOT_MODE_RESP, BOOT_SEQ_RESP, + DEVICE_HDD_1, + DEVICE_NIC_2, + render_device_dict, BOOT_SEQ_RESPONSE_DIRECTOR, INIT_RESP, INTERFACES_PATH, @@ -133,6 +136,33 @@ def test_boot_to_no_file(self, mock_get, mock_post, mock_delete): _, err = self.badfish_call() assert err == RESPONSE_BOOT_TO_NO_FILE + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + @patch("aiohttp.ClientSession.patch") + @patch("aiohttp.ClientSession.get") + def test_boot_to_type_no_match(self, mock_get, mock_patch, mock_post, mock_delete): + # Boot sequence without the custom type's first device must propagate + # the failure so badfish exits nonzero. + boot_seq_resp_fmt = BOOT_SEQ_RESP % str( + [render_device_dict(0, DEVICE_HDD_1), render_device_dict(1, DEVICE_NIC_2)] + ) + get_resp = [ + BOOT_MODE_RESP, + boot_seq_resp_fmt.replace("'", '"'), + BLANK_RESP, + ] + responses = INIT_RESP + get_resp + self.set_mock_response(mock_get, 200, responses) + self.set_mock_response(mock_patch, 200, ["OK"]) + self.set_mock_response(mock_post, 200, ["OK", JOB_OK_RESP]) + self.set_mock_response(mock_delete, 200, "OK") + self.args = ["-i", INTERFACES_PATH, self.option_arg, "custom"] + _, err = self.badfish_call(mock_host="host01.example.com") + assert err == ( + "- ERROR - Device NIC.Integrated.1-2-1 does not match any of the " + "available boot devices for host host01.example.com\n" + ) + @pytest.mark.asyncio async def test_boot_to_type_returns_false_on_no_device(tmp_path): From b2024339312046c061baa1989eab310b9d0203dc Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 12:34:50 +0100 Subject: [PATCH 30/36] fix: harden formatted output parsing and check-boot JSON - F3: timestamp-string YAML loader at all four parse() load sites so zero-date ReleaseDate (0000-00-00T00:00:00Z) stays a string instead of raising ValueError in yaml.safe_load; ValueError added to outer except. - F4: defensive diff() tolerates 0/1 hosts, missing SoftwareId/Version, compares Version via str() and avoids zip truncation. - F9: BadfishLogger creates the parent dir of --log before FileHandler. - F11/F13: emit structured check-boot data (BootOrder/HostType) and guard parse() against missing messages so -o json --check-boot is sensible. Fixes #559 #560 #565 #567 #570 --- src/badfish/helpers/logger.py | 173 +++++++++++++++++++++++----------- src/badfish/main.py | 7 +- tests/config.py | 10 +- tests/test_logger.py | 94 ++++++++++++++++-- 4 files changed, 217 insertions(+), 67 deletions(-) diff --git a/src/badfish/helpers/logger.py b/src/badfish/helpers/logger.py index dba9b22..cbaea30 100644 --- a/src/badfish/helpers/logger.py +++ b/src/badfish/helpers/logger.py @@ -1,4 +1,5 @@ import json +import os import sys from io import StringIO @@ -29,12 +30,34 @@ def ignore_aliases(self, data): return True +class BadfishSafeLoader(yaml.SafeLoader): + """SafeLoader that keeps YAML timestamps as strings. + + Real iDRAC firmware data carries ReleaseDate values such as + ``0000-00-00T00:00:00Z``. PyYAML's default timestamp constructor converts + these to ``datetime`` which raises ``ValueError: year 0 is out of range``. + Returning the raw scalar keeps the value a string and avoids the crash. + """ + + +BadfishSafeLoader.add_constructor( + "tag:yaml.org,2002:timestamp", + lambda loader, node: loader.construct_scalar(node), +) + + +def _safe_load(message): + """YAML load using :class:`BadfishSafeLoader` (timestamps stay strings).""" + return yaml.load(message, Loader=BadfishSafeLoader) + + class BadfishHandler(StreamHandler): def __init__(self, format_flag=False): StreamHandler.__init__(self) self.messages = {} self.formatted_msg = [] self.output_dict = dict() + self.structured = {} self.host = None self.format_flag = format_flag @@ -46,6 +69,12 @@ def emit(self, record): if getattr(record, "is_table", False): return + # Structured records (e.g. check-boot) are preferred over re-parsing + # the human log text when generating json/yaml output. + obj = getattr(record, "obj", None) + if obj is not None: + self.structured[record.name] = obj + if record.levelno == INFO and record.msg != "*" * 48: if record.name not in self.messages: self.messages.update({record.name: record.msg + "\n"}) @@ -58,11 +87,20 @@ def parse(self): try: if self.host: host_name = self.host.strip().split(".")[0] + structured = self.structured.get(host_name) + if structured is not None: + self.output_dict.update({self.host: structured.copy()}) + self.host = None + return # Ensure the message is properly formatted as YAML by wrapping values in quotes - message = self.messages[host_name] + message = self.messages.get(host_name) + if not message: + self.output_dict = {"unsupported_command": True} + self.host = None + return # Try to parse as is first try: - new_dict = yaml.safe_load(message) + new_dict = _safe_load(message) except yaml.YAMLError: # If parsing fails, try to format the value as a quoted string lines = message.strip().split("\n") @@ -78,15 +116,22 @@ def parse(self): else: formatted_lines.append(line) formatted_message = "\n".join(formatted_lines) - new_dict = yaml.safe_load(formatted_message) + new_dict = _safe_load(formatted_message) self.output_dict.update({self.host: new_dict.copy()}) self.host = None else: - message = self.messages["badfish.helpers.logger"] + structured = self.structured.get("badfish.helpers.logger") + if structured is not None: + self.output_dict.update(structured.copy()) + return + message = self.messages.get("badfish.helpers.logger") + if not message: + self.output_dict = {"unsupported_command": True} + return # Apply the same formatting logic for non-host messages try: - new_dict = yaml.safe_load(message) + new_dict = _safe_load(message) except yaml.YAMLError: lines = message.strip().split("\n") formatted_lines = [] @@ -100,59 +145,78 @@ def parse(self): else: formatted_lines.append(line) formatted_message = "\n".join(formatted_lines) - new_dict = yaml.safe_load(formatted_message) + new_dict = _safe_load(formatted_message) self.output_dict.update(new_dict.copy()) - except yaml.YAMLError: + except (yaml.YAMLError, ValueError): self.output_dict = {"unsupported_command": True} def diff(self): - try: - if self.output_dict["error"]: - return f"ERROR - {self.output_dict['error_msg']}" - except KeyError: - host_first, host_second, *_ = self.output_dict.keys() - first, second, *_ = self.output_dict.values() - diff_dict = {host_first: {}, host_second: {}} - for i in first: - for j in second: - if ( - first[i]["SoftwareId"] == second[j]["SoftwareId"] - and first[i]["Version"] != second[j]["Version"] - and first[i]["SoftwareId"] != 0 - ): - diff_dict[host_first].update( - { - i: { - "Version": first[i]["Version"], - "Name": first[i]["Name"], - } - } - ) - diff_dict[host_second].update( - { - j: { - "Version": second[j]["Version"], - "Name": second[j]["Name"], - } - } - ) - - if diff_dict[host_first] == {}: - return "{}" - output = "" - formatted = json.dumps(diff_dict[host_first], indent=4, sort_keys=False, default=str) - len_first = (max(len(line) for line in formatted.splitlines())) + 10 - output += f"{host_first}:".ljust(len_first) - output += f"{host_second}:\n" - for i, j in zip(diff_dict[host_first], diff_dict[host_second]): - output += f"{i}".ljust(len_first) - output += f"{j}\n" - output += f"\t- Name: {(diff_dict[host_first][i])['Name']}".ljust(len_first) - output += f"\t- Name: {(diff_dict[host_second][j])['Name']}\n" - output += f"\t- Version: {(diff_dict[host_first][i])['Version']}".ljust(len_first) - output += f"\t- Version: {(diff_dict[host_second][j])['Version']}\n" - return output + if self.output_dict.get("error"): + return f"ERROR - {self.output_dict.get('error_msg')}" + + # F4: only compare across exactly two hosts, each a dict of firmware rows. + if len(self.output_dict) != 2: + return "{}" + (host_first, first), (host_second, second) = self.output_dict.items() + if not isinstance(first, dict) or not isinstance(second, dict): + return "{}" + + diff_dict = {host_first: {}, host_second: {}} + pairs = [] + for i in first: + if not isinstance(first[i], dict): + continue + for j in second: + if not isinstance(second[j], dict): + continue + # .get() so rows missing SoftwareId/Version/Name don't raise; + # skip pairs that cannot be matched by SoftwareId. + first_sid = first[i].get("SoftwareId") + second_sid = second[j].get("SoftwareId") + if first_sid is None or second_sid is None or first_sid != second_sid: + continue + if first_sid == 0: + continue + # Skip pairs that lack a Version to compare. + if first[i].get("Version") is None or second[j].get("Version") is None: + continue + # Compare via str() so YAML floats (e.g. 3.57) resolve identically. + if str(first[i].get("Version")) == str(second[j].get("Version")): + continue + diff_dict[host_first].update( + { + i: { + "Version": first[i].get("Version"), + "Name": first[i].get("Name"), + } + } + ) + diff_dict[host_second].update( + { + j: { + "Version": second[j].get("Version"), + "Name": second[j].get("Name"), + } + } + ) + pairs.append((i, j)) + + if diff_dict[host_first] == {}: + return "{}" + output = "" + formatted = json.dumps(diff_dict[host_first], indent=4, sort_keys=False, default=str) + len_first = (max(len(line) for line in formatted.splitlines())) + 10 + output += f"{host_first}:".ljust(len_first) + output += f"{host_second}:\n" + for i, j in pairs: + output += f"{i}".ljust(len_first) + output += f"{j}\n" + output += f"\t- Name: {(diff_dict[host_first][i])['Name']}".ljust(len_first) + output += f"\t- Name: {(diff_dict[host_second][j])['Name']}\n" + output += f"\t- Version: {(diff_dict[host_first][i])['Version']}".ljust(len_first) + output += f"\t- Version: {(diff_dict[host_second][j])['Version']}\n" + return output def output(self, output_type, host_order=None): if output_type == "json": @@ -257,6 +321,9 @@ def __init__(self, verbose=False, multi_host=False, log_file=None, output=None, self.queue_listener.start() if self.log_file: + log_dir = os.path.dirname(self.log_file) + if log_dir: + os.makedirs(log_dir, exist_ok=True) self.file_handler = FileHandler(self.log_file) self.file_handler.setFormatter(Formatter(_file_format_str)) self.file_handler.setLevel(self.log_level) diff --git a/src/badfish/main.py b/src/badfish/main.py index 92d8d5c..6eac324 100644 --- a/src/badfish/main.py +++ b/src/badfish/main.py @@ -1361,15 +1361,18 @@ async def check_boot(self, _interfaces_path): if not self.boot_devices: await self.get_boot_devices() + boot_order = {"BootOrder": [device["Name"] for device in sorted(self.boot_devices, key=lambda x: x["Index"])]} + if _interfaces_path: _host_type = await self.get_host_type(_interfaces_path) if _host_type: - self.logger.warning("Current boot order is set to: %s." % _host_type) + boot_order["HostType"] = _host_type + self.logger.warning("Current boot order is set to: %s." % _host_type, extra={"obj": boot_order}) return True else: self.logger.warning("Current boot order does not match any of the given.") - self.logger.info("Current boot order:") + self.logger.info("Current boot order:", extra={"obj": boot_order}) if self._use_tables: table = Table(show_header=True, header_style="bold") table.add_column("#", justify="right") diff --git a/tests/config.py b/tests/config.py index 968433e..a0adf08 100644 --- a/tests/config.py +++ b/tests/config.py @@ -418,7 +418,7 @@ def render_device_dict(index, device): "{" '"Id": "Installed-0-16.25.40.62",' '"Name": "Mellanox ConnectX-5",' - '"ReleaseDate": "00:00:00Z",' + '"ReleaseDate": "0000-00-00T00:00:00Z",' '"SoftwareId": "0",' '"Status": {"Health": "OK","State": "Enabled"},' '"Updateable": "True",' @@ -428,7 +428,7 @@ def render_device_dict(index, device): "{" '"Id": "Installed-0-19.5.12",' '"Name": "Intel(R) Ethernet Network Adapter",' - '"ReleaseDate": "00:00:00Z",' + '"ReleaseDate": "0000-00-00T00:00:00Z",' '"SoftwareId": "0",' '"Status": {"Health": "OK","State": "Enabled"},' '"Updateable": "True",' @@ -439,7 +439,7 @@ def render_device_dict(index, device): "- INFO - Installed-0-16.25.40.62:\n" "- INFO - Id: Installed-0-16.25.40.62\n" "- INFO - Name: Mellanox ConnectX-5\n" - "- INFO - ReleaseDate: 00:00:00Z\n" + "- INFO - ReleaseDate: 0000-00-00T00:00:00Z\n" "- INFO - SoftwareId: 0\n" "- INFO - Status: {'Health': 'OK', 'State': 'Enabled'}\n" "- INFO - Updateable: True\n" @@ -447,7 +447,7 @@ def render_device_dict(index, device): "- INFO - Installed-0-19.5.12:\n" "- INFO - Id: Installed-0-19.5.12\n" "- INFO - Name: Intel(R) Ethernet Network Adapter\n" - "- INFO - ReleaseDate: 00:00:00Z\n" + "- INFO - ReleaseDate: 0000-00-00T00:00:00Z\n" "- INFO - SoftwareId: 0\n" "- INFO - Status: {'Health': 'OK', 'State': 'Enabled'}\n" "- INFO - Updateable: True\n" @@ -458,7 +458,7 @@ def render_device_dict(index, device): "- INFO - Installed-0-16.25.40.62:\n" "- INFO - Id: Installed-0-16.25.40.62\n" "- INFO - Name: Mellanox ConnectX-5\n" - "- INFO - ReleaseDate: 00:00:00Z\n" + "- INFO - ReleaseDate: 0000-00-00T00:00:00Z\n" "- INFO - SoftwareId: 0\n" "- INFO - Status: {'Health': 'OK', 'State': 'Enabled'}\n" "- INFO - Updateable: True\n" diff --git a/tests/test_logger.py b/tests/test_logger.py index c43bd13..a2e8e6b 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,6 +1,6 @@ import os import tempfile -from logging import INFO, ERROR, DEBUG, LogRecord +from logging import INFO, ERROR, DEBUG, WARNING, LogRecord from badfish.helpers.logger import BadfishHandler, BadfishFormatter, BadfishLogger import yaml @@ -94,9 +94,7 @@ def test_parse_yamlerror_path_via_patch(self): handler = BadfishHandler(format_flag=True) handler.host = "hostx" handler.messages["hostx"] = "key: value\n" - with patch( - "badfish.helpers.logger.yaml.safe_load", side_effect=[yaml.YAMLError("bad1"), yaml.YAMLError("bad2")] - ): + with patch("badfish.helpers.logger._safe_load", side_effect=[yaml.YAMLError("bad1"), yaml.YAMLError("bad2")]): handler.parse() assert handler.output_dict == {"unsupported_command": True} @@ -104,12 +102,57 @@ def test_parse_yamlerror_path_via_patch_no_host(self): handler = BadfishHandler(format_flag=True) # No host set, exercise the else branch handler.messages["badfish.helpers.logger"] = "key: value\n" - with patch( - "badfish.helpers.logger.yaml.safe_load", side_effect=[yaml.YAMLError("bad1"), yaml.YAMLError("bad2")] - ): + with patch("badfish.helpers.logger._safe_load", side_effect=[yaml.YAMLError("bad1"), yaml.YAMLError("bad2")]): handler.parse() assert handler.output_dict == {"unsupported_command": True} + def test_parse_zero_date_release_date_stays_string(self): + handler = BadfishHandler(format_flag=True) + handler.messages["badfish.helpers.logger"] = ( + "Installed-0-16.25.40.62:\n" + " Id: Installed-0-16.25.40.62\n" + " Name: Mellanox ConnectX-5\n" + " ReleaseDate: 0000-00-00T00:00:00Z\n" + " SoftwareId: 0\n" + " Version: 16.25.40.62\n" + ) + handler.parse() + row = handler.output_dict["Installed-0-16.25.40.62"] + assert row["ReleaseDate"] == "0000-00-00T00:00:00Z" + + def test_parse_missing_message_sets_error_marker(self): + handler = BadfishHandler(format_flag=True) + # No INFO message and no structured data must not raise (F13). + handler.parse() + assert handler.output_dict == {"unsupported_command": True} + + def test_parse_uses_structured_check_boot_data(self): + handler = BadfishHandler(format_flag=True) + handler.structured["badfish.helpers.logger"] = { + "BootOrder": ["NIC.Integrated.1-1-1"], + "HostType": "foreman", + } + handler.parse() + assert handler.output_dict == {"BootOrder": ["NIC.Integrated.1-1-1"], "HostType": "foreman"} + + def test_emit_stores_structured_obj(self): + handler = BadfishHandler(format_flag=True) + record = LogRecord( + name="badfish.helpers.logger", + level=WARNING, + pathname=__file__, + lineno=1, + msg="Current boot order is set to: foreman.", + args=(), + exc_info=None, + ) + record.obj = {"BootOrder": ["NIC.Integrated.1-1-1"], "HostType": "foreman"} + handler.emit(record) + assert handler.structured["badfish.helpers.logger"] == { + "BootOrder": ["NIC.Integrated.1-1-1"], + "HostType": "foreman", + } + def test_diff_returns_error_if_error_flag_set(self): handler = BadfishHandler(format_flag=True) handler.output_dict = {"error": True, "error_msg": "oops"} @@ -139,6 +182,34 @@ def test_diff_returns_empty_when_no_differences(self): } assert handler.diff() == "{}" + def test_diff_tolerates_zero_hosts(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = {} + assert handler.diff() == "{}" + + def test_diff_tolerates_single_host(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = { + "h1": {"a": {"SoftwareId": 1, "Version": "1", "Name": "A"}}, + } + assert handler.diff() == "{}" + + def test_diff_tolerates_missing_software_id(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = { + "h1": {"a": {"Version": "1", "Name": "A"}}, + "h2": {"b": {"Version": "2", "Name": "B"}}, + } + assert handler.diff() == "{}" + + def test_diff_tolerates_missing_version(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = { + "h1": {"a": {"SoftwareId": 1, "Name": "A"}}, + "h2": {"b": {"SoftwareId": 1, "Name": "B"}}, + } + assert handler.diff() == "{}" + def test_output_json_and_yaml(self): handler = BadfishHandler(format_flag=True) handler.output_dict = {"a": 1, "b": "x"} @@ -213,6 +284,15 @@ def test_logger_file_handler_attached(self): except OSError: pass + def test_logger_creates_missing_log_dir(self): + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "sub", "dir", "bad.log") + logger = BadfishLogger(verbose=False, multi_host=False, log_file=log_path) + try: + assert os.path.isdir(os.path.join(tmp, "sub", "dir")) + finally: + logger.queue_listener.stop() + def test_logger_verbose_and_output_flag_behavior(self): logger = BadfishLogger(verbose=True, multi_host=False, output=True) assert logger.logger.level == DEBUG From 801736fd23f0ea2703238845821b406740080582 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 13:05:28 +0100 Subject: [PATCH 31/36] test: cover structured parse guard and defensive diff branches --- tests/test_logger.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_logger.py b/tests/test_logger.py index a2e8e6b..f62938c 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -210,6 +210,53 @@ def test_diff_tolerates_missing_version(self): } assert handler.diff() == "{}" + def test_parse_with_host_uses_structured_data(self): + handler = BadfishHandler(format_flag=True) + handler.host = "host1.domain" + handler.structured["host1"] = {"BootOrder": ["NIC.Integrated.1-1-1"], "HostType": "foreman"} + handler.parse() + assert handler.output_dict == { + "host1.domain": {"BootOrder": ["NIC.Integrated.1-1-1"], "HostType": "foreman"} + } + + def test_parse_with_host_missing_message_sets_error_marker(self): + handler = BadfishHandler(format_flag=True) + handler.host = "host1.domain" + handler.parse() + assert handler.output_dict == {"unsupported_command": True} + + def test_diff_tolerates_non_dict_host_values(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = { + "h1": "not a dict", + "h2": {"a": {"SoftwareId": 1, "Version": "1", "Name": "A"}}, + } + assert handler.diff() == "{}" + + def test_diff_skips_non_dict_first_host_rows(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = { + "h1": {"a": "not a row"}, + "h2": {"b": {"SoftwareId": 1, "Version": "1", "Name": "B"}}, + } + assert handler.diff() == "{}" + + def test_diff_skips_non_dict_second_host_rows(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = { + "h1": {"a": {"SoftwareId": 1, "Version": "1", "Name": "A"}}, + "h2": {"b": "not a row"}, + } + assert handler.diff() == "{}" + + def test_diff_skips_software_id_zero(self): + handler = BadfishHandler(format_flag=True) + handler.output_dict = { + "h1": {"a": {"SoftwareId": 0, "Version": "1", "Name": "A"}}, + "h2": {"b": {"SoftwareId": 0, "Version": "2", "Name": "B"}}, + } + assert handler.diff() == "{}" + def test_output_json_and_yaml(self): handler = BadfishHandler(format_flag=True) handler.output_dict = {"a": 1, "b": "x"} From 3a8d12ae58f2ef6284ff83cd0407840518c4b1c7 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 8 Sep 2026 13:15:05 +0100 Subject: [PATCH 32/36] ci: re-run tox and lint on PR synchronize events --- .github/workflows/lint.yml | 2 +- .github/workflows/tox.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a318364..f0fd3d7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,7 @@ name: Lint on: pull_request: - types: [opened, edited] + types: [opened, edited, synchronize] branches: [development, master] push: diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 57bb63a..37fa6c2 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -2,7 +2,7 @@ name: Tox on: pull_request: - types: [opened, edited] + types: [opened, edited, synchronize] branches: [development, master] push: From 7054006422f306e397803a78796760a8b278361e Mon Sep 17 00:00:00 2001 From: Will Foster Date: Thu, 10 Sep 2026 21:36:42 +0100 Subject: [PATCH 33/36] docs: sync README with current badfish feature set Full documentation sweep against the development branch codebase: fix TOC structure and anchors, document missing CLI options (--timeout, --insecure, --rack, --uloc, --blade), refresh the features and requirements lists, fix broken examples and typos, and update Redfish emulator documentation. --- README.md | 139 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 7149020..2da8fea 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

The Out-of-Band Wrangler

@@ -20,13 +20,14 @@ * [Badfish RPM package](#badfish-rpm-package) * [Badfish Standalone CLI](#badfish-standalone-cli) * [Badfish Container](#badfish-container) + * [Run straight from the repository](#run-straight-from-the-repository) * [Usage](#usage) * [As Python Library](#as-python-library) * [Via Podman](#via-podman) * [Via Virtualenv](#via-virtualenv) * [Via RPM System Package](#via-rpm-system-package) * [Common Operations](#common-operations) - * [Use Environment Variables for Secrets](#using-environment-variables-for-secrets) + * [Use Environment Variables for Secrets](#use-environment-variables-for-secrets) * [Enforcing an OpenStack Director-style interface order](#enforcing-an-openstack-director-style-interface-order) * [Enforcing a Foreman-style interface order](#enforcing-a-foreman-style-interface-order) * [Enforcing a Custom interface order](#enforcing-a-custom-interface-order) @@ -34,8 +35,8 @@ * [Forcing a one time boot to a specific mac address](#forcing-a-one-time-boot-to-a-specific-mac-address) * [Forcing a one time boot to a specific type](#forcing-a-one-time-boot-to-a-specific-type) * [Forcing a one-time boot to PXE](#forcing-a-one-time-boot-to-pxe) - * [Rebooting a System](#rebooting-a-system) - * [Power Cycling a System](#power-cycling-a-system) + * [Rebooting a system](#rebooting-a-system) + * [Power cycling a system](#power-cycling-a-system) * [Power State Control](#power-state-control) * [Check Power State](#check-power-state) * [Get Power Consumed](#get-power-consumed) @@ -45,6 +46,8 @@ * [Check current boot order](#check-current-boot-order) * [Toggle boot device](#toggle-boot-device) * [Variable number of retries](#variable-number-of-retries) + * [Set request timeout](#set-request-timeout) + * [Skip TLS certificate verification](#skip-tls-certificate-verification) * [Firmware inventory](#firmware-inventory) * [Delta of firmware inventories](#delta-of-firmware-inventories) * [Clear Job Queue](#clear-job-queue) @@ -66,6 +69,9 @@ * [Detach Remote Image](#detach-remote-image) * [Get SRIOV mode](#get-sriov-mode) * [Set SRIOV mode](#set-sriov-mode) + * [Get FQDDs for all nics](#get-fqdds-for-all-nics) + * [Get NIC attributes](#get-nic-attributes) + * [Set NIC attribute](#set-nic-attribute) * [Get BIOS attributes](#get-bios-attributes) * [Get specific BIOS attribute](#get-specific-bios-attribute) * [Set BIOS attribute](#set-bios-attribute) @@ -79,13 +85,14 @@ * [Export server configuration profile](#export-server-configuration-profile) * [Import server configuration profile](#import-server-configuration-profile) * [Bulk actions via text file with list of hosts](#bulk-actions-via-text-file-with-list-of-hosts) - * [Verbose Output](#verbose-output) - * [Log to File](#log-to-file) + * [Verbose output](#verbose-output) + * [Log to file](#log-to-file) * [Formatted output](#formatted-output) * [Redfish emulator (mock iDRAC)](#redfish-emulator-mock-idrac) * [iDRAC and Data Format](#idrac-and-data-format) * [Dell Foreman and PXE Interface](#dell-foreman-and-pxe-interface) * [Host type overrides](#host-type-overrides) + * [Example for director type overrides](#example-for-director-type-overrides) * [Contributing](#contributing) * [Contact](#contact) @@ -102,23 +109,30 @@ We're mostly concentrated on programmatically enforcing interface/device boot or ## Features * Toggle and save a persistent interface/device boot order on remote system * Support for BIOS and EFI modes for interface/device boot operations -* Perform one-time boot to a specific interface, mac address or device listed for PXE booting -* Enforce a custom interface boot order +* Perform one-time boot to a specific interface, mac address or type listed for PXE booting +* Enforce a custom interface boot order with [rack/U-location/blade overrides](#host-type-overrides) * Check current boot order -* Display current power consumption in watts -* Reboot host -* 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). +* Power on/off/cycle, reboot and query power state and power consumption +* Reset BMCs (Dell iDRAC `--racreset`, Supermicro/HPE `--bmc-reset`) +* BIOS factory reset +* View, check and clear BMC job queues +* Check, mount, unmount and boot to virtual media +* Dell OpenManage OS deployment remote ISO support (check, boot, detach) +* Check and set SRIOV mode (Dell only) +* Get and set BIOS attributes, including bulk changes and BIOS/UEFI mode switching +* Set and remove BIOS passwords +* Get and set NIC attributes via FQDD (Dell only) +* Take a remote screenshot of server KVM console activity (Dell only) +* Get firmware inventory and delta between two hosts +* Obtain limited hardware information (CPU, Memory, Interfaces, GPU, Serial/Service Tag) +* Export and import server configuration profiles with Dell iDRAC SCP * Support tokenized authentication -* Check and set BIOS attributes (e.g. setting UEFI or BIOS mode) -* 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) +* Use environment variables for credentials * Bulk actions via plain text file with list of hosts for parallel execution +* JSON or YAML formatted output +* Request timeout, retry and TLS certificate verification controls * Logging to a specific path +* Built-in Redfish emulator (mock iDRAC) for development and testing * Containerized Badfish image ## Requirements @@ -126,8 +140,8 @@ We're mostly concentrated on programmatically enforcing interface/device boot or * (Dell) Firmware version ```2.60.60.60``` or higher * Any Redfish IPMI 2.0 support on non-Dell systems * 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). +* Python >= ```3.10``` or [podman](https://podman.io/getting-started/installation) as a container. +* python3-devel >= ```3.10``` (If using standalone or RPM package below). ## Setup ### Badfish RPM package @@ -164,6 +178,33 @@ Perhaps the easiest way to run Badfish is with Podman, you can see more usage de podman pull quay.io/quads/badfish ``` +### Run straight from the repository +Running Badfish straight from a git checkout is the least recommended way to use it, but you can if you prefer. You need Python >= ```3.10``` and all the libraries from `requirements.txt` installed, either in a virtualenv or as system packages: + +* With a virtualenv: + +```bash +git clone https://github.com/quadsproject/badfish && cd badfish +python3 -m venv bf +source bf/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +* Or with RPM system packages (or satisfied by your package manager/distribution): + +```bash +sudo dnf install python3-pyyaml python3-aiohttp python3-async-lru python3-rich python3-setuptools python3-build openssl +``` + +You can then invoke Badfish straight from the repository by pointing `PYTHONPATH` at the source tree: +```bash +PYTHONPATH="./src" python3 src/badfish/main.py -h +PYTHONPATH="./src" python3 src/badfish/main.py -H mgmt-your-server.example.com --power-state +``` +> [!NOTE] +> `openssl` is only needed by the [Redfish emulator](#redfish-emulator-mock-idrac) to generate its TLS certificate, and `python3-setuptools`/`python3-build` are only needed to build or install the package. The `badfish` console command is only available after a full install (see [Badfish Standalone CLI](#badfish-standalone-cli)). + ## Usage Badfish can be consumed in several ways after successful installation. Either via the standalone cli tool or as a python library. For an extensive use of the cli tool check the [Common Operations](#common-operations) section of this file. @@ -312,7 +353,7 @@ ocp5beta_f21_h23_fc640: NIC.Slot.2-4,NIC.Slot.2-1,NIC.Slot.2-2,NIC.Slot.2-3 Now you can run Badfish against the custom interface order type you have defined, refer to the [custom overrides](#host-type-overrides) on further usage examples. ```bash -src/main.py --host-list /tmp/hosts -u root -p password -i config/idrac_interfaces.yml -t ocp5beta +badfish --host-list /tmp/hosts -u root -p password -i config/idrac_interfaces.yml -t ocp5beta ``` @@ -375,7 +416,7 @@ Partial Output: ### Get Power Consumed This displays the current power usage for Dell / Supermicro server(s). ```bash -badfish -H mgmt-your-server.example.com -u root -p --get-power-consumed +badfish -H mgmt-your-server.example.com --get-power-consumed ``` Partial Output: ``` @@ -419,17 +460,29 @@ badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml --check- ### Toggle boot device If you would like to enable or disable a boot device you can use ```--toggle-boot-device``` argument which takes the device name as input and will toggle the `Enabled` state from True to False and vice versa. ```bash -badfish -H mgmt-your-server.example.com --toggle-boot-device NIC.Integrated.1-3-1``` +badfish -H mgmt-your-server.example.com --toggle-boot-device NIC.Integrated.1-3-1 ``` ### Variable number of retries -At certain points during the execution of ```badfish``` the program might come across a non responsive resources and will automatically retry to establish connection. We have included a default value of 15 retries after failed attempts but this can be customized via the ```--retries``` optional argument which takes as input an integer with the number of desired retries. +At certain points during the execution of ```badfish``` the program might come across a non responsive resources and will automatically retry to establish connection. We have included a default value of 30 retries after failed attempts but this can be customized via the ```--retries``` optional argument which takes as input an integer with the number of desired retries. ```bash badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml -t foreman --retries 20 ``` +### Set request timeout +By default every Redfish REST call waits up to 120 seconds to answer (```TIMEOUT``` in `src/badfish/config.py`). You can override that with the ```--timeout``` option, which takes a positive integer number of seconds. +```bash +badfish -H mgmt-your-server.example.com --power-state --timeout 60 +``` + +### Skip TLS certificate verification +For development and testing against hosts that present self-signed certificates, including the bundled [Redfish emulator](#redfish-emulator-mock-idrac), you can pass ```--insecure``` to disable SSL/TLS certificate verification. Do not use this flag with production BMCs. +```bash +badfish -H mgmt-your-server.example.com --insecure --power-state +``` + ### Firmware inventory -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. +If you would like to get a detailed list of all the devices supported by the BMC you can run ```badfish``` with the ```--firmware-inventory``` option which will return a list of devices with additional device info. ```bash badfish -H mgmt-your-server.example.com --firmware-inventory ``` @@ -563,7 +616,7 @@ badfish -H mgmt-your-server.example.com --get-sriov > This is only supported on DELL devices. ### Set SRIOV mode -For changing the mode of the SRIOV glabal BIOS attribute, we have included 2 new arguments. +For changing the mode of the SRIOV global BIOS attribute, we have included 2 new arguments. In case the setting is in disabled mode, you can enable it by passing ```--enable-sriov``` ```bash badfish -H mgmt-your-server.example.com --enable-sriov @@ -580,12 +633,16 @@ To get a list of all FQDDs for all NICs on the server you can run badfish with ` ```bash badfish -H mgmt-your-server.example.com --get-nic-fqdds ``` +> [!NOTE] +> This is only supported on Dell devices. ### Get NIC attributes To get a list of all NIC attributes we can potentially modify (some might be set as read-only), you can run badfish with ```--get-nic-attribute``` passing the desired FQDD and this will return a list off all NIC attributes with their current value set. ```bash badfish -H mgmt-your-server.example.com --get-nic-attribute NIC.Integrated.1-1-1 ``` +> [!NOTE] +> This is only supported on Dell devices. ### Set NIC attribute > [!WARNING] @@ -595,6 +652,8 @@ To change the value of a NIC attribute you can use ```--set-nic-attribute``` wit ```bash badfish -H mgmt-your-server.example.com --set-nic-attribute NIC.Integrated.1-1-1 --attribute LegacyBootProto --value PXE ``` +> [!NOTE] +> This is only supported on Dell devices. ### Get BIOS attributes To get a list of all BIOS attributes we can potentially modify (some might be set as read-only), you can run badfish with ```--get-bios-attribute``` alone and this will return a list off all BIOS attributes with their current value set. @@ -636,7 +695,7 @@ badfish -H mgmt-your-server.example.com --get-bios-attribute --attribute BootMod ```bash badfish -H mgmt-your-server.example.com --set-bios-attribute --attribute BootMode --value Uefi ``` -### Setting BIOS mode +#### Setting BIOS mode ```bash badfish -H mgmt-your-server.example.com --set-bios-attribute --attribute BootMode --value Bios ``` @@ -650,11 +709,13 @@ If you would like to get a screenshot with the current state of the server you c ```bash badfish -H mgmt-your-server.example.com --screenshot ``` +> [!NOTE] +> This is only supported on Dell devices. ### Targets for server configuration profile -If you want to get a list of allowed targets for SCP export or import you can get that with the `--get-scp-targets` command, takes either `Export` or `Import` as an argument. +If you want to get a list of allowed targets for SCP export or import you can get that with the `--get-scp-targets` command, pass either `Export` or `Import` as an argument. ``` -badfish -H mgmt-your-server.example.com --get-scp-targets (Export | Import) +badfish -H mgmt-your-server.example.com --get-scp-targets Export ``` > [!NOTE] > This is only supported on Dell devices. @@ -683,7 +744,9 @@ badfish --host-list /tmp/bad-hosts --clear-jobs ``` ### Verbose output -If you would like to see a more detailed output on console you can use the ```--verbose``` option and get a additional debug logs. > [!NOTE] this is the default log level for the ```--log``` argument. +If you would like to see a more detailed output on console you can use the ```--verbose``` option and get a additional debug logs. +> [!NOTE] +> `--log` uses the same log level: INFO by default, DEBUG with `--verbose`. ```bash badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml -t foreman --verbose ``` @@ -703,7 +766,7 @@ If you would like to easier query some information listed by badfish, you can te - `--check-virtual-media` - `--power-state`. ```bash -badfish -H mgmt-your-server.example.com --output json/yaml --firmware-inventory +badfish -H mgmt-your-server.example.com --output json --firmware-inventory ``` ### Redfish emulator (mock iDRAC) @@ -715,7 +778,7 @@ Run the emulator as a persistent server: badfish --redfish-emulator --port 8443 ``` -It serves HTTPS on `127.0.0.1:8443` using a self-signed certificate generated on first run (never committed to the repo or shipped in the wheel/RPM; see `src/badfish/emulator/certs/README.md`). Point a second badfish instance at it like any BMC, and pass `--insecure` to skip certificate verification, the self-signed cert will not validate otherwise: +It serves HTTPS on `127.0.0.1:8443` using a self-signed certificate generated on first run (never committed to the repo or shipped in the wheel/RPM; see `src/badfish/emulator/certs/README.md`). The keypair lives in `$XDG_CACHE_HOME/badfish/emulator/` by default; point `BADFISH_EMULATOR_CERTS` at a directory containing `emulator.crt`/`emulator.key` (or where they should be created) to relocate it. Point a second badfish instance at the emulator like any BMC, and pass `--insecure` to skip certificate verification, the self-signed cert will not validate otherwise: ```bash badfish -H 127.0.0.1:8443 -u quads -p quads --insecure --power-state @@ -733,7 +796,7 @@ Currently covered: session/token auth, user and account management with role-bas > [!NOTE] > This is a development tool, not a security boundary. The certificate (generated at runtime, unique per install) and default credentials exist so CI and laptops can spin up a mock iDRAC with zero setup. -Resource templates live in `src/badfish/emulator/templates/` and are served by URI path, the same store that vendor mockup bundles (for example DMTF DSP2043) can feed as fetch support for Dell and SuperMicro trees lands. +Resource templates live in `src/badfish/emulator/templates/` and are served by URI path. The emulator does not fetch vendor mockup bundles, for example DMTF DSP2043; templates are loaded from that directory at startup. ## iDRAC and Data Format @@ -768,6 +831,12 @@ Additionally we can do a blade only override like: ``` With rack, ULocation and blade being optional in a hierarchical fashion otherwise mandatory with the exception of the blade, as we can now use the blade independently from rack and ULocation. host_type and model values are always mandatory. +By default the rack, ULocation and blade components are parsed out of the hostname (format `mgmt-{rack}-{uloc}-{blade}-{model}`). You can override them on the command line with `--rack`, `--uloc` and `--blade` when the hostname doesn't follow that layout: +```bash +badfish -H mgmt-f21-h17-000-r620.example.com -i config/idrac_interfaces.yml -t director --blade 001 +``` +The `--blade` override produces a blade only key (`director_001_r620` in the example), while `--rack` and `--uloc` override the corresponding components when resolving hierarchical keys. + #### Example for director type overrides: | Keys defined on interfaces yaml | FQDN | Use boot order | @@ -799,5 +868,5 @@ Please refer to our contributing [guide](CONTRIBUTING.md). ## Contact * You can find us on IRC in `#badfish` (or `#quads`) on `irc.libera.chat` if you have questions or need help. -* [Click here](https://https://web.libera.chat/?channels=#quads) to join in your browser. +* [Click here](https://web.libera.chat/?channels=#quads) to join in your browser. From 513a4df8d8fbe367108241729477a00453a80a8a Mon Sep 17 00:00:00 2001 From: Will Foster Date: Fri, 11 Sep 2026 09:41:10 +0100 Subject: [PATCH 34/36] fix: retry quay pulls to handle transient CDN EOFs Add --retry=5 --retry-delay=15s to the podman build and push commands in the production release workflow so transient Quay CDN connection failures no longer fail releases. fixes: https://github.com/quadsproject/badfish/issues/582 --- .github/workflows/production-release.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml index ed4b437..28fef33 100644 --- a/.github/workflows/production-release.yml +++ b/.github/workflows/production-release.yml @@ -136,10 +136,11 @@ jobs: - name: Build and Push run: | # Added --no-cache to ensure fresh layers - podman build --no-cache -t quay.io/quads/badfish:master . + # Retry pulls/pushes to ride out transient Quay CDN EOFs + podman build --no-cache --retry=5 --retry-delay=15s -t quay.io/quads/badfish:master . podman tag quay.io/quads/badfish:master quay.io/quads/badfish:latest - podman push quay.io/quads/badfish:master - podman push quay.io/quads/badfish:latest + podman push --retry=5 --retry-delay=15s quay.io/quads/badfish:master + podman push --retry=5 --retry-delay=15s quay.io/quads/badfish:latest # ------------------------------------------------------------------ # JOB 4: AUR PUBLISH From 3e472f96827010c86a8be5477d4d695c3fa2c37a Mon Sep 17 00:00:00 2001 From: bspreston-esq <329959535+bspreston-esq@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:14:55 -0400 Subject: [PATCH 35/36] docs: fix typos and inaccuracies in README and CONTRIBUTING Fix spelling and grammar errors in README, CONTRIBUTING and the issue/PR templates, modernize stale GitHub documentation links, and correct inaccurate claims (Redfish IPMI 2.0 requirement wording, --get-power-consumed vendor scope, nonexistent 'Ready for review' template, Docs team reviewer). No functional changes; documentation only. --- .github/ISSUE_TEMPLATE/bug_report_or_issue.md | 2 +- .../pull_request_template.md | 2 +- CONTRIBUTING.md | 18 ++++---- README.md | 44 +++++++++---------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report_or_issue.md b/.github/ISSUE_TEMPLATE/bug_report_or_issue.md index 4e155b5..8dc1f6a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_or_issue.md +++ b/.github/ISSUE_TEMPLATE/bug_report_or_issue.md @@ -11,7 +11,7 @@ assignees: '' * Python Version: * Operating System: -* Target System Type: (_e.g. Dell, SuperMicro_) +* Target System Type: (_e.g. Dell, Supermicro_) * IPMI / Out-of-band Firmware Version: (_e.g. iDRAC 8 2.60.60.60) **Describe the bug** diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md index cd0d295..2a8ff4e 100644 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -8,7 +8,7 @@ ### New Feature Submissions: 1. [ ] Does your submission pass tests? -2. [ ] Have you lint your code locally before submission? +2. [ ] Have you linted your code locally before submission? ### Changes to Core Features: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df5397e..e54bab2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,14 +13,14 @@ To get an overview of the project, read the [README](README.md). Here are some r - [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github) - [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git) - [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow) -- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests) +- [Collaborating with pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests) ## Getting started We love pull requests and welcome contributions from everyone! Please use the `development` branch to send pull requests. Here are the general steps you'd want to follow. -1. Fork the Badfish Github repository +1. Fork the Badfish GitHub repository 2. Clone the forked repository 3. Push your changes to your forked clone 4. Open a pull request against our `development` branch. @@ -29,7 +29,7 @@ We love pull requests and welcome contributions from everyone! Please use the ` #### Create a new issue -If you spot a problem with badfish, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue using a relevant [issue form](https://github.com/quadsproject/badfish/issues/new/choose). +If you spot a problem with badfish, [search if an issue already exists](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue using a relevant [issue form](https://github.com/quadsproject/badfish/issues/new/choose). #### Solve an issue @@ -41,10 +41,10 @@ Scan through our [existing issues](https://github.com/quadsproject/badfish/issue 1. Fork the repository. - Using the command line: - - [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. + - [Fork the repo](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. - GitHub Codespaces: - - [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally. + - [Fork, edit, and preview](https://docs.github.com/en/codespaces/quickstart) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally. 2. Create a working branch and start with your changes! @@ -55,11 +55,11 @@ Commit the changes once you are happy with them. See [Atom's contributing guide] ### Pull Request When you're finished with the changes, create a pull request, also known as a PR. -- Fill the "Ready for review" template so that we can review your PR. This template helps reviewers understand your changes as well as the purpose of your pull request. +- Fill in the pull request template so that we can review your PR. This template helps reviewers understand your changes as well as the purpose of your pull request. - Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one. -- Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. -Once you submit your PR, a Docs team member will review your proposal. We may ask questions or request for additional information. -- We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. +- Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. +Once you submit your PR, a Badfish team member will review your proposal. We may ask questions or request for additional information. +- We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. - As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). - If you run into any merge issues, checkout this [git tutorial](https://lab.github.com/githubtraining/managing-merge-conflicts) to help you resolve merge conflicts and other issues. diff --git a/README.md b/README.md index 2da8fea..c802109 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,9 @@ Badfish is a Redfish-based API tool for managing bare-metal systems via the [Red You can read more [about badfish](https://quads.dev/about-badfish/) at the [QUADS](https://quads.dev/) website. ## Scope -Right now Badfish is focused on managing Dell, SuperMicro and HPE systems, but can potentially work with any system that supports the Redfish API. Functionality may vary depending on the vendor Redfish implementation with Dell systems having the most functionality. +Right now Badfish is focused on managing Dell, Supermicro and HPE systems, but can potentially work with any system that supports the Redfish API. Functionality may vary depending on the vendor Redfish implementation with Dell systems having the most functionality. -We're mostly concentrated on programmatically enforcing interface/device boot order to accommodate [TripleO](https://docs.openstack.org/tripleo-docs/latest/) based [OpenStack](https://www.openstack.org/) and [OpenShift](https://www.openshift.com/) deployments while simultaneously allowing easy management and provisioning of those same systems via [The Foreman](https://theforeman.org/). Badfish can be useful as a general standalone, unified vendor IPMI/OOB tool however as support for more vendors is added. +We're mostly concentrated on programmatically enforcing interface/device boot order to accommodate [TripleO](https://docs.openstack.org/tripleo-docs/latest/) based [OpenStack](https://www.openstack.org/) and [OpenShift](https://www.openshift.com/) deployments while simultaneously allowing easy management and provisioning of those same systems via [The Foreman](https://theforeman.org/). Badfish can be useful as a general standalone, unified vendor IPMI/OOB tool, with support for more vendors added over time. ## Features * Toggle and save a persistent interface/device boot order on remote system @@ -138,10 +138,10 @@ We're mostly concentrated on programmatically enforcing interface/device boot or ## Requirements * (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 +* Any Redfish implementation on non-Dell systems * BMC administrative account * Python >= ```3.10``` or [podman](https://podman.io/getting-started/installation) as a container. -* python3-devel >= ```3.10``` (If using standalone or RPM package below). +* python3-devel for Python >= ```3.10``` (if using the standalone install or RPM package below). ## Setup ### Badfish RPM package @@ -265,10 +265,10 @@ BADFISH_USERNAME=my_username BADFISH_PASSWORD=my_password \ > If you want to run any actions that would have output files like `--screenshot` you can map the container root volume to a directory on your local machine where you would like to have those files stored like `-v /tmp/screens:/badfish:Z` > [!IMPORTANT] -> When mapping a volume to a container make sure to use the `:Z` suffix for appropiate labeling +> When mapping a volume to a container make sure to use the `:Z` suffix for appropriate labeling ### Via Virtualenv -[Virtualenv](https://docs.python.org/3/library/venv.html) is a wonderful tool to sandbox running Python applications or to separate Python versions of components from your main system libaries. Unfortunately it can be problematic with running Badfish directly from the Git repo inside a virtualenv sandbox. +[Virtualenv](https://docs.python.org/3/library/venv.html) is a wonderful tool to sandbox running Python applications or to separate Python versions of components from your main system libraries. Unfortunately it can be problematic with running Badfish directly from the Git repo inside a virtualenv sandbox. While we strongly recommend using the [podman](#via-podman) method of calling Badfish inside a virtual environment you can still do it directly from the repository via virtualenv but you would need to prepend the call to Badfish with the setting of the `PYTHONPATH` environment variable pointing at the path of your Badfish repository. @@ -378,7 +378,7 @@ badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml --boot-t **Note** `--boot-to`, `--boot-to-type`, and `--boot-to-mac` require you to manually perform a reboot action, these simply just batch what the system will boot to on the next boot. For this you can use either `--power-cycle` or `--reboot-only`. ### Forcing a one-time boot to PXE -To force systems to perform a one-time boot to PXE, simply pass the `--pxe` flag to any of the commands above, by default it will pxe off the first available device for PXE booting. This is equivalent to the ipmitool command `chassis bootdev pxe options=persistent` and should be used with SuperMicro/HPE systems or non-Dell systems that support a minimal IPMI 2.0 specification. +To force systems to perform a one-time boot to PXE, simply pass the `--pxe` flag to any of the commands above, by default it will pxe off the first available device for PXE booting. This is equivalent to the ipmitool command `chassis bootdev pxe options=persistent` and should be used with Supermicro/HPE systems or non-Dell systems that support a minimal IPMI 2.0 specification. For Dell systems please use either `--boot-to`, `--boot-to-mac` or `--boot-to-type` for temporary PXE to a specific interface or change the boot order permanently to achieve your desired effect. ```bash @@ -414,7 +414,7 @@ Partial Output: ``` ### Get Power Consumed -This displays the current power usage for Dell / Supermicro server(s). +This displays the current power usage of the server. ```bash badfish -H mgmt-your-server.example.com --get-power-consumed ``` @@ -428,7 +428,7 @@ For the replacement of `racadm racreset`, the optional argument `--racreset` was ```bash badfish -H mgmt-your-server.example.com --racreset ``` -* You can also specify `--racreset --wait` and Badfish will poll the iDrac for it to complete and keep you updated on progress. +* You can also specify `--racreset --wait` and Badfish will poll the iDRAC for it to complete and keep you updated on progress. > [!NOTE] @@ -464,7 +464,7 @@ badfish -H mgmt-your-server.example.com --toggle-boot-device NIC.Integrated.1-3- ``` ### Variable number of retries -At certain points during the execution of ```badfish``` the program might come across a non responsive resources and will automatically retry to establish connection. We have included a default value of 30 retries after failed attempts but this can be customized via the ```--retries``` optional argument which takes as input an integer with the number of desired retries. +At certain points during the execution of ```badfish``` the program might come across non-responsive resources and will automatically retry to establish connection. We have included a default value of 30 retries after failed attempts but this can be customized via the ```--retries``` optional argument which takes as input an integer with the number of desired retries. ```bash badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml -t foreman --retries 20 ``` @@ -494,7 +494,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 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. +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 queries for all active jobs in the job queue and posts a request to clear the queue. ```bash badfish -H mgmt-your-server.example.com --clear-jobs ``` @@ -506,13 +506,13 @@ 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 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. +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 queries for all active jobs in the job queue and returns a list with all active items. ```bash badfish -H mgmt-your-server.example.com --ls-jobs ``` ### Check Job Status -If you would like to the status of an existing LifeCycle controller job you can run ```badfish``` with the ```--check-job``` option and passing the job id which can be obtained via ```--ls-jobs```. This will return a detail of the specific job with status and percentage of completion. +If you would like to check the status of an existing Lifecycle Controller job you can run ```badfish``` with the ```--check-job``` option and passing the job id which can be obtained via ```--ls-jobs```. This will return a detail of the specific job with status and percentage of completion. ```bash badfish -H mgmt-your-server.example.com --check-job JID_340568202796 ``` @@ -560,19 +560,19 @@ badfish -H mgmt-your-server.example.com --ls-serial ``` ### Check Virtual Media -If you would like to check for any active virtual media you can run ```badfish``` with the ```--check-virtual-media``` option which query for all active virtual devices. +If you would like to check for any active virtual media you can run ```badfish``` with the ```--check-virtual-media``` option which queries for all active virtual devices. ```bash badfish -H mgmt-your-server.example.com --check-virtual-media ``` ### Mount Virtual Media -If you would like to mount an ISO from network you can run ```badfish``` with the ```--mount-virtual-media``` option which post a request for mounting the ISO virtual media (Virtual CD). Full address to the ISO is needed as an argument. +If you would like to mount an ISO from network you can run ```badfish``` with the ```--mount-virtual-media``` option which posts a request for mounting the ISO virtual media (Virtual CD). Full address to the ISO is needed as an argument. ```bash badfish -H mgmt-your-server.example.com --mount-virtual-media http://storage.example.com/folder/linux.iso ``` ### Unmount Virtual Media -If you would like to unmount all active virtual media you can run ```badfish``` with the ```--unmount-virtual-media``` option which post a request for unmounting all active virtual devices. +If you would like to unmount all active virtual media you can run ```badfish``` with the ```--unmount-virtual-media``` option which posts a request for unmounting all active virtual devices. ```bash badfish -H mgmt-your-server.example.com --unmount-virtual-media ``` @@ -738,13 +738,13 @@ badfish -H mgmt-your-server.example.com --import-scp "./example_export.json" --s > This is only supported on Dell devices. ### Bulk actions via text file with list of hosts -In the case you would like to execute a common badfish action on a list of hosts, you can pass the optional argument ```--host-list``` in place of ```-H``` with the path to a text file with the hosts you would like to action upon and any addtional arguments defining a common action for all these hosts. +In the case you would like to execute a common badfish action on a list of hosts, you can pass the optional argument ```--host-list``` in place of ```-H``` with the path to a text file with the hosts you would like to action upon and any additional arguments defining a common action for all these hosts. ```bash badfish --host-list /tmp/bad-hosts --clear-jobs ``` ### Verbose output -If you would like to see a more detailed output on console you can use the ```--verbose``` option and get a additional debug logs. +If you would like to see a more detailed output on console you can use the ```--verbose``` option and get additional debug logs. > [!NOTE] > `--log` uses the same log level: INFO by default, DEBUG with `--verbose`. ```bash @@ -752,7 +752,7 @@ badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml -t forem ``` ### Log to file -If you would like to log the output of ```badfish``` you can use the ```--log``` option and pass the path to where you want ```badfish``` to log it's output to. +If you would like to log the output of ```badfish``` you can use the ```--log``` option and pass the path to where you want ```badfish``` to log its output to. ```bash badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml -t foreman --log /tmp/bad.log ``` @@ -807,7 +807,7 @@ Your usage may vary, this is what our configuration looks like via ```config/idr | Machine Type | Network Interface | | ------------ | ----------------------:| -| Dell fc640 | NIC.Integrated.1-1-1 | +| Dell FC640 | NIC.Integrated.1-1-1 | | Dell r620 | NIC.Integrated.1-3-1 | | Dell r630 | NIC.Slot.2-1-1 | | Dell r930 | NIC.Integrated.1-3-1 | @@ -862,8 +862,8 @@ The `--blade` override produces a blade only key (`director_001_r620` in the exa Please refer to our contributing [guide](CONTRIBUTING.md). * Here is some useful documentation - - [Creating a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request) - - [Keeping a cloned fork up to date](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) + - [Creating a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) + - [Keeping a cloned fork up to date](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) ## Contact From 36e4b62bd77f74053aa5da71ac08277302ce6918 Mon Sep 17 00:00:00 2001 From: bspreston-esq <329959535+bspreston-esq@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:59:51 -0400 Subject: [PATCH 36/36] docs: address review feedback on PR #587 Address reviewer feedback (sadsfae): - README: narrow python3-devel requirement to only pip source builds (packager-side BuildRequires otherwise) - README: restore lowercase fc640 in the machine table for consistency with the other rows and config key style - README: replace 'minimal IPMI 2.0 specification' with 'Redfish boot source override' (badfish is Redfish-only) - CONTRIBUTING: modernize the last old-scheme docs link - CONTRIBUTING: drop redundant 'for' in 'request for additional' --- CONTRIBUTING.md | 4 ++-- README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e54bab2..1127c5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,9 +58,9 @@ When you're finished with the changes, create a pull request, also known as a PR - Fill in the pull request template so that we can review your PR. This template helps reviewers understand your changes as well as the purpose of your pull request. - Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one. - Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. -Once you submit your PR, a Badfish team member will review your proposal. We may ask questions or request for additional information. +Once you submit your PR, a Badfish team member will review your proposal. We may ask questions or request additional information. - We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. -- As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). +- As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#resolving-conversations). - If you run into any merge issues, checkout this [git tutorial](https://lab.github.com/githubtraining/managing-merge-conflicts) to help you resolve merge conflicts and other issues. ### Your PR is merged! diff --git a/README.md b/README.md index c802109..d03156a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ We're mostly concentrated on programmatically enforcing interface/device boot or * Any Redfish implementation on non-Dell systems * BMC administrative account * Python >= ```3.10``` or [podman](https://podman.io/getting-started/installation) as a container. -* python3-devel for Python >= ```3.10``` (if using the standalone install or RPM package below). +* python3-devel (only needed if pip has to compile a dependency from source; see the note under Badfish Standalone CLI) ## Setup ### Badfish RPM package @@ -378,7 +378,7 @@ badfish -H mgmt-your-server.example.com -i config/idrac_interfaces.yml --boot-t **Note** `--boot-to`, `--boot-to-type`, and `--boot-to-mac` require you to manually perform a reboot action, these simply just batch what the system will boot to on the next boot. For this you can use either `--power-cycle` or `--reboot-only`. ### Forcing a one-time boot to PXE -To force systems to perform a one-time boot to PXE, simply pass the `--pxe` flag to any of the commands above, by default it will pxe off the first available device for PXE booting. This is equivalent to the ipmitool command `chassis bootdev pxe options=persistent` and should be used with Supermicro/HPE systems or non-Dell systems that support a minimal IPMI 2.0 specification. +To force systems to perform a one-time boot to PXE, simply pass the `--pxe` flag to any of the commands above, by default it will pxe off the first available device for PXE booting. This is equivalent to the ipmitool command `chassis bootdev pxe options=persistent` and should be used with Supermicro/HPE systems or non-Dell systems that support the Redfish boot source override. For Dell systems please use either `--boot-to`, `--boot-to-mac` or `--boot-to-type` for temporary PXE to a specific interface or change the boot order permanently to achieve your desired effect. ```bash @@ -807,7 +807,7 @@ Your usage may vary, this is what our configuration looks like via ```config/idr | Machine Type | Network Interface | | ------------ | ----------------------:| -| Dell FC640 | NIC.Integrated.1-1-1 | +| Dell fc640 | NIC.Integrated.1-1-1 | | Dell r620 | NIC.Integrated.1-3-1 | | Dell r630 | NIC.Slot.2-1-1 | | Dell r930 | NIC.Integrated.1-3-1 |