Repository: cobbler/cobbler

Issue:
PXE Boot menu never includes profiles
### Describe the bug

When attempting to use Grub and UEFI, the PXE menu presented for a host with no system record is always blank. The only available option is to use "local" boot. 

### Steps to reproduce

1. Install the latest main branch version of cobbler (as of 17/04/2023)
2. Configure "server:" settings
3. Import a distro
4. Restart cobblerd and run cobbler sync
5. Observe that the files under tftpboot/grub are blank. (files like x86_64_menu_items.cfg)

### Expected behavior

Cobbler generates PXE menu entries for each profile, split up by architecture. In the previous example, I would expect "x86_64_menu_items.cfg" to include an entry for the profile I setup.

### Cobbler version

<!--- Paste output from `cobbler version` -->
````
Cobbler 3.4.0
         source: ?, ?
         build time: Thu Apr 13 20:01:39 2023
````

### Operating system
Rocky 8

### Cobbler log

N/A

### Screenshots

N/A

### Additional information

It would appear that this problem is caused by an oversight in the profile module. When we go to generate PXE menu entries, we call `api.find_profile()` which eventually leads to the following section in item:
````
def find_match_single_key(
        self, data: Dict[str, Any], key: str, value: Any, no_errors: bool = False
    ) -> bool:
        """
        Look if the data matches or not. This is an alternative for ``find_match()``.
        :param data: The data to search through.
        :param key: The key to look for int the item.
        :param value: The value for the key.
        :param no_errors: How strict this matching is.
        :return: Whether the data matches or not.
        """
        # special case for systems
        key_found_already = False
        if "interfaces" in data:
            if key in [
                "cnames",
                "connected_mode",
                "if_gateway",
                "ipv6_default_gateway",
                "ipv6_mtu",
                "ipv6_prefix",
                "ipv6_secondaries",
                "ipv6_static_routes",
                "management",
                "mtu",
                "static",
                "mac_address",
                "ip_address",
                "ipv6_address",
                "netmask",
                "virt_bridge",
                "dhcp_tag",
                "dns_name",
                "static_routes",
                "interface_type",
                "interface_master",
                "bonding_opts",
                "bridge_opts",
                "interface",
            ]:
                key_found_already = True
                for (name, interface) in list(data["interfaces"].items()):
                    if value == name:
                        return True
                    if value is not None and key in interface:
                        if self.__find_compare(interface[key], value):
                            return True

        if key not in data:
            if not key_found_already:
                if not no_errors:
                    # FIXME: removed for 2.0 code, shouldn't cause any problems to not have an exception here?
                    # raise CX("searching for field that does not exist: %s" % key)
                    return False
            else:
                if value is not None:  # FIXME: new?
                    return False

        if value is None:
            return True
        return self.__find_compare(value, data[key])
````
Note the FIXME which once would have thrown an exception if a key we were expecting to find was not found. The issue is that as the `api.find_profile()` call includes a filter based on the profile field `arch`, the find code expects to find that field on the profile and does not. This appears to be related to the fact that `arch` is a derived property which actually comes from the parent of a profile, the distro. 

I have not fully investigated, but it is certainly possible that more problems of this nature exist within Cobbler, especially since there is no warning that a key we expected to filter on was not found on the object.



Discussion:
The same problem would appear in the Cobbler item `Image`. Thanks for the very well-written bug report and the corresponding Pull Request!

Resolving patch:
diff --git a/cobbler/items/profile.py b/cobbler/items/profile.py
--- a/cobbler/items/profile.py
+++ b/cobbler/items/profile.py
@@ -162,7 +162,7 @@
 # SPDX-FileCopyrightText: Michael DeHaan <michael.dehaan AT gmail>
 
 import copy
-from typing import TYPE_CHECKING, Any, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
 
 from cobbler import autoinstall_manager, enums, validate
 from cobbler.cexceptions import CX
@@ -280,6 +280,26 @@ def check_if_valid(self):
         if distro is None:
             raise CX(f"Error with profile {self.name} - distro is required")
 
+    def find_match_single_key(
+        self, data: Dict[str, Any], key: str, value: Any, no_errors: bool = False
+    ) -> bool:
+        """
+        Look if the data matches or not. This is an alternative for ``find_match()``.
+
+        :param data: The data to search through.
+        :param key: The key to look for int the item.
+        :param value: The value for the key.
+        :param no_errors: How strict this matching is.
+        :return: Whether the data matches or not.
+        """
+        # special case for profile, since arch is a derived property from the parent distro
+        if key == "arch":
+            if self.arch:
+                return self.arch.value == value
+            return value is None
+
+        return super().find_match_single_key(data, key, value, no_errors)
+
     #
     # specific methods for item.Profile
     #


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,6 +14,7 @@
 from cobbler.api import CobblerAPI
 from cobbler.items.distro import Distro
 from cobbler.items.image import Image
+from cobbler.items.menu import Menu
 from cobbler.items.profile import Profile
 from cobbler.items.system import NetworkInterface, System
 
