Repository: cobbler/cobbler

Issue:
Cobbler sync fails on systemd systems if "service" is not installed
as is the case in the docker container implementation of cobbler here: https://github.com/ContainerSolutions/docker-cobbler?__hstc=137489263.10558a28489c3392c90356ca62160898.1471286471934.1471286471934.1471286471934.1&__hssc=137489263.1.1471286471934&__hsfp=1495062989

```
unning post-sync triggers
running python triggers from /var/lib/cobbler/triggers/sync/post/*
running python trigger cobbler.modules.sync_post_restart_services
running: dhcpd -t -q
received on stdout: 
received on stderr: 
running: service dhcpd restart
received on stdout: 
received on stderr: /bin/sh: service: command not found

running: service named restart
received on stdout: 
received on stderr: /bin/sh: service: command not found
```


Discussion:
I have this issue fixed in a local tree for master (3.0).
Need to see if we can fix this for release28 branch
@jmaas What are your plans here?  I'm looking at some other changes that need to be made for systemd and it would be good to coordinate?
@opoplawski i have the beginnings of a runtime abstraction layer. basically you get a config file per os/version which tells where things are, what commands to use. the goal is that when a new distro wants to support Cobbler no code changes are necessary.

i will try to push it to a development branch in the coming days.
@opoplawski but i really dont want to make such a big change in the 2.8 tree. but 2.8 surely does need to get a decent fix for systemd/service/whatnot. especially considering we need to support 2.8 for quite some time

so what did you have in mind ?
If there any workaround for this? I'm trying to get cobbler up and running on a Fedora 30 server and the sync fails because it does not know to use systemctl instead of service.

LE: Installing initscripts-10.02-1.fc30.x86_64 fixes things.
@mpsOxygen The easiest fix would probably be to build cobbler form the current master tree.
Using the service command provides maximal compatibility across all RH variants, though. Would it not be easier to just keep that as a requirement?
I have no idea if /sbin/service will ever disappear, but I think cobbler should move to systemd at some point.  As for Fedora packages, I'm just going to explicitly require /sbin/service for now.  I'm not planning on updating Fedora 30 to cobbler 3.
Yes we will need systemctl asap, I agree. Ideally we could check with `service cobblerd status` and `systemctl status cobblerd` what is installed.
In the meantime, on Debian 10:

```
root@cobbler:~# service cobblerd status
● cobblerd.service - Cobbler Helper Daemon
   Loaded: loaded (/lib/systemd/system/cobblerd.service; enabled; vendor preset: enabled)
   Active: active (running) since Sun 2020-02-16 10:51:21 UTC; 12min ago
  Process: 12004 ExecStartPost=/usr/bin/touch /usr/share/cobbler/web/cobbler.wsgi (code=exited, status=0/SUCCESS)
 Main PID: 12003 (cobblerd)
    Tasks: 1 (limit: 2356)
   Memory: 30.8M
   CGroup: /system.slice/cobblerd.service
           └─12003 /usr/bin/python3 /usr/bin/cobblerd -F

Feb 16 10:51:21 cobbler systemd[1]: Starting Cobbler Helper Daemon...
Feb 16 10:51:21 cobbler systemd[1]: Started Cobbler Helper Daemon.

root@cobbler:~# service cobblerd condrestart
cobblerd: unrecognized service
```

https://github.com/cobbler/cobbler/blob/master/config/rotate/cobblerd_rotate#L13

