Repository: cobbler/cobbler

Issue:
setting network interface using xmlrpc not working
### Describe the bug

<!--- A clear and concise description of what the bug is. -->
When adding/modifying a system to add an interface eth0, only a default interface is created and the eth0 is missing
<!--- HINT: You can paste gist.github.com links for long logs or larger files -->

### Steps to reproduce

1. Using ansible cobbler_system or prov.py (https://github.com/HarrisonTotty/prov) with an interface eth0
2. cobbler system report only shows a default interface
3.

### Expected behavior

<!--- A clear and concise description of what you expected to happen. -->
There should also be an entry with eth0

### Cobbler version

<!--- Paste output from `cobbler version` -->
````paste below
3.3.1 (github/master)
````

### Operating system

<!--- On which operating system do you use Cobbler? -->
OpenSuse 15.3

### Cobbler log

<!--- Paste (partial) output from `/var/log/cobbler/cobbler.log` -->
````paste below
````
No error shown

### Screenshots

<!--- If applicable, add screenshots to help explain your problem. -->

### Additional information

<!--- Add any other context about the problem here. -->
The file I used for prov.py has:
```yaml
systems:
  th-vm-11:
    interfaces:
      eth0:
        dns_name: 'th-vm-11.labnet.hpe'
        mac_address: '01:02:03:04:05:06'
        management: true
    mgmt_classes:
    - hanahosts
    profile: sles12sp5-x86_64
```

The interface shown by cobbler system report:
```
Interface =====                : default
Bonding Opts                   :
Bridge Opts                    :
CNAMES                         : []
InfiniBand Connected Mode      : False
DHCP Tag                       :
DNS Name                       :
Per-Interface Gateway          :
Master Interface               :
Interface Type                 : NA
IP Address                     :
IPv6 Address                   :
IPv6 Default Gateway           :
IPv6 MTU                       :
IPv6 Prefix                    :
IPv6 Secondaries               : []
IPv6 Static Routes             : []
MAC Address                    :
Management Interface           : False
MTU                            :
Subnet Mask                    :
Static                         : False
Static Routes                  : []
Virt Bridge                    : xenbr0
```
No eth0. 

BTW https://github.com/HarrisonTotty/prov is a nice tool to test xmlrpc with cobbler. Maybe it should mentioned in the docs somewhere.


Discussion:
I also get an error:

```
Error: Unable to validate entry - interface type of system interface "default" is not one of ['bmc', 'bond', 'bond_slave', 'bonded_bridge_slave', 'bridge', 'bridge_slave', 'infiniband', 'na'].
```
@th-2021 So the error you get is not an error but a failing validation. There is no interface type "default". The nearest type you would possibly want is then "na". If "default" was available before then this is a regression we need to fix.
"default" is the name of the network interface. And it has  Interface Type                 : NA

The main issue is that eth0 is not added to system.
Thanks for the additional information.
Any movement on this one?  I'm seeing it too from python & terraform (go) on latest Cobbler (v3.3.1).  Since interfaces cannot be configured, Cobbler cannot build systems:

```
import xmlrpc.client
remote=xmlrpc.client.Server("http://localhost:8081/cobbler_api")
token=remote.login("cobbler", "cobbler")
system_id = remote.new_system(token)
remote.modify_system(system_id,"name", "test", token)
remote.modify_system(system_id,"profile", "Ubuntu-20.04-x86_64", token)
remote.modify_system(system_id,'modify_interface', {"macaddress-eth0" : "aa:bb:cc:dd:ee:ff"}, token)
remote.save_system(system_id, token)
```
System is saved minus the interface:
```
b365c9174ba4:/code # cobbler system report
Name                           : test
Automatic Installation Template : <<inherit>>
Automatic Installation Template Metadata : <<inherit>>
TFTP Boot Files                : <<inherit>>
Boot loaders                   : <<inherit>>
Comment                        :
Enable iPXE?                   : <<inherit>>
Fetchable Files                : <<inherit>>
DHCP Filename Override         : <<inherit>>
Gateway                        :
Hostname                       :
Image                          :
IPv6 Autoconfiguration         : False
IPv6 Default Device            :
Kernel Options                 : <<inherit>>
Kernel Options (Post Install)  : <<inherit>>
Management Classes             : <<inherit>>
Management Parameters          : <<inherit>>
Name Servers                   : []
Name Servers Search Path       : []
Netboot Enabled                : False
Next Server (IPv4) Override    : <<inherit>>
Next Server (IPv6) Override    : <<inherit>>
Owners                         : <<inherit>>
Power Management Address       :
Power Management ID            :
Power Identity File            :
Power Management Options       :
Power Management Password      :
Power Management Type          :
Power Management Username      :
Profile                        : Ubuntu-20.04-x86_64
Internal Proxy                 : <<inherit>>
Redhat Management Key          : <<inherit>>
Repos Enabled                  : False
Serial Baud Rate               : 0
Serial Device #                : 0
Server Override                : <<inherit>>
Status                         :
Template Files                 : {}
Virt Auto Boot                 : <<inherit>>
Virt CPUs                      : <<inherit>>
Virt Disk Driver Type          : <<inherit>>
Virt File Size(GB)             : <<inherit>>
Virt Path                      : <<inherit>>
Virt PXE Boot                  : False
Virt RAM (MB)                  : <<inherit>>
Virt Type                      : <<inherit>>
Interface =====                : default
Bonding Opts                   :
Bridge Opts                    :
CNAMES                         : []
InfiniBand Connected Mode      : False
DHCP Tag                       :
DNS Name                       :
Per-Interface Gateway          :
Master Interface               :
Interface Type                 : NA
IP Address                     :
IPv6 Address                   :
IPv6 Default Gateway           :
IPv6 MTU                       :
IPv6 Prefix                    :
IPv6 Secondaries               : []
IPv6 Static Routes             : []
MAC Address                    :
Management Interface           : False
MTU                            :
Subnet Mask                    :
Static                         : False
Static Routes                  : []
Virt Bridge                    :
```

Any attempt to `modify_interface` returns `False` boolean.  But same via CLI works fine:
```
cobbler system add --name test --profile Ubuntu-20.04-x86_64
cobbler system edit --name test --interface eth0 --mac-address aa:bb:cc:dd:ee:ff
b365c9174ba4:/code # cobbler system report
Name                           : test
<snip>
Interface =====                : eth0
Bonding Opts                   :
Bridge Opts                    :
CNAMES                         : []
InfiniBand Connected Mode      : False
DHCP Tag                       :
DNS Name                       :
Per-Interface Gateway          :
Master Interface               :
Interface Type                 : NA
IP Address                     :
IPv6 Address                   :
IPv6 Default Gateway           :
IPv6 MTU                       :
IPv6 Prefix                    :
IPv6 Secondaries               : []
IPv6 Static Routes             : []
MAC Address                    : aa:bb:cc:dd:ee:ff
Management Interface           : False
MTU                            :
Subnet Mask                    :
Static                         : False
Static Routes                  : []
Virt Bridge                    :
```
modify_interface works fine in v3.2.2:
```
>>> remote.modify_system(system_id,'modify_interface', {"macaddress-eth0" : "aa:bb:cc:dd:ee:ff"}, token)
True
```
So the reason the CLI works and the Terraform Provider don't is the following: The CLI uses the `xapi_object_edit` method. This seems to work as expected. However the `modify_item` method used by `modify_system` tries to use the setter of the object which is not existing.

This is happened due to my rewrite to properties and thus I need more testing and more compatibility for network interfaces in general. I will do my best but this is not a one-line fix and thus will take a while.

Resolving patch:
diff --git a/cobbler/items/system.py b/cobbler/items/system.py
--- a/cobbler/items/system.py
+++ b/cobbler/items/system.py
@@ -941,18 +941,33 @@ def interfaces(self, value: Dict[str, Any]):
         raise ValueError("The values of the interfaces must be fully of type dict (one level with values) or "
                          "NetworkInterface objects")
 
-    def delete_interface(self, name: str):
+    def modify_interface(self, interface_values: dict):
         """
-        Used to remove an interface.
+        Modifies a magic interface dictionary in the form of: {"macaddress-eth0" : "aa:bb:cc:dd:ee:ff"}
+        """
+        for key in interface_values.keys():
+            (_, interface) = key.split("-", 1)
+            if interface not in self.interfaces:
+                self.__create_interface(interface)
+            self.interfaces[interface].modify_interface({key: interface_values[key]})
 
-        :raises TypeError: If the name of the interface is not of type str
+    def delete_interface(self, name: Union[str, dict]):
         """
-        if not isinstance(name, str):
-            raise TypeError("The name of the interface must be of type str")
-        if not name:
+        Used to remove an interface.
+
+        :raises TypeError: If the name of the interface is not of type str or dict.
+        """
+        if isinstance(name, str):
+            if not name:
+                return
+            if name in self.interfaces:
+                self.interfaces.pop(name)
+                return
+        if isinstance(name, dict):
+            interface_name = name.get("interface", "")
+            self.interfaces.pop(interface_name)
             return
-        if name in self.interfaces:
-            self.interfaces.pop(name)
+        raise TypeError("The name of the interface must be of type str or dict")
 
     def rename_interface(self, old_name: str, new_name: str):
         r"""
diff --git a/cobbler/remote.py b/cobbler/remote.py
--- a/cobbler/remote.py
+++ b/cobbler/remote.py
@@ -1718,7 +1718,7 @@ def new_menu(self, token: str):
         """
         return self.new_item("menu", token)
 
-    def modify_item(self, what, object_id, attribute, arg, token: str) -> bool:
+    def modify_item(self, what: str, object_id, attribute, arg, token: str) -> bool:
         """
         Adjusts the value of a given field, specified by 'what' on a given object id. Allows modification of certain
         attributes on newly created or existing distro object handle.
@@ -1734,6 +1734,20 @@ def modify_item(self, what, object_id, attribute, arg, token: str) -> bool:
         obj = self.__get_object(object_id)
         self.check_access(token, "modify_%s" % what, obj, attribute)
 
+        if what == "system":
+            if attribute == "modify_interface":
+                obj.modify_interface(arg)
+                return True
+            elif attribute == "delete_interface":
+                obj.delete_interface(arg)
+                return True
+            elif attribute == "rename_interface":
+                obj.rename_interface(
+                    old_name=arg.get("interface", ""),
+                    new_name=arg.get("rename_interface", "")
+                )
+                return True
+
         if hasattr(obj, attribute):
             setattr(obj, attribute, arg)
             return True


Test patch:
diff --git a/.github/workflows/system_testing.yml b/.github/workflows/system_testing.yml
--- a/.github/workflows/system_testing.yml
+++ b/.github/workflows/system_testing.yml
@@ -13,7 +13,7 @@ jobs:
         uses: actions/checkout@v2
 
       - name: Run the test suite
-        run: ./docker/rpms/build-and-install-rpms.sh --with-system-tests el8 docker/rpms/CentOS_8/CentOS8.dockerfile
+        run: ./docker/rpms/build-and-install-rpms.sh --with-system-tests rl8 docker/rpms/Rocky_Linux_8/Rocky_Linux_8.dockerfile
 
       - name: Publish the test results
         uses: actions/upload-artifact@v2
diff --git a/tests/xmlrpcapi/system_test.py b/tests/xmlrpcapi/system_test.py
--- a/tests/xmlrpcapi/system_test.py
+++ b/tests/xmlrpcapi/system_test.py
@@ -21,70 +21,82 @@ def test_get_systems(self, remote, token):
         # Assert
         assert result == []
 
-    @pytest.mark.usefixtures("create_testdistro", "create_testmenu", "create_testprofile", "remove_testdistro",
-                             "remove_testmenu", "remove_testprofile", "remove_testsystem")
-    @pytest.mark.parametrize("field_name,field_value", [
-        ("comment", "test comment"),
-        ("enable_ipxe", True),
-        ("enable_ipxe", False),
-        ("kernel_options", "a=1 b=2 c=3 c=4 c=5 d e"),
-        ("kernel_options_post", "a=1 b=2 c=3 c=4 c=5 d e"),
-        ("autoinstall", "test.ks"),
-        ("autoinstall", "test.xml"),
-        ("autoinstall", "test.seed"),
-        ("autoinstall_meta", "a=1 b=2 c=3 c=4 c=5 d e"),
-        ("mgmt_classes", "one two three"),
-        ("mgmt_parameters", "<<inherit>>"),
-        ("name", "testsystem0"),
-        ("netboot_enabled", True),
-        ("netboot_enabled", False),
-        ("owners", "user1 user2 user3"),
-        ("profile", "testprofile0"),
-        ("repos_enabled", True),
-        ("repos_enabled", False),
-        ("status", "development"),
-        ("status", "testing"),
-        ("status", "acceptance"),
-        ("status", "production"),
-        ("proxy", "testproxy"),
-        ("server", "1.1.1.1"),
-        # ("boot_loaders", "pxe ipxe grub"), FIXME: This raises currently but it did not in the past
-        ("virt_auto_boot", True),
-        ("virt_auto_boot", False),
-        ("virt_auto_boot", "yes"),
-        ("virt_auto_boot", "no"),
-        ("virt_cpus", "<<inherit>>"),
-        ("virt_cpus", 1),
-        ("virt_cpus", 2),
-        ("virt_cpus", "<<inherit>>"),
-        ("virt_file_size", "<<inherit>>"),
-        ("virt_file_size", 5),
-        ("virt_file_size", 10),
-        ("virt_disk_driver", "<<inherit>>"),
-        ("virt_disk_driver", "raw"),
-        ("virt_disk_driver", "qcow2"),
-        ("virt_disk_driver", "vdmk"),
-        ("virt_ram", "<<inherit>>"),
-        ("virt_ram", 256),
-        ("virt_ram", 1024),
-        ("virt_type", "<<inherit>>"),
-        ("virt_type", "xenpv"),
-        ("virt_type", "xenfv"),
-        ("virt_type", "qemu"),
-        ("virt_type", "kvm"),
-        ("virt_type", "vmware"),
-        ("virt_type", "openvz"),
-        ("virt_path", "<<inherit>>"),
-        ("virt_path", "/path/to/test"),
-        ("virt_pxe_boot", True),
-        ("virt_pxe_boot", False),
-        ("power_type", "ipmilanplus"),
-        ("power_address", "127.0.0.1"),
-        ("power_id", "pmachine:lpar1"),
-        ("power_pass", "pass"),
-        ("power_user", "user")
-    ])
-    def test_create_system_positive(self, remote, token, template_files, field_name, field_value):
+    @pytest.mark.usefixtures(
+        "create_testdistro",
+        "create_testmenu",
+        "create_testprofile",
+        "remove_testdistro",
+        "remove_testmenu",
+        "remove_testprofile",
+        "remove_testsystem",
+    )
+    @pytest.mark.parametrize(
+        "field_name,field_value",
+        [
+            ("comment", "test comment"),
+            ("enable_ipxe", True),
+            ("enable_ipxe", False),
+            ("kernel_options", "a=1 b=2 c=3 c=4 c=5 d e"),
+            ("kernel_options_post", "a=1 b=2 c=3 c=4 c=5 d e"),
+            ("autoinstall", "test.ks"),
+            ("autoinstall", "test.xml"),
+            ("autoinstall", "test.seed"),
+            ("autoinstall_meta", "a=1 b=2 c=3 c=4 c=5 d e"),
+            ("mgmt_classes", "one two three"),
+            ("mgmt_parameters", "<<inherit>>"),
+            ("name", "testsystem0"),
+            ("netboot_enabled", True),
+            ("netboot_enabled", False),
+            ("owners", "user1 user2 user3"),
+            ("profile", "testprofile0"),
+            ("repos_enabled", True),
+            ("repos_enabled", False),
+            ("status", "development"),
+            ("status", "testing"),
+            ("status", "acceptance"),
+            ("status", "production"),
+            ("proxy", "testproxy"),
+            ("server", "1.1.1.1"),
+            # ("boot_loaders", "pxe ipxe grub"), FIXME: This raises currently but it did not in the past
+            ("virt_auto_boot", True),
+            ("virt_auto_boot", False),
+            ("virt_auto_boot", "yes"),
+            ("virt_auto_boot", "no"),
+            ("virt_cpus", "<<inherit>>"),
+            ("virt_cpus", 1),
+            ("virt_cpus", 2),
+            ("virt_cpus", "<<inherit>>"),
+            ("virt_file_size", "<<inherit>>"),
+            ("virt_file_size", 5),
+            ("virt_file_size", 10),
+            ("virt_disk_driver", "<<inherit>>"),
+            ("virt_disk_driver", "raw"),
+            ("virt_disk_driver", "qcow2"),
+            ("virt_disk_driver", "vdmk"),
+            ("virt_ram", "<<inherit>>"),
+            ("virt_ram", 256),
+            ("virt_ram", 1024),
+            ("virt_type", "<<inherit>>"),
+            ("virt_type", "xenpv"),
+            ("virt_type", "xenfv"),
+            ("virt_type", "qemu"),
+            ("virt_type", "kvm"),
+            ("virt_type", "vmware"),
+            ("virt_type", "openvz"),
+            ("virt_path", "<<inherit>>"),
+            ("virt_path", "/path/to/test"),
+            ("virt_pxe_boot", True),
+            ("virt_pxe_boot", False),
+            ("power_type", "ipmilanplus"),
+            ("power_address", "127.0.0.1"),
+            ("power_id", "pmachine:lpar1"),
+            ("power_pass", "pass"),
+            ("power_user", "user"),
+        ],
+    )
+    def test_create_system_positive(
+        self, remote, token, template_files, field_name, field_value
+    ):
         """
         Test: create/edit a system object
         """
@@ -99,19 +111,29 @@ def test_create_system_positive(self, remote, token, template_files, field_name,
         # Assert
         assert result
 
-    @pytest.mark.usefixtures("create_testdistro", "create_testmenu", "create_testprofile", "remove_testdistro",
-                             "remove_testmenu", "remove_testprofile", "remove_testsystem")
-    @pytest.mark.parametrize("field_name,field_value", [
-        ("autoinstall", "/path/to/bad/autoinstall"),
-        ("mgmt_parameters", "badyaml"),
-        ("profile", "badprofile"),
-        ("boot_loaders", "badloader"),
-        ("virt_cpus", "a"),
-        ("virt_file_size", "a"),
-        ("virt_ram", "a"),
-        ("virt_type", "bad"),
-        ("power_type", "bla")
-    ])
+    @pytest.mark.usefixtures(
+        "create_testdistro",
+        "create_testmenu",
+        "create_testprofile",
+        "remove_testdistro",
+        "remove_testmenu",
+        "remove_testprofile",
+        "remove_testsystem",
+    )
+    @pytest.mark.parametrize(
+        "field_name,field_value",
+        [
+            ("autoinstall", "/path/to/bad/autoinstall"),
+            ("mgmt_parameters", "badyaml"),
+            ("profile", "badprofile"),
+            ("boot_loaders", "badloader"),
+            ("virt_cpus", "a"),
+            ("virt_file_size", "a"),
+            ("virt_ram", "a"),
+            ("virt_type", "bad"),
+            ("power_type", "bla"),
+        ],
+    )
     def test_create_system_negative(self, remote, token, field_name, field_value):
         """
         Test: create/edit a system object
@@ -142,8 +164,15 @@ def test_find_system(self, remote, token):
         # Assert --> A not exiting system returns an empty list
         assert result == []
 
-    @pytest.mark.usefixtures("create_testdistro", "create_testmenu", "create_testprofile", "remove_testdistro",
-                             "remove_testmenu", "remove_testprofile", "remove_testsystem")
+    @pytest.mark.usefixtures(
+        "create_testdistro",
+        "create_testmenu",
+        "create_testprofile",
+        "remove_testdistro",
+        "remove_testmenu",
+        "remove_testprofile",
+        "remove_testsystem",
+    )
     def test_add_interface_to_system(self, remote, token):
         """
         Test: add an interface to a system
@@ -155,14 +184,114 @@ def test_add_interface_to_system(self, remote, token):
         remote.modify_system(system, "profile", "testprofile0", token)
 
         # Act
-        result = remote.modify_system(system, "modify_interface", {"macaddress-eth0": "aa:bb:cc:dd:ee:ff"}, token)
+        result = remote.modify_system(
+            system, "modify_interface", {"macaddress-eth0": "aa:bb:cc:dd:ee:ff"}, token
+        )
+        remote.save_system(system, token)
 
         # Assert --> returns true if successful
         assert result
+        assert (
+            remote.get_system("testsystem0")
+            .get("interfaces", {})
+            .get("eth0", {})
+            .get("mac_address")
+            == "aa:bb:cc:dd:ee:ff"
+        )
+
+    @pytest.mark.usefixtures(
+        "create_testdistro",
+        "create_testmenu",
+        "create_testprofile",
+        "remove_testdistro",
+        "remove_testmenu",
+        "remove_testprofile",
+        "remove_testsystem",
+    )
+    def test_remove_interface_from_system(self, remote, token):
+        """
+        Test: remove an interface from a system
+        """
+
+        # Arrange
+        system = remote.new_system(token)
+        remote.modify_system(system, "name", "testsystem0", token)
+        remote.modify_system(system, "profile", "testprofile0", token)
+        remote.modify_system(
+            system, "modify_interface", {"macaddress-eth0": "aa:bb:cc:dd:ee:ff"}, token
+        )
+        remote.save_system(system, token)
+
+        # Act
+        result = remote.modify_system(
+            system, "delete_interface", {"interface": "eth0"}, token
+        )
+        remote.save_system(system, token)
+
+        # Assert --> returns true if successful
+        assert result
+        assert (
+            remote.get_system("testsystem0").get("interfaces", {}).get("eth0", None)
+            is None
+        )
+
+    @pytest.mark.usefixtures(
+        "create_testdistro",
+        "create_testmenu",
+        "create_testprofile",
+        "remove_testdistro",
+        "remove_testmenu",
+        "remove_testprofile",
+        "remove_testsystem",
+    )
+    def test_rename_interface(self, remote, token):
+        """
+        Test: rename an interface on a system
+        """
 
-    @pytest.mark.usefixtures("create_testdistro", "create_testmenu", "create_testprofile", "create_testsystem",
-                             "remove_testdistro",
-                             "remove_testmenu", "remove_testprofile", "remove_testsystem")
+        # Arrange
+        system = remote.new_system(token)
+        remote.modify_system(system, "name", "testsystem0", token)
+        remote.modify_system(system, "profile", "testprofile0", token)
+        result_add = remote.modify_system(
+            system, "modify_interface", {"macaddress-eth0": "aa:bb:cc:dd:ee:ff"}, token
+        )
+        remote.save_system(system, token)
+
+        # Act
+        result_rename = remote.modify_system(
+            system,
+            "rename_interface",
+            {"interface": "eth0", "rename_interface": "eth_new"},
+            token,
+        )
+        remote.save_system(system, token)
+
+        # Assert --> returns true if successful
+        assert result_add
+        assert result_rename
+        assert (
+            remote.get_system("testsystem0").get("interfaces", {}).get("eth0", None)
+            is None
+        )
+        assert (
+            remote.get_system("testsystem0")
+            .get("interfaces", {})
+            .get("eth_new", {})
+            .get("mac_address")
+            == "aa:bb:cc:dd:ee:ff"
+        )
+
+    @pytest.mark.usefixtures(
+        "create_testdistro",
+        "create_testmenu",
+        "create_testprofile",
+        "create_testsystem",
+        "remove_testdistro",
+        "remove_testmenu",
+        "remove_testprofile",
+        "remove_testsystem",
+    )
     def test_copy_system(self, remote, token):
         """
         Test: copy a system object
@@ -180,9 +309,16 @@ def test_copy_system(self, remote, token):
         # Cleanup
         remote.remove_system("testsytemcopy", token)
 
-    @pytest.mark.usefixtures("create_testdistro", "create_testmenu", "create_testprofile", "create_testsystem",
-                             "remove_testdistro",
-                             "remove_testmenu", "remove_testprofile", "remove_testsystem")
+    @pytest.mark.usefixtures(
+        "create_testdistro",
+        "create_testmenu",
+        "create_testprofile",
+        "create_testsystem",
+        "remove_testdistro",
+        "remove_testmenu",
+        "remove_testprofile",
+        "remove_testsystem",
+    )
     def test_rename_system(self, remote, token):
         """
         Test: rename a system object