🐛 Fix interface detection when MAC/IP matches multiple interfaces#935
Conversation
9429b46 to
f62ce29
Compare
|
/test metal3-centos-e2e-integration-test-main metal3-ubuntu-e2e-integration-test-main |
|
This will supersede #788 as well. |
There was a problem hiding this comment.
Pull request overview
This PR fixes incorrect interface detection when the same MAC/IP matches multiple interfaces (e.g., a physical NIC enslaved to a bridge sharing its MAC), replacing fragile bash parsing with a structured ip -json -detail based Python implementation.
Changes:
- Replace bash text-parsing in
get_provisioning_interface()/get_interface_of_ip()with calls to a new Python helper script. - Add
scripts/detect_interface.pyto select a single “best” interface for MAC-based detection and the first match for IP-based detection. - Add unit tests for the detection logic and a GitHub Actions workflow to run them on PRs.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
scripts/ironic-common.sh |
Switches interface detection helpers to invoke detect_interface.py. |
scripts/detect_interface.py |
Implements JSON-based interface matching and selection logic using ip -json -detail. |
tests/test_detect_interface.py |
Adds unit tests covering helper functions, selection logic, and CLI behavior. |
.github/workflows/unit-tests.yml |
Adds a PR workflow to run the Python unit tests. |
.gitignore |
Ignores common Python test/cache artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
yes, sorry about that but I was tired of bash :D |
f62ce29 to
75cceb2
Compare
|
/test metal3-centos-e2e-integration-test-main metal3-ubuntu-e2e-integration-test-main |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
When a physical interface is enslaved to a bridge (e.g. OVN-Kubernetes), both the physical interface and the bridge share the same MAC address. The bash text-parsing pipelines in get_provisioning_interface() and get_interface_of_ip() would return multi-line values like "eno12399\nbr-ex", which is not a valid interface name and causes ironic to fail to start. Replace both functions with a Python script (detect_interface.py) that uses `ip -json -detail` for structured output and correctly selects a single interface when multiple match. For MAC-based detection, the selection prefers the interface that already carries an IP address (important for dnsmasq binding), then non-bridge interfaces. For IP-based detection, only the first match is returned. The script is invoked via two subcommands: - default / interface-of-mac: MAC-based detection from PROVISIONING_MACS - interface-of-ip <addr> [4|6]: IP-based detection Input validation is enforced at both layers: the bash wrappers reject invalid arguments before invoking Python, and the Python functions validate subcommands and IP version independently. Unit tests are added in tests/test_detect_interface.py covering all selection branches, and a GitHub Actions workflow runs them on every PR. Assisted-By: Claude 4.6 Opus High (Commercial License) Signed-off-by: Riccardo Pittau <elfosardo@gmail.com>
75cceb2 to
bb5a5fa
Compare
|
/test metal3-centos-e2e-integration-test-main metal3-ubuntu-e2e-integration-test-main |
Thats fine, that Bash was growing out of hand. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| sys.exit(1) | ||
| ip_addr: str = sys.argv[2] | ||
| ip_version: str | None = sys.argv[3] if len(sys.argv) > 3 else None | ||
| print(find_by_ip(ip_addr, ip_version)) | ||
| else: |
There was a problem hiding this comment.
The CLI currently accepts and silently ignores extra positional arguments (e.g., interface-of-ip <addr> 4 extra or interface-of-mac <macs> extra). This can mask invocation bugs and makes the tool harder to use/debug. Consider validating the exact argc per subcommand and exiting with a non-zero status plus usage when too many args are provided.
| sys.exit(1) | |
| ip_addr: str = sys.argv[2] | |
| ip_version: str | None = sys.argv[3] if len(sys.argv) > 3 else None | |
| print(find_by_ip(ip_addr, ip_version)) | |
| else: | |
| sys.exit(1) | |
| if len(sys.argv) > 4: | |
| print(f"ERROR: too many arguments for interface-of-ip\n{_USAGE}", | |
| file=sys.stderr) | |
| sys.exit(1) | |
| ip_addr: str = sys.argv[2] | |
| ip_version: str | None = sys.argv[3] if len(sys.argv) > 3 else None | |
| print(find_by_ip(ip_addr, ip_version)) | |
| else: | |
| if len(sys.argv) > 3: | |
| print(f"ERROR: too many arguments for interface-of-mac\n{_USAGE}", | |
| file=sys.stderr) | |
| sys.exit(1) |
There was a problem hiding this comment.
It starts to smell like argparse :)
There was a problem hiding this comment.
heh yeah
I see the point but maybe we just keep this simple for now and migrate to argparse later
it's just used internally anyway
| sys.exit(1) | ||
| ip_addr: str = sys.argv[2] | ||
| ip_version: str | None = sys.argv[3] if len(sys.argv) > 3 else None | ||
| print(find_by_ip(ip_addr, ip_version)) |
There was a problem hiding this comment.
find_by_ip() can raise ValueError for an invalid IP version, but main() doesn't catch it, so users get a Python traceback instead of a consistent CLI error/usage message. Suggest catching ValueError around the find_by_ip() call, printing a concise error + usage to stderr, and exiting 1.
| print(find_by_ip(ip_addr, ip_version)) | |
| try: | |
| print(find_by_ip(ip_addr, ip_version)) | |
| except ValueError as exc: | |
| print(f"ERROR: {exc}\n{_USAGE}", file=sys.stderr) | |
| sys.exit(1) |
| class TestMain(unittest.TestCase): | ||
|
|
||
| @mock.patch.object(detect_interface, "detect_provisioning_interface", | ||
| return_value="eth0") | ||
| def test_default_subcommand(self, _mock): | ||
| with mock.patch("sys.argv", ["detect_interface.py"]): | ||
| with mock.patch("builtins.print") as mock_print: | ||
| detect_interface.main() | ||
| mock_print.assert_called_once_with("eth0") | ||
|
|
||
| @mock.patch.object(detect_interface, "find_by_ip", return_value="eno1") | ||
| def test_interface_of_ip_subcommand(self, mock_find): | ||
| with mock.patch("sys.argv", | ||
| ["detect_interface.py", "interface-of-ip", | ||
| "10.0.0.1", "4"]): | ||
| with mock.patch("builtins.print") as mock_print: | ||
| detect_interface.main() | ||
| mock_find.assert_called_once_with("10.0.0.1", "4") | ||
| mock_print.assert_called_once_with("eno1") | ||
|
|
||
| def test_interface_of_ip_missing_addr_exits(self): | ||
| with mock.patch("sys.argv", | ||
| ["detect_interface.py", "interface-of-ip"]): | ||
| with self.assertRaises(SystemExit) as ctx: | ||
| detect_interface.main() | ||
| self.assertEqual(ctx.exception.code, 1) | ||
|
|
||
| @mock.patch.object(detect_interface, "detect_provisioning_interface", | ||
| return_value="eth0") | ||
| def test_explicit_interface_of_mac_with_arg(self, mock_detect): | ||
| with mock.patch("sys.argv", | ||
| ["detect_interface.py", "interface-of-mac", | ||
| "aa:bb:cc:dd:ee:ff"]): | ||
| with mock.patch("builtins.print") as mock_print: | ||
| detect_interface.main() | ||
| mock_detect.assert_called_once_with("aa:bb:cc:dd:ee:ff") | ||
| mock_print.assert_called_once_with("eth0") | ||
|
|
||
| @mock.patch.object(detect_interface, "detect_provisioning_interface", | ||
| return_value="eth0") | ||
| def test_explicit_interface_of_mac_no_arg(self, mock_detect): | ||
| with mock.patch("sys.argv", | ||
| ["detect_interface.py", "interface-of-mac"]): | ||
| with mock.patch("builtins.print") as mock_print: | ||
| detect_interface.main() | ||
| mock_detect.assert_called_once_with(None) | ||
| mock_print.assert_called_once_with("eth0") | ||
|
|
||
| def test_unknown_subcommand_exits(self): | ||
| with mock.patch("sys.argv", | ||
| ["detect_interface.py", "interface-of-Ip"]): | ||
| with self.assertRaises(SystemExit) as ctx: | ||
| detect_interface.main() | ||
| self.assertEqual(ctx.exception.code, 1) | ||
|
|
||
| def test_garbage_argument_exits(self): | ||
| with mock.patch("sys.argv", | ||
| ["detect_interface.py", "foobar"]): | ||
| with self.assertRaises(SystemExit) as ctx: | ||
| detect_interface.main() | ||
| self.assertEqual(ctx.exception.code, 1) | ||
|
|
There was a problem hiding this comment.
CLI tests cover unknown subcommands and missing interface-of-ip address, but there’s no test for the user-facing behavior when an invalid IP version is provided (e.g. interface-of-ip 10.0.0.1 5) or when extra args are passed. Adding tests for these cases would prevent regressions once main() is tightened to validate args / handle ValueError cleanly.
|
I'm going to take note of all the copilot comments for a possible follow up, I think I'm ok with this for the time being, unless someone objects |
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: Rozzii The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
When a physical interface is enslaved to a bridge (e.g. OVN-Kubernetes), both the physical interface and the bridge share the same MAC address. The bash text-parsing pipelines in get_provisioning_interface() and get_interface_of_ip() would return multi-line values like "eno12399\nbr-ex", which is not a valid interface name and causes ironic to fail to start.
Replace both functions with a Python script (detect_interface.py) that uses
ip -json -detailfor structured output and correctly selects a single interface when multiple match. For MAC-based detection, the selection prefers the interface that already carries an IP address (important for dnsmasq binding), then non-bridge interfaces. For IP-based detection, only the first match is returned.The script is invoked via two subcommands:
Unit tests are added in tests/test_detect_interface.py covering all selection branches, and a GitHub Actions workflow runs them on every PR.
Assisted-By: Claude 4.6 Opus High (Commercial License)