diff --git a/changelog/64584.fixed.md b/changelog/64584.fixed.md new file mode 100644 index 000000000000..66229a27a21f --- /dev/null +++ b/changelog/64584.fixed.md @@ -0,0 +1 @@ +Fix ``KeyError`` in the ``network_settings`` beacon when a new network interface appears after beacon initialization. diff --git a/salt/beacons/network_settings.py b/salt/beacons/network_settings.py index 2badc314beab..bed9493fd785 100644 --- a/salt/beacons/network_settings.py +++ b/salt/beacons/network_settings.py @@ -221,6 +221,8 @@ def beacon(config): log.debug("interfaces %s", interfaces) for interface in interfaces: _send_event = False + if interface not in LAST_STATS: + LAST_STATS[interface] = set() _diff_stats = _stats[interface] - LAST_STATS[interface] _ret_diff = {} interface_config = _config["interfaces"][interface] diff --git a/tests/pytests/unit/beacons/test_network_settings.py b/tests/pytests/unit/beacons/test_network_settings.py index 429035b9155b..f65156f4ec16 100644 --- a/tests/pytests/unit/beacons/test_network_settings.py +++ b/tests/pytests/unit/beacons/test_network_settings.py @@ -151,6 +151,52 @@ def test_wildcard_interface(): assert ret == _expected +def test_ephemeral_interface_no_keyerror(): + """ + Beacon must not raise KeyError when a new interface appears between calls. + + LAST_STATS is seeded from the first beacon() call. If an interface comes + up afterwards (ephemeral bridge, WireGuard tunnel, hotplug NIC), it will + be present in _stats but absent from LAST_STATS. Without the guard added + in #64584 the set-difference ``_stats[iface] - LAST_STATS[iface]`` raises + a KeyError. + """ + config = [{"interfaces": {"eth*": {"promiscuity": None}}}] + + # First call: only eth0 exists. + INITIAL_STATS = network_settings._copy_interfaces_info( + {"eth0": {"family": "0", "promiscuity": "0", "group": "0"}} + ) + # Second call: eth1 has appeared (ephemeral addition). + AFTER_STATS = network_settings._copy_interfaces_info( + { + "eth0": {"family": "0", "promiscuity": "0", "group": "0"}, + "eth1": {"family": "0", "promiscuity": "0", "group": "0"}, + } + ) + + ret = network_settings.validate(config) + assert ret == (True, "Valid beacon configuration") + + with patch.object(network_settings, "LAST_STATS", {}), patch.object( + network_settings, "IP", MockIPClass + ), patch( + "salt.beacons.network_settings._copy_interfaces_info", + MagicMock(side_effect=[INITIAL_STATS, AFTER_STATS]), + ): + # First call seeds LAST_STATS with eth0 only; no events expected. + ret = network_settings.beacon(config) + assert ret == [] + + # Second call: eth1 is new — must not raise KeyError; eth1 fires an + # event because all of its attrs are "new" relative to the empty seed. + ret = network_settings.beacon(config) + assert isinstance(ret, list) + assert not any(isinstance(item, Exception) for item in ret) + tags = [item["tag"] for item in ret] + assert "eth1" in tags + + @pytest.mark.skipif(HAS_IPDB is False, reason="pyroute2.IPDB not available, skipping") def test_interface_dict_fields_old(): with IPDB() as ipdb: