diff --git a/python/esxi-netinit/README.md b/python/esxi-netinit/README.md new file mode 100644 index 000000000..f44d5b6e7 --- /dev/null +++ b/python/esxi-netinit/README.md @@ -0,0 +1 @@ +# ESX Networking setup scripts diff --git a/python/esxi-netinit/doc/example.json b/python/esxi-netinit/doc/example.json new file mode 100644 index 000000000..d30287b46 --- /dev/null +++ b/python/esxi-netinit/doc/example.json @@ -0,0 +1,34 @@ +{ + "links": [ + { + "ethernet_mac_address": "14:23:f3:f5:21:50", + "id": "c23067a8-b2ca-4114-9fa9-32b0b9151e18", + "mtu": 1500, + "type": "vif", + "vif_id": "145cfec6-bedb-444c-9ef1-de1bfef67b2a" + } + ], + "networks": [ + { + "id": "b0fa63d0-fb0c-446f-bfd3-26c0a50730c0", + "ip_address": "10.4.50.118", + "link": "c23067a8-b2ca-4114-9fa9-32b0b9151e18", + "netmask": "255.255.255.192", + "network_id": "c57e4a02-73bb-4c6e-ab03-537ea11168e3", + "routes": [ + { + "gateway": "10.4.50.65", + "netmask": "0.0.0.0", + "network": "0.0.0.0" + } + ], + "type": "ipv4" + } + ], + "services": [ + { + "address": "10.4.204.3", + "type": "dns" + } + ] +} diff --git a/python/esxi-netinit/doc/multiif.json b/python/esxi-netinit/doc/multiif.json new file mode 100644 index 000000000..8ad2f8803 --- /dev/null +++ b/python/esxi-netinit/doc/multiif.json @@ -0,0 +1,90 @@ +{ + "links": [ + { + "id": "tap47bb4c37-f6", + "vif_id": "47bb4c37-f60d-474f-8ce5-c7c1d9982585", + "type": "phy", + "mtu": 1450, + "ethernet_mac_address": "14:23:f3:f5:3a:d0" + }, + { + "id": "tap1b9c25a9-39", + "vif_id": "1b9c25a9-396f-43e7-9f1c-a2dcdfd3989c", + "type": "vlan", + "mtu": 1450, + "ethernet_mac_address": "fa:16:3e:07:86:96", + "vlan_link": "tap47bb4c37-f6", + "vlan_id": 111, + "vlan_mac_address": "fa:16:3e:07:86:96" + }, + { + "id": "tape3fbe9a7-93", + "vif_id": "e3fbe9a7-933d-4e27-8ac5-858054be7772", + "type": "vlan", + "mtu": 1450, + "ethernet_mac_address": "fa:16:3e:31:50:d6", + "vlan_link": "tap47bb4c37-f6", + "vlan_id": 222, + "vlan_mac_address": "fa:16:3e:31:50:d6" + }, + { + "id": "tapd097f698-89", + "vif_id": "d097f698-8926-44e1-afe7-09fb03947f23", + "type": "vlan", + "mtu": 1450, + "ethernet_mac_address": "fa:16:3e:48:91:ef", + "vlan_link": "tap47bb4c37-f6", + "vlan_id": 444, + "vlan_mac_address": "fa:16:3e:48:91:ef" + } + ], + "networks": [ + { + "id": "network0", + "type": "ipv4", + "link": "tap47bb4c37-f6", + "ip_address": "192.168.100.170", + "netmask": "255.255.255.0", + "routes": [ + { + "network": "0.0.0.0", + "netmask": "0.0.0.0", + "gateway": "192.168.100.1" + } + ], + "network_id": "783b4239-7220-4a74-8253-415539469860", + "services": [] + }, + { + "id": "network1", + "type": "ipv4", + "link": "tap1b9c25a9-39", + "ip_address": "192.168.200.174", + "netmask": "255.255.255.0", + "routes": [], + "network_id": "9608ea7d-18d9-4298-8951-ac9dbe20db06", + "services": [] + }, + { + "id": "network2", + "type": "ipv4", + "link": "tape3fbe9a7-93", + "ip_address": "192.168.0.24", + "netmask": "255.255.255.0", + "routes": [], + "network_id": "ecff22e4-b364-4575-9d2b-dffc83c8d5b7", + "services": [] + }, + { + "id": "network3", + "type": "ipv4", + "link": "tapd097f698-89", + "ip_address": "192.168.10.133", + "netmask": "255.255.255.0", + "routes": [], + "network_id": "c47d5a38-c646-42e8-b6ca-3eecc977d645", + "services": [] + } + ], + "services": [] +} diff --git a/python/esxi-netinit/netinit/__init__.py b/python/esxi-netinit/netinit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/esxi-netinit/netinit/esxconfig.py b/python/esxi-netinit/netinit/esxconfig.py new file mode 100644 index 000000000..b5b159e3d --- /dev/null +++ b/python/esxi-netinit/netinit/esxconfig.py @@ -0,0 +1,90 @@ +from functools import cached_property + +from .esxhost import ESXHost +from .network_data import NetworkData +from .nic import NIC +from .nic_list import NICList + + +class ESXConfig: + def __init__(self, network_data: NetworkData, dry_run=False) -> None: + self.network_data = network_data + self.dry_run = dry_run + self.host = ESXHost(dry_run) + + def add_default_mgmt_interface( + self, portgroup_name, switch_name, interface_name="vmk0" + ): + self.host.portgroup_add(portgroup_name=portgroup_name, switch_name=switch_name) + self.host.add_ip_interface(name=interface_name, portgroup_name=portgroup_name) + + def clean_default_network_setup(self, portgroup_name, switch_name): + """Removes default networking setup left by the installer.""" + self.host.delete_vmknic(portgroup_name=portgroup_name) + self.host.portgroup_remove( + switch_name=switch_name, portgroup_name=portgroup_name + ) + self.host.destroy_vswitch(name=switch_name) + + def configure_default_route(self): + """Configures default route. + + If multiple default routes are present, only first one is used. + """ + route = self.network_data.default_route() + self.host.configure_default_route(route.gateway) + + def configure_portgroups(self, switch_name="vSwitch0"): + portgroups = [] + for link in self.network_data.links: + if link.type == "vlan": + vid = link.vlan_id + pg_name = f"internal_net_vid_{vid}" + self.host.portgroup_add(portgroup_name=pg_name, switch_name=switch_name) + self.host.portgroup_set_vlan(portgroup_name=pg_name, vlan_id=vid) + portgroups.append(pg_name) + return portgroups + + def configure_management_interface(self): + mgmt_network = next( + net for net in self.network_data.networks if net.default_routes() + ) + return self.host.change_ip( + "vmk0", mgmt_network.ip_address, mgmt_network.netmask + ) + + def configure_vswitch(self, uplink: NIC, switch_name: str, mtu: int): + """Sets up vSwitch.""" + self.host.create_vswitch(switch_name) + self.host.uplink_add(nic=uplink.name, switch_name=switch_name) + self.host.vswitch_failover_uplinks( + active_uplinks=[uplink.name], name=switch_name + ) + self.host.vswitch_security(name=switch_name) + self.host.vswitch_settings(mtu=mtu, name=switch_name) + + def configure_requested_dns(self): + """Configures DNS servers that were provided in network_data.json.""" + dns_servers = [ + srv.address for srv in self.network_data.services if srv.type == "dns" + ] + if not dns_servers: + return + + return self.host.configure_dns(servers=dns_servers) + + def identify_uplink(self) -> NIC: + eligible_networks = [ + net for net in self.network_data.networks if net.default_routes() + ] + if len(eligible_networks) != 1: + raise ValueError( + "the network_data.json should only contain a single default route." + "Unable to identify uplink interface" + ) + link = eligible_networks[0].link + return self.nics.find_by_mac(link.ethernet_mac_address) + + @cached_property + def nics(self): + return NICList() diff --git a/python/esxi-netinit/netinit/esxhost.py b/python/esxi-netinit/netinit/esxhost.py new file mode 100644 index 000000000..9056164be --- /dev/null +++ b/python/esxi-netinit/netinit/esxhost.py @@ -0,0 +1,267 @@ +import subprocess + + +class ESXHost: + """Low level commands for configuring various aspects of ESXi hypervisor.""" + + def __init__(self, dry_run=False) -> None: + self.dry_run = dry_run + + def __execute(self, cmd: list): + if self.dry_run: + print(f"Would execute: {' '.join(cmd)}") + return cmd + else: + subprocess.run(cmd, check=True) # noqa: S603 + + def add_ip_interface(self, name, portgroup_name): + """Adds IP interface.""" + return self.__execute( + [ + "/bin/esxcli", + "network", + "ip", + "interface", + "add", + "--interface-name", + name, + "--portgroup-name", + portgroup_name, + ] + ) + + def configure_default_route(self, gateway): + cmd = [ + "/bin/esxcli", + "network", + "ip", + "route", + "ipv4", + "add", + "-g", + gateway, + "-n", + "default", + ] + return self.__execute(cmd) + + def change_ip(self, interface, ip, netmask): + """Configures IP address on logical interface.""" + cmd = [ + "/bin/esxcli", + "network", + "ip", + "interface", + "ipv4", + "set", + "-i", + interface, + "-I", + ip, + "-N", + netmask, + "-t", + "static", + ] + return self.__execute(cmd) + + def configure_dns(self, servers=None, search=None): + """Sets up arbitrary DNS servers.""" + if not servers: + servers = [] + if not search: + search = [] + + for server in servers: + self.__execute( + [ + "/bin/esxcli", + "network", + "ip", + "dns", + "server", + "add", + "--server", + server, + ] + ) + + for domain in search: + self.__execute( + [ + "/bin/esxcli", + "network", + "ip", + "dns", + "search", + "add", + "--domain", + domain, + ] + ) + + def create_vswitch(self, name="vSwitch0", ports=256): + """Creates vSwitch.""" + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "add", + "--ports", + str(ports), + "--vswitch-name", + str(name), + ] + + return self.__execute(cmd) + + def delete_vmknic(self, portgroup_name): + """Deletes a vmknic from a portgroup.""" + return self.__execute(["/bin/esxcfg-vmknic", "-d", portgroup_name]) + + def destroy_vswitch(self, name): + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "remove", + "--vswitch-name", + name, + ] + + return self.__execute(cmd) + + def portgroup_add(self, portgroup_name, switch_name="vswitch0"): + """Adds Portgroup to a vSwitch.""" + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "portgroup", + "add", + "--portgroup-name", + str(portgroup_name), + "--vswitch-name", + str(switch_name), + ] + return self.__execute(cmd) + + # + def portgroup_remove(self, portgroup_name, switch_name): + """Removes Portgroup from a vSwitch.""" + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "portgroup", + "remove", + "--portgroup-name", + str(portgroup_name), + "--vswitch-name", + str(switch_name), + ] + return self.__execute(cmd) + + def portgroup_set_vlan(self, portgroup_name, vlan_id): + """Configures VLANid to be used on a portgroup.""" + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "portgroup", + "set", + "--portgroup-name", + str(portgroup_name), + "--vlan-id", + str(vlan_id), + ] + return self.__execute(cmd) + + def uplink_add(self, nic, switch_name="vSwitch0"): + """Adds uplink to a vSwitch.""" + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "uplink", + "add", + "--uplink-name", + str(nic), + "--vswitch-name", + str(switch_name), + ] + return self.__execute(cmd) + + def vswitch_settings(self, mtu=9000, cdp="listen", name="vSwitch0"): + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "set", + "--mtu", + str(mtu), + "--cdp-status", + cdp, + "--vswitch-name", + str(name), + ] + return self.__execute(cmd) + + def vswitch_failover_uplinks( + self, active_uplinks=None, standby_uplinks=None, name="vSwitch0" + ): + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "policy", + "failover", + "set", + ] + + if active_uplinks: + cmd.extend(["--active-uplinks", ",".join(active_uplinks)]) + if standby_uplinks: + cmd.extend(["--standby-uplinks", ",".join(standby_uplinks)]) + + cmd.extend( + [ + "--vswitch-name", + str(name), + ] + ) + return self.__execute(cmd) + + def vswitch_security( + self, + allow_forged_transmits="no", + allow_mac_change="no", + allow_promiscuous="no", + name="vSwitch0", + ): + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "policy", + "security", + "set", + "--allow-forged-transmits", + allow_forged_transmits, + "--allow-mac-change", + allow_mac_change, + "--allow-promiscuous", + allow_promiscuous, + "--vswitch-name", + str(name), + ] + return self.__execute(cmd) diff --git a/python/esxi-netinit/netinit/link.py b/python/esxi-netinit/netinit/link.py new file mode 100644 index 000000000..3f59d5eaa --- /dev/null +++ b/python/esxi-netinit/netinit/link.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from dataclasses import field + + +@dataclass +class Link: + ethernet_mac_address: str + id: str + mtu: int + type: str + vif_id: str + vlan_id: "int | None" = field(default=None) + vlan_mac_address: "str | None" = field(default=None) + vlan_link: "Link | None" = field(default=None) diff --git a/python/esxi-netinit/netinit/main.py b/python/esxi-netinit/netinit/main.py new file mode 100644 index 000000000..af91aeb04 --- /dev/null +++ b/python/esxi-netinit/netinit/main.py @@ -0,0 +1,42 @@ +import argparse +import sys + +from netinit.esxconfig import ESXConfig +from netinit.network_data import NetworkData + +OLD_MGMT_PG = "Management Network" +OLD_VSWITCH = "vSwitch0" +NEW_MGMT_PG = "mgmt" +NEW_VSWITCH = "vSwitch22" + + +def main(json_file, dry_run): + network_data = NetworkData.from_json_file(json_file) + esx = ESXConfig(network_data, dry_run=dry_run) + esx.clean_default_network_setup(OLD_MGMT_PG, OLD_VSWITCH) + esx.configure_vswitch( + uplink=esx.identify_uplink(), switch_name=NEW_VSWITCH, mtu=9000 + ) + + esx.configure_portgroups() + esx.add_default_mgmt_interface(NEW_MGMT_PG, NEW_VSWITCH) + esx.configure_management_interface() + esx.configure_default_route() + esx.configure_requested_dns() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Network configuration script") + parser.add_argument("json_file", help="Path to the JSON configuration file") + parser.add_argument( + "--dry-run", + action="store_true", + help="Perform a dry run without making any changes", + ) + args = parser.parse_args() + + try: + main(args.json_file, args.dry_run) + except Exception as e: + print(f"Error configuring network: {str(e)}") + sys.exit(1) diff --git a/python/esxi-netinit/netinit/network.py b/python/esxi-netinit/netinit/network.py new file mode 100644 index 000000000..27c008ad2 --- /dev/null +++ b/python/esxi-netinit/netinit/network.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from dataclasses import field + +from .link import Link + + +@dataclass +class Network: + id: str + ip_address: str + netmask: str + network_id: str + link: Link + type: str + routes: list + services: "list | None" = field(default=None) + + def default_routes(self): + return [route for route in self.routes if route.is_default()] diff --git a/python/esxi-netinit/netinit/network_data.py b/python/esxi-netinit/netinit/network_data.py new file mode 100644 index 000000000..41a179167 --- /dev/null +++ b/python/esxi-netinit/netinit/network_data.py @@ -0,0 +1,56 @@ +import json + +from .link import Link +from .network import Network +from .route import Route +from .service import Service + + +class NetworkData: + """Represents network_data.json.""" + + def __init__(self, data: dict) -> None: + self.data = data + self.links = self._init_links(data.get("links", [])) + self.networks = [] + + for net_data in data.get("networks", []): + net_data = net_data.copy() + routes_data = net_data.pop("routes", []) + routes = [Route(**route) for route in routes_data] + link_id = net_data.pop("link", []) + try: + relevant_link = next(link for link in self.links if link.id == link_id) + except StopIteration: + raise ValueError( + f"Link {link_id} is not defined in links section" + ) from None + self.networks.append(Network(**net_data, routes=routes, link=relevant_link)) + + self.services = [Service(**service) for service in data.get("services", [])] + + def _init_links(self, links_data): + links_data = links_data.copy() + links = [] + for link in links_data: + if "vlan_link" in link: + phy_link = next( + plink for plink in links if plink.id == link["vlan_link"] + ) + link["vlan_link"] = phy_link + + links.append(Link(**link)) + return links + + def default_route(self) -> Route: + return next( + network.default_routes()[0] + for network in self.networks + if network.default_routes() + ) + + @staticmethod + def from_json_file(path): + with open(path) as f: + data = json.load(f) + return NetworkData(data) diff --git a/python/esxi-netinit/netinit/nic.py b/python/esxi-netinit/netinit/nic.py new file mode 100644 index 000000000..533e7d5ae --- /dev/null +++ b/python/esxi-netinit/netinit/nic.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass +class NIC: + name: str + status: str + link: str + mac: str diff --git a/python/esxi-netinit/netinit/nic_list.py b/python/esxi-netinit/netinit/nic_list.py new file mode 100644 index 000000000..561b2aea0 --- /dev/null +++ b/python/esxi-netinit/netinit/nic_list.py @@ -0,0 +1,34 @@ +import subprocess + +from .nic import NIC + + +class NICList(list): + def __init__(self, data=None) -> None: + nic_data = data or self._esxi_nics() + return super().__init__(NICList.parse(nic_data)) + + @staticmethod + def parse(data): + output = [] + for line in data.split("\n"): + if line.startswith("vmnic"): + parts = line.split() + nic = NIC(name=parts[0], status=parts[3], link=parts[4], mac=parts[7]) + output.append(nic) + return output + + def _esxi_nics(self) -> str: + return subprocess.run( # noqa: S603 + [ + "/bin/esxcli", + "network", + "nic", + "list", + ], + check=True, + capture_output=True, + ).stdout.decode() + + def find_by_mac(self, mac) -> NIC: + return next(nic for nic in self if nic.mac == mac) diff --git a/python/esxi-netinit/netinit/route.py b/python/esxi-netinit/netinit/route.py new file mode 100644 index 000000000..9346e0662 --- /dev/null +++ b/python/esxi-netinit/netinit/route.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class Route: + gateway: str + netmask: str + network: str + + def is_default(self): + return self.network == "0.0.0.0" and self.netmask == "0.0.0.0" diff --git a/python/esxi-netinit/netinit/service.py b/python/esxi-netinit/netinit/service.py new file mode 100644 index 000000000..b5770ec43 --- /dev/null +++ b/python/esxi-netinit/netinit/service.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class Service: + address: str + type: str diff --git a/python/esxi-netinit/poetry.lock b/python/esxi-netinit/poetry.lock new file mode 100644 index 000000000..cc3774191 --- /dev/null +++ b/python/esxi-netinit/poetry.lock @@ -0,0 +1,167 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-subprocess" +version = "1.5.3" +description = "A plugin to fake subprocess for pytest" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest_subprocess-1.5.3-py3-none-any.whl", hash = "sha256:b62580f5a84335fb9f2ec65d49e56a3c93f4722c148fe1771a002835d310a75b"}, + {file = "pytest_subprocess-1.5.3.tar.gz", hash = "sha256:c00b1140fb0211b3153e09500d770db10770baccbe6e05ee9c140036d1d811d5"}, +] + +[package.dependencies] +pytest = ">=4.0.0" + +[package.extras] +dev = ["changelogd", "nox"] +docs = ["changelogd", "furo", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-napoleon"] +test = ["Pygments (>=2.0)", "anyio", "docutils (>=0.12)", "pytest (>=4.0)", "pytest-asyncio (>=0.15.1)", "pytest-rerunfailures", "pytest-timeout"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "639fa5d98334b296e832b33dfb58cfff03ccc40fe6d064b8b2fe5df027ba0d7b" diff --git a/python/esxi-netinit/pyproject.toml b/python/esxi-netinit/pyproject.toml new file mode 100644 index 000000000..e661764d5 --- /dev/null +++ b/python/esxi-netinit/pyproject.toml @@ -0,0 +1,53 @@ +[tool.poetry] +name = "esxi-netinit" +version = "0.1.0" +description = "Script to initialize networking on ESXi hosts with usage of Openstack network_data.json" +authors = ["Marek Skrobacki "] +license = "MIT" +readme = "README.md" +packages = [{include = "netinit"}] + +[tool.poetry.dependencies] +python = "^3.8" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.4" +pytest-subprocess = "^1.5.3" +pytest-mock = "^3.14.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = [ + "tests", +] +filterwarnings = [] + +[tool.ruff] +# use our default and override anything we need specifically +extend = "../pyproject.toml" +target-version = "py310" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S101", # allow 'assert' for pytest + "S105", # allow hardcoded passwords for testing + "S104", # false positive on binding to all ifaces +] +"tests/test_esxconfig.py" = [ +] +"tests/test_esxhost.py" = [ + "E501" # esxcli outputs with long lines +] +"tests/test_niclist.py" = [ + "E501" # esxcli outputs with long lines +] +"netinit/route.py" = [ + "S104", # false positive on binding to all ifaces +] + +[tool.poetry.scripts] +netinit = "netinit.main:main" diff --git a/python/esxi-netinit/tests/__init__.py b/python/esxi-netinit/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/esxi-netinit/tests/conftest.py b/python/esxi-netinit/tests/conftest.py new file mode 100644 index 000000000..b621d581a --- /dev/null +++ b/python/esxi-netinit/tests/conftest.py @@ -0,0 +1,133 @@ +import pytest + + +@pytest.fixture +def network_data_single(): + return { + "links": [ + { + "ethernet_mac_address": "00:11:22:33:44:55", + "id": "eth0", + "mtu": 1500, + "type": "vif", + "vif_id": "vif-12345", + } + ], + "networks": [ + { + "id": "net0", + "ip_address": "192.168.1.10", + "netmask": "255.255.255.0", + "network_id": "public", + "link": "eth0", + "type": "ipv4", + "routes": [ + { + "gateway": "192.168.1.1", + "netmask": "255.255.255.0", + "network": "192.168.2.0", + }, + { + "gateway": "192.168.1.1", + "netmask": "0.0.0.0", + "network": "0.0.0.0", + }, + ], + } + ], + "services": [{"address": "8.8.4.4", "type": "dns"}], + } + + +@pytest.fixture +def network_data_multi(): + return { + "links": [ + { + "id": "tap47bb4c37-f6", + "vif_id": "47bb4c37-f60d-474f-8ce5-c7c1d9982585", + "type": "phy", + "mtu": 1450, + "ethernet_mac_address": "14:23:f3:f5:3a:d0", + }, + { + "id": "tap1b9c25a9-39", + "vif_id": "1b9c25a9-396f-43e7-9f1c-a2dcdfd3989c", + "type": "vlan", + "mtu": 1450, + "ethernet_mac_address": "fa:16:3e:07:86:96", + "vlan_link": "tap47bb4c37-f6", + "vlan_id": 111, + "vlan_mac_address": "fa:16:3e:07:86:96", + }, + { + "id": "tape3fbe9a7-93", + "vif_id": "e3fbe9a7-933d-4e27-8ac5-858054be7772", + "type": "vlan", + "mtu": 1450, + "ethernet_mac_address": "fa:16:3e:31:50:d6", + "vlan_link": "tap47bb4c37-f6", + "vlan_id": 222, + "vlan_mac_address": "fa:16:3e:31:50:d6", + }, + { + "id": "tapd097f698-89", + "vif_id": "d097f698-8926-44e1-afe7-09fb03947f23", + "type": "vlan", + "mtu": 1450, + "ethernet_mac_address": "fa:16:3e:48:91:ef", + "vlan_link": "tap47bb4c37-f6", + "vlan_id": 444, + "vlan_mac_address": "fa:16:3e:48:91:ef", + }, + ], + "networks": [ + { + "id": "network0", + "type": "ipv4", + "link": "tap47bb4c37-f6", + "ip_address": "192.168.100.170", + "netmask": "255.255.255.0", + "routes": [ + { + "network": "0.0.0.0", + "netmask": "0.0.0.0", + "gateway": "192.168.100.1", + } + ], + "network_id": "783b4239-7220-4a74-8253-415539469860", + "services": [], + }, + { + "id": "network1", + "type": "ipv4", + "link": "tap1b9c25a9-39", + "ip_address": "192.168.200.174", + "netmask": "255.255.255.0", + "routes": [], + "network_id": "9608ea7d-18d9-4298-8951-ac9dbe20db06", + "services": [], + }, + { + "id": "network2", + "type": "ipv4", + "link": "tape3fbe9a7-93", + "ip_address": "192.168.0.24", + "netmask": "255.255.255.0", + "routes": [], + "network_id": "ecff22e4-b364-4575-9d2b-dffc83c8d5b7", + "services": [], + }, + { + "id": "network3", + "type": "ipv4", + "link": "tapd097f698-89", + "ip_address": "192.168.10.133", + "netmask": "255.255.255.0", + "routes": [], + "network_id": "c47d5a38-c646-42e8-b6ca-3eecc977d645", + "services": [], + }, + ], + "services": [], + } diff --git a/python/esxi-netinit/tests/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py new file mode 100644 index 000000000..801a31319 --- /dev/null +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -0,0 +1,47 @@ +import pytest + +from netinit.esxconfig import ESXConfig +from netinit.esxhost import ESXHost +from netinit.network_data import NetworkData + + +@pytest.fixture +def host_mock(mocker): + return mocker.Mock(spec=ESXHost) + + +def test_configure_requested_dns(host_mock, network_data_single): + ndata = NetworkData(network_data_single) + ec = ESXConfig(ndata, dry_run=False) + ec.host = host_mock + ec.configure_requested_dns() + print(host_mock.configure_dns.call_args_list) + host_mock.configure_dns.assert_called_once_with(servers=["8.8.4.4"]) + + +def test_configure_default_route(network_data_single, host_mock): + ndata = NetworkData(network_data_single) + ec = ESXConfig(ndata, dry_run=False) + ec.host = host_mock + ec.configure_default_route() + host_mock.configure_default_route.assert_called_once_with("192.168.1.1") + + +def test_configure_management_interface(network_data_single, host_mock): + ndata = NetworkData(network_data_single) + ec = ESXConfig(ndata, dry_run=False) + ec.host = host_mock + ec.configure_management_interface() + host_mock.change_ip.assert_called_once_with("vmk0", "192.168.1.10", "255.255.255.0") + + +def test_configure_portgroups(network_data_multi, host_mock): + ndata = NetworkData(network_data_multi) + ec = ESXConfig(ndata, dry_run=False) + ec.host = host_mock + ec.configure_portgroups() + assert host_mock.portgroup_add.call_count == 3 + assert host_mock.portgroup_set_vlan.call_count == 3 + host_mock.portgroup_set_vlan.assert_called_with( + portgroup_name="internal_net_vid_444", vlan_id=444 + ) diff --git a/python/esxi-netinit/tests/test_esxhost.py b/python/esxi-netinit/tests/test_esxhost.py new file mode 100644 index 000000000..7b3b084ab --- /dev/null +++ b/python/esxi-netinit/tests/test_esxhost.py @@ -0,0 +1,135 @@ +import pytest + +from netinit.esxhost import ESXHost + + +@pytest.fixture +def esx_host(fp): + fp.register(["/bin/esxcli", fp.any()]) + return ESXHost() + + +def test_portgroup_add(fp, esx_host): + esx_host.portgroup_add("mypg") + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard portgroup add --portgroup-name mypg --vswitch-name vswitch0" + ) + == 1 + ) + + +def test_portgroup_set_vlan(fp, esx_host): + esx_host.portgroup_set_vlan("mypg", 1984) + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard portgroup set --portgroup-name mypg --vlan-id 1984" + ) + == 1 + ) + + +def test_create_vswitch(fp, esx_host): + esx_host.create_vswitch(name="vSwitch8", ports=512) + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard add --ports 512 --vswitch-name vSwitch8" + ) + == 1 + ) + + +def test_destroy_vswitch(fp, esx_host): + esx_host.destroy_vswitch(name="vSwitch8") + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard remove --vswitch-name vSwitch8" + ) + == 1 + ) + + +def test_portgroup_remove(fp, esx_host): + esx_host.portgroup_remove(switch_name="vSwitch20", portgroup_name="Management") + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard portgroup remove --portgroup-name Management --vswitch-name vSwitch20" + ) + == 1 + ) + + +def test_uplink_add(fp, esx_host): + esx_host.uplink_add(switch_name="vSwitch8", nic="vmnic4") + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard uplink add --uplink-name vmnic4 --vswitch-name vSwitch8" + ) + == 1 + ) + + +def test_vswitch_settings(fp, esx_host): + esx_host.vswitch_settings(mtu=9000, cdp="listen", name="vSwitch8") + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard set --mtu 9000 --cdp-status listen --vswitch-name vSwitch8" + ) + == 1 + ) + + +def test_vswitch_failover_uplinks_active(fp, esx_host): + esx_host.vswitch_failover_uplinks(active_uplinks=["vmnic4", "vmnic10"]) + assert fp.call_count( + "/bin/esxcli network vswitch standard policy failover set --active-uplinks vmnic4,vmnic10 --vswitch-name vSwitch0" + ) + + +def test_vswitch_failover_uplinks_standby(fp, esx_host): + esx_host.vswitch_failover_uplinks(standby_uplinks=["vmnic3", "vmnic7"]) + assert fp.call_count( + "/bin/esxcli network vswitch standard policy failover set --standby-uplinks vmnic3,vmnic7 --vswitch-name vSwitch0" + ) + + +def test_vswitch_security(fp, esx_host): + esx_host.vswitch_security( + allow_forged_transmits="no", + allow_mac_change="no", + allow_promiscuous="yes", + name="vSwitch7", + ) + assert ( + fp.call_count( + "/bin/esxcli network vswitch standard policy security set --allow-forged-transmits no --allow-mac-change no --allow-promiscuous yes --vswitch-name vSwitch7" + ) + == 1 + ) + + +def test_configure_dns(fp, esx_host): + fp.register(["/bin/esxcli", fp.any()]) + fp.keep_last_process(True) + esx_host.configure_dns(servers=["8.8.8.8", "4.4.4.4"], search=["example.com"]) + assert fp.call_count("/bin/esxcli network ip dns server add --server 8.8.8.8") == 1 + assert fp.call_count("/bin/esxcli network ip dns server add --server 4.4.4.4") == 1 + assert ( + fp.call_count("/bin/esxcli network ip dns search add --domain example.com") == 1 + ) + + +def test_delete_vmknic(fp, esx_host): + fp.register(["/bin/esxcfg-vmknic", fp.any()]) + esx_host.delete_vmknic(portgroup_name="ManagementPG") + assert fp.call_count("/bin/esxcfg-vmknic -d ManagementPG") == 1 + + +def test_add_ip_interface(fp, esx_host): + esx_host.add_ip_interface(name="vmk1", portgroup_name="VMNet-Mgmt") + assert ( + fp.call_count( + "/bin/esxcli network ip interface add --interface-name vmk1 --portgroup-name VMNet-Mgmt" + ) + == 1 + ) diff --git a/python/esxi-netinit/tests/test_networkdata.py b/python/esxi-netinit/tests/test_networkdata.py new file mode 100644 index 000000000..5ca8a8cb2 --- /dev/null +++ b/python/esxi-netinit/tests/test_networkdata.py @@ -0,0 +1,89 @@ +import json +from dataclasses import is_dataclass + +from netinit.link import Link +from netinit.network_data import NetworkData +from netinit.route import Route + + +def test_links_parsing(network_data_single): + network_data = NetworkData(network_data_single) + assert len(network_data.links) == 1 + + link = network_data.links[0] + assert link.ethernet_mac_address == "00:11:22:33:44:55" + assert link.id == "eth0" + assert link.mtu == 1500 + assert link.type == "vif" + assert link.vif_id == "vif-12345" + + +def test_networks_parsing(network_data_single): + network_data = NetworkData(network_data_single) + assert len(network_data.networks) == 1 + + network = network_data.networks[0] + assert network.id == "net0" + assert network.ip_address == "192.168.1.10" + assert network.netmask == "255.255.255.0" + assert network.network_id == "public" + assert network.link == Link( + ethernet_mac_address="00:11:22:33:44:55", + id="eth0", + mtu=1500, + type="vif", + vif_id="vif-12345", + ) + + # Test routes parsing + assert len(network.routes) == 2 + assert all(is_dataclass(route) for route in network.routes) + + # Test route values + assert network.routes[0].gateway == "192.168.1.1" + assert network.routes[1].network == "0.0.0.0" + + +def test_services_parsing(network_data_single): + network_data = NetworkData(network_data_single) + assert len(network_data.services) == 1 + + service = network_data.services[0] + assert is_dataclass(service) + assert service.address == "8.8.4.4" + assert service.type == "dns" + + +def test_route_default_check(): + default_route = Route(gateway="10.0.0.1", netmask="0.0.0.0", network="0.0.0.0") + non_default_route = Route( + gateway="192.168.1.1", netmask="255.255.255.0", network="192.168.1.0" + ) + + assert default_route.is_default() is True + assert non_default_route.is_default() is False + + +def test_from_json_file(tmp_path, network_data_single): + # Create temporary JSON file + file_path = tmp_path / "test.json" + with open(file_path, "w") as f: + json.dump(network_data_single, f) + + # Test loading from file + network_data = NetworkData.from_json_file(file_path) + + # Verify basic structure + assert len(network_data.links) == 1 + assert len(network_data.networks) == 1 + assert len(network_data.services) == 1 + + # Spot check one value + assert network_data.networks[0].routes[1].is_default() is True + + +def test_empty_data(): + empty_data = NetworkData({}) + assert len(empty_data.links) == 0 + assert len(empty_data.networks) == 0 + assert len(empty_data.services) == 0 diff --git a/python/esxi-netinit/tests/test_niclist.py b/python/esxi-netinit/tests/test_niclist.py new file mode 100644 index 000000000..570bf8b68 --- /dev/null +++ b/python/esxi-netinit/tests/test_niclist.py @@ -0,0 +1,38 @@ +import pytest + +from netinit.nic_list import NICList + + +@pytest.fixture +def sample_niclist_data(): + return """Name PCI Device Driver Admin Status Link Status Speed Duplex MAC Address MTU Description +------ ------------ ------- ------------ ----------- ----- ------ ----------------- ---- ----------- +vmnic0 0000:c3:00.0 ntg3 Up Down 0 Half c4:cb:e1:bf:79:b6 1500 Broadcom Corporation NetXtreme BCM5720 Gigabit Ethernet +vmnic1 0000:c3:00.1 ntg3 Up Down 0 Half c4:cb:e1:bf:79:b7 1500 Broadcom Corporation NetXtreme BCM5720 Gigabit Ethernet +vmnic2 0000:c5:00.0 bnxtnet Up Up 25000 Full d4:04:e6:50:3e:9c 1500 Broadcom NetXtreme E-Series Dual-port 25Gb SFP28 Ethernet OCP 3.0 Adapter +vmnic3 0000:c5:00.1 bnxtnet Up Up 25000 Full d4:04:e6:50:3e:9d 1500 Broadcom NetXtreme E-Series Dual-port 25Gb SFP28 Ethernet OCP 3.0 Adapter +vmnic4 0000:c4:00.0 bnxtnet Up Up 25000 Full 14:23:f3:f5:21:50 1500 Broadcom BCM57414 NetXtreme-E 10Gb/25Gb RDMA Ethernet Controller +vmnic5 0000:c4:00.1 bnxtnet Up Up 25000 Full 14:23:f3:f5:21:51 1500 Broadcom BCM57414 NetXtreme-E 10Gb/25Gb RDMA Ethernet Controller""" + + +def test_parse_niclist(sample_niclist_data): + nics = NICList.parse(sample_niclist_data) + + assert len(nics) == 6 + assert nics[0].name == "vmnic0" + assert nics[0].mac == "c4:cb:e1:bf:79:b6" + assert nics[0].status == "Up" + assert nics[0].link == "Down" + assert nics[2].name == "vmnic2" + assert nics[2].mac == "d4:04:e6:50:3e:9c" + assert nics[2].status == "Up" + assert nics[2].link == "Up" + + +def test_find_by_mac(sample_niclist_data): + nics = NICList(sample_niclist_data) + + found = nics.find_by_mac("d4:04:e6:50:3e:9c") + + assert found.name == "vmnic2" + assert found.mac == "d4:04:e6:50:3e:9c"