Repository: cobbler/cobbler

Issue:
Exception occurred: <class 'KeyError'> while "cobbler sync"
### Describe the bug

Getting below error while "cobbler sync".
Exception occurred: <class 'KeyError'>
Exception value: 'bind_zonefiles'

### Steps to reproduce

1. execute "cobbler sync" command
2.
3.

### Expected behavior

it should execute without error

### Cobbler version

<!--- Paste output from `cobbler version` -->
```
mycobbler:/etc/cobbler # cobbler --version
Cobbler 3.3.1
  source: ?, ?
  build time: Fri Feb 18 12:00:00 2022
```

### Operating system

openSUSE Leap 15.4

### Cobbler log

<!--- Paste (partial) output from `/var/log/cobbler/cobbler.log` -->
````paste below
[2022-06-02_152144_sync] 2022-06-02T15:21:45 - INFO | generating /etc/dhcpd6.conf
[2022-06-02_152144_sync] 2022-06-02T15:21:45 - INFO | rendering DNS files
[2022-06-02_152144_sync] 2022-06-02T15:21:45 - INFO | generating /etc/named.conf
[2022-06-02_152144_sync] 2022-06-02T15:21:45 - INFO | generating /etc/secondary.conf
[2022-06-02_152144_sync] 2022-06-02T15:21:45 - INFO | Exception occurred: <class 'KeyError'>
[2022-06-02_152144_sync] 2022-06-02T15:21:45 - INFO | Exception value: 'bind_zonefiles'
[2022-06-02_152144_sync] 2022-06-02T15:21:45 - INFO | Exception Info:
  File "/usr/lib/python3.6/site-packages/cobbler/remote.py", line 99, in run
    rc = self._run(self)

  File "/usr/lib/python3.6/site-packages/cobbler/remote.py", line 218, in runner
    self.remote.api.sync(self.options.get("verbose", False), what=what)

  File "/usr/lib/python3.6/site-packages/cobbler/api.py", line 1406, in sync
    sync_obj.run()

  File "/usr/lib/python3.6/site-packages/cobbler/actions/sync.py", line 164, in run
    self.dns.write_configs()

  File "/usr/lib/python3.6/site-packages/cobbler/modules/managers/bind.py", line 605, in write_configs
    self.__write_secondary_conf()

  File "/usr/lib/python3.6/site-packages/cobbler/modules/managers/bind.py", line 380, in __write_secondary_conf
    self.templar.render(template_data, metadata, settings_file)

  File "/usr/lib/python3.6/site-packages/cobbler/templar.py", line 148, in render
    data_out = data_out.replace(match, search_table[match.strip("@@")])

[2022-06-02_152144_sync] 2022-06-02T15:21:45 - ERROR | ### TASK FAILED ###
[Thread-9] 2022-06-02T15:27:06 - INFO | REMOTE version; user(?)
````

### Screenshots

<!--- If applicable, add screenshots to help explain your problem. -->

### Additional information

<!--- Add any other context about the problem here. -->



Discussion:
@amit-prayag Am I right to assume from the logs that you are using `managers.bind` for DNS management and you have set `manage_dns` to `True`?

To help me reproduce the issue it would be great to know if you have adjusted the templates or if you are using the standard Cobbler supplied ones.
I ran into this same issue as well, on version 3.3.2.  In my case I had set manage_dns to true, and left bind as the default dns implementation.  I happen to be using Rocky, which uses /var/named as the zonefile path.  I confirmed that the path was set correctly in settings.yaml:
```
$ grep zonefile /etc/cobbler/settings.yaml
bind_zonefile_path: /var/named
```
As a workaround, I ended up replacing @@bind_zonefiles@@ in my template files with the actual path for the zone files for my system, and then cobbler sync no longer reported this error and finished successfully.  
```
# workaround, distro specific path
sed -ri 's|@@bind_zonefiles@@|/var/named|g' /etc/cobbler/*.template
cobbler sync
```
I noticed there have been some commits surrounding how bind's zonefile path is specified, perhaps these might be related:
https://github.com/cobbler/cobbler/commit/86522c77cdfd0d8bb8068984d9e2acb165fc9a62
https://github.com/cobbler/cobbler/commit/10ff603171db4362307153ca4dda9707e7acaecb

Okay I will try to create a test specifically for this and see if I can recreate this. Contributions are very welcome though as I am spread very thin with the new Webinterface, the Terraform SDK V2 migration and the new CLI.
Same issue on openSUSE Tumbleweed, Cobbler v3.3.3

# Workaround 1

Patching `templar.py`

```
patch -p0 <<EOF
+++ $(python3 -c 'import cobbler; print(cobbler.__path__[0])')/templar.py
@@ -133,4 +133,6 @@
             repstr = server
         search_table["http_server"] = repstr
+
+        search_table["bind_zonefiles"] = self.settings.bind_zonefile_path

         # string replacements for @@xyz@@ in data_out with prior regex lookups of keys
EOF
```