@@ -213,6 +214,31 @@ def _create_system(
     return _create_system
 
 
+@pytest.fixture(scope="function")
+def create_menu(request: "pytest.FixtureRequest", cobbler_api: CobblerAPI):
+    """
+    Returns a function which has the profile name as an argument. The function returns a system object. The system is
+    already added to the CobblerAPI.
+    """
+
+    def _create_menu(name: str = "", display_name: str = "") -> Menu:
+        test_menu = cobbler_api.new_menu()
+
+        if name == "":
+            test_menu.name = (
+                request.node.originalname  # type: ignore
+                if request.node.originalname  # type: ignore
+                else request.node.name
+            )
+
+        test_menu.display_name = display_name
+
+        cobbler_api.add_menu(test_menu)
+        return test_menu
+
+    return _create_menu
+
+
 @pytest.fixture(scope="function", autouse=True)
 def cleanup_leftover_items():
     """
diff --git a/tests/items/item_test.py b/tests/items/item_test.py
--- a/tests/items/item_test.py
+++ b/tests/items/item_test.py
@@ -524,23 +524,51 @@ def test_sort_key(request: "pytest.FixtureRequest", cobbler_api: CobblerAPI):
     ]
 
 
-@pytest.mark.skip("Test not yet implemented")
-def test_find_match(cobbler_api: CobblerAPI):
+@pytest.mark.parametrize(
+    "in_keys, check_keys, expect_match",
+    [
+        ({"uid": "test-uid"}, {"uid": "test-uid"}, True),
+        ({"name": "test-object"}, {"name": "test-object"}, True),
+        ({"comment": "test-comment"}, {"comment": "test-comment"}, True),
+        ({"uid": "test-uid"}, {"uid": ""}, False),
+    ],
+)
+def test_find_match(
+    cobbler_api: CobblerAPI,
+    in_keys: Dict[str, Any],
+    check_keys: Dict[str, Any],
+    expect_match: bool,
+):
     """
     Assert that given a desired amount of key-value pairs is matching the item or not.
     """
     # Arrange
-    titem = Item(cobbler_api)
+    titem = Item(cobbler_api, **in_keys)
 
     # Act
-    titem.find_match({})
+    result = titem.find_match(check_keys)
 
     # Assert
-    assert False
+    assert expect_match == result
 
 
-@pytest.mark.skip("Test not yet implemented")
-def test_find_match_single_key(cobbler_api: CobblerAPI):
+@pytest.mark.parametrize(
+    "data_keys, check_key, check_value, expect_match",
+    [
+        ({"uid": "test-uid"}, "uid", "test-uid", True),
+        ({"menu": "testmenu0"}, "menu", "testmenu0", True),
+        ({"uid": "test", "name": "test-name"}, "uid", "test", True),
+        ({"depth": "1"}, "name", "test", False),
+        ({"uid": "test", "name": "test-name"}, "menu", "testmenu0", False),
+    ],
+)
+def test_find_match_single_key(
+    cobbler_api: CobblerAPI,
+    data_keys: Dict[str, Any],
+    check_key: str,
+    check_value: Any,
+    expect_match: bool,
+):
     """
     Assert that a single given key and value match the object or not.
     """
@@ -548,10 +576,10 @@ def test_find_match_single_key(cobbler_api: CobblerAPI):
     titem = Item(cobbler_api)
 
     # Act
-    titem.find_match_single_key({}, "", "")
+    result = titem.find_match_single_key(data_keys, check_key, check_value)
 
     # Assert
-    assert False
+    assert expect_match == result
 
 
 def test_dump_vars(cobbler_api: CobblerAPI):
diff --git a/tests/items/profile_test.py b/tests/items/profile_test.py
--- a/tests/items/profile_test.py
+++ b/tests/items/profile_test.py
@@ -2,7 +2,7 @@
 Test module to validate the functionallity of the Cobbler Profile item.
 """
 
-from typing import Any, Callable, List
+from typing import Any, Callable, Dict, List
 
 import pytest
 
@@ -684,3 +684,39 @@ def test_display_name(cobbler_api: CobblerAPI):
 
     # Assert
     assert profile.display_name == ""
+
+
+@pytest.mark.parametrize(
+    "data_keys, check_key, check_value, expect_match",
+    [
+        ({"uid": "test-uid"}, "uid", "test-uid", True),
+        ({"menu": "testmenu0"}, "menu", "testmenu0", True),
+        ({"uid": "test", "name": "test-name"}, "uid", "test", True),
+        ({"uid": "test"}, "arch", "x86_64", True),
+        ({"uid": "test"}, "arch", "aarch64", False),
+        ({"depth": "1"}, "name", "test", False),
+        ({"uid": "test", "name": "test-name"}, "menu", "testmenu0", False),
+    ],
+)
+def test_find_match_single_key(
+    cobbler_api: CobblerAPI,
+    create_distro: Callable[[], Distro],
+    data_keys: Dict[str, Any],
+    check_key: str,
+    check_value: Any,
+    expect_match: bool,
+):
+    """
+    Assert that a single given key and value match the object or not.
+    """
+    # Arrange
+    test_distro_obj = create_distro()
+    test_distro_obj.arch = enums.Archs.X86_64
+    profile = Profile(cobbler_api)
+    profile.distro = test_distro_obj.name
+
+    # Act
+    result = profile.find_match_single_key(data_keys, check_key, check_value)
+
+    # Assert
+    assert expect_match == result