Something like this will do (on Debian with systemd 241 - won't work on CentOS7 - systemd 219):

```
root@cobbler:~# /bin/systemctl try-reload-or-restart cobblerd.service

root@cobbler:~# service cobblerd status
● cobblerd.service - Cobbler Helper Daemon
   Loaded: loaded (/lib/systemd/system/cobblerd.service; enabled; vendor preset: enabled)
   Active: active (running) since Sun 2020-02-16 11:09:18 UTC; 2s ago
  Process: 12093 ExecStartPost=/usr/bin/touch /usr/share/cobbler/web/cobbler.wsgi (code=exited, status=0/SUCCESS)
 Main PID: 12092 (cobblerd)
    Tasks: 1 (limit: 2356)
   Memory: 30.8M
   CGroup: /system.slice/cobblerd.service
           └─12092 /usr/bin/python3 /usr/bin/cobblerd -F

Feb 16 11:09:18 cobbler systemd[1]: Starting Cobbler Helper Daemon...
Feb 16 11:09:18 cobbler systemd[1]: Started Cobbler Helper Daemon.
```
I believe this is already fixed but we should check explicitly before the next release.

Resolving patch:
diff --git a/cobbler/api.py b/cobbler/api.py
--- a/cobbler/api.py
+++ b/cobbler/api.py
@@ -930,7 +930,20 @@ def rename_menu(self, ref, newname: str):
 
     # ==========================================================================
 
-    # FIXME: add a new_item method
+    def new_item(self, what: str = "", is_subobject: bool = False):
+        """
+        Creates a new (unconfigured) object. The object is not persisted.
+
+        :param what: Specifies the type of object. Valid item types can be seen at
+                     :func:`~cobbler.enums.ItemTypes`.
+        :param is_subobject: If the object is a subobject of an already existing object or not.
+        :return: The newly created object.
+        """
+        try:
+            enums.ItemTypes(what)  # verify that <what> is an ItemTypes member
+            return getattr(self, f"new_{what}")(is_subobject=is_subobject)
+        except (ValueError, AttributeError):
+            raise Exception(f"internal error, collection name is {what}")
 
     def new_distro(self, is_subobject: bool = False):
         """
diff --git a/cobbler/enums.py b/cobbler/enums.py
--- a/cobbler/enums.py
+++ b/cobbler/enums.py
@@ -43,6 +43,50 @@ def to_enum(cls, value: Union[str, "ConvertableEnum"]) -> "ConvertableEnum":
             raise ValueError(f"{value} must be one of {list(cls)}")
 
 
+class ItemTypes(ConvertableEnum):
+    """
+    This enum represents all valid item types in Cobbler. If a new item type is created it must be added into this enum.
+    Abstract base item types don't have to be added here.
+    """
+
+    DISTRO = "distro"
+    """
+    See :func:`~cobbler.items.distro.Distro`
+    """
+    PROFILE = "profile"
+    """
+    See :func:`~cobbler.items.profile.Profile`
+    """
+    SYSTEM = "system"
+    """
+    See :func:`~cobbler.items.system.System`
+    """
+    REPO = "repo"
+    """
+    See :func:`~cobbler.items.repo.Repo`
+    """
+    IMAGE = "image"
+    """
+    See :func:`~cobbler.items.image.Image`
+    """
+    MGMTCLASS = "mgmtclass"
+    """
+    See :func:`~cobbler.items.mgmtclass.Mgmtclass`
+    """
+    PACKAGE = "package"
+    """
+    See :func:`~cobbler.items.package.Package`
+    """
+    FILE = "file"
+    """
+    See :func:`~cobbler.items.file.File`
+    """
+    MENU = "menu"
+    """
+    See :func:`~cobbler.items.menu.Menu`
+    """
+
+
 class DHCP(enum.Enum):
     V4 = 4
     V6 = 6
diff --git a/cobbler/remote.py b/cobbler/remote.py
--- a/cobbler/remote.py
+++ b/cobbler/remote.py
@@ -1154,6 +1154,7 @@ def find_items(
         criteria: Optional[dict] = None,
         sort_field=None,
         expand: bool = True,
+        resolved: bool = False,
     ) -> list:
         """Works like get_items but also accepts criteria as a dict to search on.
 
@@ -1165,6 +1166,8 @@ def find_items(
         :param criteria: The criteria an item needs to match.
         :param sort_field: The field to sort the results after.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :returns: A list of dicts.
         """
         if criteria is None:
@@ -1179,134 +1182,197 @@ def find_items(
         if not expand:
             items = [x.name for x in items]
         else:
-            items = [x.to_dict() for x in items]
+            items = [x.to_dict(resolved=resolved) for x in items]
         return self.xmlrpc_hacks(items)
 
     def find_distro(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a distro matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All distributions which have matched the criteria.
         """
-        return self.find_items("distro", criteria, expand=expand)
+        return self.find_items("distro", criteria, expand=expand, resolved=resolved)
 
     def find_profile(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a profile matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All profiles which have matched the criteria.
         """
-        return self.find_items("profile", criteria, expand=expand)
+        return self.find_items("profile", criteria, expand=expand, resolved=resolved)
 
     def find_system(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a system matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All systems which have matched the criteria.
         """
-        return self.find_items("system", criteria, expand=expand)
+        return self.find_items("system", criteria, expand=expand, resolved=resolved)
 
     def find_repo(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a repository matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All repositories which have matched the criteria.
         """
-        return self.find_items("repo", criteria, expand=expand)
+        return self.find_items("repo", criteria, expand=expand, resolved=resolved)
 
     def find_image(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find an image matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All images which have matched the criteria.
         """
-        return self.find_items("image", criteria, expand=expand)
+        return self.find_items("image", criteria, expand=expand, resolved=resolved)
 
     def find_mgmtclass(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a management class matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All management classes which have matched the criteria.
         """
-        return self.find_items("mgmtclass", criteria, expand=expand)
+        return self.find_items("mgmtclass", criteria, expand=expand, resolved=resolved)
 
     def find_package(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a package matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All packages which have matched the criteria.
         """
-        return self.find_items("package", criteria, expand=expand)
+        return self.find_items("package", criteria, expand=expand, resolved=resolved)
 
     def find_file(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a file matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All files which have matched the criteria.
         """
-        return self.find_items("file", criteria, expand=expand)
+        return self.find_items("file", criteria, expand=expand, resolved=resolved)
 
     def find_menu(
-        self, criteria: Optional[dict] = None, expand=False, token=None, **rest
+        self,
+        criteria: Optional[dict] = None,
+        expand=False,
+        resolved: bool = False,
+        token=None,
+        **rest
     ):
         """
         Find a menu matching certain criteria.
 
         :param criteria: The criteria a distribution needs to match.
         :param expand: Not only get the names but also the complete object in form of a dict.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :param rest: This parameter is not used currently.
         :return: All files which have matched the criteria.
         """
-        return self.find_items("menu", criteria, expand=expand)
+        return self.find_items("menu", criteria, expand=expand, resolved=resolved)
 
     def find_items_paged(
         self,
@@ -1315,6 +1381,7 @@ def find_items_paged(
         sort_field: Optional[str] = None,
         page=1,
         items_per_page=25,
+        resolved: bool = False,
         token: Optional[str] = None,
     ):
         """
@@ -1326,6 +1393,8 @@ def find_items_paged(
         :param sort_field: The field to sort the results after.
         :param page: The page to return
         :param items_per_page: The number of items per page.
+        :param resolved: This only has an effect when ``expand = True``. It returns the resolved representation of the
+                         object instead of the raw data.
         :param token: The API-token obtained via the login() method.
         :return: The found items.
         """
@@ -1340,7 +1409,7 @@ def find_items_paged(
             items = self.api.find_items(what, criteria=criteria)
         items = self.__sort(items, sort_field)
         (items, pageinfo) = self.__paginate(items, page, items_per_page)
-        items = [x.to_dict() for x in items]
+        items = [x.to_dict(resolved=resolved) for x in items]
         return self.xmlrpc_hacks({"items": items, "pageinfo": pageinfo})
 
     def has_item(self, what: str, name: str, token: Optional[str] = None):
@@ -1828,26 +1897,7 @@ def new_item(self, what: str, token: str, is_subobject: bool = False):
         """
         self._log("new_item(%s)" % what, token=token)
         self.check_access(token, "new_%s" % what)
-        if what == "distro":
-            new_item = self.api.new_distro(is_subobject=is_subobject)
-        elif what == "profile":
-            new_item = self.api.new_profile(is_subobject=is_subobject)
-        elif what == "system":
-            new_item = self.api.new_system(is_subobject=is_subobject)
-        elif what == "repo":
-            new_item = self.api.new_repo(is_subobject=is_subobject)
-        elif what == "image":
-            new_item = self.api.new_image(is_subobject=is_subobject)
-        elif what == "mgmtclass":
-            new_item = self.api.new_mgmtclass(is_subobject=is_subobject)
-        elif what == "package":
-            new_item = self.api.new_package(is_subobject=is_subobject)
-        elif what == "file":
-            new_item = self.api.new_file(is_subobject=is_subobject)
-        elif what == "menu":
-            new_item = self.api.new_menu(is_subobject=is_subobject)
-        else:
-            raise CX('internal error, collection name is "%s"' % what)
+        new_item = self.api.new_item(what, is_subobject)
         self.unsaved_items[new_item.uid] = (time.time(), new_item)
         return new_item.uid
 


Test patch:
diff --git a/tests/utils/process_management_test.py b/tests/utils/process_management_test.py
--- a/tests/utils/process_management_test.py
+++ b/tests/utils/process_management_test.py
@@ -56,6 +56,9 @@ def test_service_restart_systemctl(mocker):
     mocker.patch(
         "cobbler.utils.process_management.is_systemd", autospec=True, return_value=True
     )
+    mocker.patch(
+        "cobbler.utils.process_management.is_service", autospec=True, return_value=False
+    )
     subprocess_mock = mocker.patch(
         "cobbler.utils.subprocess_call", autospec=True, return_value=0
     )