# Workaround 2

Parsing value from `settings.yaml` via Cheetah3.

In `named.template`:

```
#import yaml
#set $CS = yaml.safe_load(self.getFileContents('/etc/cobbler/settings.yaml'))
#set $cobbler_server = $CS.server
#set $bind_zonefile_path = $CS.bind_zonefile_path
...
options {
... 
directory          "$bind_zonefile_path";
...
};
```

This requires `yaml` in [cheetah_import_whitelist](https://cobbler.readthedocs.io/en/latest/cobbler-conf/settings-yaml.html#cheetah-import-whitelist)

IMO the Cobbler settings variable `CS` could generally be a nice to have in the `searchList`
I was able to identify the problem. It is fixed but I will try and see what other surprises are hiding when enabling dns management.

Resolving patch:
diff --git a/cobbler/modules/managers/bind.py b/cobbler/modules/managers/bind.py
--- a/cobbler/modules/managers/bind.py
+++ b/cobbler/modules/managers/bind.py
@@ -15,6 +15,7 @@
 from cobbler import utils
 from cobbler.cexceptions import CX
 from cobbler.modules.managers import DnsManagerModule
+from cobbler.utils import process_management
 
 if TYPE_CHECKING:
     from cobbler.api import CobblerAPI
@@ -31,6 +32,10 @@ def register() -> str:
 
 
 class MetadataZoneHelper:
+    """
+    Helper class to hold data for template rendering of named config files.
+    """
+
     def __init__(
         self,
         forward_zones: List[str],
@@ -41,6 +46,7 @@ def __init__(
         self.reverse_zones: List[Tuple[str, str]] = reverse_zones
         self.zone_include = zone_include
         self.bind_master = ""
+        self.bind_zonefiles = ""
 
 
 class _BindManager(DnsManagerModule):
@@ -275,6 +281,7 @@ def __write_named_conf(self) -> None:
         # reverse_zones = self.settings.manage_reverse_zones
 
         metadata = MetadataZoneHelper(list(self.__forward_zones().keys()), [], "")
+        metadata.bind_zonefiles = self.settings.bind_zonefile_path
 
         for zone in metadata.forward_zones:
             txt = f"""
@@ -330,6 +337,7 @@ def __write_secondary_conf(self) -> None:
         # reverse_zones = self.settings.manage_reverse_zones
 
         metadata = MetadataZoneHelper(list(self.__forward_zones().keys()), [], "")
+        metadata.bind_zonefiles = self.settings.bind_zonefile_path
 
         for zone in metadata.forward_zones:
             txt = f"""
@@ -629,13 +637,8 @@ def restart_service(self) -> int:
         This syncs the bind server with it's new config files.
         Basically this restarts the service to apply the changes.
         """
-        # TODO: Reuse the utils method for service restarts
         named_service_name = utils.named_service_name()
-        dns_restart_command = ["service", named_service_name, "restart"]
-        ret: int = utils.subprocess_call(dns_restart_command, shell=False)
-        if ret != 0:
-            self.logger.error("%s service failed", named_service_name)
-        return ret
+        return process_management.service_restart(named_service_name)
 
 
 def get_manager(api: "CobblerAPI") -> "_BindManager":


Test patch:
diff --git a/tests/modules/managers/bind_test.py b/tests/modules/managers/bind_test.py
--- a/tests/modules/managers/bind_test.py
+++ b/tests/modules/managers/bind_test.py
@@ -1,7 +1,65 @@
+"""
+Test to verify the functionallity of the isc bind module.
+"""
+
+import time
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+from cobbler.api import CobblerAPI
 from cobbler.modules.managers import bind
 
+if TYPE_CHECKING:
+    from pytest_mock import MockerFixture
+
+
+@pytest.fixture(scope="function")
+def named_template() -> str:
+    """
+    This provides a minmal test templated for named that is close to the real one.
+    """
+    return """options {
+          listen-on port 53 { 127.0.0.1; };
+          directory       "@@bind_zonefiles@@";
+          dump-file       "@@bind_zonefiles@@/data/cache_dump.db";
+          statistics-file "@@bind_zonefiles@@/data/named_stats.txt";
+          memstatistics-file "@@bind_zonefiles@@/data/named_mem_stats.txt";
+          allow-query     { localhost; };
+          recursion yes;
+};
+
+#for $zone in $forward_zones
+zone "${zone}." {
+    type master;
+    file "$zone";
+};
+
+#end for
+#for $zone, $arpa in $reverse_zones
+zone "${arpa}." {
+    type master;
+    file "$zone";
+};
+
+#end for
+"""
+
+
+@pytest.fixture(scope="function", autouse=True)
+def reset_singleton():
+    """
+    Helper fixture to reset the isc singleton before and after a test.
+    """
+    bind.MANAGER = None
+    yield
+    bind.MANAGER = None
+
 
 def test_register():
+    """
+    Test if the manager registers with the correct ID.
+    """
     # Arrange & Act
     result = bind.register()
 
@@ -10,13 +68,123 @@ def test_register():
 
 
 def test_manager_what():
+    """
+    Test if the manager identifies itself correctly.
+    """
+    # pylint: disable=protected-access
     # Arrange & Act & Assert
-    assert bind._BindManager.what() == "bind"
+    assert bind._BindManager.what() == "bind"  # type: ignore
 
 
-def test_get_manager(cobbler_api):
+def test_get_manager(cobbler_api: CobblerAPI):
+    """
+    Test if the singleton is correctly initialized.
+    """
+    # pylint: disable=protected-access
     # Arrange & Act
     result = bind.get_manager(cobbler_api)
 
     # Assert
-    isinstance(result, bind._BindManager)
+    isinstance(result, bind._BindManager)  # type: ignore
+
+
+def test_write_configs(
+    mocker: "MockerFixture", cobbler_api: CobblerAPI, named_template: str
+):
+    """
+    Test if the manager is able to correctly write the configuration files.
+    """
+    # Arrange
+    open_mock = mocker.mock_open()
+    mock_named_template = mocker.mock_open(read_data=named_template)
+    mock_secondary_template = mocker.mock_open(read_data="garbage")
+    mock_zone_template = mocker.mock_open(read_data="garbage 2")
+    mock_bind_serial = mocker.mock_open()
+    mock_etc_named_conf = mocker.mock_open()
+    mock_etc_secondary_conf = mocker.mock_open()
+
+    def mock_open(*args: Any, **kwargs: Any):
+        if args[0] == "/etc/cobbler/named.template":
+            return mock_named_template(*args, **kwargs)
+        if args[0] == "/etc/cobbler/secondary.template":
+            return mock_secondary_template(*args, **kwargs)
+        if args[0] == "/etc/cobbler/zone.template":
+            return mock_zone_template(*args, **kwargs)
+        if args[0] == "/var/lib/cobbler/bind_serial":
+            return mock_bind_serial(*args, **kwargs)
+        if args[0] == "/etc/named.conf":
+            return mock_etc_named_conf(*args, **kwargs)
+        if args[0] == "/etc/secondary.conf":
+            return mock_etc_secondary_conf(*args, **kwargs)
+        return open_mock(*args, **kwargs)
+
+    mocker.patch("builtins.open", mock_open)
+    manager = bind.get_manager(cobbler_api)
+    # TODO Mock settings for manage_dns and forward/reverse zones
+
+    # Act
+    manager.write_configs()
+
+    # Assert
+    # TODO: Extend assertions
+    mock_bind_serial.assert_any_call(
+        "/var/lib/cobbler/bind_serial", "r", encoding="UTF-8"
+    )
+    mock_bind_serial_handle = mock_bind_serial()
+    mock_bind_serial_handle.write.assert_any_call(time.strftime("%Y%m%d00"))
+    open_mock.assert_not_called()
+
+
+@pytest.mark.skip("Advanced complicated test scenario for now.")
+def test_write_configs_zone_template(cobbler_api: CobblerAPI):
+    """
+    TODO
+    """
+    # Arrange
+    # TODO Mock zone specific template /etc/cobbler/zone_templates/{zone}
+    manager = bind.get_manager(cobbler_api)
+
+    # Act
+    manager.write_configs()
+
+    # Assert
+    # TODO: Mock that template was read
+    assert False
+
+
+@pytest.mark.skip("Advanced complicated test scenario for now.")
+def test_chrooted_named(cobbler_api: CobblerAPI):
+    """
+    TODO
+    """
+    # Arrange
+    manager = bind.get_manager(cobbler_api)
+
+    # Act
+    manager.write_configs()
+
+    # Assert
+    # TODO: Assert that correct paths are trying to be written
+    assert False
+
+
+def test_manager_restart_service(mocker: "MockerFixture", cobbler_api: CobblerAPI):
+    """
+    Test if the manager is able to correctly handle restarting the named server on different distros.
+    """
+    # Arrange
+    manager = bind.get_manager(cobbler_api)
+    mocked_service_name = mocker.patch(
+        "cobbler.utils.named_service_name", autospec=True, return_value="named"
+    )
+    mock_service_restart = mocker.patch(
+        "cobbler.utils.process_management.service_restart", return_value=0
+    )
+
+    # Act
+    result = manager.restart_service()
+
+    # Assert
+    assert mocked_service_name.call_count == 1
+    mock_service_restart.assert_called_with("named")
+    assert result == 0