From d153fe34b819ed1405e2a53e59fb3f7d2cc4abfa Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 11:36:12 +0000 Subject: [PATCH 01/19] first draft of networkdata parsing --- python/esxi-netinit/README.md | 1 + python/esxi-netinit/example.json | 34 ++++ python/esxi-netinit/netinit.py | 198 ++++++++++++++++++++ python/esxi-netinit/poetry.lock | 131 +++++++++++++ python/esxi-netinit/pyproject.toml | 43 +++++ python/esxi-netinit/tests/__init__.py | 0 python/esxi-netinit/tests/test_esxconfig.py | 38 ++++ python/esxi-netinit/tests/test_netinit.py | 126 +++++++++++++ 8 files changed, 571 insertions(+) create mode 100644 python/esxi-netinit/README.md create mode 100644 python/esxi-netinit/example.json create mode 100644 python/esxi-netinit/netinit.py create mode 100644 python/esxi-netinit/poetry.lock create mode 100644 python/esxi-netinit/pyproject.toml create mode 100644 python/esxi-netinit/tests/__init__.py create mode 100644 python/esxi-netinit/tests/test_esxconfig.py create mode 100644 python/esxi-netinit/tests/test_netinit.py 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/example.json b/python/esxi-netinit/example.json new file mode 100644 index 000000000..d30287b46 --- /dev/null +++ b/python/esxi-netinit/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/netinit.py b/python/esxi-netinit/netinit.py new file mode 100644 index 000000000..711e37d60 --- /dev/null +++ b/python/esxi-netinit/netinit.py @@ -0,0 +1,198 @@ +import json +import subprocess +import sys +from dataclasses import dataclass +from functools import cached_property + + +@dataclass +class Link: + ethernet_mac_address: str + id: str + mtu: int + type: str + vif_id: str + + +@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" + + +@dataclass +class Network: + id: str + ip_address: str + netmask: str + network_id: str + link: Link + type: str + routes: list + services: list + + def default_routes(self): + return [route for route in self.routes if route.is_default()] + + +@dataclass +class Service: + address: str + type: str + + +class NetworkData: + """Represents network_data.json.""" + + def __init__(self, data: dict) -> None: + self.data = data + self.links = [Link(**link) for link in 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", []) + relevant_link = next(link for link in self.links if link.id == link_id) + self.networks.append(Network(**net_data, routes=routes, link=relevant_link)) + + self.services = [Service(**service) for service in data.get("services", [])] + + 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) + + +@dataclass +class NIC: + name: str + status: str + link: str + mac: str + + +class NICList: + def __init__(self, data=None) -> None: + self.nics = NICList.parse(data or self._esxi_nics()) + + @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.nics if nic.mac == mac) + + +class ESXConfig: + def __init__(self, network_data: NetworkData, dry_run=False) -> None: + self.network_data = network_data + self.dry_run = dry_run + + 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() + cmd = [ + "/bin/esxcli", + "network", + "ip", + "route", + "ipv4", + "add", + "-g", + route.gateway, + "-n", + "default", + ] + if self.dry_run: + print(f"Executing: {cmd}") + else: + return subprocess.run(cmd, check=True) # noqa: S603 + + def configure_interfaces(self): + for net in self.network_data.networks: + nic = self.nics.find_by_mac(net.link.ethernet_mac_address) + self._change_ip(nic.name, net.ip_address, net.netmask) + + @cached_property + def nics(self): + return NICList() + + def _change_ip(self, interface, ip, netmask): + cmd = [ + "/bin/esxcli", + "network", + "ip", + "interface", + "ipv4", + "set", + "-i", + interface, + "-I", + ip, + "-N", + netmask, + "-t", + "static", + ] + if self.dry_run: + print(f"Executing: {cmd}") + else: + subprocess.run(cmd, check=True) # noqa: S603 + + + +### + +def main(json_file, dry_run): + network_data = NetworkData.from_json_file(json_file) + esx = ESXConfig(network_data, dry_run=dry_run) + esx.configure_interfaces() + esx.configure_default_route() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [--dry-run]") + sys.exit(1) + + try: + main(sys.argv[1], sys.argv[2] == "--dry-run") + except Exception as e: + print(f"Error configuring network: {str(e)}") + sys.exit(1) + diff --git a/python/esxi-netinit/poetry.lock b/python/esxi-netinit/poetry.lock new file mode 100644 index 000000000..cabecea77 --- /dev/null +++ b/python/esxi-netinit/poetry.lock @@ -0,0 +1,131 @@ +# 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 = "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 = "216fba107e98e72fe9daa75b7262bddfaa8a7fc6cc991c83962f67f684f6b6ec" diff --git a/python/esxi-netinit/pyproject.toml b/python/esxi-netinit/pyproject.toml new file mode 100644 index 000000000..d3de351e8 --- /dev/null +++ b/python/esxi-netinit/pyproject.toml @@ -0,0 +1,43 @@ +[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" + +[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" = [ + "E501" # esxcli outputs with long lines +] +"netinit.py" = [ + "S104", # false positive on binding to all ifaces +] 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/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py new file mode 100644 index 000000000..38bef7eca --- /dev/null +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -0,0 +1,38 @@ +import pytest + +from netinit 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" diff --git a/python/esxi-netinit/tests/test_netinit.py b/python/esxi-netinit/tests/test_netinit.py new file mode 100644 index 000000000..0a2553edf --- /dev/null +++ b/python/esxi-netinit/tests/test_netinit.py @@ -0,0 +1,126 @@ +import json +from dataclasses import is_dataclass + +import pytest + +from netinit import Link +from netinit import NetworkData +from netinit import Route + + +@pytest.fixture +def sample_data(): + 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.1.0", + }, + {"gateway": "10.0.0.1", "netmask": "0.0.0.0", "network": "0.0.0.0"}, + ], + } + ], + "services": [{"address": "8.8.8.8", "type": "dns"}], + } + + +def test_links_parsing(sample_data): + network_data = NetworkData(sample_data) + assert len(network_data.links) == 1 + + link = network_data.links[0] + assert is_dataclass(link) + 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(sample_data): + network_data = NetworkData(sample_data) + 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(sample_data): + network_data = NetworkData(sample_data) + assert len(network_data.services) == 1 + + service = network_data.services[0] + assert is_dataclass(service) + assert service.address == "8.8.8.8" + 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, sample_data): + # Create temporary JSON file + file_path = tmp_path / "test.json" + with open(file_path, "w") as f: + json.dump(sample_data, 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 From 7c11ca572cacff9d27f56509b9cc52d04b822d14 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 15:03:35 +0000 Subject: [PATCH 02/19] separate configuration for mgmt and others --- python/esxi-netinit/netinit.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 711e37d60..85c5c153a 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -145,9 +145,18 @@ def configure_default_route(self): def configure_interfaces(self): for net in self.network_data.networks: + if net.default_routes(): + # we handle the management interface differently + continue nic = self.nics.find_by_mac(net.link.ethernet_mac_address) self._change_ip(nic.name, net.ip_address, net.netmask) + def configure_management_interface(self): + mgmt_network = next( + net for net in self.network_data.networks if net.default_routes() + ) + return self._change_ip("vmk0", mgmt_network.ip_address, mgmt_network.netmask) + @cached_property def nics(self): return NICList() @@ -175,14 +184,15 @@ def _change_ip(self, interface, ip, netmask): subprocess.run(cmd, check=True) # noqa: S603 - ### + def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) - esx.configure_interfaces() + esx.configure_management_interface() esx.configure_default_route() + esx.configure_interfaces() if __name__ == "__main__": @@ -195,4 +205,3 @@ def main(json_file, dry_run): except Exception as e: print(f"Error configuring network: {str(e)}") sys.exit(1) - From cbb62ad3c5d82b7ee8159d6add5d3d72431f3447 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 15:41:15 +0000 Subject: [PATCH 03/19] esxinit: add methods for portgroups --- python/esxi-netinit/netinit.py | 51 ++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 85c5c153a..8007711df 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -1,7 +1,7 @@ import json import subprocess import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import cached_property @@ -12,6 +12,7 @@ class Link: mtu: int type: str vif_id: str + vlan_link: 'Link | None' = field(default=None) @dataclass @@ -120,6 +121,12 @@ def __init__(self, network_data: NetworkData, dry_run=False) -> None: self.network_data = network_data self.dry_run = dry_run + def __execute(self, cmd: list[str]): + if self.dry_run: + print(f"Executing: {cmd}") + else: + subprocess.run(cmd, check=True) # noqa: S603 + def configure_default_route(self): """Configures default route. @@ -138,10 +145,7 @@ def configure_default_route(self): "-n", "default", ] - if self.dry_run: - print(f"Executing: {cmd}") - else: - return subprocess.run(cmd, check=True) # noqa: S603 + return self.__execute(cmd) def configure_interfaces(self): for net in self.network_data.networks: @@ -157,6 +161,38 @@ def configure_management_interface(self): ) return self._change_ip("vmk0", mgmt_network.ip_address, mgmt_network.netmask) + def portgroup_add(self, portgroup_name, switch_name="vswitch0"): + """Adds Portgroup to a vSwitch.""" + cmd = [ + "esxcli", + "network", + "vswitch", + "standard", + "portgroup", + "add", + "--portgroup-name", + portgroup_name, + "--vswitch-name", + switch_name, + ] + return self.__execute(cmd) + + def portgroup_set_vlan(self, portgroup_name, vlan_id): + """Configures VLANid to be used on a portgroup.""" + cmd = [ + "esxcli", + "network", + "vswitch", + "standard", + "portgroup", + "add", + "--portgroup-name", + portgroup_name, + "--vlan-id", + vlan_id, + ] + return self.__execute(cmd) + @cached_property def nics(self): return NICList() @@ -178,10 +214,7 @@ def _change_ip(self, interface, ip, netmask): "-t", "static", ] - if self.dry_run: - print(f"Executing: {cmd}") - else: - subprocess.run(cmd, check=True) # noqa: S603 + return self.__execute(cmd) ### From 469c7cbc77a4f39c215e9b89dc9516031881a262 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 15:41:27 +0000 Subject: [PATCH 04/19] add multi interface example --- python/esxi-netinit/multiif.json | 90 ++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 python/esxi-netinit/multiif.json diff --git a/python/esxi-netinit/multiif.json b/python/esxi-netinit/multiif.json new file mode 100644 index 000000000..8ad2f8803 --- /dev/null +++ b/python/esxi-netinit/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": [] +} From ee78441d12f5d2c4f2250e6ce974eba57c8bc0aa Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 16:25:32 +0000 Subject: [PATCH 05/19] support portgroups configuration --- python/esxi-netinit/netinit.py | 51 ++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 8007711df..90991f544 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -12,7 +12,9 @@ class Link: mtu: int type: str vif_id: str - vlan_link: 'Link | None' = field(default=None) + vlan_id: int | None = field(default=None) + vlan_mac_address: str | None = field(default=None) + vlan_link: "Link | None" = field(default=None) @dataclass @@ -34,7 +36,7 @@ class Network: link: Link type: str routes: list - services: list + services: list | None = field(default=None) def default_routes(self): return [route for route in self.routes if route.is_default()] @@ -51,7 +53,7 @@ class NetworkData: def __init__(self, data: dict) -> None: self.data = data - self.links = [Link(**link) for link in data.get("links", [])] + self.links = self._init_links(data.get("links", [])) self.networks = [] for net_data in data.get("networks", []): @@ -64,6 +66,19 @@ def __init__(self, data: dict) -> None: 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] @@ -123,7 +138,7 @@ def __init__(self, network_data: NetworkData, dry_run=False) -> None: def __execute(self, cmd: list[str]): if self.dry_run: - print(f"Executing: {cmd}") + print(f"Would exececute: {' '.join(cmd)}") else: subprocess.run(cmd, check=True) # noqa: S603 @@ -147,13 +162,13 @@ def configure_default_route(self): ] return self.__execute(cmd) - def configure_interfaces(self): - for net in self.network_data.networks: - if net.default_routes(): - # we handle the management interface differently - continue - nic = self.nics.find_by_mac(net.link.ethernet_mac_address) - self._change_ip(nic.name, net.ip_address, net.netmask) + def configure_portgroups(self): + for link in self.network_data.links: + if link.type == "vlan": + vid = link.vlan_id + pg_name = f"internal_net_vid_{vid}" + self.portgroup_add(portgroup_name=pg_name) + self.portgroup_set_vlan(portgroup_name=pg_name, vlan_id=vid) def configure_management_interface(self): mgmt_network = next( @@ -164,32 +179,32 @@ def configure_management_interface(self): def portgroup_add(self, portgroup_name, switch_name="vswitch0"): """Adds Portgroup to a vSwitch.""" cmd = [ - "esxcli", + "/bin/esxcli", "network", "vswitch", "standard", "portgroup", "add", "--portgroup-name", - portgroup_name, + str(portgroup_name), "--vswitch-name", - switch_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 = [ - "esxcli", + "/bin/esxcli", "network", "vswitch", "standard", "portgroup", "add", "--portgroup-name", - portgroup_name, + str(portgroup_name), "--vlan-id", - vlan_id, + str(vlan_id), ] return self.__execute(cmd) @@ -225,7 +240,7 @@ def main(json_file, dry_run): esx = ESXConfig(network_data, dry_run=dry_run) esx.configure_management_interface() esx.configure_default_route() - esx.configure_interfaces() + esx.configure_portgroups() if __name__ == "__main__": From eefa654d56b2c989f0ac49ed65b0f4de24b6e30d Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 17:20:36 +0000 Subject: [PATCH 06/19] better tests and bugfixes --- python/esxi-netinit/netinit.py | 17 ++- python/esxi-netinit/poetry.lock | 38 ++++- python/esxi-netinit/pyproject.toml | 2 + python/esxi-netinit/tests/conftest.py | 133 ++++++++++++++++++ python/esxi-netinit/tests/test_esxconfig.py | 99 +++++++++---- .../{test_netinit.py => test_networkdata.py} | 51 ++----- python/esxi-netinit/tests/test_niclist.py | 38 +++++ 7 files changed, 300 insertions(+), 78 deletions(-) create mode 100644 python/esxi-netinit/tests/conftest.py rename python/esxi-netinit/tests/{test_netinit.py => test_networkdata.py} (63%) create mode 100644 python/esxi-netinit/tests/test_niclist.py diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 90991f544..0794e95a9 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -1,7 +1,8 @@ import json import subprocess import sys -from dataclasses import dataclass, field +from dataclasses import dataclass +from dataclasses import field from functools import cached_property @@ -61,7 +62,12 @@ def __init__(self, data: dict) -> None: routes_data = net_data.pop("routes", []) routes = [Route(**route) for route in routes_data] link_id = net_data.pop("link", []) - relevant_link = next(link for link in self.links if link.id == link_id) + 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", [])] @@ -139,6 +145,7 @@ def __init__(self, network_data: NetworkData, dry_run=False) -> None: def __execute(self, cmd: list[str]): if self.dry_run: print(f"Would exececute: {' '.join(cmd)}") + return cmd else: subprocess.run(cmd, check=True) # noqa: S603 @@ -200,7 +207,7 @@ def portgroup_set_vlan(self, portgroup_name, vlan_id): "vswitch", "standard", "portgroup", - "add", + "set", "--portgroup-name", str(portgroup_name), "--vlan-id", @@ -231,10 +238,6 @@ def _change_ip(self, interface, ip, netmask): ] return self.__execute(cmd) - -### - - def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) diff --git a/python/esxi-netinit/poetry.lock b/python/esxi-netinit/poetry.lock index cabecea77..cc3774191 100644 --- a/python/esxi-netinit/poetry.lock +++ b/python/esxi-netinit/poetry.lock @@ -84,6 +84,42 @@ 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" @@ -128,4 +164,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "216fba107e98e72fe9daa75b7262bddfaa8a7fc6cc991c83962f67f684f6b6ec" +content-hash = "639fa5d98334b296e832b33dfb58cfff03ccc40fe6d064b8b2fe5df027ba0d7b" diff --git a/python/esxi-netinit/pyproject.toml b/python/esxi-netinit/pyproject.toml index d3de351e8..666e27544 100644 --- a/python/esxi-netinit/pyproject.toml +++ b/python/esxi-netinit/pyproject.toml @@ -12,6 +12,8 @@ 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"] diff --git a/python/esxi-netinit/tests/conftest.py b/python/esxi-netinit/tests/conftest.py new file mode 100644 index 000000000..eeceb2a42 --- /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.8.8", "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 index 38bef7eca..8a0eb6f7e 100644 --- a/python/esxi-netinit/tests/test_esxconfig.py +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -1,38 +1,81 @@ -import pytest +from netinit import ESXConfig +from netinit import NetworkData -from netinit import NICList +def test_configure_default_route(fp, network_data_single): + fp.register(["/bin/esxcli", fp.any()]) + ndata = NetworkData(network_data_single) + ec = ESXConfig(ndata, dry_run=False) + ec.configure_default_route() + assert fp.call_count("/bin/esxcli network ip route ipv4 add -g 192.168.1.1 -n default") == 1 -@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_configure_management_interface(fp, network_data_single): + fp.register(["/bin/esxcli", fp.any()]) + ndata = NetworkData(network_data_single) + ec = ESXConfig(ndata, dry_run=False) + ec.configure_management_interface() + assert fp.call_count("/bin/esxcli network ip interface ipv4 set -i vmk0 -I 192.168.1.10 -N 255.255.255.0 -t static") == 1 +def test_portgroup_add(fp): + fp.register(["/bin/esxcli", fp.any()]) + ec = ESXConfig(NetworkData({})) + ec.portgroup_add("mypg") + print(fp.calls) + assert fp.call_count("/bin/esxcli network vswitch standard portgroup add --portgroup-name mypg --vswitch-name vswitch0") == 1 -def test_parse_niclist(sample_niclist_data): - nics = NICList.parse(sample_niclist_data) +def test_portgroup_set_vlan(fp): + fp.register(["/bin/esxcli", fp.any()]) + ec = ESXConfig(NetworkData({})) + ec.portgroup_set_vlan("mypg", 1984) + print(fp.calls) + assert fp.call_count("/bin/esxcli network vswitch standard portgroup set --portgroup-name mypg --vlan-id 1984") == 1 - 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_configure_portgroups(fp, mocker, network_data_multi) : + fp.register(["/bin/esxcli", fp.any()]) + ndata = NetworkData(network_data_multi) + ec = ESXConfig(ndata, dry_run=False) + pgadd_mock = mocker.patch.object(ec, "portgroup_add") + pgset_mock = mocker.patch.object(ec, "portgroup_set_vlan") + ec.configure_portgroups() + assert pgadd_mock.call_count == 3 + assert pgset_mock.call_count == 3 + pgset_mock.assert_called_with(portgroup_name="internal_net_vid_444", vlan_id=444) +def test_create_vswitch(fp, empty_ec): + empty_ec.create_vswitch(name="vSwitch8", ports=512) + assert fp.call_count("/bin/esxcli network vswitch standard add --ports 512 --vswitch-name vSwitch8") == 1 -def test_find_by_mac(sample_niclist_data): - nics = NICList(sample_niclist_data) +def test_uplink_add(fp, empty_ec): + empty_ec.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 - found = nics.find_by_mac("d4:04:e6:50:3e:9c") +def test_vswitch_settings(fp, empty_ec): + empty_ec.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 - assert found.name == "vmnic2" - assert found.mac == "d4:04:e6:50:3e:9c" +def test_vswitch_failover_uplinks_active(fp, empty_ec): + empty_ec.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, empty_ec): + empty_ec.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, empty_ec): + empty_ec.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, empty_ec): + fp.register(["/bin/esxcli", fp.any()]) + fp.keep_last_process(True) + empty_ec.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_configure_requested_dns(fp, network_data_single): + fp.register(["/bin/esxcli", fp.any()]) + ndata = NetworkData(network_data_single) + ec = ESXConfig(ndata, dry_run=False) + ec.configure_requested_dns() + assert fp.call_count("/bin/esxcli network ip dns server add --server 8.8.4.4") == 1 diff --git a/python/esxi-netinit/tests/test_netinit.py b/python/esxi-netinit/tests/test_networkdata.py similarity index 63% rename from python/esxi-netinit/tests/test_netinit.py rename to python/esxi-netinit/tests/test_networkdata.py index 0a2553edf..74e2183e8 100644 --- a/python/esxi-netinit/tests/test_netinit.py +++ b/python/esxi-netinit/tests/test_networkdata.py @@ -8,42 +8,9 @@ from netinit import Route -@pytest.fixture -def sample_data(): - 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.1.0", - }, - {"gateway": "10.0.0.1", "netmask": "0.0.0.0", "network": "0.0.0.0"}, - ], - } - ], - "services": [{"address": "8.8.8.8", "type": "dns"}], - } - - -def test_links_parsing(sample_data): - network_data = NetworkData(sample_data) + +def test_links_parsing(network_data_single): + network_data = NetworkData(network_data_single) assert len(network_data.links) == 1 link = network_data.links[0] @@ -55,8 +22,8 @@ def test_links_parsing(sample_data): assert link.vif_id == "vif-12345" -def test_networks_parsing(sample_data): - network_data = NetworkData(sample_data) +def test_networks_parsing(network_data_single): + network_data = NetworkData(network_data_single) assert len(network_data.networks) == 1 network = network_data.networks[0] @@ -81,8 +48,8 @@ def test_networks_parsing(sample_data): assert network.routes[1].network == "0.0.0.0" -def test_services_parsing(sample_data): - network_data = NetworkData(sample_data) +def test_services_parsing(network_data_single): + network_data = NetworkData(network_data_single) assert len(network_data.services) == 1 service = network_data.services[0] @@ -101,11 +68,11 @@ def test_route_default_check(): assert non_default_route.is_default() is False -def test_from_json_file(tmp_path, sample_data): +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(sample_data, f) + json.dump(network_data_single, f) # Test loading from file network_data = NetworkData.from_json_file(file_path) diff --git a/python/esxi-netinit/tests/test_niclist.py b/python/esxi-netinit/tests/test_niclist.py new file mode 100644 index 000000000..38bef7eca --- /dev/null +++ b/python/esxi-netinit/tests/test_niclist.py @@ -0,0 +1,38 @@ +import pytest + +from netinit 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" From 08f722733eaa099e3e6cc8d76713d6c23388b56a Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 17:32:45 +0000 Subject: [PATCH 07/19] add create_vswitch and uplink_add --- python/esxi-netinit/netinit.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 0794e95a9..0b7ecce7c 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -220,6 +220,7 @@ def nics(self): return NICList() def _change_ip(self, interface, ip, netmask): + """Configures IP address on logical interface.""" cmd = [ "/bin/esxcli", "network", @@ -238,6 +239,38 @@ def _change_ip(self, interface, ip, netmask): ] return self.__execute(cmd) + 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 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 main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) From 089ec3547466b466ab7e1986fc81270f0d26b8dd Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 17:59:37 +0000 Subject: [PATCH 08/19] add low level cmds to configure vswitch --- python/esxi-netinit/netinit.py | 68 +++++++++++++++++++++ python/esxi-netinit/tests/test_esxconfig.py | 21 ++++--- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 0b7ecce7c..cf21e3638 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -270,6 +270,74 @@ def uplink_add(self, nic, switch_name="vSwitch0"): ] 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="yes", + name="vSwitch7", + ): + 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) + def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) diff --git a/python/esxi-netinit/tests/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py index 8a0eb6f7e..b75288cfc 100644 --- a/python/esxi-netinit/tests/test_esxconfig.py +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -1,7 +1,14 @@ +import pytest + from netinit import ESXConfig from netinit import NetworkData +@pytest.fixture +def empty_ec(fp): + fp.register(["/bin/esxcli", fp.any()]) + return ESXConfig(NetworkData({})) + def test_configure_default_route(fp, network_data_single): fp.register(["/bin/esxcli", fp.any()]) ndata = NetworkData(network_data_single) @@ -16,18 +23,12 @@ def test_configure_management_interface(fp, network_data_single): ec.configure_management_interface() assert fp.call_count("/bin/esxcli network ip interface ipv4 set -i vmk0 -I 192.168.1.10 -N 255.255.255.0 -t static") == 1 -def test_portgroup_add(fp): - fp.register(["/bin/esxcli", fp.any()]) - ec = ESXConfig(NetworkData({})) - ec.portgroup_add("mypg") - print(fp.calls) +def test_portgroup_add(fp, empty_ec): + empty_ec.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): - fp.register(["/bin/esxcli", fp.any()]) - ec = ESXConfig(NetworkData({})) - ec.portgroup_set_vlan("mypg", 1984) - print(fp.calls) +def test_portgroup_set_vlan(fp, empty_ec): + empty_ec.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_configure_portgroups(fp, mocker, network_data_multi) : From a9d14ad13cea089ff2e21e624515b6e423f07aea Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 18:20:38 +0000 Subject: [PATCH 09/19] add basic configure_vswitch The MTU situation is bit tricky - we need to figure out what to do when there are multiple links. --- python/esxi-netinit/netinit.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index cf21e3638..138191c8f 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -316,8 +316,8 @@ def vswitch_security( self, allow_forged_transmits="no", allow_mac_change="no", - allow_promiscuous="yes", - name="vSwitch7", + allow_promiscuous="no", + name="vSwitch0", ): cmd = [ "/bin/esxcli", @@ -338,12 +338,35 @@ def vswitch_security( ] return self.__execute(cmd) + 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) + + def configure_vswitch(self, uplink: NIC, switch_name: str, mtu: int): + """Sets up vSwitch.""" + self.create_vswitch(switch_name) + self.uplink_add(nic=uplink.name, switch_name=switch_name) + self.vswitch_failover_uplinks(active_uplinks=[uplink.name], name=switch_name) + self.vswitch_security(name=switch_name) + self.vswitch_settings(mtu=mtu, name=switch_name) + def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) esx.configure_management_interface() esx.configure_default_route() + esx.configure_vswitch( + uplink=esx.identify_uplink(), switch_name="vSwitch0", mtu=9000 + ) esx.configure_portgroups() From 1eb5451e4f452e1af8d6dceb226e182917830d22 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 26 Feb 2025 18:57:26 +0000 Subject: [PATCH 10/19] add DNS configuration also some adjustments for python3.8 compat --- python/esxi-netinit/netinit.py | 58 +++++++++++++++++-- python/esxi-netinit/tests/conftest.py | 2 +- python/esxi-netinit/tests/test_networkdata.py | 6 +- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 138191c8f..48841f15b 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -13,8 +13,8 @@ class Link: mtu: int type: str vif_id: str - vlan_id: int | None = field(default=None) - vlan_mac_address: str | None = field(default=None) + vlan_id: "int | None" = field(default=None) + vlan_mac_address: "str | None" = field(default=None) vlan_link: "Link | None" = field(default=None) @@ -37,7 +37,7 @@ class Network: link: Link type: str routes: list - services: list | None = field(default=None) + services: "list | None" = field(default=None) def default_routes(self): return [route for route in self.routes if route.is_default()] @@ -142,9 +142,9 @@ def __init__(self, network_data: NetworkData, dry_run=False) -> None: self.network_data = network_data self.dry_run = dry_run - def __execute(self, cmd: list[str]): + def __execute(self, cmd: list): if self.dry_run: - print(f"Would exececute: {' '.join(cmd)}") + print(f"Would execute: {' '.join(cmd)}") return cmd else: subprocess.run(cmd, check=True) # noqa: S603 @@ -252,6 +252,7 @@ def create_vswitch(self, name="vSwitch0", ports=256): "--vswitch-name", str(name), ] + return self.__execute(cmd) def uplink_add(self, nic, switch_name="vSwitch0"): @@ -358,6 +359,51 @@ def configure_vswitch(self, uplink: NIC, switch_name: str, mtu: int): self.vswitch_security(name=switch_name) self.vswitch_settings(mtu=mtu, name=switch_name) + 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 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.configure_dns(servers=dns_servers) + def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) @@ -367,7 +413,9 @@ def main(json_file, dry_run): esx.configure_vswitch( uplink=esx.identify_uplink(), switch_name="vSwitch0", mtu=9000 ) + esx.configure_portgroups() + esx.configure_requested_dns() if __name__ == "__main__": diff --git a/python/esxi-netinit/tests/conftest.py b/python/esxi-netinit/tests/conftest.py index eeceb2a42..b621d581a 100644 --- a/python/esxi-netinit/tests/conftest.py +++ b/python/esxi-netinit/tests/conftest.py @@ -35,7 +35,7 @@ def network_data_single(): ], } ], - "services": [{"address": "8.8.8.8", "type": "dns"}], + "services": [{"address": "8.8.4.4", "type": "dns"}], } diff --git a/python/esxi-netinit/tests/test_networkdata.py b/python/esxi-netinit/tests/test_networkdata.py index 74e2183e8..1d5f22890 100644 --- a/python/esxi-netinit/tests/test_networkdata.py +++ b/python/esxi-netinit/tests/test_networkdata.py @@ -1,20 +1,16 @@ import json from dataclasses import is_dataclass -import pytest - from netinit import Link from netinit import NetworkData from netinit 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 is_dataclass(link) assert link.ethernet_mac_address == "00:11:22:33:44:55" assert link.id == "eth0" assert link.mtu == 1500 @@ -54,7 +50,7 @@ def test_services_parsing(network_data_single): service = network_data.services[0] assert is_dataclass(service) - assert service.address == "8.8.8.8" + assert service.address == "8.8.4.4" assert service.type == "dns" From 8007632f6c81c0eee4ba7597ba8046f4e7a14b40 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 27 Feb 2025 10:46:50 +0000 Subject: [PATCH 11/19] add ArgumentParser --- python/esxi-netinit/netinit.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 48841f15b..df6c9e2ec 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -1,3 +1,4 @@ +import argparse import json import subprocess import sys @@ -419,12 +420,17 @@ def main(json_file, dry_run): if __name__ == "__main__": - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} [--dry-run]") - sys.exit(1) + 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(sys.argv[1], sys.argv[2] == "--dry-run") + main(args.json_file, args.dry_run) except Exception as e: print(f"Error configuring network: {str(e)}") sys.exit(1) From a8f4fc296e57edd8726ff864bd50ff4c2db24afb Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 27 Feb 2025 10:58:28 +0000 Subject: [PATCH 12/19] make NICList more pythonic --- python/esxi-netinit/netinit.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index df6c9e2ec..548771ad0 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -107,10 +107,10 @@ class NIC: link: str mac: str - -class NICList: +class NICList(list): def __init__(self, data=None) -> None: - self.nics = NICList.parse(data or self._esxi_nics()) + nic_data = data or self._esxi_nics() + return super().__init__(NICList.parse(nic_data)) @staticmethod def parse(data): @@ -135,8 +135,7 @@ def _esxi_nics(self) -> str: ).stdout.decode() def find_by_mac(self, mac) -> NIC: - return next(nic for nic in self.nics if nic.mac == mac) - + return next(nic for nic in self if nic.mac == mac) class ESXConfig: def __init__(self, network_data: NetworkData, dry_run=False) -> None: From 1066c6e41c8598fc7d0aaf84945c147d0f9397cf Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 27 Feb 2025 18:41:21 +0000 Subject: [PATCH 13/19] add destroy_vswitch --- python/esxi-netinit/netinit.py | 19 +++++++++++++++++-- python/esxi-netinit/tests/test_esxconfig.py | 4 ++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 548771ad0..fef2a5594 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -107,6 +107,7 @@ class NIC: link: str mac: str + class NICList(list): def __init__(self, data=None) -> None: nic_data = data or self._esxi_nics() @@ -255,6 +256,19 @@ def create_vswitch(self, name="vSwitch0", ports=256): return self.__execute(cmd) + def destroy_vswitch(self, name): + cmd = [ + "/bin/esxcli", + "network", + "vswitch", + "standard", + "remove", + "--vswitch-name", + name + ] + + return self.__execute(cmd) + def uplink_add(self, nic, switch_name="vSwitch0"): """Adds uplink to a vSwitch.""" cmd = [ @@ -353,6 +367,7 @@ def identify_uplink(self) -> NIC: def configure_vswitch(self, uplink: NIC, switch_name: str, mtu: int): """Sets up vSwitch.""" + self.destroy_vswitch(switch_name) self.create_vswitch(switch_name) self.uplink_add(nic=uplink.name, switch_name=switch_name) self.vswitch_failover_uplinks(active_uplinks=[uplink.name], name=switch_name) @@ -408,11 +423,11 @@ def configure_requested_dns(self): def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) - esx.configure_management_interface() - esx.configure_default_route() esx.configure_vswitch( uplink=esx.identify_uplink(), switch_name="vSwitch0", mtu=9000 ) + esx.configure_management_interface() + esx.configure_default_route() esx.configure_portgroups() esx.configure_requested_dns() diff --git a/python/esxi-netinit/tests/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py index b75288cfc..e15194e04 100644 --- a/python/esxi-netinit/tests/test_esxconfig.py +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -46,6 +46,10 @@ def test_create_vswitch(fp, empty_ec): empty_ec.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, empty_ec): + empty_ec.destroy_vswitch(name="vSwitch8") + assert fp.call_count("/bin/esxcli network vswitch standard remove --vswitch-name vSwitch8") == 1 + def test_uplink_add(fp, empty_ec): empty_ec.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 From 0c7c12c70cc932dcc180024999683879764937ce Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 27 Feb 2025 18:52:45 +0000 Subject: [PATCH 14/19] add portgroup_remove --- python/esxi-netinit/netinit.py | 16 ++++++++++++++++ python/esxi-netinit/tests/test_esxconfig.py | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index fef2a5594..ef9e46be9 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -199,6 +199,22 @@ def portgroup_add(self, portgroup_name, switch_name="vswitch0"): 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.""" diff --git a/python/esxi-netinit/tests/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py index e15194e04..e8a59a4fb 100644 --- a/python/esxi-netinit/tests/test_esxconfig.py +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -50,6 +50,10 @@ def test_destroy_vswitch(fp, empty_ec): empty_ec.destroy_vswitch(name="vSwitch8") assert fp.call_count("/bin/esxcli network vswitch standard remove --vswitch-name vSwitch8") == 1 +def test_portgroup_remove(fp, empty_ec): + empty_ec.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, empty_ec): empty_ec.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 From ebd25fd441edc98be4227e8df2cf8023a44f0c0f Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 27 Feb 2025 19:07:19 +0000 Subject: [PATCH 15/19] destroy initial vswitch and portgroups --- python/esxi-netinit/netinit.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index ef9e46be9..49b492edd 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -439,6 +439,8 @@ def configure_requested_dns(self): def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) + esx.portgroup_remove(switch_name="vSwitch0", portgroup_name="Management Network") + esx.destroy_vswitch(name="vSwitch0") esx.configure_vswitch( uplink=esx.identify_uplink(), switch_name="vSwitch0", mtu=9000 ) From bb8d25b3271aace1d9df37967958e4aeae901f02 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 3 Mar 2025 15:38:01 +0000 Subject: [PATCH 16/19] esxinit: add portgroup and vmknic manipulation --- python/esxi-netinit/netinit.py | 37 +++++++++++++++++---- python/esxi-netinit/tests/test_esxconfig.py | 9 +++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 49b492edd..d8c742f13 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -170,13 +170,16 @@ def configure_default_route(self): ] return self.__execute(cmd) - def configure_portgroups(self): + 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.portgroup_add(portgroup_name=pg_name) + self.portgroup_add(portgroup_name=pg_name, switch_name=switch_name) self.portgroup_set_vlan(portgroup_name=pg_name, vlan_id=vid) + portgroups.append(pg_name) + return portgroups def configure_management_interface(self): mgmt_network = next( @@ -383,7 +386,6 @@ def identify_uplink(self) -> NIC: def configure_vswitch(self, uplink: NIC, switch_name: str, mtu: int): """Sets up vSwitch.""" - self.destroy_vswitch(switch_name) self.create_vswitch(switch_name) self.uplink_add(nic=uplink.name, switch_name=switch_name) self.vswitch_failover_uplinks(active_uplinks=[uplink.name], name=switch_name) @@ -435,19 +437,42 @@ def configure_requested_dns(self): return self.configure_dns(servers=dns_servers) + def delete_vmknic(self, portgroup_name): + """Deletes a vmknic from a portgroup.""" + return self.__execute(["/bin/esxcfg-vmknic", "-d", portgroup_name]) + + 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 main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) + esx.delete_vmknic(portgroup_name="Management Network") esx.portgroup_remove(switch_name="vSwitch0", portgroup_name="Management Network") esx.destroy_vswitch(name="vSwitch0") esx.configure_vswitch( - uplink=esx.identify_uplink(), switch_name="vSwitch0", mtu=9000 + uplink=esx.identify_uplink(), switch_name="vSwitch22", mtu=9000 ) - esx.configure_management_interface() - esx.configure_default_route() esx.configure_portgroups() + esx.portgroup_add(portgroup_name="mgmt", switch_name="vSwitch22") + esx.add_ip_interface(name="vmk0", portgroup_name="mgmt") + esx.configure_management_interface() + esx.configure_default_route() esx.configure_requested_dns() diff --git a/python/esxi-netinit/tests/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py index e8a59a4fb..e3191cc7d 100644 --- a/python/esxi-netinit/tests/test_esxconfig.py +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -88,3 +88,12 @@ def test_configure_requested_dns(fp, network_data_single): ec = ESXConfig(ndata, dry_run=False) ec.configure_requested_dns() assert fp.call_count("/bin/esxcli network ip dns server add --server 8.8.4.4") == 1 + +def test_delete_vmknic(fp, empty_ec): + fp.register(["/bin/esxcfg-vmknic", fp.any()]) + empty_ec.delete_vmknic(portgroup_name="ManagementPG") + assert fp.call_count("/bin/esxcfg-vmknic -d ManagementPG") == 1 + +def test_add_ip_interface(fp, empty_ec): + empty_ec.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 From 8ed835a019eee3dca636ae64775151dc50e85cb4 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 3 Mar 2025 17:50:33 +0000 Subject: [PATCH 17/19] refactor: split low-level and high-level ESX methods This change makes the configuration process slightly more readable by separating low-level commands that are essentially wrappers around esxcli and esxcfg-vmknic binaries from a higher level code that takes Openstack's network_data as an input. --- python/esxi-netinit/netinit.py | 336 +++++++++++--------- python/esxi-netinit/pyproject.toml | 3 + python/esxi-netinit/tests/test_esxconfig.py | 105 ++---- python/esxi-netinit/tests/test_esxhost.py | 67 ++++ 4 files changed, 274 insertions(+), 237 deletions(-) create mode 100644 python/esxi-netinit/tests/test_esxhost.py diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index d8c742f13..537fed0ca 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -142,13 +142,7 @@ class ESXConfig: def __init__(self, network_data: NetworkData, dry_run=False) -> None: self.network_data = network_data 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 + self.host = ESXHost(dry_run) def configure_default_route(self): """Configures default route. @@ -156,19 +150,7 @@ def configure_default_route(self): If multiple default routes are present, only first one is used. """ route = self.network_data.default_route() - cmd = [ - "/bin/esxcli", - "network", - "ip", - "route", - "ipv4", - "add", - "-g", - route.gateway, - "-n", - "default", - ] - return self.__execute(cmd) + self.host.configure_default_route(route.gateway) def configure_portgroups(self, switch_name="vSwitch0"): portgroups = [] @@ -176,8 +158,8 @@ def configure_portgroups(self, switch_name="vSwitch0"): if link.type == "vlan": vid = link.vlan_id pg_name = f"internal_net_vid_{vid}" - self.portgroup_add(portgroup_name=pg_name, switch_name=switch_name) - self.portgroup_set_vlan(portgroup_name=pg_name, vlan_id=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 @@ -185,61 +167,92 @@ def configure_management_interface(self): mgmt_network = next( net for net in self.network_data.networks if net.default_routes() ) - return self._change_ip("vmk0", mgmt_network.ip_address, mgmt_network.netmask) + return self.host.change_ip( + "vmk0", mgmt_network.ip_address, mgmt_network.netmask + ) - 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), + 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" ] - 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), + 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() ] - return self.__execute(cmd) + 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) - def portgroup_set_vlan(self, portgroup_name, vlan_id): - """Configures VLANid to be used on a portgroup.""" + @cached_property + def nics(self): + return NICList() + + +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", - "vswitch", - "standard", - "portgroup", - "set", - "--portgroup-name", - str(portgroup_name), - "--vlan-id", - str(vlan_id), + "ip", + "route", + "ipv4", + "add", + "-g", + gateway, + "-n", + "default", ] return self.__execute(cmd) - @cached_property - def nics(self): - return NICList() - - def _change_ip(self, interface, ip, netmask): + def change_ip(self, interface, ip, netmask): """Configures IP address on logical interface.""" cmd = [ "/bin/esxcli", @@ -259,6 +272,41 @@ def _change_ip(self, interface, ip, netmask): ] 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 = [ @@ -275,6 +323,10 @@ def create_vswitch(self, name="vSwitch0", ports=256): 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", @@ -283,9 +335,58 @@ def destroy_vswitch(self, name): "standard", "remove", "--vswitch-name", - 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"): @@ -372,105 +473,26 @@ def vswitch_security( ] return self.__execute(cmd) - 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) - - def configure_vswitch(self, uplink: NIC, switch_name: str, mtu: int): - """Sets up vSwitch.""" - self.create_vswitch(switch_name) - self.uplink_add(nic=uplink.name, switch_name=switch_name) - self.vswitch_failover_uplinks(active_uplinks=[uplink.name], name=switch_name) - self.vswitch_security(name=switch_name) - self.vswitch_settings(mtu=mtu, name=switch_name) - - 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 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.configure_dns(servers=dns_servers) - - def delete_vmknic(self, portgroup_name): - """Deletes a vmknic from a portgroup.""" - return self.__execute(["/bin/esxcfg-vmknic", "-d", portgroup_name]) - 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, - ] - ) +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.delete_vmknic(portgroup_name="Management Network") - esx.portgroup_remove(switch_name="vSwitch0", portgroup_name="Management Network") - esx.destroy_vswitch(name="vSwitch0") + esx.host.delete_vmknic(portgroup_name=OLD_MGMT_PG) + esx.host.portgroup_remove(switch_name=OLD_VSWITCH, portgroup_name=OLD_MGMT_PG) + esx.host.destroy_vswitch(name=OLD_VSWITCH) esx.configure_vswitch( - uplink=esx.identify_uplink(), switch_name="vSwitch22", mtu=9000 + uplink=esx.identify_uplink(), switch_name=NEW_VSWITCH, mtu=9000 ) esx.configure_portgroups() - esx.portgroup_add(portgroup_name="mgmt", switch_name="vSwitch22") - esx.add_ip_interface(name="vmk0", portgroup_name="mgmt") + esx.host.portgroup_add(portgroup_name=NEW_MGMT_PG, switch_name=NEW_VSWITCH) + esx.host.add_ip_interface(name="vmk0", portgroup_name=NEW_MGMT_PG) esx.configure_management_interface() esx.configure_default_route() esx.configure_requested_dns() diff --git a/python/esxi-netinit/pyproject.toml b/python/esxi-netinit/pyproject.toml index 666e27544..4dfe5f7a2 100644 --- a/python/esxi-netinit/pyproject.toml +++ b/python/esxi-netinit/pyproject.toml @@ -40,6 +40,9 @@ target-version = "py310" "tests/test_esxconfig.py" = [ "E501" # esxcli outputs with long lines ] +"tests/test_esxhost.py" = [ + "E501" # esxcli outputs with long lines +] "netinit.py" = [ "S104", # false positive on binding to all ifaces ] diff --git a/python/esxi-netinit/tests/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py index e3191cc7d..b6d8227a4 100644 --- a/python/esxi-netinit/tests/test_esxconfig.py +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -1,99 +1,44 @@ import pytest from netinit import ESXConfig +from netinit import ESXHost from netinit import NetworkData @pytest.fixture -def empty_ec(fp): - fp.register(["/bin/esxcli", fp.any()]) - return ESXConfig(NetworkData({})) +def host_mock(mocker): + return mocker.Mock(spec=ESXHost) -def test_configure_default_route(fp, network_data_single): - fp.register(["/bin/esxcli", fp.any()]) +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() - assert fp.call_count("/bin/esxcli network ip route ipv4 add -g 192.168.1.1 -n default") == 1 + host_mock.configure_default_route.assert_called_once_with("192.168.1.1") -def test_configure_management_interface(fp, network_data_single): - fp.register(["/bin/esxcli", fp.any()]) +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() - assert fp.call_count("/bin/esxcli network ip interface ipv4 set -i vmk0 -I 192.168.1.10 -N 255.255.255.0 -t static") == 1 - -def test_portgroup_add(fp, empty_ec): - empty_ec.portgroup_add("mypg") - assert fp.call_count("/bin/esxcli network vswitch standard portgroup add --portgroup-name mypg --vswitch-name vswitch0") == 1 + host_mock.change_ip.assert_called_once_with("vmk0", "192.168.1.10", "255.255.255.0") -def test_portgroup_set_vlan(fp, empty_ec): - empty_ec.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_configure_portgroups(fp, mocker, network_data_multi) : - fp.register(["/bin/esxcli", fp.any()]) +def test_configure_portgroups(network_data_multi, host_mock): ndata = NetworkData(network_data_multi) ec = ESXConfig(ndata, dry_run=False) - pgadd_mock = mocker.patch.object(ec, "portgroup_add") - pgset_mock = mocker.patch.object(ec, "portgroup_set_vlan") + ec.host = host_mock ec.configure_portgroups() - assert pgadd_mock.call_count == 3 - assert pgset_mock.call_count == 3 - pgset_mock.assert_called_with(portgroup_name="internal_net_vid_444", vlan_id=444) - -def test_create_vswitch(fp, empty_ec): - empty_ec.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, empty_ec): - empty_ec.destroy_vswitch(name="vSwitch8") - assert fp.call_count("/bin/esxcli network vswitch standard remove --vswitch-name vSwitch8") == 1 - -def test_portgroup_remove(fp, empty_ec): - empty_ec.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, empty_ec): - empty_ec.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, empty_ec): - empty_ec.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, empty_ec): - empty_ec.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, empty_ec): - empty_ec.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, empty_ec): - empty_ec.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, empty_ec): - fp.register(["/bin/esxcli", fp.any()]) - fp.keep_last_process(True) - empty_ec.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_configure_requested_dns(fp, network_data_single): - fp.register(["/bin/esxcli", fp.any()]) - ndata = NetworkData(network_data_single) - ec = ESXConfig(ndata, dry_run=False) - ec.configure_requested_dns() - assert fp.call_count("/bin/esxcli network ip dns server add --server 8.8.4.4") == 1 - -def test_delete_vmknic(fp, empty_ec): - fp.register(["/bin/esxcfg-vmknic", fp.any()]) - empty_ec.delete_vmknic(portgroup_name="ManagementPG") - assert fp.call_count("/bin/esxcfg-vmknic -d ManagementPG") == 1 - -def test_add_ip_interface(fp, empty_ec): - empty_ec.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 + 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..738379c2a --- /dev/null +++ b/python/esxi-netinit/tests/test_esxhost.py @@ -0,0 +1,67 @@ + +import pytest + +from netinit 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 From 57adcad29fdb885bc26c358a83585a41ac7869a1 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 3 Mar 2025 18:01:59 +0000 Subject: [PATCH 18/19] refactor: enforce boundaries Removes LoD violation --- python/esxi-netinit/netinit.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py index 537fed0ca..1cdddd48c 100644 --- a/python/esxi-netinit/netinit.py +++ b/python/esxi-netinit/netinit.py @@ -144,6 +144,21 @@ def __init__(self, network_data: NetworkData, dry_run=False) -> None: 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. @@ -483,16 +498,13 @@ def vswitch_security( def main(json_file, dry_run): network_data = NetworkData.from_json_file(json_file) esx = ESXConfig(network_data, dry_run=dry_run) - esx.host.delete_vmknic(portgroup_name=OLD_MGMT_PG) - esx.host.portgroup_remove(switch_name=OLD_VSWITCH, portgroup_name=OLD_MGMT_PG) - esx.host.destroy_vswitch(name=OLD_VSWITCH) + 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.host.portgroup_add(portgroup_name=NEW_MGMT_PG, switch_name=NEW_VSWITCH) - esx.host.add_ip_interface(name="vmk0", portgroup_name=NEW_MGMT_PG) + esx.add_default_mgmt_interface(NEW_MGMT_PG, NEW_VSWITCH) esx.configure_management_interface() esx.configure_default_route() esx.configure_requested_dns() From d9d93ba8755785a01184055edc11d764e1d83652 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 3 Mar 2025 18:52:33 +0000 Subject: [PATCH 19/19] refactor: split netinit.py into files --- python/esxi-netinit/{ => doc}/example.json | 0 python/esxi-netinit/{ => doc}/multiif.json | 0 python/esxi-netinit/netinit.py | 527 ------------------ python/esxi-netinit/netinit/__init__.py | 0 python/esxi-netinit/netinit/esxconfig.py | 90 +++ python/esxi-netinit/netinit/esxhost.py | 267 +++++++++ python/esxi-netinit/netinit/link.py | 14 + python/esxi-netinit/netinit/main.py | 42 ++ python/esxi-netinit/netinit/network.py | 19 + python/esxi-netinit/netinit/network_data.py | 56 ++ python/esxi-netinit/netinit/nic.py | 9 + python/esxi-netinit/netinit/nic_list.py | 34 ++ python/esxi-netinit/netinit/route.py | 11 + python/esxi-netinit/netinit/service.py | 7 + python/esxi-netinit/pyproject.toml | 9 +- python/esxi-netinit/tests/test_esxconfig.py | 9 +- python/esxi-netinit/tests/test_esxhost.py | 100 +++- python/esxi-netinit/tests/test_networkdata.py | 6 +- python/esxi-netinit/tests/test_niclist.py | 2 +- 19 files changed, 650 insertions(+), 552 deletions(-) rename python/esxi-netinit/{ => doc}/example.json (100%) rename python/esxi-netinit/{ => doc}/multiif.json (100%) delete mode 100644 python/esxi-netinit/netinit.py create mode 100644 python/esxi-netinit/netinit/__init__.py create mode 100644 python/esxi-netinit/netinit/esxconfig.py create mode 100644 python/esxi-netinit/netinit/esxhost.py create mode 100644 python/esxi-netinit/netinit/link.py create mode 100644 python/esxi-netinit/netinit/main.py create mode 100644 python/esxi-netinit/netinit/network.py create mode 100644 python/esxi-netinit/netinit/network_data.py create mode 100644 python/esxi-netinit/netinit/nic.py create mode 100644 python/esxi-netinit/netinit/nic_list.py create mode 100644 python/esxi-netinit/netinit/route.py create mode 100644 python/esxi-netinit/netinit/service.py diff --git a/python/esxi-netinit/example.json b/python/esxi-netinit/doc/example.json similarity index 100% rename from python/esxi-netinit/example.json rename to python/esxi-netinit/doc/example.json diff --git a/python/esxi-netinit/multiif.json b/python/esxi-netinit/doc/multiif.json similarity index 100% rename from python/esxi-netinit/multiif.json rename to python/esxi-netinit/doc/multiif.json diff --git a/python/esxi-netinit/netinit.py b/python/esxi-netinit/netinit.py deleted file mode 100644 index 1cdddd48c..000000000 --- a/python/esxi-netinit/netinit.py +++ /dev/null @@ -1,527 +0,0 @@ -import argparse -import json -import subprocess -import sys -from dataclasses import dataclass -from dataclasses import field -from functools import cached_property - - -@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) - - -@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" - - -@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()] - - -@dataclass -class Service: - address: str - type: str - - -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) - - -@dataclass -class NIC: - name: str - status: str - link: str - mac: str - - -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) - -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() - - -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) - - -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/__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/pyproject.toml b/python/esxi-netinit/pyproject.toml index 4dfe5f7a2..e661764d5 100644 --- a/python/esxi-netinit/pyproject.toml +++ b/python/esxi-netinit/pyproject.toml @@ -38,11 +38,16 @@ target-version = "py310" "S104", # false positive on binding to all ifaces ] "tests/test_esxconfig.py" = [ - "E501" # esxcli outputs with long lines ] "tests/test_esxhost.py" = [ "E501" # esxcli outputs with long lines ] -"netinit.py" = [ +"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/test_esxconfig.py b/python/esxi-netinit/tests/test_esxconfig.py index b6d8227a4..801a31319 100644 --- a/python/esxi-netinit/tests/test_esxconfig.py +++ b/python/esxi-netinit/tests/test_esxconfig.py @@ -1,14 +1,15 @@ import pytest -from netinit import ESXConfig -from netinit import ESXHost -from netinit import NetworkData +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) @@ -25,6 +26,7 @@ def test_configure_default_route(network_data_single, 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) @@ -32,6 +34,7 @@ def test_configure_management_interface(network_data_single, 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) diff --git a/python/esxi-netinit/tests/test_esxhost.py b/python/esxi-netinit/tests/test_esxhost.py index 738379c2a..7b3b084ab 100644 --- a/python/esxi-netinit/tests/test_esxhost.py +++ b/python/esxi-netinit/tests/test_esxhost.py @@ -1,7 +1,6 @@ - import pytest -from netinit import ESXHost +from netinit.esxhost import ESXHost @pytest.fixture @@ -9,59 +8,128 @@ 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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") + 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") + 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 + 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"]) + 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 + 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 + 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 index 1d5f22890..5ca8a8cb2 100644 --- a/python/esxi-netinit/tests/test_networkdata.py +++ b/python/esxi-netinit/tests/test_networkdata.py @@ -1,9 +1,9 @@ import json from dataclasses import is_dataclass -from netinit import Link -from netinit import NetworkData -from netinit import Route +from netinit.link import Link +from netinit.network_data import NetworkData +from netinit.route import Route def test_links_parsing(network_data_single): diff --git a/python/esxi-netinit/tests/test_niclist.py b/python/esxi-netinit/tests/test_niclist.py index 38bef7eca..570bf8b68 100644 --- a/python/esxi-netinit/tests/test_niclist.py +++ b/python/esxi-netinit/tests/test_niclist.py @@ -1,6 +1,6 @@ import pytest -from netinit import NICList +from netinit.nic_list import NICList @pytest.fixture