diff --git a/.gitignore b/.gitignore index 172bf5786..81ccda05c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .tox +.stestr diff --git a/.zuul.yaml b/.zuul.yaml index 585abffbf..09ad56229 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -7,11 +7,13 @@ - stx-puppet-linters - stx-puppet-tox-pep8 - stx-puppet-tox-pylint + - puppet-manifests-tox-py39 gate: jobs: - stx-puppet-linters - stx-puppet-tox-pep8 - stx-puppet-tox-pylint + - puppet-manifests-tox-py39 post: jobs: - stx-stx-puppet-upload-git-mirror @@ -41,6 +43,19 @@ vars: python_version: 3.9 +- job: + name: puppet-manifests-tox-py39 + parent: openstack-tox-py39 + description: | + Run py39 test for puppet-manifests + nodeset: debian-bullseye + files: + - puppet-manifests/* + vars: + tox_envlist: py39 + python_version: 3.9 + tox_extra_args: -c puppet-manifests/tox.ini + - job: name: stx-stx-puppet-upload-git-mirror parent: upload-git-mirror diff --git a/puppet-manifests/.stestr.conf b/puppet-manifests/.stestr.conf new file mode 100644 index 000000000..ea359caed --- /dev/null +++ b/puppet-manifests/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./tests +top_dir=./ diff --git a/puppet-manifests/src/Makefile b/puppet-manifests/src/Makefile index 44226e3e0..2e3f3d818 100644 --- a/puppet-manifests/src/Makefile +++ b/puppet-manifests/src/Makefile @@ -14,6 +14,7 @@ ifdef ignore_puppet_warnings else install -m 755 -D bin/puppet-manifest-apply.sh $(BINDIR)/puppet-manifest-apply.sh endif + install -m 755 -D bin/apply_network_config.py $(BINDIR)/apply_network_config.py install -m 755 -D bin/apply_network_config.sh $(BINDIR)/apply_network_config.sh install -m 755 -D bin/k8s_wait_for_endpoints_health.py $(BINDIR)/k8s_wait_for_endpoints_health.py install -m 755 -D bin/kube-wait-control-plane-terminated.sh $(BINDIR)/kube-wait-control-plane-terminated.sh diff --git a/puppet-manifests/src/bin/apply_network_config.py b/puppet-manifests/src/bin/apply_network_config.py new file mode 100644 index 000000000..fa28d5049 --- /dev/null +++ b/puppet-manifests/src/bin/apply_network_config.py @@ -0,0 +1,1049 @@ +#!/usr/bin/python3 +# +# Copyright (c) 2025 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import argparse +from datetime import datetime +import errno +import fcntl +import logging as LOG +from netaddr import AddrFormatError +from netaddr import IPAddress +import os +import re +import signal +import shlex +import subprocess +import sys +import time + +LOG_FILE = "/var/log/user.log" +PUPPET_DIR = "/var/run/network-scripts.puppet" +PUPPET_FILE = "/var/run/network-scripts.puppet/interfaces" +PUPPET_ROUTES_FILE = "/var/run/network-scripts.puppet/routes" +PUPPET_ROUTES6_FILE = "/var/run/network-scripts.puppet/routes6" +ETC_ROUTES_FILE = "/etc/network/routes" +ETC_DIR = "/etc/network/interfaces.d" +SYSINV_LOCK_FILE = "/var/run/apply_network_config.lock" +UPGRADE_FILE = "/var/run/.network_upgrade_bootstrap" +SUBCLOUD_ENROLLMENT_FILE = "/var/run/.enroll-init-reconfigure" +CLOUD_INIT_FILE = ETC_DIR + "/50-cloud-init" +IFSTATE_BASE_PATH = "/run/network/ifstate." +DEVLINK_BASE_PATH = "/sys/class/net/" +CFG_PREFIX = "ifcfg-" +TERM_WAIT_TIME = 10 + +# Interface types +ETH = "eth" +VLAN = "vlan" +BONDING = "bonding" +SLAVE = "slave" +LABEL = "label" +LO = "lo" + +# Order for setting interfaces down +DOWN_ORDER = (LABEL, VLAN, BONDING, ETH, LO) +# Order for setting interfaces up +UP_ORDER = (LO, ETH, BONDING, VLAN, LABEL) +# Order for configuring interfaces without down/up operation +ONLINE_ORDER = (ETH, BONDING, VLAN, LABEL) +# Order of auto file +AUTO_ORDER = (LO, ETH, BONDING, SLAVE, VLAN, LABEL) + +# Interface property sort positions +PROPERTY_SORT_POS = { + "iface": 0, + "vlan-raw-device": 1, + "address": 2, + "netmask": 3, + "gateway": 4, + "bond-master": 5, + "bond-miimon": 6, + "bond-mode": 7, + "bond-primary": 8, + "bond-slaves": 9, + "hwaddress": 10, + "mtu": 11, + "pre-up": 12, + "up": 13, + "post-up": 14, + "pre-down": 15, + "down": 16, + "post-down": 17, + "allow-": 19, + # Position DEFAULT_POS holds properties that are not in the list, allow- is put last to not + # break ifupdown parsing, see https://review.opendev.org/c/starlingx/stx-puppet/+/839620 +} + +# Default sort position for properties +DEFAULT_POS = 18 + + +class InvalidNetmaskError(BaseException): + pass + + +class StanzaParser(): + + @staticmethod + def ParseLines(lines): + parser = StanzaParser() + parser.parse_lines(lines) + return parser.get_auto_and_ifaces() + + def __init__(self): + self.auto = [] + self.auto_set = set() + self.ifaces = dict() + self.iface = None + self.state = "none" + + def _proc_state_auto(self, verbs): + for iface in verbs[1:]: + if iface not in self.auto_set: + self.auto.append(iface) + self.auto_set.add(iface) + self.state = "none" + + def _proc_state_start_iface(self, verbs): + self.iface = self.ifaces.setdefault(verbs[1], {verbs[0]: " ".join(verbs[1:])}) + self.state = "continue-iface" + + def _proc_state_continue_iface(self, verbs): + # Special case for allow- property + if "allow-" in verbs[0]: + self.iface["allow-"] = " ".join(verbs) + else: + self.iface[verbs[0]] = " ".join(verbs[1:]) if len(verbs) > 1 else None + + STATES = { + "none": lambda self, line: None, + "start-auto": _proc_state_auto, + "start-iface": _proc_state_start_iface, + "continue-iface": _proc_state_continue_iface, + "standby-iface": lambda self, line: None, + } + + NEXT_STATES = { + "none": {"new-auto": "start-auto", + "new-iface": "start-iface"}, + "continue-iface": {"new-auto": "start-auto", + "new-iface": "start-iface", + "empty": "standby-iface", + "reset": "none"}, + "standby-iface": {"new-auto": "start-auto", + "new-iface": "start-iface", + "continue": "continue-iface", + "reset": "none"} + } + + def _proc_state(self, verbs): + func = self.STATES[self.state] + func(self, verbs) + + def _proc_event(self, event): + self.state = self.NEXT_STATES[self.state].get(event, self.state) + + @staticmethod + def _get_event(verbs): + if len(verbs) == 0 or verbs[0].startswith("#"): + return "empty" + if verbs[0] == "auto": + return "new-auto" + if verbs[0] == "iface": + if len(verbs) > 1: + return "new-iface" + return "reset" + return "continue" + + def _parse_line(self, line): + verbs = line.split() + event = self._get_event(verbs) + self._proc_event(event) + self._proc_state(verbs) + + def parse_lines(self, lines): + for line in lines: + self._parse_line(line.strip()) + self.state = "none" + + def get_auto_and_ifaces(self): + return self.auto, self.ifaces + + +def read_file_lines(path): + with open(path, "r") as f: + lines = f.readlines() + return [line.strip() for line in lines] + + +def read_file_text(path): + with open(path, "r") as f: + return f.read() + + +def is_label(iface): + return ":" in iface + + +def get_base_iface(iface): + return iface.split(":")[0] + + +def execute_system_cmd(cmd, timeout=30): + # When transitioning management network to a VLAN, ifup (for the mgmt interface) does its job + # in configuring the link but blocks sub.communicate() for a long period of time, long enough + # to cause the puppet task to end by timeout. + # If the subprocess is ended via sub.terminate(), sub.communicate() still blocks for an + # indefinite period of time. The only way that was found for the function to work as intended + # was to add start_new_session=True to subprocess.Popen() and to terminate the process group via + # os.killpg(). + + sub = subprocess.Popen(shlex.split(cmd), + start_new_session=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + try: + stdout, _ = sub.communicate(timeout=timeout) + decoded_stdout = stdout.decode('utf-8') + except subprocess.TimeoutExpired: + pgid = os.getpgid(sub.pid) + LOG.warning(f"Execution time exceeded for command '{cmd}', " + f"sending SIGTERM to subprocess (pid={sub.pid}, pgid={pgid})") + os.killpg(pgid, signal.SIGTERM) + try: + stdout, _ = sub.communicate(timeout=TERM_WAIT_TIME) + except subprocess.TimeoutExpired: + LOG.warning(f"Command '{cmd}' has not terminated after {TERM_WAIT_TIME} seconds, " + f"sending SIGKILL to subprocess (pid={sub.pid}, pgid={pgid})") + os.killpg(pgid, signal.SIGKILL) + stdout, _ = sub.communicate() + decoded_stdout = stdout.decode('utf-8') + if sub.returncode == 0: + LOG.info(f"Command '{cmd}' output:{format_stdout(decoded_stdout)}") + return sub.returncode, decoded_stdout + + +def apply_config(routes_only): + if routes_only: + LOG.info("Process Debian route config") + update_routes() + else: + if not os.path.isdir(PUPPET_DIR): + LOG.error("No puppet files? Nothing to do! Aborting...") + sys.exit(1) + LOG.info("Process Debian network config") + log_network_info() + updated_ifaces = update_interfaces() + update_routes(updated_ifaces) + check_enrollment_config() + log_network_info() + LOG.info("Finished") + + +def log_network_info(): + _, links = execute_system_cmd("/usr/sbin/ip addr show") + _, routes_ipv4 = execute_system_cmd("/usr/sbin/ip route show") + _, routes_ipv6 = execute_system_cmd("/usr/sbin/ip -6 route show") + LOG.info("Network info:\n************ Links/addresses ************\n" + f"{links}" + "************ IPv4 routes ****************\n" + f"{routes_ipv4}" + "************ IPv6 routes ****************\n" + f"{routes_ipv6}" + "*****************************************") + + +def get_new_config(): + '''Gets new network config from puppet directory''' + auto, ifaces = parse_interface_stanzas() + return build_config(auto, ifaces, is_from_puppet=True) + + +def parse_interface_stanzas(): + lines = read_file_lines(PUPPET_FILE) + return StanzaParser.ParseLines(lines) + + +def get_current_config(): + '''Gets current network config in etc directory''' + auto = parse_auto_file() + ifaces = parse_ifcfg_files(auto) + return build_config(auto, ifaces, is_from_puppet=False) + + +def parse_auto_list(input_auto, ifaces, is_from_puppet): + valid_auto = [] + invalid_auto = [] + for iface in input_auto: + if iface in ifaces: + valid_auto.append(iface) + else: + invalid_auto.append(iface) + if invalid_auto: + origin = "PUPPET" if is_from_puppet else "ETC DIR" + LOG.error(f"Auto list from {origin} has interfaces that have no or invalid " + f"config: {', '.join(invalid_auto)}") + return valid_auto + + +def build_config(auto, ifaces, is_from_puppet): + valid_auto = parse_auto_list(auto, ifaces, is_from_puppet) + ifaces_types, dependencies = get_types_and_dependencies(ifaces) + return {"auto": set(valid_auto), + "ifaces": ifaces, + "ifaces_types": ifaces_types, + "dependencies": dependencies} + + +def parse_auto_file(): + path = get_auto_path() + if not os.path.isfile(path): + LOG.info(f"Auto file not found: '{path}'") + return [] + lines = read_file_lines(path) + auto, _ = StanzaParser.ParseLines(lines) + return auto + + +def get_auto_path(): + return os.path.join(ETC_DIR, "auto") + + +def get_ifcfg_path(iface): + return os.path.join(ETC_DIR, CFG_PREFIX + iface) + + +def parse_ifcfg_files(ifaces): + iface_configs = dict() + for iface in ifaces: + iface_configs[iface] = parse_ifcfg_file(iface) + return iface_configs + + +def parse_ifcfg_file(iface): + path = get_ifcfg_path(iface) + if not os.path.isfile(path): + LOG.warning(f"Interface config file not found: '{path}'") + return dict() + lines = read_file_lines(path) + _, ifaces = StanzaParser.ParseLines(lines) + if len(ifaces) == 0: + LOG.warning(f"No interface config found in '{path}'") + return dict() + if (ifconfig := ifaces.get(iface, None)) is None: + LOG.warning(f"Config for interface '{iface}' not found in '{path}'. Instead, file has " + f"config(s) for the following interface(s): {' '.join(sorted(ifaces.keys()))}") + return dict() + if len(ifaces) > 1: + LOG.warning(f"Multiple interface configs found in '{path}': " + f"{' '.join(sorted(ifaces.keys()))}") + return ifconfig + + +def get_types_and_dependencies(iface_configs): + ifaces_types = dict() + dependencies = dict() + + def set_type(iface, iftype): + ifaces_types[iface] = iftype + + def add_dependent(iface, dependent): + entry = dependencies.setdefault(iface, set()) + entry.add(dependent) + + for iface, config in iface_configs.items(): + if is_label(iface): + set_type(iface, LABEL) + parent = get_base_iface(iface) + add_dependent(parent, iface) + elif iface == "lo": + set_type(iface, LO) + elif vlan_attribs := get_vlan_attributes(iface, config): + set_type(iface, VLAN) + add_dependent(vlan_attribs[0], iface) + elif slaves := config.get("bond-slaves", None): + set_type(iface, BONDING) + for slave in slaves.split(): + add_dependent(slave, iface) + elif master := config.get("bond-master", None): + set_type(iface, SLAVE) + add_dependent(iface, master) + else: + set_type(iface, ETH) + + return ifaces_types, dependencies + + +def get_vlan_attributes(iface, config): + '''Returns (vlan-raw-device, vlan-id) if iface is VLAN, else None''' + if result := re.search(R"^vlan([0-9]+)$", iface): + if raw_dev := config.get("vlan-raw-device", None): + return raw_dev, int(result.group(1)) + LOG.warning("vlan-raw-device property is empty or not specified for " + f"interface {iface}, so it will not be considered as a valid VLAN") + return None + if result := re.search(R"^(.*)\.([0-9]+)$", iface): + return result.group(1), int(result.group(2)) + if preup := config.get("pre-up", None): + if result := re.search(R"ip\s+link\s+add\s+link\s+(\S+)\s+name\s+\S+\s+type" + R"\s+vlan\s+id\s+(\d+)", preup): + return result.group(1), int(result.group(2)) + return None + + +def compare_configs(new_config, current_config): + added = new_config["auto"].difference(current_config["auto"]) + if added: + LOG.info(f"Added interfaces: {' '.join(sorted(added))}") + removed = current_config["auto"].difference(new_config["auto"]) + if removed: + LOG.info(f"Removed interfaces: {' '.join(sorted(removed))}") + modified = get_modified_ifaces(new_config, current_config) + if modified: + LOG.info(f"Modified interfaces: {' '.join(sorted(modified))}") + return {"added": added, "removed": removed, "modified": modified} + + +def get_modified_ifaces(new_config, current_config): + modified = set() + new_ifaces = new_config["ifaces"] + current_ifaces = current_config["ifaces"] + for iface, new_if_config in new_ifaces.items(): + current_if_config = current_ifaces.get(iface, None) + if not current_if_config: + continue + if is_iface_modified(iface, new_if_config, current_if_config): + modified.add(iface) + return modified + + +def is_iface_modified(iface, new, current): + filtered_new = {p for p in new.keys() if p in PROPERTY_SORT_POS} + filtered_current = {p for p in current.keys() if p in PROPERTY_SORT_POS} + removed_props = filtered_current.difference(filtered_new) + added_props = filtered_new.difference(filtered_current) + modified_props = [p for p in filtered_new.intersection(filtered_current) + if new[p] != current[p]] + if not removed_props and not added_props and not modified_props: + return False + text = f"Differences found for interface {iface}:" + if removed_props: + text += "\n Removed properties:" + for prop in sort_properties(list(removed_props)): + text += f"\n {prop} {current[prop]}" + if added_props: + text += "\n Added properties:" + for prop in sort_properties(list(added_props)): + text += f"\n {prop} {new[prop]}" + if modified_props: + text += "\n Modified properties:" + for prop in sort_properties(list(modified_props)): + text += f"\n '{prop}' went from '{current[prop]}' to '{new[prop]}'" + LOG.info(text) + return True + + +def get_dependent_list(config, ifaces): + auto = config["auto"] + dep_map = config["dependencies"] + covered = set() + + def add_dependent(iface): + if iface in covered or iface not in auto: + return + covered.add(iface) + dependents = dep_map.get(iface, None) + if not dependents: + return + for dependent in dependents: + add_dependent(dependent) + + for iface in ifaces: + add_dependent(iface) + + return covered + + +def get_down_list(current_config, comparison): + base_set = comparison["modified"].union(comparison["removed"]) + dependents = get_dependent_list(current_config, base_set) + return base_set.union(dependents) + + +def get_up_list(new_config, comparison): + base_set = comparison["modified"].union(comparison["added"]) + missing_set = get_missing_list(new_config, base_set) + up_set = base_set.union(missing_set) + dependents = get_dependent_list(new_config, up_set) + return up_set.union(dependents) + + +def get_missing_list(config, base_set): + ifaces_types = config["ifaces_types"] + types = {ETH, BONDING, VLAN} + ifaces = {i for i in config["auto"].difference(base_set) if ifaces_types[i] in types} + out_set = set() + for iface in ifaces: + if is_iface_missing_or_down(iface): + LOG.info(f"Interface {iface} is missing or down, adding to up list") + out_set.add(iface) + return out_set + + +def get_updated_ifaces(new_config, up_list): + ifaces_types = new_config["ifaces_types"] + types = {ETH, VLAN, BONDING, LO} + updated = set() + for iface in up_list: + if ifaces_types[iface] == LABEL: + updated.add(get_base_iface(iface)) + elif ifaces_types[iface] in types: + updated.add(iface) + return updated + + +def sort_ifaces_by_type(config, ifaces, type_order): + ifaces_types = config["ifaces_types"] + ifaces_by_type = dict() + for iface in ifaces: + iftype = ifaces_types[iface] + iface_list = ifaces_by_type.setdefault(iftype, []) + iface_list.append(iface) + sorted_ifaces = [] + for iftype in type_order: + if iface_list := ifaces_by_type.get(iftype, None): + iface_list.sort() + sorted_ifaces.extend(iface_list) + return sorted_ifaces + + +def set_ifaces_down(config, ifaces): + sorted_ifaces = sort_ifaces_by_type(config, ifaces, DOWN_ORDER) + for iface in sorted_ifaces: + set_iface_down(iface) + + +def format_stdout(stdout): + cln_stdout = stdout.strip() + return f"\n{cln_stdout}" if "\n" in cln_stdout else f" '{cln_stdout}'" + + +def set_iface_down(iface): + LOG.info(f"Bringing {iface} down") + + ifstate_path = IFSTATE_BASE_PATH + iface + if os.path.isfile(ifstate_path) and read_file_text(ifstate_path).strip() == iface: + retcode, stdout = execute_system_cmd(f"/sbin/ifdown -v {iface}") + if retcode != 0: + LOG.error(f"Command 'ifdown' failed for interface {iface}:{format_stdout(stdout)}") + + if not is_label(iface): + devlink_path = DEVLINK_BASE_PATH + iface + if os.path.islink(devlink_path): + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip link set down dev {iface}") + if retcode != 0: + LOG.error(f"Command 'ip link set down' failed for " + f"interface {iface}:{format_stdout(stdout)}") + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip addr flush dev {iface}") + if retcode != 0: + LOG.error(f"Command 'ip addr flush' failed for interface {iface}:" + f"{format_stdout(stdout)}") + + +def set_ifaces_up(config, ifaces): + sorted_ifaces = sort_ifaces_by_type(config, ifaces, UP_ORDER) + for iface in sorted_ifaces: + set_iface_up(iface) + + +def set_iface_up(iface): + LOG.info(f"Bringing {iface} up") + retcode, stdout = execute_system_cmd(f"/sbin/ifup -v {iface}") + if retcode != 0: + LOG.error(f"Command 'ifup' failed for interface {iface}: {format_stdout(stdout)}") + return retcode + + +def update_files(new_config): + for iface, iface_config in new_config["ifaces"].items(): + write_iface_config_file(iface, iface_config) + write_auto_file(new_config) + + +def remove_iface_config_files(comparison): + for to_remove in comparison["removed"]: + remove_iface_config_file(to_remove) + + +def path_exists(path): + return os.path.exists(path) + + +def remove_iface_config_file(iface): + path = get_ifcfg_path(iface) + if path_exists(path): + LOG.info(f"Removing {path}") + try: + os.remove(path) + except OSError as e: + LOG.error(f"Failed to remove {path}: {e}") + else: + LOG.info(f"File {path} does not exist, no need to remove") + + +def write_iface_config_file(iface, iface_config): + lines = get_ifcfg_lines(iface_config) + path = get_ifcfg_path(iface) + with open(path, "w") as f: + f.write("\n".join(lines) + "\n") + + +def write_auto_file(config): + sorted_auto = sort_ifaces_by_type(config, config["auto"], AUTO_ORDER) + contents = get_header() + "\nauto " + " ".join(sorted_auto) + "\n" + path = get_auto_path() + with open(path, "w") as f: + f.write(contents) + + +def sort_properties(props): + # Key is position number (from PROPERTY_SORT_POS) with 2 digits followed by property name + def get_sort_key(v): + return f"{PROPERTY_SORT_POS.get(v, DEFAULT_POS):02d}{v}" + props.sort(key=get_sort_key) + return props + + +def get_ifcfg_lines(iface_config): + props = list(iface_config.keys()) + sort_properties(props) + lines = [get_header()] + for prop in props: + lines.append(iface_config[prop] if prop == "allow-" else prop + " " + iface_config[prop]) + return lines + + +def get_header(): + dt = datetime.now().astimezone() + return dt.strftime("# HEADER: Last generated at: %Y-%m-%d %H:%M:%S %z") + + +def get_route_entries(files): + entries = [] + for file in files: + if os.path.isfile(file): + lines = read_file_lines(file) + entries.extend(get_route_entries_from_lines(lines, file)) + return entries + + +def get_route_entries_from_lines(lines, file): + routes = [] + for line in lines: + clean_line = line.strip() + if len(clean_line) > 0 and not clean_line.startswith("#"): + verbs = clean_line.split() + if len(verbs) >= 4: + routes.append(' '.join(verbs)) + else: + LOG.warning(f"Invalid route in file '{file}', must have at least 4 " + f"parameters, {len(verbs)} found: '{clean_line}'") + return routes + + +def get_route_iface(route): + return route.split()[3] + + +def create_route_obj_from_entry(route_entry): + verbs = route_entry.split() + route_obj = {"network": verbs[0], + "netmask": verbs[1], + "nexthop": verbs[2], + "ifname": verbs[3]} + if len(verbs) > 4: + route_obj["metric"] = verbs[5] + return route_obj + + +def get_prefix_length(netmask): + try: + addr = IPAddress(netmask) + if addr.is_netmask(): + return addr.netmask_bits() + except AddrFormatError: + pass + raise InvalidNetmaskError(f"Failed to get prefix length, invalid netmask: '{netmask}'") + + +def get_linux_network(route): + network = route["network"] + if network == "default": + return "default" + prefixlen = get_prefix_length(route["netmask"]) + return f"{network}/{prefixlen}" + + +def remove_route_entry_from_kernel(route_entry): + route = create_route_obj_from_entry(route_entry) + try: + remove_route_from_kernel(route) + except InvalidNetmaskError as e: + LOG.error(f"Failed to remove route entry '{route_entry}' from the kernel: {e}") + + +def remove_route_from_kernel(route): + description = get_route_description(route) + LOG.info(f"Removing route: {description}") + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip route del {description}") + if retcode != 0: + LOG.error(f"Failed removing route {description}:{format_stdout(stdout)}") + + +def add_route_entry_to_kernel(route_entry): + route = create_route_obj_from_entry(route_entry) + try: + add_route_to_kernel(route) + except InvalidNetmaskError as e: + LOG.error(f"Failed to add route entry '{route_entry}' to the kernel: {e}") + + +def get_route_description(route, full=True): + linux_network = get_linux_network(route) + gateway = f" via {route['nexthop']} dev {route['ifname']}" if full else "" + descr = f"{linux_network}{gateway}" + if metric := route.get("metric", None): + descr += f" metric {metric}" + return descr + + +def add_route_to_kernel(route): + prot = "-6 " if ":" in route["nexthop"] else "" + description = get_route_description(route) + LOG.info(f"Adding route: {description}") + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip {prot}route show {description}") + if retcode == 0 and route["network"] in stdout: + LOG.info("Route already exists, skipping") + else: + short_descr = get_route_description(route, full=False) + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip {prot}route show {short_descr}") + if retcode == 0 and route["network"] in stdout: + LOG.info(f"Route to specified network already exists, replacing: {stdout.strip()}") + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip route replace {description}") + if retcode != 0: + LOG.error(f"Failed replacing route {description}:{format_stdout(stdout)}") + else: + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip route add {description}") + if retcode != 0: + LOG.error(f"Failed adding route {description}:{format_stdout(stdout)}") + + +def acquire_sysinv_agent_lock(): + LOG.info("Acquiring lock to synchronize with sysinv-agent audit") + lock_file_fd = os.open(SYSINV_LOCK_FILE, os.O_CREAT | os.O_RDONLY) + return acquire_file_lock(lock_file_fd, fcntl.LOCK_EX | fcntl.LOCK_NB, 5, 5) + + +def release_sysinv_agent_lock(lockfd): + if lockfd: + LOG.info("Releasing lock") + release_file_lock(lockfd) + os.close(lockfd) + + +def acquire_file_lock(lockfd, operation, max_retry, wait_interval): + count = 1 + while count <= max_retry: + try: + fcntl.flock(lockfd, operation) + LOG.info("Successfully acquired lock (fd={})".format(lockfd)) + return lockfd + except IOError as e: + # raise on unrelated IOErrors + if e.errno != errno.EAGAIN: + raise + LOG.info("Could not acquire lock({}): {} ({}/{}), will retry".format( + lockfd, str(e), count, max_retry)) + time.sleep(wait_interval) + count += 1 + LOG.error("Failed to acquire lock (fd={}). Stopped trying.".format(lockfd)) + sys.exit(1) + + +def release_file_lock(lockfd): + if lockfd: + fcntl.flock(lockfd, fcntl.LOCK_UN) + + +def is_upgrade(): + return os.path.isfile(UPGRADE_FILE) + + +def update_interfaces(): + new_config = get_new_config() + + auto = new_config["auto"] + if len(auto) == 0 or (len(auto) == 1 and next(iter(auto)) == "lo"): + LOG.info(f"Generated {PUPPET_FILE} with empty configuration: '{' '.join(auto)}', exiting") + return None + + disable_pxeboot_interface() + + if is_upgrade(): + LOG.info("Upgrade bootstrap is in execution") + return update_ifaces_online(new_config) + + return update_ifaces_ifupdown(new_config) + + +def disable_pxeboot_interface(): + path = get_ifcfg_path("pxeboot") + if not os.path.isfile(path): + return + + lines = read_file_lines(path) + _, ifaces = StanzaParser.ParseLines(lines) + if len(ifaces) == 0: + LOG.info(f"Pxeboot install config file '{path}' has no valid interface config, skipping") + return + + for iface in ifaces.keys(): + LOG.info(f"Turn off pxeboot install config for {iface}, will be turned on later") + set_iface_down(iface) + + LOG.info("Remove ifcfg-pxeboot, left from kickstart install phase") + remove_iface_config_file("pxeboot") + + +def update_ifaces_ifupdown(new_config): + current_config = get_current_config() + comparison = compare_configs(new_config, current_config) + down_list = get_down_list(current_config, comparison) + up_list = get_up_list(new_config, comparison) + + lock = acquire_sysinv_agent_lock() if down_list or up_list else None + try: + set_ifaces_down(current_config, down_list) + remove_iface_config_files(comparison) + update_files(new_config) + set_ifaces_up(new_config, up_list) + finally: + release_sysinv_agent_lock(lock) + + return get_updated_ifaces(new_config, up_list) + + +def update_ifaces_online(config): + sorted_ifaces = sort_ifaces_by_type(config, config["auto"], ONLINE_ORDER) + if not sorted_ifaces: + return set() + update_files(config) + for iface in sorted_ifaces: + LOG.info(f"Configuring interface {iface}") + ensure_iface_configured(iface, config["ifaces"][iface]) + return get_updated_ifaces(config, sorted_ifaces) + + +def is_iface_missing_or_down(iface): + path = f"{DEVLINK_BASE_PATH}{iface}/operstate" + if os.path.isfile(path): + state = read_file_text(path) + if state != "down": + return False + return True + + +def get_iface_address(iface, cfg): + if address := cfg.get("address", None): + if "/" not in address: + if netmask := cfg.get("netmask", None): + if ":" in address: + try: + prefixlen = int(netmask) + except ValueError: + LOG.error(f"Failed to get {iface} interface prefixlen, " + f"invalid value: '{netmask}'") + return None + else: + try: + prefixlen = get_prefix_length(netmask) + except InvalidNetmaskError as e: + LOG.error(f"Failed to get {iface} interface netmask: {e}") + return None + return f"{address}/{prefixlen}" + LOG.error(f"Interface {iface} has address but no netmask") + return None + return address + + +def ensure_iface_configured_label(iface, cfg): + address = get_iface_address(iface, cfg) + if not address: + return + base_iface = get_base_iface(iface) + existing = get_link_addresses(base_iface) + if address in existing: + LOG.info(f"Link already has address '{address}', no need to set label up") + else: + if set_iface_up(iface) == 0: + return + add_ip_to_iface(base_iface, address) + if gateway := cfg.get("gateway", None): + add_default_route(base_iface, gateway) + + +def ensure_iface_configured_non_label(iface, cfg): + if is_iface_missing_or_down(iface): + LOG.info(f"Interface '{iface}' is missing or down, flushing IPs and bringing up") + flush_ips(iface) + if set_iface_up(iface) == 0: + return + address = get_iface_address(iface, cfg) + if not address: + return + existing = get_link_addresses(iface) + if address not in existing: + add_ip_to_iface(iface, address) + if gateway := cfg.get("gateway", None): + add_default_route(iface, gateway) + + +def ensure_iface_configured(iface, cfg): + if is_label(iface): + ensure_iface_configured_label(iface, cfg) + else: + ensure_iface_configured_non_label(iface, cfg) + + +def get_link_addresses(name): + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip -br addr show dev {name}") + if retcode == 0: + verbs = stdout.split() + return verbs[2:] + LOG.error(f"Failed to get IP address list from {name}:{format_stdout(stdout)}") + return None + + +def add_ip_to_iface(iface, ip): + LOG.info(f"Adding IP {ip} to interface {iface}") + existing = get_link_addresses(iface) + if existing is None: + return + if ip in existing: + LOG.info(f"Interface {iface} already has address {ip}, skipping") + return + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip addr add {ip} dev {iface}") + if retcode != 0: + LOG.error(f"Failed to add IP address to interface {iface}:{format_stdout(stdout)}") + + +def add_default_route(iface, gateway): + route = {"network": "default", + "nexthop": gateway, + "ifname": iface} + add_route_to_kernel(route) + + +def flush_ips(iface): + path = DEVLINK_BASE_PATH + iface + if os.path.islink(path): + retcode, stdout = execute_system_cmd(f"/usr/sbin/ip addr flush dev {iface}") + if retcode != 0: + LOG.error(f"Command 'ip addr flush' failed for interface {iface}:" + f"{format_stdout(stdout)}") + + +def write_routes_file(route_entries): + lines = [get_header()] + route_entries + with open(ETC_ROUTES_FILE, "w") as f: + f.write("\n".join(lines) + "\n") + + +def update_routes(updated_ifaces=None): + if updated_ifaces is None: + updated_ifaces = set() + + new_routes = get_route_entries([PUPPET_ROUTES_FILE, PUPPET_ROUTES6_FILE]) + new_routes_set = set(new_routes) + + current_routes = get_route_entries([ETC_ROUTES_FILE]) + current_routes_set = set(current_routes) + + write_routes_file(new_routes) + + if new_routes_set != current_routes_set: + LOG.info(f"Differences found between {PUPPET_ROUTES_FILE} and {ETC_ROUTES_FILE}") + # Remove routes that are currently present and no longer needed, following the order in + # which they appear in the file + for route_entry in current_routes: + if route_entry not in new_routes_set: + remove_route_entry_from_kernel(route_entry) + else: + LOG.info(f"No differences found between {PUPPET_ROUTES_FILE} and {ETC_ROUTES_FILE}") + if not updated_ifaces: + return + + for route_entry in new_routes: + if route_entry not in current_routes_set: + LOG.info(f"Route not previously present in {ETC_ROUTES_FILE}, adding") + elif get_route_iface(route_entry) in updated_ifaces: + LOG.info("Route is associated with and updated interface, adding") + else: + continue + add_route_entry_to_kernel(route_entry) + + +def check_enrollment_config(): + if not os.path.isfile(SUBCLOUD_ENROLLMENT_FILE) or not os.path.isfile(CLOUD_INIT_FILE): + return + LOG.info(f"Enrollment: Parsing file '{CLOUD_INIT_FILE}'") + lines = read_file_lines(CLOUD_INIT_FILE) + _, ifaces = StanzaParser.ParseLines(lines) + ifaces.pop("lo", None) + if len(ifaces) == 0: + LOG.warning(f"Enrollment: Could not find any valid interface config in '{CLOUD_INIT_FILE}'") + return + ifaces_with_gateway = dict() + for iface, cfg in ifaces.items(): + if gateway := cfg.get("gateway", None): + try: + gateway_ip = IPAddress(gateway) + except AddrFormatError: + LOG.warning(f"Enrollment: Invalid gateway address '{gateway}' " + f"for interface '{iface}'") + continue + ifaces_with_gateway.setdefault(gateway_ip.version, dict())[iface] = cfg + if len(ifaces_with_gateway) == 0: + LOG.warning("Enrollment: No interface with gateway address found, skipping") + return + for version, iface_cfgs in ifaces_with_gateway.items(): + if len(iface_cfgs) > 1: + LOG.warning(f"Enrollment: Multiple interfaces with gateway for ipv{version} found: " + f"{', '.join(iface_cfgs.keys())}") + for iface, cfg in iface_cfgs.items(): + LOG.info(f"Enrollment: Configuring interface {iface} with gateway {cfg['gateway']}") + ensure_iface_configured(iface, cfg) + + +def main(): + log_format = ('%(asctime)s: [%(process)s]: %(filename)s(%(lineno)s): ' + '%(levelname)s: %(message)s') + LOG.basicConfig(filename=LOG_FILE, format=log_format, level=LOG.INFO, datefmt="%FT%T") + + parser = argparse.ArgumentParser( + prog='Network Configuration Applier', + description='Applies the network configuration generated by Puppet to the linux kernel' + ) + parser.add_argument("--routes", action='store_true') + args = parser.parse_args() + + apply_config(args.routes) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/puppet-manifests/src/bin/apply_network_config.sh b/puppet-manifests/src/bin/apply_network_config.sh index 0feedf633..e478833d3 100755 --- a/puppet-manifests/src/bin/apply_network_config.sh +++ b/puppet-manifests/src/bin/apply_network_config.sh @@ -7,6 +7,8 @@ # ################################################################################ +# WARNING: This file is OBSOLETE, use apply_network_config.py instead + # # Purpose of this script is to copy the puppet-built ifcfg-* network config # files from the PUPPET_DIR to the ETC_DIR. Only files that are detected as diff --git a/puppet-manifests/src/bin/k8s_wait_for_endpoints_health.py b/puppet-manifests/src/bin/k8s_wait_for_endpoints_health.py index fa391d321..0e235855f 100644 --- a/puppet-manifests/src/bin/k8s_wait_for_endpoints_health.py +++ b/puppet-manifests/src/bin/k8s_wait_for_endpoints_health.py @@ -62,10 +62,10 @@ def k8s_wait_for_endpoints_health(tries=TRIES, try_sleep=TRY_SLEEP, timeout=TIME healthz_endpoints = [APISERVER_READYZ_ENDPOINT, CONTROLLER_MANAGER_HEALTHZ_ENDPOINT, SCHEDULER_HEALTHZ_ENDPOINT, KUBELET_HEALTHZ_ENDPOINT] for endpoint in healthz_endpoints: - is_k8s_endpoint_healthy = kubernetes.k8s_health_check(tries = tries, - try_sleep = try_sleep, - timeout = timeout, - healthz_endpoint = endpoint) + is_k8s_endpoint_healthy = kubernetes.k8s_health_check(tries=tries, + try_sleep=try_sleep, + timeout=timeout, + healthz_endpoint=endpoint) if not is_k8s_endpoint_healthy: LOG.error("Timeout: Kubernetes control-plane endpoints not healthy") return 1 @@ -93,4 +93,3 @@ def main(): if __name__ == "__main__": sys.exit(main()) - diff --git a/puppet-manifests/src/bin/network_ifupdown.sh b/puppet-manifests/src/bin/network_ifupdown.sh index 88b86f409..1a0412e58 100644 --- a/puppet-manifests/src/bin/network_ifupdown.sh +++ b/puppet-manifests/src/bin/network_ifupdown.sh @@ -5,6 +5,8 @@ # ################################################################################ +# WARNING: This file is OBSOLETE + # # This file purpose is to provide helper functions if the system is Debian based # for the apply_network_config.sh script diff --git a/puppet-manifests/src/bin/network_sysconfig.sh b/puppet-manifests/src/bin/network_sysconfig.sh index bf6cd890e..003ee70f8 100644 --- a/puppet-manifests/src/bin/network_sysconfig.sh +++ b/puppet-manifests/src/bin/network_sysconfig.sh @@ -5,6 +5,8 @@ # ################################################################################ +# WARNING: This file is OBSOLETE + # # This file purpose is to provide helper functions if the system is CentOS based # for the apply_network_config.sh script diff --git a/puppet-manifests/src/bin/puppet-update-grub-env.py b/puppet-manifests/src/bin/puppet-update-grub-env.py index 18c5d8352..686859822 100755 --- a/puppet-manifests/src/bin/puppet-update-grub-env.py +++ b/puppet-manifests/src/bin/puppet-update-grub-env.py @@ -28,6 +28,7 @@ import sys BOOT_ENV = "/boot/efi/EFI/BOOT/boot.env" KERNEL_PARAMS_STRING = "kernel_params" + # Get value of kernel_params from conf def read_kernel_params(conf): """Get value of kernel_params from conf""" @@ -46,6 +47,7 @@ def read_kernel_params(conf): return res + # Write key=value string to conf def write_conf(conf, string): """Write key=value string to conf""" @@ -59,6 +61,7 @@ def write_conf(conf, string): print(err) raise + def set_parser(): """Set command parser""" @@ -110,6 +113,7 @@ def set_parser(): return parser + def convert_dict_to_value(kernel_params_dict): """Dictionary to value""" @@ -128,6 +132,7 @@ def convert_dict_to_value(kernel_params_dict): return f"kernel_params={kernel_params}" + def convert_value_to_dict(value): """Value to dictionary""" @@ -156,7 +161,6 @@ def convert_value_to_dict(value): else: key, val = param, '' - kernel_params_dict[key] = val if hugepage_cache: @@ -177,6 +181,7 @@ def convert_value_to_dict(value): return kernel_params_dict + def edit_boot_env(args): """Edit boot environment""" @@ -212,6 +217,7 @@ def edit_boot_env(args): kernel_params = convert_dict_to_value(kernel_params_dict) write_conf(BOOT_ENV, kernel_params) + def get_kernel_dir(): """Get kernel directory""" @@ -223,11 +229,12 @@ def get_kernel_dir(): return "/boot/1" + def edit_kernel_env(args): """Edit kernel environment""" kernel_dir = get_kernel_dir() - path_all = os.path.join(kernel_dir,"vmlinuz*-amd64") + path_all = os.path.join(kernel_dir, "vmlinuz*-amd64") path_rt = os.path.join(kernel_dir, "vmlinuz*rt*-amd64") glob_all_kernels = [os.path.basename(f) for f in glob.glob(path_all)] @@ -254,6 +261,7 @@ def edit_kernel_env(args): kernel_rollback_env = f"kernel_rollback={kernel}" write_conf(kernel_env, kernel_rollback_env) + def list_kernels(): """List kernels""" @@ -272,6 +280,7 @@ def list_kernels(): print(output) + def list_kernel_params(): """List kernel params""" @@ -288,6 +297,7 @@ def list_kernel_params(): print(line) break + def main(): """Main""" parser = set_parser() @@ -305,5 +315,6 @@ def main(): if args.list_kernel_params: list_kernel_params() + if __name__ == "__main__": main() diff --git a/puppet-manifests/src/modules/platform/files/change_k8s_control_plane_params.py b/puppet-manifests/src/modules/platform/files/change_k8s_control_plane_params.py index f531088da..66b595fac 100644 --- a/puppet-manifests/src/modules/platform/files/change_k8s_control_plane_params.py +++ b/puppet-manifests/src/modules/platform/files/change_k8s_control_plane_params.py @@ -729,14 +729,15 @@ def get_kubelet_cfg_from_service_parameters(service_params): # map[string]string & []string if value.startswith(('[', '{')) and value.endswith((']', '}')): try: - value = json.loads(value.replace('True', 'true').replace('False', 'false').replace("'", '"')) + value = json.loads( + value.replace('True', 'true').replace('False', 'false').replace("'", '"')) except Exception as e: msg = 'Parsing param: %s / value: %s. [Error: %s]' % (param, value, e) LOG.error(msg) return 3 # bool elif value in ['False', 'false'] or value in ['True', 'true']: - value = True if value in ['True', 'true'] else False # pylint: disable-msg=simplifiable-if-expression + value = True if value in ['True', 'true'] else False # pylint: disable-msg=simplifiable-if-expression # noqa: E501 # float elif '.' in value: try: @@ -1157,7 +1158,8 @@ def main(): parser.add_argument("--kubelet_latest_config_file", default="/var/lib/kubelet/config.yaml") parser.add_argument("--kubelet_bak_config_file", default="/var/lib/kubelet/config.yaml.bak") parser.add_argument("--kubelet_error_log", default="/tmp/kubelet_errors.log") - parser.add_argument("--k8s_configmaps_init_flag", default="/tmp/.sysinv_k8s_configmaps_initialized") + parser.add_argument("--k8s_configmaps_init_flag", + default="/tmp/.sysinv_k8s_configmaps_initialized") parser.add_argument("--automatic_recovery", default=True) parser.add_argument("--timeout", default=RECOVERY_TIMEOUT) diff --git a/puppet-manifests/src/modules/platform/manifests/network.pp b/puppet-manifests/src/modules/platform/manifests/network.pp index 74ee2c3d5..e2051535d 100644 --- a/puppet-manifests/src/modules/platform/manifests/network.pp +++ b/puppet-manifests/src/modules/platform/manifests/network.pp @@ -819,7 +819,7 @@ class platform::network::apply { -> Exec['apply-network-config'] exec {'apply-network-config': - command => 'apply_network_config.sh', + command => 'apply_network_config.py', } # Wait for network interface to leave tentative state during ipv6 DAD, if interface is UP @@ -909,7 +909,7 @@ class platform::network::routes::runtime { } exec {'apply-network-config route setup': - command => 'apply_network_config.sh --routes', + command => 'apply_network_config.py --routes', } } diff --git a/puppet-manifests/test-requirements.txt b/puppet-manifests/test-requirements.txt new file mode 100644 index 000000000..31b716025 --- /dev/null +++ b/puppet-manifests/test-requirements.txt @@ -0,0 +1,3 @@ +mock>=2.0.0 +stestr>=1.0.0 +netaddr diff --git a/puppet-manifests/tests/__init__.py b/puppet-manifests/tests/__init__.py new file mode 100644 index 000000000..e4937264c --- /dev/null +++ b/puppet-manifests/tests/__init__.py @@ -0,0 +1,5 @@ +# +# Copyright (c) 2025 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/puppet-manifests/tests/filesystem_mock.py b/puppet-manifests/tests/filesystem_mock.py new file mode 100644 index 000000000..109c4b077 --- /dev/null +++ b/puppet-manifests/tests/filesystem_mock.py @@ -0,0 +1,318 @@ +# +# Copyright (c) 2025 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import io + +# Keys for filesystem node properties +PARENT = "parent" +TYPE = "type" +FILE = "file" +DIR = "dir" +LINK = "link" +CONTENTS = "contents" +TARGET = "target" +REF = "ref" +LISTENERS = "listeners" + + +class FilesystemMockError(BaseException): + pass + + +class FileMock(): + def __init__(self, fs, entry): + self.fs = fs + self.entry = entry + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + def readlines(self): + lines = self.entry[CONTENTS].split("\n") + out_lines = [line + "\n" for line in lines[:-1]] + if len(lines[-1]) > 0: + out_lines.append(lines[-1]) + return out_lines + + def read(self): + return self.entry[CONTENTS] + + def write(self, contents): + if REF not in self.entry: + raise io.UnsupportedOperation("not writable") + self.entry[CONTENTS] += contents + + +class ReadOnlyFileContainer(): + def __init__(self, contents=None): + self.next_id = 0 + self.root = self._get_new_dir(None) + if contents: + self.batch_add(contents) + + def batch_add(self, contents): + for path, data in contents.items(): + if data is None: + self._add_dir(path) + elif type(data) == str: + self._add_file(path, data) + elif type(data) == tuple and len(data) == 1 and type(data[0]) == str: + self._add_link(path, data[0]) + else: + raise FilesystemMockError("Invalid entry, must be None for directory, " + "str for file or tuple with 1 str element for link") + + def get_root_node(self): + return self.root + + @staticmethod + def _get_new_dir(parent): + return {PARENT: parent, TYPE: DIR, CONTENTS: dict()} + + @staticmethod + def _get_new_file(parent, contents): + return {PARENT: parent, TYPE: FILE, CONTENTS: contents} + + @staticmethod + def _get_new_link(parent, entry, target_path): + return {PARENT: parent, TYPE: LINK, CONTENTS: entry, TARGET: target_path} + + def _do_add_dir(self, path_pieces): + def add_dir_rec(parent, pieces): + if len(pieces) == 0: + return parent + current = parent[CONTENTS].get(pieces[0], None) + if not current: + current = self._get_new_dir(parent) + parent[CONTENTS][pieces[0]] = current + return add_dir_rec(current, pieces[1:]) + return add_dir_rec(self.root, path_pieces) + + def _get_entry(self, path): + pieces = path.split("/")[1:] + + def get_entry_rec(parent, pieces): + if len(pieces) == 0: + return parent + current = parent[CONTENTS].get(pieces[0], None) + if not current: + raise FilesystemMockError(f"Path not found: '{path}'") + return get_entry_rec(current, pieces[1:]) + + return get_entry_rec(self.root, pieces) + + def _add_dir(self, path): + pieces = path.split("/")[1:] + self._do_add_dir(pieces) + + def _add_file(self, path, contents): + pieces = path.split("/")[1:] + new_dir = self._do_add_dir(pieces[:-1]) + file_entry = self._get_new_file(new_dir, contents) + new_dir[CONTENTS][pieces[-1]] = file_entry + + def _add_link(self, path, ref_path): + pieces = path.split("/")[1:] + new_dir = self._do_add_dir(pieces[:-1]) + ref_entry = self._get_entry(ref_path) + link_entry = self._get_new_link(new_dir, ref_entry, ref_path) + new_dir[CONTENTS][pieces[-1]] = link_entry + + +class FilesystemMock(): + def __init__(self, contents: dict = None, fs: ReadOnlyFileContainer = None): + if fs is not None: + self.fs = fs + add_contents = True + else: + self.fs = ReadOnlyFileContainer(contents) + add_contents = False + + self.root = self._get_new_entry(self.fs.get_root_node(), None) + if add_contents and contents: + self.batch_add(contents) + + def batch_add(self, contents): + for path, data in contents.items(): + if data is None: + self.create_directory(path) + elif type(data) == str: + self.set_file_contents(path, data) + elif type(data) == tuple and len(data) == 1 and type(data[0]) == str: + self.set_link_contents(path, data[0]) + else: + raise FilesystemMockError("Invalid entry, must be None for directory, " + "str for file or tuple with 1 str element for link") + + @staticmethod + def _get_new_entry(ref, parent, node_type=None): + if not node_type: + node_type = ref[TYPE] + entry = {REF: ref, PARENT: parent, TYPE: node_type} + if node_type == DIR: + entry[CONTENTS] = ref[CONTENTS].copy() if ref else dict() + elif node_type == LINK: + entry[CONTENTS] = ref[CONTENTS] if ref else None + entry[TARGET] = ref[TARGET] if ref else None + else: + entry[CONTENTS] = '' + return entry + + def _get_entry(self, path, translate_link=False): + pieces = path.split("/")[1:] + + def get_entry_rec(contents, pieces): + if len(pieces) == 0: + if translate_link and contents[TYPE] == LINK: + return contents[CONTENTS] + return contents + if contents[TYPE] == LINK: + contents = contents[CONTENTS] + if REF in contents and contents[CONTENTS] is None: + child = contents[REF][CONTENTS].get(pieces[0], None) + else: + child = contents[CONTENTS].get(pieces[0], None) + if child is None: + return None + return get_entry_rec(child, pieces[1:]) + + return get_entry_rec(self.root, pieces) + + def _patch_entry(self, path, node_type): + pieces = path.split("/")[1:] + + def translate_link(entry): + target = entry[CONTENTS] + if REF not in target: + target = self._patch_entry(entry[TARGET], target[TYPE]) + entry[CONTENTS] = target + return target + + def patch_entry_rec(level, entry, pieces): + if len(pieces) == 0: + if entry[TYPE] == LINK and node_type != LINK: + entry = translate_link(entry) + if entry[TYPE] != node_type: + if node_type == FILE: + raise IsADirectoryError(f"[Errno 21] Is a directory: '{path}'") + raise NotADirectoryError(f"[Errno 20] Not a directory: '{path}'") + return entry + if entry[TYPE] == LINK: + entry = translate_link(entry) + if entry[TYPE] != DIR: + raise NotADirectoryError(f"[Errno 20] Not a directory: '{path}'") + if entry[CONTENTS] is None: + entry[CONTENTS] = entry[REF][CONTENTS].copy() + child = entry[CONTENTS].get(pieces[0], None) + if child is None or REF not in child: + if child is None: + new_type = node_type if len(pieces) == 1 else DIR + child = self._get_new_entry(None, entry, new_type) + else: + child = self._get_new_entry(child, entry) + entry[CONTENTS][pieces[0]] = child + return patch_entry_rec(level + 1, child, pieces[1:]) + + return patch_entry_rec(0, self.root, pieces) + + def exists(self, path): + entry = self._get_entry(path) + return entry is not None + + def isfile(self, path): + entry = self._get_entry(path) + return entry and entry[TYPE] == FILE + + def isdir(self, path): + entry = self._get_entry(path) + return entry and entry[TYPE] == DIR + + def islink(self, path): + entry = self._get_entry(path) + return entry and entry[TYPE] == LINK + + def open(self, path, mode="r"): + if "w" in mode: + entry = self._patch_entry(path, FILE) + else: + entry = self._get_entry(path, translate_link=True) + if not entry: + raise FileNotFoundError(f"[Errno 2] No such file or directory: '{path}'") + if entry[TYPE] == DIR: + raise IsADirectoryError(f"[Errno 21] Is a directory: '{path}'") + if "w" in mode: + self._call_listeners(entry) + return FileMock(self, entry) + + def _call_listeners(self, entry): + if parent := entry[PARENT]: + self._call_listeners(parent) + if listeners := entry.get(LISTENERS, None): + for listener in listeners: + listener() + + def create_directory(self, path): + entry = self._patch_entry(path, DIR) + self._call_listeners(entry) + + def set_file_contents(self, path, contents): + entry = self._patch_entry(path, FILE) + entry[CONTENTS] = contents + self._call_listeners(entry) + + def get_file_contents(self, path): + entry = self._get_entry(path, translate_link=True) + if entry is None: + raise FilesystemMockError("Path does not exist") + if entry[TYPE] != FILE: + raise FilesystemMockError("Path is not a file") + return entry[CONTENTS] + + def set_link_contents(self, link_path, target_path): + target = self._get_entry(target_path) + if target is None: + raise FilesystemMockError("Target path does not exist") + entry = self._patch_entry(link_path, LINK) + entry[CONTENTS] = target + entry[TARGET] = target_path + self._call_listeners(entry) + + def get_file_list(self, path): + entry = self._get_entry(path, translate_link=True) + if entry is None: + raise FilesystemMockError("Path does not exist") + if entry[TYPE] != DIR: + raise FilesystemMockError("Path is not a directory") + files = [] + for name, child in entry[CONTENTS].items(): + if child[TYPE] == FILE: + files.append(name) + files.sort() + return files + + def add_listener(self, path, callback): + entry = self._get_entry(path, translate_link=True) + if entry is None: + raise FilesystemMockError("Path does not exist") + if REF not in entry: + entry = self._patch_entry(path, entry[TYPE]) + listeners = entry.setdefault(LISTENERS, list()) + listeners.append(callback) + + def delete(self, path): + pieces = path.split("/")[1:] + + entry = self._get_entry(path) + if entry is None: + raise FileNotFoundError(f"[Errno 2] No such file or directory: '{path}'") + + pieces = path.split("/") + patched_entry = self._patch_entry("/".join(pieces[:-1]), DIR) + patched_entry[CONTENTS].pop(pieces[-1]) + self._call_listeners(patched_entry) diff --git a/puppet-manifests/tests/system_cmd_test_script.sh b/puppet-manifests/tests/system_cmd_test_script.sh new file mode 100755 index 000000000..2932829af --- /dev/null +++ b/puppet-manifests/tests/system_cmd_test_script.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +################################################################################ +# Copyright (c) 2025 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +################################################################################ + +# +# This script is used by the automated tests +# tests.test_apply_network_config.GeneralTests.test_execute_system_cmd_timeout_*, it simulates a +# command that takes too long to terminate and triggers a timeout. In certain situations, the ifup +# command can exhibit this behavior. +# + +return_code=$1 +extra_sleep=$2 + +terminate() +{ + echo "< SIGTERM RECEIVED >" + + if [[ "$extra_sleep" == "-e" ]]; then + sleep 10 + echo "< AFTER EXTRA SLEEP >" + fi + + exit $return_code +} + +trap terminate 15 + +echo "< BEFORE SLEEP >" +sleep 10 +echo "< AFTER SLEEP >" diff --git a/puppet-manifests/tests/test_apply_network_config.py b/puppet-manifests/tests/test_apply_network_config.py new file mode 100644 index 000000000..202a08c5c --- /dev/null +++ b/puppet-manifests/tests/test_apply_network_config.py @@ -0,0 +1,3061 @@ +# +# Copyright (c) 2025 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import mock +import os +import re +import testtools +from netaddr import IPAddress +from netaddr import IPNetwork +from netaddr import AddrFormatError + +from tests.filesystem_mock import FilesystemMock +from tests.filesystem_mock import ReadOnlyFileContainer +import src.bin.apply_network_config as anc + + +class NetworkingMockError(BaseException): + pass + + +class NetworkingMock(): # pylint: disable=too-many-instance-attributes + def __init__(self, fs: FilesystemMock, ifaces: list): + self._stdout = '' + self._history = [] + self._etc_changed = True + self._fs = fs + self._current_config = None + self._links = dict() + self._routes = dict() + self._next_route_id = 0 + self._allow_multiple_default_gateways = False + self._add_eth_ifaces(ifaces) + self._fs.add_listener(anc.ETC_DIR, self._etc_dir_changed) + + def _etc_dir_changed(self): + self._etc_changed = True + + def _add_eth_ifaces(self, ifaces): + for iface in ifaces: + self._add_eth_iface(iface) + + @staticmethod + def _get_device_path(iface, is_virtual=False): + if is_virtual: + return f"/sys/devices/virtual/net/{iface}" + return f"/sys/devices/pci0000:00/net/{iface}" + + def _add_eth_iface(self, iface): + phys_path = self._get_device_path(iface) + self._fs.set_file_contents(phys_path + "/operstate", "down") + self._fs.set_link_contents(anc.DEVLINK_BASE_PATH + iface, phys_path) + self._links[iface] = {"adm_state": False, "virtual": False, + "addresses": set(), "routes": set()} + + def _parse_etc_interfaces(self): + file_list = self._fs.get_file_list(anc.ETC_DIR) + parser = anc.StanzaParser() + for file in file_list: + file_contents = self._fs.get_file_contents(anc.ETC_DIR + "/" + file) + parser.parse_lines(file_contents.split("\n")) + return parser.get_auto_and_ifaces() + + @staticmethod + def _decode_iface_config(name, config): + if anc.is_label(name): + parent = name.split(":")[0] + props = {"type": anc.LABEL, "parent": parent} + elif name == "lo": + props = {"type": anc.LO} + elif vlan_attribs := anc.get_vlan_attributes(name, config): + preup = config.get("pre-up", None) + add_link_cmd = preup and "ip link add" in preup + props = {"type": anc.VLAN, "raw_dev": vlan_attribs[0], "vlan_id": vlan_attribs[1], + "add_link_cmd": add_link_cmd} + elif slaves := config.get("bond-slaves", None): + props = {"type": anc.BONDING, "slaves": slaves.split()} + elif master := config.get("bond-master", None): + props = {"type": anc.SLAVE, "master": master} + else: + props = {"type": anc.ETH} + mode = config["iface"].split()[2] + props["mode"] = mode + if mode == "static": + if not (address := config.get("address", None)): + raise NetworkingMockError( + f"Interface '{name}' is set to STATIC but has no address specified") + if "/" in address: + props["address"] = IPNetwork(address) + else: + if not (netmask := config.get("netmask", None)): + raise NetworkingMockError( + f"Interface '{name}' is set to STATIC but has no netmask specified") + props["address"] = IPNetwork(f"{address}/{netmask}") + if gateway := config.get("gateway", None): + props["gateway"] = IPAddress(gateway) + return props + + def _decode_config(self): + auto, etc_ifaces = self._parse_etc_interfaces() + decoded_ifaces = dict() + for iface, config in etc_ifaces.items(): + decoded_ifaces[iface] = self._decode_iface_config(iface, config) + return auto, decoded_ifaces + + def _update_config(self): + if not self._etc_changed: + return + self._etc_changed = False + auto, ifaces = self._decode_config() + self._current_config = {"auto": auto, "ifaces": ifaces} + + def _add_route_line(self, line): + pieces = line.split() + if len(pieces) < 4: + raise NetworkingMockError(f"Invalid route in '{anc.ETC_ROUTES_FILE}' file: '{line}'") + netmask_ip = IPAddress(pieces[1]) + prefixlen = netmask_ip.netmask_bits() + network = f"{pieces[0]}/{prefixlen}" + metric = pieces[5] if len(pieces) > 4 else None + self._do_ip_route_add(network, pieces[2], pieces[3], metric) + + def _apply_etc_routes(self): + if not self._fs.isfile(anc.ETC_ROUTES_FILE): + return + file_contents = self._fs.get_file_contents(anc.ETC_ROUTES_FILE) + lines = [line.strip() for line in file_contents.split("\n")] + for line in lines: + clean_line = line.strip() + if clean_line and not clean_line.startswith("#"): + self._add_route_line(line) + + def apply_auto(self): + self._reset_stdout() + self._etc_changed = True + self._update_config() + auto = [iface for iface in self._current_config["auto"] + if self._current_config["ifaces"][iface]["type"] != anc.SLAVE] + for iface in auto: + self._do_ifup(iface) + self._apply_etc_routes() + return self._stdout + + def reset_history(self): + self._history = [] + + def get_history(self): + return self._history + + def _add_history(self, command, *args): + self._history.append((command, *args)) + + def _reset_stdout(self): + self._stdout = '' + + def _print_stdout(self, msg): + self._stdout += msg + "\n" + + def _is_up(self, iface): + state_file_path = anc.IFSTATE_BASE_PATH + iface + if self._fs.isfile(state_file_path): + data = self._fs.get_file_contents(state_file_path) + return data == iface + return False + + def _set_link_state(self, iface, link, state): + if link["adm_state"] == state: + return + link["adm_state"] = state + operstate_path = self._get_device_path(iface, link["virtual"]) + "/operstate" + value = "up" if state else "down" + self._fs.set_file_contents(operstate_path, value) + + def _create_virtual_link(self, name): + if link := self._links.get(name, None): + self._print_stdout("RTNETLINK answers: File exists") + return link, 1 + phys_path = self._get_device_path(name, True) + self._fs.set_file_contents(phys_path + "/operstate", "down") + self._fs.set_link_contents(anc.DEVLINK_BASE_PATH + name, phys_path) + link = {"adm_state": False, "virtual": True, "addresses": set(), "routes": set()} + self._links[name] = link + return link, 0 + + def _remove_virtual_link(self, name): + link, retcode = self._get_link(name) + if retcode != 0: + return 1 + for route_id in link["routes"]: + self._routes.pop(route_id) + if deps := link.get("deps", None): + for dep in deps: + self._remove_virtual_link(dep) + del self._links[name] + self._fs.delete(anc.DEVLINK_BASE_PATH + name) + self._fs.delete(self._get_device_path(name, True)) + return 0 + + def _get_link(self, name): + link = self._links.get(name, None) + if link: + return link, 0 + self._print_stdout(f'Cannot find device "{name}"') + return None, 1 + + def _get_link_for_ip_cmd(self, name): + link = self._links.get(name, None) + if link: + return link, 0 + self._print_stdout(f'Device "{name}" does not exist.') + return None, 1 + + def _enslave_iface(self, iface, master, master_failed): + if master_failed or not (link := self._links.get(iface, None)): + self._print_stdout(f"Failed to enslave {iface} to {master}. " + f"Is {master} ready and a bonding interface ?") + return None, 1 + link["master"] = master + self._set_link_state(iface, link, True) + return link, 0 + + def _unenslave_iface(self, iface): + if not (link := self._links.get(iface, None)): + return 1 + link.pop("master", None) + self._set_link_state(iface, link, False) + return 0 + + def _add_address(self, iface, config, link): + mode = config["mode"] + if mode == "static": + address = config["address"] + if address in link["addresses"]: + self._print_stdout(f"Error: ipv{address.version}: Address already assigned.") + return 1 + link["addresses"].add(address) + if gateway := config.get("gateway", None): + self._add_default_gateway(iface, link, gateway) + return 0 + + def _remove_routes_associated_to_address(self, link, address): + to_remove = [] + for route_id in link["routes"]: + route = self._routes[route_id] + if route["via"] in address: + to_remove.append(route_id) + for route_id in to_remove: + self._routes.pop(route_id) + link["routes"].remove(route_id) + + def _remove_address(self, config, link): + mode = config["mode"] + if mode == "static": + address = config["address"] + if address not in link["addresses"]: + self._print_stdout(f"Error: ipv{address.version}: Address not found.") + return 1 + link["addresses"].remove(address) + self._remove_routes_associated_to_address(link, address) + return 0 + + def _add_default_gateway(self, ifname, link, gateway): + net = '0.0.0.0/0' if gateway.version == 4 else '::0/0' + route_filter = self._get_route_filter(net, None, None, None) + existing = self._find_routes(route_filter, True) + if existing: + if not self._allow_multiple_default_gateways: + raise NetworkingMockError("Trying to create default route from ifup for " + f"interface '{ifname}', default route already exists") + route_obj = self._get_route_obj(net, gateway, ifname, None) + retcode = self._check_can_add_route(route_obj, link) + if retcode != 0: + return retcode + for route_id in existing.keys(): + self._remove_route(route_id) + self._add_route(route_obj, link) + return 0 + + def _set_lo_up(self, iface, config): + return self._set_eth_up(iface, config) + + def _set_lo_down(self, iface, config): + return self._set_eth_down(iface, config) + + def _set_eth_up(self, iface, config): + link, retcode = self._get_link(iface) + if retcode != 0: + return 1 + self._set_link_state(iface, link, True) + return self._add_address(iface, config, link) + + def _set_eth_down(self, iface, config): + link, retcode = self._get_link(iface) + if retcode != 0: + return 0 + self._set_link_state(iface, link, False) + self._remove_address(config, link) + return 0 + + def _set_slave_up(self, iface, config): # pylint: disable=no-self-use,unused-argument + raise NetworkingMockError( + f"ifup is not supposed to be called for slave interfaces: {iface}") + + def _set_slave_down(self, iface, config): # pylint: disable=no-self-use,unused-argument + raise NetworkingMockError( + f"ifdown is not supposed to be called for slave interfaces: {iface}") + + def _set_bonding_up(self, iface, config): + link, retcode = self._create_virtual_link(iface) + if retcode != 0: + self._print_stdout("/etc/network/if-pre-up.d/ifenslave: line 39: /sys/class/net/" + f"{iface}/bonding/miimon: No such file or directory") + self._print_stdout("/etc/network/if-pre-up.d/ifenslave: line 39: /sys/class/net/" + f"{iface}/bonding/mode: No such file or directory") + link["slaves"] = config["slaves"] + for slave in config["slaves"]: + self._enslave_iface(slave, iface, retcode != 0) + self._set_link_state(iface, link, True) + return self._add_address(iface, config, link) + + def _set_bonding_down(self, iface, config): + link, retcode = self._get_link(iface) + if retcode != 0: + return 0 + self._remove_address(config, link) + self._set_link_state(iface, link, False) + for slave in config["slaves"]: + self._unenslave_iface(slave) + self._remove_virtual_link(iface) + return 0 + + def _set_vlan_up(self, iface, config): + raw_dev = config["raw_dev"] + if config["add_link_cmd"]: + link, retcode = self._get_link(raw_dev) + if retcode != 0: + return retcode + else: + if raw_dev not in self._links: + self._print_stdout(f'cat: /sys/class/net/{raw_dev}/mtu: No such file or directory') + self._print_stdout(f'Device "{raw_dev}" does not exist.') + self._print_stdout(f'{raw_dev} does not exist, unable to create {iface}') + self._print_stdout('run-parts: /etc/network/if-pre-up.d/vlan exited with ' + 'return code 1') + return 1 + link, retcode = self._create_virtual_link(iface) + if retcode != 0: + return 1 + link["raw_dev"] = raw_dev + link["vlan_id"] = config["vlan_id"] + deps = self._links[raw_dev].setdefault("deps", list()) + deps.append(iface) + self._set_link_state(iface, link, True) + return self._add_address(iface, config, link) + + def _set_vlan_down(self, iface, config): + link, retcode = self._get_link(iface) + if retcode != 0: + return 0 + self._remove_address(config, link) + self._set_link_state(iface, link, False) + self._remove_virtual_link(iface) + return 0 + + def _set_label_up(self, iface, config): # pylint: disable=unused-argument + parent = config["parent"] + link, retcode = self._get_link(parent) + if retcode != 0: + return retcode + return self._add_address(parent, config, link) + + def _set_label_down(self, iface, config): # pylint: disable=unused-argument + parent = config["parent"] + link, retcode = self._get_link(parent) + if retcode == 0: + self._remove_address(config, link) + return 0 + + def _set_ifstate(self, iface, state): + path = anc.IFSTATE_BASE_PATH + iface + contents = iface if state else '' + self._fs.set_file_contents(path, contents) + + _UP_FUNCTIONS = {anc.LO: _set_lo_up, + anc.ETH: _set_eth_up, + anc.SLAVE: _set_slave_up, + anc.BONDING: _set_bonding_up, + anc.VLAN: _set_vlan_up, + anc.LABEL: _set_label_up} + + _DOWN_FUNCTIONS = {anc.LO: _set_lo_down, + anc.ETH: _set_eth_down, + anc.SLAVE: _set_slave_down, + anc.BONDING: _set_bonding_down, + anc.VLAN: _set_vlan_down, + anc.LABEL: _set_label_down} + + def _get_iface_config(self, iface): + self._update_config() + if not (config := self._current_config["ifaces"].get(iface, None)): + self._print_stdout(f"ifup: unknown interface {iface}") + return None, 1 + return config, 0 + + def _run_command(self, fxn, *args, **kwargs): + self._reset_stdout() + retcode = fxn(*args, **kwargs) + return retcode, self._stdout + + def _do_ifup(self, iface): + config, retcode = self._get_iface_config(iface) + if retcode != 0: + return 1 + if self._is_up(iface): + self._print_stdout(f"ifup: interface {iface} already configured") + return 0 + fxn = self._UP_FUNCTIONS[config["type"]] + retcode = fxn(self, iface, config) + if retcode == 0: + self._set_ifstate(iface, True) + else: + self._print_stdout(f"ifup: failed to bring up {iface}") + return retcode + + def ifup(self, iface): + self._add_history("ifup", iface) + return self._run_command(self._do_ifup, iface) + + def _do_ifdown(self, iface): + config, retcode = self._get_iface_config(iface) + if retcode != 0: + return 1 + if not self._is_up(iface): + self._print_stdout(f"ifdown: interface {iface} not configured") + return 0 + fxn = self._DOWN_FUNCTIONS[config["type"]] + retcode = fxn(self, iface, config) + if retcode == 0: + self._set_ifstate(iface, False) + return retcode + + def ifdown(self, iface): + self._add_history("ifdown", iface) + return self._run_command(self._do_ifdown, iface) + + def ip_addr_show(self): + self._add_history("ip_addr_show") + return 0, "< 'ip addr show' output placeholder >\n" + + def _do_ip_addr_show_dev(self, iface): + link, retcode = self._get_link_for_ip_cmd(iface) + if retcode != 0: + return retcode + name = iface + if raw_dev := link.get("raw_dev", None): + name += "@" + raw_dev + state = "UP" if link["adm_state"] else "DOWN" + addresses = [str(addr) for addr in sorted(list(link["addresses"]))] + text = f"{name:<16} {state:<14} {' '.join(addresses)}" + self._print_stdout(text) + return 0 + + def ip_addr_show_dev(self, iface): + self._add_history("ip_addr_show_dev", iface) + return self._run_command(self._do_ip_addr_show_dev, iface) + + def _do_ip_addr_add(self, addr, iface): + link, retcode = self._get_link_for_ip_cmd(iface) + if retcode != 0: + return retcode + try: + ip = IPNetwork(addr) + except AddrFormatError: + self._print_stdout(f'Error: any valid prefix is expected rather than "{addr}".') + return 1 + if ip in link["addresses"]: + self._print_stdout(f"Error: ipv{ip.version}: Address already assigned.") + return 1 + link["addresses"].add(ip) + return 0 + + def ip_addr_add(self, addr, iface): + self._add_history("ip_addr_add", addr, iface) + return self._run_command(self._do_ip_addr_add, addr, iface) + + def _do_ip_addr_flush(self, iface): + link, retcode = self._get_link_for_ip_cmd(iface) + if retcode != 0: + return retcode + for address in link["addresses"]: + self._remove_routes_associated_to_address(link, address) + link["addresses"].clear() + return 0 + + def ip_addr_flush(self, iface): + self._add_history("ip_addr_flush", iface) + return self._run_command(self._do_ip_addr_flush, iface) + + def _do_ip_link_set_updown(self, iface, state): + link, retcode = self._get_link_for_ip_cmd(iface) + if retcode != 0: + return retcode + self._set_link_state(iface, link, state) + return 0 + + def ip_link_set_down(self, iface): + self._add_history("ip_link_set_down", iface) + return self._run_command(self._do_ip_link_set_updown, iface, False) + + def ip_link_set_up(self, iface): + self._add_history("ip_link_set_up", iface) + return self._run_command(self._do_ip_link_set_updown, iface, True) + + def ip_route_show_all(self, prot): + self._add_history("ip_route_show_all", prot) + return 0, "< 'ip route show all' output placeholder >\n" + + @staticmethod + def _sort_routes(routes): + return [routes[k] for k in sorted(routes.keys())] + + def _print_route(self, route, route_filter=None): + net = route["net"] + if net.value == 0 and net.prefixlen == 0: + pieces = ["default"] + else: + pieces = [f"{net.ip}/{net.prefixlen}"] + if not route_filter or not route_filter["via"]: + pieces.append(f'via {route["via"]}') + if not route_filter or not route_filter["dev"]: + pieces.append(f'dev {route["dev"]}') + if not route_filter or not route_filter["metric"]: + if (metric := route["metric"]) != 0 or net.version != 4: + pieces.append(f'metric {metric}') + if net.version == 6: + pieces.append("pref medium") + self._print_stdout(" ".join(pieces)) + + def _do_ip_route_show(self, prot, network, gateway, dev, metric): + # pylint: disable=too-many-arguments + ip_version = 6 if prot == "-6" else 4 + filter_filter = self._get_route_filter(network, gateway, dev, metric, ip_version) + routes = self._find_routes(filter_filter) + for route in self._sort_routes(routes): + self._print_route(route, filter_filter) + return 0 + + def ip_route_show(self, prot, network, gateway, dev, metric): + # pylint: disable=too-many-arguments + self._add_history("ip_route_show", prot, network, gateway, dev, metric) + return self._run_command(self._do_ip_route_show, prot, network, gateway, dev, metric) + + @staticmethod + def _get_route_obj(network, gateway, dev, metric): + gateway_ip = IPAddress(gateway) + if network == "default": + net = IPNetwork('0.0.0.0/0') if gateway_ip.version == 4 else IPNetwork('::0/0') + else: + net = IPNetwork(network) + if metric: + metric_val = int(metric) + if gateway_ip.version == 6 and metric_val == 0: + metric_val = 1024 + else: + metric_val = 0 if gateway_ip.version == 4 else 1024 + return {"net": net, "via": gateway_ip, "dev": dev, "metric": metric_val} + + def _add_route(self, route_obj, link): + route_id = self._next_route_id + self._next_route_id += 1 + self._routes[route_id] = route_obj + link["routes"].add(route_id) + + def _remove_route(self, route_id): + route_obj = self._routes.pop(route_id) + self._links[route_obj["dev"]]["routes"].remove(route_id) + + @staticmethod + def _get_route_filter(network, gateway, dev, metric, version=None): + gateway_ip = IPAddress(gateway) if gateway else None + if network == "default": + if (version and version == 6) or (gateway_ip and gateway_ip.version == 6): + net = IPNetwork('::0/0') + else: + net = IPNetwork('0.0.0.0/0') + else: + net = IPNetwork(network) if network else None + metric_val = int(metric) if metric else None + return {"net": net, "via": gateway_ip, "dev": dev, + "metric": metric_val, "version": version} + + @staticmethod + def _route_matches(route_filter, route): + filter_net = route_filter["net"] + route_net = route["net"] + version = route_filter["version"] + if version and route_net.version != version: + return False + if route_net != filter_net: + return False + for prop in ["via", "dev", "metric"]: + if not (val := route_filter[prop]): + continue + if route[prop] != val: + return False + return True + + def _find_routes(self, route_filter, single=False): + routes = dict() + for route_id, route in self._routes.items(): + if self._route_matches(route_filter, route): + routes[route_id] = route + if single: + break + return routes + + def _route_exists(self, network, gateway, dev, metric): + route_filter = self._get_route_filter(network, gateway, dev, metric) + return bool(self._find_routes(route_filter, True)) + + def _erase_routes_by_filter(self, route_filter): + to_remove = [] + for route_id, route in self._routes.items(): + if self._route_matches(route_filter, route): + to_remove.append(route_id) + for route_id in to_remove: + self._remove_route(route_id) + + def _check_can_add_route(self, route_obj, link): + gateway = route_obj["via"] + for addr in link["addresses"]: + if gateway in addr: + return 0 + self._print_stdout("RTNETLINK answers: No route to host") + return 2 + + def _do_ip_route_add(self, network, gateway, dev, metric): + link, retcode = self._get_link(dev) + if retcode != 0: + return retcode + if self._route_exists(network, gateway, dev, metric): + self._print_stdout("RTNETLINK answers: File exists") + return 2 + route_obj = self._get_route_obj(network, gateway, dev, metric) + retcode = self._check_can_add_route(route_obj, link) + if retcode != 0: + return retcode + self._add_route(route_obj, link) + return 0 + + def ip_route_add(self, network, gateway, dev, metric): + self._add_history("ip_route_add", network, gateway, dev, metric) + return self._run_command(self._do_ip_route_add, network, gateway, dev, metric) + + def _do_ip_route_replace(self, network, gateway, dev, metric): + link, retcode = self._get_link(dev) + if retcode != 0: + return retcode + ip_version = IPAddress(gateway).version + route_obj = self._get_route_obj(network, gateway, dev, metric) + retcode = self._check_can_add_route(route_obj, link) + if retcode != 0: + return retcode + route_filter = self._get_route_filter(network, None, None, metric, ip_version) + self._erase_routes_by_filter(route_filter) + self._add_route(route_obj, link) + return 0 + + def ip_route_replace(self, network, gateway, dev, metric): + self._add_history("ip_route_replace", network, gateway, dev, metric) + return self._run_command(self._do_ip_route_replace, network, gateway, dev, metric) + + def _do_ip_route_del(self, network, gateway, dev, metric): + route_filter = self._get_route_filter(network, gateway, dev, metric) + routes = self._find_routes(route_filter) + if len(routes) == 0: + self._print_stdout("RTNETLINK answers: No such process") + return 2 + for route_id in routes.keys(): + self._remove_route(route_id) + return 0 + + def ip_route_del(self, network, gateway, dev, metric): + self._add_history("ip_route_del", network, gateway, dev, metric) + return self._run_command(self._do_ip_route_del, network, gateway, dev, metric) + + @staticmethod + def _get_link_text(link): + pieces = ["UP" if link["adm_state"] else "DOWN"] + if raw_dev := link.get("raw_dev", None): + pieces.append(f"VLAN({raw_dev},{link['vlan_id']})") + elif master := link.get("master", None): + pieces.append(f"SLAVE({master})") + elif slaves := link.get("slaves", None): + pieces.append(f"BONDING({','.join(slaves)})") + pieces.extend([str(ip) for ip in sorted(link["addresses"])]) + return " ".join(pieces) + + def get_link_status(self, name): + if not (link := self._links.get(name, None)): + raise NetworkingMockError(f"Link does not exist: '{name}'") + return self._get_link_text(link) + + def get_links_status(self): + return [name + " " + self._get_link_text(self._links[name]) + for name in sorted(self._links.keys())] + + @staticmethod + def _get_route_text(route): + net = route["net"] + net_text = "default" if net.value == 0 and net.prefixlen == 0 else str(net) + text = f"{net_text} via {route['via']} dev {route['dev']}" + if metric := route["metric"]: + text += f" metric {metric}" + return text + + def get_routes(self): + return [self._get_route_text(self._routes[id]) for id in sorted(self._routes.keys())] + + def set_allow_multiple_default_gateways(self, allow: bool): + self._allow_multiple_default_gateways = allow + + +class SystemCommandMockError(BaseException): + pass + + +class SystemCommandMock(): # pylint: disable=too-few-public-methods + def __init__(self, nwmock: NetworkingMock): + self._nwmock = nwmock + + def _ip_addr_show(self, _): + return self._nwmock.ip_addr_show() + + def _ip_br_addr_show_dev(self, args): + return self._nwmock.ip_addr_show_dev(args[0]) + + def _ip_addr_add(self, args): + return self._nwmock.ip_addr_add(args[0], args[1]) + + def _ip_addr_flush(self, args): + return self._nwmock.ip_addr_flush(args[0]) + + def _ip_link_set_down(self, args): + return self._nwmock.ip_link_set_down(args[0]) + + def _ip_route_show_all(self, args): + return self._nwmock.ip_route_show_all(args[0]) + + def _ip_route_show(self, args): + return self._nwmock.ip_route_show(args[0], args[1], args[2], args[3], args[4]) + + def _ip_route_add(self, args): + return self._nwmock.ip_route_add(args[0], args[1], args[2], args[3]) + + def _ip_route_replace(self, args): + return self._nwmock.ip_route_replace(args[0], args[1], args[2], args[3]) + + def _ip_route_del(self, args): + return self._nwmock.ip_route_del(args[0], args[1], args[2], args[3]) + + def _ifup(self, args): + return self._nwmock.ifup(args[0]) + + def _ifdown(self, args): + return self._nwmock.ifdown(args[0]) + + _MAPPINGS = ( + (re.compile(R"^/sbin/ifup (?:-v )?(\S+)$"), _ifup), + (re.compile(R"^/sbin/ifdown (?:-v )?(\S+)$"), _ifdown), + (re.compile(R"^/usr/sbin/ip addr show$"), _ip_addr_show), + (re.compile(R"^/usr/sbin/ip -br addr show dev (\S+)$"), _ip_br_addr_show_dev), + (re.compile(R"^/usr/sbin/ip addr add (\S+) dev (\S+)$"), _ip_addr_add), + (re.compile(R"^/usr/sbin/ip addr flush dev (\S+)$"), _ip_addr_flush), + (re.compile(R"^/usr/sbin/ip link set down dev (\S+)$"), _ip_link_set_down), + (re.compile(R"^/usr/sbin/ip (?:(-6) )?route show$"), _ip_route_show_all), + (re.compile(R"^/usr/sbin/ip (?:(-6) )?route show (\S+)(?: via (\S+) " + R"dev (\S+))?(?: metric (\S+))?$"), _ip_route_show), + (re.compile(R"^/usr/sbin/ip route add (\S+) via (\S+) " + R"dev (\S+)(?: metric (\S+))?$"), _ip_route_add), + (re.compile(R"^/usr/sbin/ip route replace (\S+) via (\S+) " + R"dev (\S+)(?: metric (\S+))?$"), _ip_route_replace), + (re.compile(R"^/usr/sbin/ip route del (\S+) via (\S+) " + R"dev (\S+)(?: metric (\S+))?$"), _ip_route_del),) + + def execute_system_cmd(self, cmd): + for mapping in self._MAPPINGS: + if result := mapping[0].search(cmd): + return mapping[1](self, result.groups()) + raise SystemCommandMockError(f"Unrecognized command: '{cmd}'") + + +class LoggerMock(): + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" + FATAL = "fatal" + + def __init__(self): + self._entries = list() + + def _log(self, log_type, msg): + self._entries.append((log_type, msg)) + + def get_history(self): + return self._entries + + def reset_history(self): + self._entries.clear() + + def basicConfig(self): + pass + + def debug(self, msg): + self._log(self.DEBUG, msg) + + def info(self, msg): + self._log(self.INFO, msg) + + def warning(self, msg): + self._log(self.WARNING, msg) + + def error(self, msg): + self._log(self.ERROR, msg) + + def fatal(self, msg): + self._log(self.FATAL, msg) + + +class ConfigFileGenerator(): + _SHORT_HEADER = ["# HEADER: Last generated at: 2025-01-01 00:00:00 +0000"] + + _LONG_HEADER = ["# HEADER: This file is being managed by puppet. Changes to", + "# HEADER: interfaces that are not being managed by puppet will persist;", + "# HEADER: however changes to interfaces that are being managed by puppet will", + "# HEADER: be overwritten. In addition, file order is NOT guaranteed.", + "# HEADER: Last generated at: 2025-01-01 00:00:00 +0000", "", ""] + + _AUTOCFG = ("echo 0 > /proc/sys/net/ipv6/conf/{ifname}/autoconf; " + "echo 0 > /proc/sys/net/ipv6/conf/{ifname}/accept_ra; " + "echo 0 > /proc/sys/net/ipv6/conf/{ifname}/accept_redirects") + + _TEMPLATE = { + "iface": "iface {ifname} {inet} {mode}", + "vlan-raw-device": "vlan-raw-device {raw_dev}", + "address": "address {address}", + "netmask": "netmask {netmask}", + "gateway": "{indent}gateway {gateway}", + "bond-master": "{indent}bond-master {master}", + "bond-miimon": "{indent}bond-miimon 100", + "bond-mode": "{indent}bond-mode active-backup", + "bond-primary": "{indent}bond-primary {primary}", + "bond-slaves": "{indent}bond-slaves {slaves}", + "hwaddress": "{indent}hwaddress {hwaddress}", + "mtu": "{indent}mtu {mtu}", + "pre-up-slave": "{indent}pre-up /usr/sbin/ip link set dev {device} promisc on; " + _AUTOCFG, + "pre-up-vlan-ifupdown": "{indent}pre-up /sbin/modprobe -q 8021q", + "pre-up-vlan-manual": "{indent}pre-up /sbin/modprobe -q 8021q; " + "ip link add link {raw_dev} name {device} type vlan id {vlan_id}", + "up": "{indent}up sleep 10", + "post-up": "{indent}post-up " + _AUTOCFG, + "post-up-lo": "{indent}post-up /usr/local/bin/tc_setup.sh " + "lo mgmt 10000 > /dev/null; " + _AUTOCFG, + "post-up-vlan": "{indent}post-up /usr/sbin/ip link set dev {device} mtu {mtu}; " + _AUTOCFG, + "post-down": "{indent}post-down ip link del {device}", + "scope": "{indent}scope host", + "stx-description": "{indent}stx-description ifname:{device},net:None", + "allow-": "{indent}allow-{master} {device}", + } + + _PROPERTY_MAP = { + "lo": ["mtu", "post-up-lo", "scope", "stx-description"], + "eth": ["mtu", "post-up", "stx-description"], + "slave": ["bond-master", "mtu", "pre-up-slave", "stx-description", "allow-"], + "bonding": ["bond-miimon", "bond-mode", "bond-primary", "bond-slaves", "hwaddress", + "mtu", "post-up", "stx-description", "up"], + "vlan-NNN": ["vlan-raw-device", "mtu", "post-up-vlan", "pre-up-vlan-ifupdown", + "stx-description"], + "vlan-dot": ["mtu", "post-up-vlan", "pre-up-vlan-ifupdown", "stx-description"], + "vlan-manual": ["mtu", "post-down", "post-up-vlan", "pre-up-vlan-manual", + "stx-description"], + } + + def __init__(self): + self._hwaddr_seq = 1 + + @staticmethod + def _get_basic_props(config): + props = ["iface"] + if config.get("address", None): + props.append("address") + props.append("netmask") + if config.get("gateway", None): + props.append("gateway") + return props + + def _get_props(self, config): + return self._get_basic_props(config) + self._PROPERTY_MAP[config["type"]] + + def _gen_cfg_lines(self, config): + props = self._get_props(config) + lines = list() + for prop in props: + lines.append(self._TEMPLATE[prop].format(**config)) + return lines + + def _get_new_hw_address(self): + hwaddr = f"08:00:27:f2:66:{self._hwaddr_seq:02d}" + self._hwaddr_seq += 1 + return hwaddr + + def _parse_cfg(self, ifname, input_config, indent=False): + config = input_config.copy() + if ifname == "lo": + iftype = "lo" + elif res := re.search(R"^(\S+)\.(\d+)(?:\:\S+)?$", ifname): + iftype = "vlan-dot" + config["raw_dev"] = res.groups()[0] + config["vlan_id"] = res.groups()[1] + elif res := re.search(R"^vlan(\d+)(?:\:\S+)?$", ifname): + iftype = "vlan-NNN" + config["vlan_id"] = res.groups()[0] + elif "vlan_id" in input_config: + iftype = "vlan-manual" + elif "master" in input_config: + iftype = "slave" + elif slaves := input_config.get("slaves", None): + iftype = "bonding" + config["slaves"] = " ".join(slaves) + config["primary"] = slaves[0] + if "hwaddress" not in input_config: + config["hwaddress"] = self._get_new_hw_address() + else: + iftype = "eth" + config["type"] = iftype + config["ifname"] = ifname + config["device"] = ifname.split(":")[0] if ":" in ifname else ifname + config["mtu"] = input_config.get("mtu", 1500) + config["indent"] = " " if indent else "" + + if address := input_config.get("address", None): + net = IPNetwork(address) + config["address"] = str(net.ip) + config["netmask"] = str(net.netmask) if net.version == 4 else net.prefixlen + config["inet"] = "inet6" if net.version == 6 else "inet" + config["mode"] = "static" + else: + config["inet"] = input_config.get("inet", "inet") + config["mode"] = input_config.get("mode", "manual") + + return config + + @staticmethod + def _gen_auto_lines(auto): + return ["auto " + " ".join(auto)] + + @staticmethod + def _gen_route_line(route): + net = IPNetwork(route["net"]) + line = f"{net.ip} {net.netmask} {route['via']} {route['dev']}" + if metric := route.get("metric", None): + line += f" metric {metric}" + return line + + def _gen_routes_lines(self, routes): + return [self._gen_route_line(route) for route in routes] + + def generate_auto_file(self, auto): + lines = self._gen_auto_lines(auto) + return '\n'.join(lines) + '\n' + + def generate_ifcfg_file(self, ifname, config): + config = self._parse_cfg(ifname, config) + lines = self._SHORT_HEADER + self._gen_cfg_lines(config) + return '\n'.join(lines) + '\n' + + def generate_interfaces_file(self, config): + lines = self._LONG_HEADER.copy() + for ifname, input_cfg in config.items(): + if ifname == "auto": + lines.extend(self._gen_auto_lines(input_cfg)) + else: + output_cfg = self._parse_cfg(ifname, input_cfg, True) + lines.extend(self._gen_cfg_lines(output_cfg)) + lines.append('') + return '\n'.join(lines) + '\n' + + def generate_routes_file(self, routes): + lines = self._LONG_HEADER + self._gen_routes_lines(routes) + return '\n'.join(lines) + '\n' + + def _generate_ifcfg_files(self, tree, contents): + for name, config in contents.items(): + if name == "auto": + tree[anc.ETC_DIR + "/auto"] = self.generate_auto_file(config) + else: + tree[anc.ETC_DIR + "/ifcfg-" + name] = self.generate_ifcfg_file(name, config) + + def generate_file_tree(self, puppet_files=None, etc_files=None): + tree = dict() + + if puppet_files: + if interfaces := puppet_files.get("interfaces", None): + tree[anc.PUPPET_FILE] = self.generate_interfaces_file(interfaces) + if routes := puppet_files.get("routes", None): + tree[anc.PUPPET_ROUTES_FILE] = self.generate_routes_file(routes) + if routes6 := puppet_files.get("routes6", None): + tree[anc.PUPPET_ROUTES6_FILE] = self.generate_routes_file(routes6) + + if etc_files: + if interfaces := etc_files.get("interfaces", None): + self._generate_ifcfg_files(tree, interfaces) + routes = etc_files.get("routes", []) + routes6 = etc_files.get("routes6", []) + if routes or routes6: + tree[anc.ETC_ROUTES_FILE] = self.generate_routes_file(routes + routes6) + + return tree + + +FILE_GEN = ConfigFileGenerator() + + +class BaseTestCase(testtools.TestCase): + def tearDown(self): + self._log = None + self._scmdmock = None + self._nwmock = None + self._fs = None + return super().tearDown() + + def _add_fs_mock(self, contents=None): + self._fs = FilesystemMock(contents) + + def _add_logger_mock(self): + self._log = LoggerMock() + + def _add_nw_mock(self, static_links): + self._nwmock = NetworkingMock(self._fs, static_links) + + def _add_scmd_mock(self): + self._scmdmock = SystemCommandMock(self._nwmock) + + def _mock_fs(self, mocks, fxn, *args, **kwargs): + with ( + mock.patch("src.bin.apply_network_config.path_exists", self._fs.exists), + mock.patch("os.remove", self._fs.delete), + mock.patch("builtins.open", self._fs.open), + mock.patch.multiple("os.path", + isfile=self._fs.isfile, + isdir=self._fs.isdir, + islink=self._fs.islink) + ): + return self._mocked_call(mocks, fxn, *args, **kwargs) + + def _mock_logger(self, mocks, fxn, *args, **kwargs): + with mock.patch.multiple("logging", + basicConfig=self._log.basicConfig, + debug=self._log.debug, + info=self._log.info, + warning=self._log.warning, + error=self._log.error, + fatal=self._log.fatal): + return self._mocked_call(mocks, fxn, *args, **kwargs) + + def _mock_syscmd(self, mocks, fxn, *args, **kwargs): + with mock.patch("src.bin.apply_network_config.execute_system_cmd", + self._scmdmock.execute_system_cmd): + return self._mocked_call(mocks, fxn, *args, **kwargs) + + def _mock_sysinv_lock(self, mocks, fxn, *args, **kwargs): + with mock.patch.multiple("src.bin.apply_network_config", + acquire_sysinv_agent_lock=mock.DEFAULT, + release_sysinv_agent_lock=mock.DEFAULT): + return self._mocked_call(mocks, fxn, *args, **kwargs) + + @staticmethod + def _mocked_call(mocks, fxn, *args, **kwargs): + if len(mocks) == 0: + return fxn(*args, **kwargs) + return mocks[0](mocks[1:], fxn, *args, **kwargs) + + +class GeneralTests(BaseTestCase): # pylint: disable=too-many-public-methods + def test_stanza_parser(self): + parser = anc.StanzaParser() + parser.parse_lines([ + "# HEADER: Last generated at: 2024-11-06 00:54:24 +0000", + "iface enp0s3\tinet manual ", + "# Comment", + " \t # Comment", + "", + "mtu 1500", + "\tpost-up echo 0 > /proc/sys/net/ipv6/conf/enp0s3/autoconf ", + " stx-description ifname:oam0,net:None", + ""]) + parser.parse_lines([ + "# HEADER: Last generated at: 2024-11-06 00:54:24 +0000", + "auto\tlo\tenp0s3 vlan200 ", + "iface vlan200 inet manual", + "vlan-raw-device enp0s3", + " mtu 1500", + " post-up /usr/sbin/ip link set dev vlan200 mtu 1500", + " pre-up /sbin/modprobe -q 8021q", + " stx-description ifname:vlan200,net:None", + "iface ", + " address 10.23.44.11", + " netmask 255.255.255.0", + " mtu 1500", + "iface enp0s8 inet manual", + " mtu 1500", + " post-up echo 0 > /proc/sys/net/ipv6/conf/enp0s8/autoconf", + " stx-description ifname:etc0,net:None", + ""]) + parser.parse_lines([" auto "]) + parser.parse_lines([" auto lo enp0s3 enp0s8"]) + parser.parse_lines(["\tauto \t lo \t enp0s8 enp0s9"]) + + auto, ifaces = parser.get_auto_and_ifaces() + self.assertEqual(["lo", "enp0s3", "vlan200", "enp0s8", "enp0s9"], auto) + self.assertEqual({ + 'enp0s3': { + 'iface': 'enp0s3 inet manual', + 'mtu': '1500', + 'post-up': 'echo 0 > /proc/sys/net/ipv6/conf/enp0s3/autoconf', + 'stx-description': 'ifname:oam0,net:None'}, + 'vlan200': { + 'iface': 'vlan200 inet manual', + 'mtu': '1500', + 'post-up': '/usr/sbin/ip link set dev vlan200 mtu 1500', + 'pre-up': '/sbin/modprobe -q 8021q', + 'stx-description': 'ifname:vlan200,net:None', + 'vlan-raw-device': 'enp0s3'}, + 'enp0s8': { + 'iface': 'enp0s8 inet manual', + 'mtu': '1500', + 'post-up': 'echo 0 > /proc/sys/net/ipv6/conf/enp0s8/autoconf', + 'stx-description': 'ifname:etc0,net:None'}}, + ifaces) + + def test_is_label(self): + self.assertEqual(True, anc.is_label("enp0s8:2-7")) + self.assertEqual(False, anc.is_label("enp0s8")) + + def test_get_base_iface(self): + self.assertEqual("enp0s8", anc.get_base_iface("enp0s8:2-7")) + self.assertEqual("vlan-200", anc.get_base_iface("vlan-200:11")) + + def test_read_file_lines(self): + self._add_fs_mock({"/test-dir/test-file": "0\n1\n2\n"}) + lines = self._mocked_call([self._mock_fs], anc.read_file_lines, "/test-dir/test-file") + self.assertEqual(3, len(lines)) + self.assertEqual("0", lines[0]) + self.assertEqual("1", lines[1]) + self.assertEqual("2", lines[2]) + + _HEADER = "# HEADER: Last generated at: 2025-01-01 00:00:00 +0000" + + _IFACE_CONFIG = {"iface": "enp0s8 inet static", + "mtu": "9000", + "address": "12.12.1.55", + "netmask": "255.255.255.0", + "post-up": "echo # > /proc/sys/net/ipv6/conf/enp0s8/autoconf", + "stx-description": "ifname:etc0,net:None"} + + _IFACE_FILE = (f"{_HEADER}\n" + "iface enp0s8 inet static\n" + "address 12.12.1.55\n" + "netmask 255.255.255.0\n" + "mtu 9000\n" + "post-up echo # > /proc/sys/net/ipv6/conf/enp0s8/autoconf\n" + "stx-description ifname:etc0,net:None\n") + + def test_parse_valid_ifcfg_file(self): + self._add_fs_mock({anc.ETC_DIR + "/ifcfg-enp0s8": self._IFACE_FILE}) + config = self._mocked_call([self._mock_fs], anc.parse_ifcfg_file, "enp0s8") + self.assertEqual(6, len(config)) + self.assertEqual("enp0s8 inet static", config["iface"]) + self.assertEqual("12.12.1.55", config["address"]) + self.assertEqual("255.255.255.0", config["netmask"]) + self.assertEqual("9000", config["mtu"]) + self.assertEqual("echo # > /proc/sys/net/ipv6/conf/enp0s8/autoconf", config["post-up"]) + self.assertEqual("ifname:etc0,net:None", config["stx-description"]) + + def test_parse_missing_ifcfg_file(self): + self._add_fs_mock() + self._add_logger_mock() + config = self._mocked_call([self._mock_fs, self._mock_logger], + anc.parse_ifcfg_file, "enp0s8") + self.assertEqual(0, len(config)) + self.assertEqual(LoggerMock.WARNING, self._log.get_history()[-1][0]) + self.assertEqual(f"Interface config file not found: '{anc.ETC_DIR + '/ifcfg-enp0s8'}'", + self._log.get_history()[-1][1]) + + def test_parse_ifcfg_file_with_multiple_config(self): + path = anc.ETC_DIR + "/ifcfg-enp0s8" + self._add_fs_mock({path: self._IFACE_FILE + + "iface enp0s9 inet static\n" + "mtu 9000\n" + "stx-description ifname:etc1,net:None\n"}) + self._add_logger_mock() + config = self._mocked_call([self._mock_fs, self._mock_logger], + anc.parse_ifcfg_file, "enp0s8") + self.assertEqual(6, len(config)) + self.assertEqual(LoggerMock.WARNING, self._log.get_history()[-1][0]) + self.assertEqual(f"Multiple interface configs found in '{path}': enp0s8 enp0s9", + self._log.get_history()[-1][1]) + + def test_parse_invalid_ifcfg_file(self): + path = anc.ETC_DIR + "/ifcfg-enp0s8" + self._add_fs_mock({path: "invalid content line 1\n" + "invalid content line 2\n" + "invalid content line 3\n"}) + self._add_logger_mock() + config = self._mocked_call([self._mock_fs, self._mock_logger], + anc.parse_ifcfg_file, "enp0s8") + self.assertEqual(0, len(config)) + self.assertEqual(LoggerMock.WARNING, self._log.get_history()[-1][0]) + self.assertEqual(f"No interface config found in '{path}'", self._log.get_history()[-1][1]) + + def test_parse_ifcfg_file_with_unrelated_ifaces(self): + path = anc.ETC_DIR + "/ifcfg-enp0s8" + self._add_fs_mock({path: "iface enp0s9 inet static\n" + "mtu 9000\n" + "stx-description ifname:etc1,net:None\n" + "iface enp0s10 inet static\n" + "mtu 9000\n" + "stx-description ifname:etc2,net:None\n"}) + self._add_logger_mock() + config = self._mocked_call([self._mock_fs, self._mock_logger], + anc.parse_ifcfg_file, "enp0s8") + self.assertEqual(0, len(config)) + self.assertEqual(LoggerMock.WARNING, self._log.get_history()[-1][0]) + self.assertEqual(f"Config for interface 'enp0s8' not found in '{path}'. Instead, " + f"file has config(s) for the following interface(s): enp0s10 enp0s9", + self._log.get_history()[-1][1]) + + def test_parse_auto_file(self): + self._add_fs_mock({anc.ETC_DIR + "/auto": + "auto lo enp0s3\tenp0s3:1-17 enp0s8 vlan100"}) + auto = self._mocked_call([self._mock_fs], anc.parse_auto_file) + self.assertEqual(["lo", "enp0s3", "enp0s3:1-17", "enp0s8", "vlan100"], auto) + + def test_parse_missing_auto_file(self): + self._add_fs_mock() + self._add_logger_mock() + auto = self._mocked_call([self._mock_fs, self._mock_logger], anc.parse_auto_file) + self.assertEqual(0, len(auto)) + self.assertEqual(LoggerMock.INFO, self._log.get_history()[-1][0]) + self.assertEqual(f"Auto file not found: '{anc.ETC_DIR + '/auto'}'", + self._log.get_history()[-1][1]) + + def test_get_vlan_attributes_vlanNNN(self): + dev, vlan_id = anc.get_vlan_attributes("vlan123", {"vlan-raw-device": "enp0s8"}) + self.assertEqual("enp0s8", dev) + self.assertEqual(123, vlan_id) + + def test_get_vlan_attributes_vlanNNN_no_dev(self): + self._add_logger_mock() + attribs = self._mocked_call([self._mock_logger], anc.get_vlan_attributes, + "vlan123", {"iface": "vlan123 inet static"}) + self.assertIsNone(attribs) + self.assertEqual(LoggerMock.WARNING, self._log.get_history()[-1][0]) + self.assertEqual("vlan-raw-device property is empty or not specified for " + "interface vlan123, so it will not be considered as a valid VLAN", + self._log.get_history()[-1][1]) + + def test_get_vlan_attributes_vlan_dot(self): + dev, vlan_id = anc.get_vlan_attributes("enp0s8.123", {"iface": "enp0s8.123 inet static"}) + self.assertEqual("enp0s8", dev) + self.assertEqual(123, vlan_id) + + def test_get_vlan_attributes_vlan_manual(self): + dev, vlan_id = anc.get_vlan_attributes( + "data0", + {"pre-up": "/sbin/modprobe -q 8021q; " + "/usr/sbin/ip link add link\tenp0s8 name data0 type vlan id 123"}) + self.assertEqual("enp0s8", dev) + self.assertEqual(123, vlan_id) + + def test_get_vlan_attributes_not_vlan(self): + attribs = anc.get_vlan_attributes("enp0s8", {"iface": "enp0s8 inet static"}) + self.assertIsNone(attribs) + + def test_get_types_and_dependencies(self): + iface_configs = {"bond0": {"bond-slaves": "enp0s9 enp0s10"}, + "bond0:0-16": {}, + "enp0s10": {"bond-master": "bond0"}, + "enp0s3": {}, + "enp0s3:3-7": {}, + "enp0s4": {}, + "enp0s4:5-17": {}, + "enp0s9": {"bond-master": "bond0"}, + "lo": {}, + "lo:1-2": {}, + "lo:5-14": {}, + "vlan200": {"vlan-raw-device": "bond0"}, + "vlan200:0-17": {}} + + ifaces_types, dependencies = anc.get_types_and_dependencies(iface_configs) + + self.assertEqual({ + "bond0": "bonding", + "bond0:0-16": "label", + "enp0s10": "slave", + "enp0s3": "eth", + "enp0s3:3-7": "label", + "enp0s4": "eth", + "enp0s4:5-17": "label", + "enp0s9": "slave", + "lo": "lo", + "lo:1-2": "label", + "lo:5-14": "label", + "vlan200": "vlan", + "vlan200:0-17": "label" + }, ifaces_types) + + self.assertEqual({ + "bond0": {"vlan200", "bond0:0-16"}, + "enp0s10": {"bond0"}, + "enp0s3": {"enp0s3:3-7"}, + "enp0s4": {"enp0s4:5-17"}, + "enp0s9": {"bond0"}, + "lo": {"lo:1-2", "lo:5-14"}, + "vlan200": {"vlan200:0-17"}}, dependencies) + + def test_is_iface_modified_true(self): + self._add_logger_mock() + + current = {"iface": "enp0s8 inet manual", + "mtu": "1500", + "post-up": "echo 0 > /proc/sys/net/ipv6/conf/enp0s8/autoconf", + "down": "ip addr flush dev enp0s8", + "stx-description": "ifname:etc0,net:None"} + + new = {"iface": "enp0s8 inet static", + "mtu": "9000", + "address": "12.12.1.55", + "netmask": "255.255.255.0", + "post-up": "echo 0 > /proc/sys/net/ipv6/conf/enp0s8/autoconf", + "stx-description": "ifname:data0,net:None"} + + modified = self._mocked_call([self._mock_logger], + anc.is_iface_modified, "enp0s8", new, current) + + self.assertEqual(True, modified) + self.assertEqual(LoggerMock.INFO, self._log.get_history()[-1][0]) + self.assertEqual("Differences found for interface enp0s8:\n" + " Removed properties:\n" + " down ip addr flush dev enp0s8\n" + " Added properties:\n" + " address 12.12.1.55\n" + " netmask 255.255.255.0\n" + " Modified properties:\n" + " 'iface' went from 'enp0s8 inet manual' to 'enp0s8 inet static'\n" + " 'mtu' went from '1500' to '9000'", + self._log.get_history()[-1][1]) + + def test_is_iface_modified_false(self): + current = {"iface": "enp0s8 inet manual", + "mtu": "1500", + "post-up": "echo 0 > /proc/sys/net/ipv6/conf/enp0s8/autoconf", + "down": "ip addr flush dev enp0s8", + "stx-description": "ifname:etc0,net:None", + "random-property": "potato"} + + new = {"iface": "enp0s8 inet manual", + "mtu": "1500", + "post-up": "echo 0 > /proc/sys/net/ipv6/conf/enp0s8/autoconf", + "down": "ip addr flush dev enp0s8", + "stx-description": "ifname:data0,net:None", + "random-property": "banana"} + + modified = anc.is_iface_modified("enp0s8", new, current) + + self.assertEqual(False, modified) + + def test_get_dependent_list(self): + config = {"auto": {"lo", "lo:1-2", "lo:5-14", "enp0s3", "enp0s3:3-7", "enp0s4", + "enp0s4:5-17", "enp0s9", "enp0s10", "bond0", "bond0:0-16", + "vlan200", "vlan200:0-17"}, + "dependencies": {"bond0": {"vlan200", "bond0:0-16"}, + "enp0s10": {"bond0"}, + "enp0s3": {"enp0s3:3-7"}, + "enp0s4": {"enp0s4:5-17"}, + "enp0s9": {"bond0"}, + "lo": {"lo:1-2", "lo:5-14"}, + "vlan200": {"vlan200:0-17"}}} + + dep1 = anc.get_dependent_list(config, {"vlan200"}) + self.assertEqual({"vlan200", "vlan200:0-17"}, dep1) + + dep2 = anc.get_dependent_list(config, {"bond0"}) + self.assertEqual({"bond0", "bond0:0-16", "vlan200", "vlan200:0-17"}, dep2) + + dep3 = anc.get_dependent_list(config, {"enp0s9"}) + self.assertEqual({"enp0s9", "bond0", "bond0:0-16", "vlan200", "vlan200:0-17"}, dep3) + + dep4 = anc.get_dependent_list(config, {"vlan200", "enp0s3"}) + self.assertEqual({"vlan200", "enp0s3", "vlan200:0-17", "enp0s3:3-7"}, dep4) + + dep5 = anc.get_dependent_list(config, {"enp0s4:5-17"}) + self.assertEqual({"enp0s4:5-17"}, dep5) + + def test_is_iface_missing_or_down(self): + dev_path = "/sys/devices/pci0000:00/net/enp0s8" + self._add_fs_mock({dev_path + "/operstate": "up", + anc.DEVLINK_BASE_PATH + "enp0s8": (dev_path, )}) + + def check_result(value): + result = self._mocked_call([self._mock_fs], anc.is_iface_missing_or_down, "enp0s8") + self.assertEqual(value, result) + + check_result(False) + + self._fs.set_file_contents(anc.DEVLINK_BASE_PATH + "enp0s8/operstate", "down") + check_result(True) + + self._fs.delete(anc.DEVLINK_BASE_PATH + "enp0s8") + check_result(True) + + def test_get_updated_ifaces(self): + new_config = {"ifaces_types": {"enp0s3": anc.ETH, + "enp0s8": anc.ETH, + "enp0s9": anc.SLAVE, + "enp0s10": anc.SLAVE, + "bond0": anc.BONDING, + "bond1": anc.BONDING, + "vlan100": anc.VLAN, + "vlan200": anc.VLAN, + "enp0s3:1-1": anc.LABEL, + "enp0s8:2-4": anc.LABEL, + "bond0:5-14": anc.LABEL, + "bond1:6-16": anc.LABEL, + "vlan100:3-9": anc.LABEL, + "vlan200:4-11": anc.LABEL}} + up_list = ["enp0s3", "enp0s9", "enp0s10", "bond0", "vlan100", + "enp0s8:2-4", "bond1:6-16", "vlan200:4-11"] + updated = anc.get_updated_ifaces(new_config, up_list) + self.assertEqual({"enp0s3", "enp0s8", "bond0", "bond1", "vlan100", "vlan200"}, updated) + + def test_sort_ifaces_by_type(self): + config = {"ifaces_types": {"lo": anc.ETH, + "enp0s3": anc.ETH, + "enp0s8": anc.ETH, + "enp0s9": anc.SLAVE, + "enp0s10": anc.SLAVE, + "bond0": anc.BONDING, + "bond1": anc.BONDING, + "vlan100": anc.VLAN, + "vlan200": anc.VLAN, + "enp0s3:1-1": anc.LABEL, + "bond0:5-14": anc.LABEL, + "vlan100:3-9": anc.LABEL}} + ifaces = {"vlan100:3-9", "vlan200", "bond1", "bond0:5-14", "enp0s9", + "enp0s8", "enp0s3", "enp0s3:1-1", "vlan100", "bond0", "enp0s10", "lo"} + sorted_ifaces = anc.sort_ifaces_by_type(config, ifaces, anc.UP_ORDER) + self.assertEqual(["enp0s3", "enp0s8", "lo", "bond0", "bond1", "vlan100", + "vlan200", "bond0:5-14", "enp0s3:1-1", "vlan100:3-9"], sorted_ifaces) + + def _test_set_iface_down(self, delete_ifstate): + etc_files = { + "interfaces": { + "auto": ["enp0s8", "enp0s8:2-3", "enp0s8:2-4"], + "enp0s8": {"address": "169.254.202.2/24"}, + "enp0s8:2-3": {"address": "192.168.204.2/24"}, + "enp0s8:2-4": {"address": "fd01::2/64"}}, + "routes": [ + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "enp0s8", "metric": 1}], + "routes6": [ + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "enp0s8", "metric": 1}], + } + + self._add_fs_mock(FILE_GEN.generate_file_tree(etc_files=etc_files)) + self._add_nw_mock(["enp0s8"]) + self._add_scmd_mock() + self._add_logger_mock() + self._nwmock.apply_auto() + + if delete_ifstate: + self._fs.delete(anc.IFSTATE_BASE_PATH + "enp0s8") + + self.assertEqual(['enp0s8 UP 169.254.202.2/24 192.168.204.2/24 fd01::2/64'], + self._nwmock.get_links_status()) + + self.assertEqual(['14.15.1.0/24 via 169.254.202.111 dev enp0s8 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev enp0s8 metric 1', + 'fa01:2::/64 via fd01::111 dev enp0s8 metric 1'], + self._nwmock.get_routes()) + + self._mocked_call([self._mock_fs, self._mock_syscmd, self._mock_logger], + anc.set_iface_down, "enp0s8") + + self.assertEqual(['enp0s8 DOWN'], self._nwmock.get_links_status()) + self.assertEqual([], self._nwmock.get_routes()) + + def test_set_iface_down_ifstate_up(self): + self._test_set_iface_down(delete_ifstate=False) + self.assertEqual([('ifdown', 'enp0s8'), + ('ip_link_set_down', 'enp0s8'), + ('ip_addr_flush', 'enp0s8')], + self._nwmock.get_history()) + + def test_set_iface_down_ifstate_down(self): + self._test_set_iface_down(delete_ifstate=True) + self.assertEqual([('ip_link_set_down', 'enp0s8'), + ('ip_addr_flush', 'enp0s8')], + self._nwmock.get_history()) + + def test_set_iface_down_error_messages(self): + def exec_sys_cmd(cmd): + if cmd.startswith("/sbin/ifdown"): + return 1, "< IFDOWN ERROR MESSAGE >\n" + if cmd.startswith("/usr/sbin/ip link set down"): + return 1, "< IP LINK SET DOWN ERROR MESSAGE >\n" + if cmd.startswith("/usr/sbin/ip addr flush"): + return 1, ("\n< IP ADDR FLUSH ERROR MESSAGE LINE 1 >\n" + "< IP ADDR FLUSH ERROR MESSAGE LINE 2 >\n\n\n") + raise Exception(f"Unexpected system command: '{cmd}'") + + dev_path = "/sys/devices/pci0000:00/net/enp0s8" + self._add_fs_mock({dev_path + "/operstate": "up", + anc.DEVLINK_BASE_PATH + "enp0s8": (dev_path, ), + anc.IFSTATE_BASE_PATH + "enp0s8": "enp0s8"}) + self._add_logger_mock() + + with mock.patch('src.bin.apply_network_config.execute_system_cmd', exec_sys_cmd): + self._mocked_call([self._mock_fs, self._mock_logger], anc.set_iface_down, "enp0s8") + + self.assertEqual([ + ('info', 'Bringing enp0s8 down'), + ('error', "Command 'ifdown' failed for interface enp0s8: '< IFDOWN ERROR MESSAGE >'"), + ('error', "Command 'ip link set down' failed for interface enp0s8: " + "'< IP LINK SET DOWN ERROR MESSAGE >'"), + ('error', "Command 'ip addr flush' failed for interface enp0s8:\n" + "< IP ADDR FLUSH ERROR MESSAGE LINE 1 >\n" + "< IP ADDR FLUSH ERROR MESSAGE LINE 2 >")], + self._log.get_history()) + + def test_remove_iface_config_file(self): + self._add_logger_mock() + + def run_function(path_exists: bool): + with(mock.patch('src.bin.apply_network_config.path_exists', return_value=path_exists), + mock.patch('os.remove', side_effect=OSError("< OS ERROR >"))): + self._mocked_call([self._mock_logger], anc.remove_iface_config_file, "enp0s8") + + run_function(False) + self.assertEqual([('info', 'File /etc/network/interfaces.d/ifcfg-enp0s8 does not exist, ' + 'no need to remove')], self._log.get_history()) + + self._log.reset_history() + run_function(True) + self.assertEqual([ + ('info', 'Removing /etc/network/interfaces.d/ifcfg-enp0s8'), + ('error', 'Failed to remove /etc/network/interfaces.d/ifcfg-enp0s8: < OS ERROR >')], + self._log.get_history()) + + def _test_write_iface_config_file(self, has_existing_file): + path = anc.ETC_DIR + "/ifcfg-enp0s8" + contents = {path: "EXISTING CONTENTS\n"} if has_existing_file else None + self._add_fs_mock(contents) + with mock.patch('src.bin.apply_network_config.get_header', return_value=self._HEADER): + self._mocked_call([self._mock_fs], + anc.write_iface_config_file, "enp0s8", self._IFACE_CONFIG) + contents = self._fs.get_file_contents(path) + self.assertEqual(self._IFACE_FILE, contents) + + def test_write_iface_config_file_new(self): + self._test_write_iface_config_file(False) # pylint: disable=no-value-for-parameter + + def test_write_iface_config_file_existing(self): + self._test_write_iface_config_file(True) # pylint: disable=no-value-for-parameter + + _AUTO_SAMPLE_CFG = { + "auto": {"enp0s8", "enp0s3:1-3", "lo:3-7", "vlan10", "vlan11:4-8", "lo", "bond0:2-5", + "vlan11", "bond0", "enp0s3", "vlan10:5-11", "enp0s9"}, + "ifaces_types": {"enp0s8": anc.SLAVE, + "enp0s3:1-3": anc.LABEL, + "lo:3-7": anc.LABEL, + "vlan10": anc.VLAN, + "vlan11:4-8": anc.LABEL, + "lo": anc.LO, + "bond0:2-5": anc.LABEL, + "vlan11": anc.VLAN, + "bond0": anc.BONDING, + "enp0s3": anc.ETH, + "vlan10:5-11": anc.LABEL, + "enp0s9": anc.SLAVE} + } + + _AUTO_FILE = (f"{_HEADER}\n" + "auto lo enp0s3 bond0 enp0s8 enp0s9 vlan10 vlan11 bond0:2-5 enp0s3:1-3 lo:3-7 " + "vlan10:5-11 vlan11:4-8\n") + + def _test_write_auto_file(self, has_existing_file): + path = anc.ETC_DIR + "/auto" + contents = {path: "EXISTING CONTENTS\n"} if has_existing_file else None + self._add_fs_mock(contents) + with mock.patch('src.bin.apply_network_config.get_header', return_value=self._HEADER): + self._mocked_call([self._mock_fs], anc.write_auto_file, self._AUTO_SAMPLE_CFG) + contents = self._fs.get_file_contents(path) + self.assertEqual(self._AUTO_FILE, contents) + + def test_write_auto_file_new(self): + self._test_write_auto_file(False) # pylint: disable=no-value-for-parameter + + def test_write_auto_file_existing(self): + self._test_write_auto_file(True) # pylint: disable=no-value-for-parameter + + def test_sort_properties(self): + props = ["other3", "allow-", "gateway", "other1", "mtu", "bond-miimon", "other2", "iface"] + sorted_props = anc.sort_properties(props) + self.assertEqual(["iface", "gateway", "bond-miimon", "mtu", + "other1", "other2", "other3", "allow-"], sorted_props) + + def test_get_route_entries(self): + self._add_fs_mock( + {anc.PUPPET_ROUTES_FILE: + "13.13.1.0 255.255.255.0 12.12.1.65 bond0 metric 1\n" + "13.13.2.0 255.255.255.0 12.12.3.37 enp0s8\n", + anc.PUPPET_ROUTES6_FILE: + "dead:beef:55:: ffff:ffff:ffff:ffff:: dead:beef::aa:1:453 bond0 metric 1\n" + "dead:beef:78:: ffff:ffff:ffff:ffff:: dead:beef:bb::bb:1:172 vlan200"}) + self._add_logger_mock() + + entries = self._mocked_call([self._mock_fs, self._mock_logger], anc.get_route_entries, + [anc.PUPPET_ROUTES_FILE, anc.PUPPET_ROUTES6_FILE]) + + self.assertEqual(['13.13.1.0 255.255.255.0 12.12.1.65 bond0 metric 1', + '13.13.2.0 255.255.255.0 12.12.3.37 enp0s8', + 'dead:beef:55:: ffff:ffff:ffff:ffff:: dead:beef::aa:1:453 bond0 metric 1', + 'dead:beef:78:: ffff:ffff:ffff:ffff:: dead:beef:bb::bb:1:172 vlan200'], + entries) + self.assertEqual([], self._log.get_history()) + + def test_get_route_entries_from_lines(self): + self._add_logger_mock() + + contents = [ + "# Comment 1", + "", + " # Comment 2", + "\t # Comment 3", + "13.13.1.0 255.255.255.0 12.12.1.65 bond0 metric 1", + "\t13.13.2.0\t255.255.255.0\t12.12.3.37\tenp0s8\t\t\t", + " 13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1 ", + " 13.13.4.0 255.255.255.0 12.12.4.16 ", + " \t dead:beef:55:: ffff:ffff:ffff:ffff:: dead:beef::aa:1:453 bond0 metric 1 ", + " dead:beef:78:: ffff:ffff:ffff:ffff:: dead:beef:bb::bb:1:172 vlan200 metric 1\t"] + + entries = self._mocked_call([self._mock_logger], + anc.get_route_entries_from_lines, contents, anc.ETC_ROUTES_FILE) + + self.assertEqual([ + '13.13.1.0 255.255.255.0 12.12.1.65 bond0 metric 1', + '13.13.2.0 255.255.255.0 12.12.3.37 enp0s8', + '13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1', + 'dead:beef:55:: ffff:ffff:ffff:ffff:: dead:beef::aa:1:453 bond0 metric 1', + 'dead:beef:78:: ffff:ffff:ffff:ffff:: dead:beef:bb::bb:1:172 vlan200 metric 1'], + entries) + + self.assertEqual([( + 'warning', + "Invalid route in file '/etc/network/routes', must have at least 4 " + "parameters, 3 found: '13.13.4.0 255.255.255.0 12.12.4.16'")], + self._log.get_history()) + + def test_get_route_iface(self): + self.assertEqual("vlan200", anc.get_route_iface("13.13.3.0 255.255.255.0 12.12.3.113 " + "vlan200 metric 1")) + + def test_create_route_obj_from_entry(self): + self.assertEqual({'ifname': 'enp0s8', + 'network': '13.13.2.0', + 'netmask': '255.255.255.0', + 'nexthop': '12.12.3.37'}, + anc.create_route_obj_from_entry( + "13.13.2.0 255.255.255.0 12.12.3.37 enp0s8")) + self.assertEqual({'ifname': 'bond0', + 'network': '13.13.1.0', + 'netmask': '255.255.255.0', + 'nexthop': '12.12.1.65', + 'metric': '1'}, + anc.create_route_obj_from_entry( + "13.13.1.0 255.255.255.0 12.12.1.65 bond0 metric 1")) + + def test_get_prefix_length(self): + self.assertEqual(0, anc.get_prefix_length('0.0.0.0')) + self.assertEqual(1, anc.get_prefix_length('128.0.0.0')) + self.assertEqual(8, anc.get_prefix_length('255.0.0.0')) + self.assertEqual(31, anc.get_prefix_length('255.255.255.254')) + + self.assertEqual(0, anc.get_prefix_length('0::')) + self.assertEqual(1, anc.get_prefix_length('8000::')) + self.assertEqual(16, anc.get_prefix_length('ffff::')) + self.assertEqual(127, anc.get_prefix_length('ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe')) + + def assert_fails(netmask): + exc = self.assertRaises(anc.InvalidNetmaskError, anc.get_prefix_length, netmask) + self.assertEqual(f"Failed to get prefix length, invalid netmask: '{netmask}'", str(exc)) + + assert_fails("2555.0.0.0") + assert_fails("255.0.255.0") + assert_fails("0.255.0.0") + + assert_fails("fffff:ffff::") + assert_fails("ffff::ffff") + assert_fails("::ffff") + + def test_get_linux_network(self): + self.assertEqual("192.168.1.0/24", anc.get_linux_network({"network": "192.168.1.0", + "netmask": "255.255.255.0"})) + self.assertEqual("default", anc.get_linux_network({"network": "default"})) + + def _test_remove_route_entry_from_kernel(self, entry, return_code=0, stdout=""): + received_cmd = None + + def exec_sys_cmd(cmd): + nonlocal received_cmd + received_cmd = cmd + return return_code, stdout + + with mock.patch('src.bin.apply_network_config.execute_system_cmd', exec_sys_cmd): + self._mocked_call([self._mock_logger], anc.remove_route_entry_from_kernel, entry) + + return received_cmd + + def test_remove_route_entry_from_kernel_invalid_netmask(self): + self._add_logger_mock() + self._test_remove_route_entry_from_kernel("13.13.3.0 2555.255.255.0 12.12.3.113 " + "vlan200 metric 1") + self.assertEqual([( + 'error', + "Failed to remove route entry '13.13.3.0 2555.255.255.0 12.12.3.113 vlan200 " + "metric 1' from the kernel: Failed to get prefix length, invalid netmask: " + "'2555.255.255.0'")], + self._log.get_history()) + + def test_remove_route_entry_from_kernel_fail(self): + self._add_logger_mock() + self._test_remove_route_entry_from_kernel("13.13.3.0 255.255.255.0 12.12.3.113 " + "vlan200 metric 1", 1, "< ERROR >") + self.assertEqual( + [('info', 'Removing route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1'), + ('error', "Failed removing route 13.13.3.0/24 via 12.12.3.113 dev vlan200 " + "metric 1: '< ERROR >'")], + self._log.get_history()) + + def test_remove_route_entry_from_kernel_succeed(self): + self._add_logger_mock() + cmd = self._test_remove_route_entry_from_kernel("13.13.3.0 255.255.255.0 12.12.3.113 " + "vlan200 metric 1") + self.assertEqual( + [('info', 'Removing route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1')], + self._log.get_history()) + self.assertEqual( + "/usr/sbin/ip route del 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", cmd) + + def test_get_route_description(self): + route_1 = {"network": "13.13.3.0", "netmask": "255.255.255.0", + "nexthop": "12.12.3.113", "ifname": "vlan200"} + self.assertEqual("13.13.3.0/24 via 12.12.3.113 dev vlan200", + anc.get_route_description(route_1)) + self.assertEqual("13.13.3.0/24", anc.get_route_description(route_1, False)) + route_1["metric"] = 1 + self.assertEqual("13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", + anc.get_route_description(route_1)) + self.assertEqual("13.13.3.0/24 metric 1", anc.get_route_description(route_1, False)) + + route_2 = {"network": "default", "nexthop": "12.12.3.113", "ifname": "vlan200"} + self.assertEqual("default via 12.12.3.113 dev vlan200", anc.get_route_description(route_2)) + self.assertEqual("default", anc.get_route_description(route_2, False)) + route_2["metric"] = 1 + self.assertEqual("default via 12.12.3.113 dev vlan200 metric 1", + anc.get_route_description(route_2)) + self.assertEqual("default metric 1", anc.get_route_description(route_2, False)) + + route_3 = {"network": "aabb::", "netmask": "ffff:ffff:ffff:ffff::", + "nexthop": "fe88::1", "ifname": "enp0s9"} + self.assertEqual("aabb::/64 via fe88::1 dev enp0s9", anc.get_route_description(route_3)) + self.assertEqual("aabb::/64", anc.get_route_description(route_3, False)) + route_3["metric"] = 1 + self.assertEqual("aabb::/64 via fe88::1 dev enp0s9 metric 1", + anc.get_route_description(route_3)) + self.assertEqual("aabb::/64 metric 1", anc.get_route_description(route_3, False)) + + route_4 = {"network": "default", "nexthop": "fe88::1", "ifname": "enp0s9"} + self.assertEqual("default via fe88::1 dev enp0s9", anc.get_route_description(route_4)) + self.assertEqual("default", anc.get_route_description(route_4, False)) + route_4["metric"] = 1 + self.assertEqual("default via fe88::1 dev enp0s9 metric 1", + anc.get_route_description(route_4)) + self.assertEqual("default metric 1", anc.get_route_description(route_4, False)) + + def _test_add_route_entry_to_kernel(self, entry, cmd_responses): + position = 0 + self._add_logger_mock() + + def exec_sys_cmd(cmd): + nonlocal position + pos = position + position += 1 + self.assertEqual(cmd_responses[pos][0], cmd) + return cmd_responses[pos][1], cmd_responses[pos][2] + + with mock.patch('src.bin.apply_network_config.execute_system_cmd', exec_sys_cmd): + self._mocked_call([self._mock_logger], anc.add_route_entry_to_kernel, entry) + + def test_add_route_entry_to_kernel_existing(self): + self._test_add_route_entry_to_kernel( + "13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1", + (("/usr/sbin/ip route show 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, + "13.13.3.0/24"), )) + self.assertEqual( + [('info', 'Adding route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1'), + ('info', 'Route already exists, skipping')], + self._log.get_history()) + + def test_add_route_entry_to_kernel_show_fail(self): + self._test_add_route_entry_to_kernel( + "13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1", + (("/usr/sbin/ip route show 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 1, + "< ERROR 1 >"), + ("/usr/sbin/ip route show 13.13.3.0/24 metric 1", 1, "< ERROR 2 >"), + ("/usr/sbin/ip route add 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, ""))) + self.assertEqual( + [('info', 'Adding route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1')], + self._log.get_history()) + + def test_add_route_entry_to_kernel_add_fail(self): + self._test_add_route_entry_to_kernel( + "13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1", + (("/usr/sbin/ip route show 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, ""), + ("/usr/sbin/ip route show 13.13.3.0/24 metric 1", 0, ""), + ("/usr/sbin/ip route add 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 1, + "< ERROR >"))) + self.assertEqual( + [('info', 'Adding route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1'), + ('error', "Failed adding route 13.13.3.0/24 via 12.12.3.113 dev " + "vlan200 metric 1: '< ERROR >'")], + self._log.get_history()) + + def test_add_route_entry_to_kernel_replace_fail(self): + self._test_add_route_entry_to_kernel( + "13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1", + (("/usr/sbin/ip route show 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, ""), + ("/usr/sbin/ip route show 13.13.3.0/24 metric 1", 0, + "13.13.3.0/24 via 12.12.3.1 dev vlan200"), + ("/usr/sbin/ip route replace 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 1, + "< ERROR >"))) + self.assertEqual( + [('info', 'Adding route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1'), + ('info', 'Route to specified network already exists, replacing: 13.13.3.0/24 via ' + '12.12.3.1 dev vlan200'), + ('error', "Failed replacing route 13.13.3.0/24 via 12.12.3.113 dev " + "vlan200 metric 1: '< ERROR >'")], + self._log.get_history()) + + def test_add_route_entry_to_kernel_add_succeed(self): + self._test_add_route_entry_to_kernel( + "13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1", + (("/usr/sbin/ip route show 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, ""), + ("/usr/sbin/ip route show 13.13.3.0/24 metric 1", 0, ""), + ("/usr/sbin/ip route add 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, ""))) + self.assertEqual( + [('info', 'Adding route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1')], + self._log.get_history()) + + def test_add_route_entry_to_kernel_replace_succeed(self): + self._test_add_route_entry_to_kernel( + "13.13.3.0 255.255.255.0 12.12.3.113 vlan200 metric 1", + (("/usr/sbin/ip route show 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, ""), + ("/usr/sbin/ip route show 13.13.3.0/24 metric 1", 0, + "13.13.3.0/24 via 12.12.3.1 dev vlan200"), + ("/usr/sbin/ip route replace 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1", 0, + ""))) + self.assertEqual( + [('info', 'Adding route: 13.13.3.0/24 via 12.12.3.113 dev vlan200 metric 1'), + ('info', 'Route to specified network already exists, replacing: 13.13.3.0/24 via ' + '12.12.3.1 dev vlan200')], + self._log.get_history()) + + def _test_update_routes(self, etc_routes, puppet_routes, updated_ifaces=None): + links = ["enc10", "enc11", "enc12", "enc13"] + self._add_fs_mock(FILE_GEN.generate_file_tree( + puppet_files={ + "routes": [route for route in puppet_routes if ":" not in route["net"]], + "routes6": [route for route in puppet_routes if ":" in route["net"]] + }, + etc_files={ + "interfaces": { + "auto": links, + "enc10": {"address": "10.10.10.3/24"}, + "enc11": {"address": "10.10.11.3/24"}, + "enc12": {"address": "fd12::3/64"}, + "enc13": {"address": "fd13::3/64"}, + }, + "routes": etc_routes, + } + )) + self._add_nw_mock(links) + self._add_scmd_mock() + self._add_logger_mock() + self._nwmock.apply_auto() + + if updated_ifaces: + for iface in updated_ifaces: + self._nwmock.ifdown(iface) + self._nwmock.ifup(iface) + + with mock.patch('src.bin.apply_network_config.get_header', return_value=self._HEADER): + self._mocked_call([self._mock_fs, self._mock_syscmd, self._mock_sysinv_lock, + self._mock_logger], anc.update_routes, updated_ifaces) + + def test_update_routes(self): + self._test_update_routes( + etc_routes=[ + {"net": "10.33.1.0/24", "via": "10.10.10.101", "dev": "enc10", "metric": 1}, + {"net": "10.33.2.0/24", "via": "10.10.10.101", "dev": "enc10", "metric": 1}, + {"net": "10.33.3.0/24", "via": "10.10.10.101", "dev": "enc10", "metric": 1}, + {"net": "fd33:1::/64", "via": "fd12::101", "dev": "enc12", "metric": 1}, + {"net": "fd33:2::/64", "via": "fd12::101", "dev": "enc12", "metric": 1}, + {"net": "fd33:3::/64", "via": "fd12::101", "dev": "enc12", "metric": 1}], + puppet_routes=[ + {"net": "10.33.1.0/24", "via": "10.10.10.101", "dev": "enc10", "metric": 1}, + {"net": "10.33.2.0/24", "via": "10.10.10.202", "dev": "enc10", "metric": 1}, + {"net": "10.33.4.0/24", "via": "10.10.10.101", "dev": "enc10", "metric": 1}, + {"net": "fd33:1::/64", "via": "fd12::101", "dev": "enc12", "metric": 1}, + {"net": "fd33:2::/64", "via": "fd12::202", "dev": "enc12", "metric": 1}, + {"net": "fd33:4::/64", "via": "fd12::101", "dev": "enc12", "metric": 1}]) + + self.assertEqual([ + '10.33.1.0/24 via 10.10.10.101 dev enc10 metric 1', + 'fd33:1::/64 via fd12::101 dev enc12 metric 1', + '10.33.2.0/24 via 10.10.10.202 dev enc10 metric 1', + '10.33.4.0/24 via 10.10.10.101 dev enc10 metric 1', + 'fd33:2::/64 via fd12::202 dev enc12 metric 1', + 'fd33:4::/64 via fd12::101 dev enc12 metric 1'], + self._nwmock.get_routes()) + + self.assertEqual([ + ('info', 'Differences found between /var/run/network-scripts.puppet/routes and ' + '/etc/network/routes'), + ('info', 'Removing route: 10.33.2.0/24 via 10.10.10.101 dev enc10 metric 1'), + ('info', 'Removing route: 10.33.3.0/24 via 10.10.10.101 dev enc10 metric 1'), + ('info', 'Removing route: fd33:2::/64 via fd12::101 dev enc12 metric 1'), + ('info', 'Removing route: fd33:3::/64 via fd12::101 dev enc12 metric 1'), + ('info', 'Route not previously present in /etc/network/routes, adding'), + ('info', 'Adding route: 10.33.2.0/24 via 10.10.10.202 dev enc10 metric 1'), + ('info', 'Route not previously present in /etc/network/routes, adding'), + ('info', 'Adding route: 10.33.4.0/24 via 10.10.10.101 dev enc10 metric 1'), + ('info', 'Route not previously present in /etc/network/routes, adding'), + ('info', 'Adding route: fd33:2::/64 via fd12::202 dev enc12 metric 1'), + ('info', 'Route not previously present in /etc/network/routes, adding'), + ('info', 'Adding route: fd33:4::/64 via fd12::101 dev enc12 metric 1')], + self._log.get_history()) + + self.assertEqual( + self._HEADER + "\n" + "10.33.1.0 255.255.255.0 10.10.10.101 enc10 metric 1\n" + "10.33.2.0 255.255.255.0 10.10.10.202 enc10 metric 1\n" + "10.33.4.0 255.255.255.0 10.10.10.101 enc10 metric 1\n" + "fd33:1:: ffff:ffff:ffff:ffff:: fd12::101 enc12 metric 1\n" + "fd33:2:: ffff:ffff:ffff:ffff:: fd12::202 enc12 metric 1\n" + "fd33:4:: ffff:ffff:ffff:ffff:: fd12::101 enc12 metric 1\n", + self._fs.get_file_contents(anc.ETC_ROUTES_FILE)) + + def test_update_routes_updated_interfaces(self): + routes = [ + {"net": "10.33.1.0/24", "via": "10.10.10.101", "dev": "enc10", "metric": 1}, + {"net": "10.33.2.0/24", "via": "10.10.10.101", "dev": "enc10", "metric": 1}, + {"net": "10.33.3.0/24", "via": "10.10.11.101", "dev": "enc11", "metric": 1}, + {"net": "10.33.4.0/24", "via": "10.10.11.101", "dev": "enc11", "metric": 1}, + {"net": "fd33:1::/64", "via": "fd12::101", "dev": "enc12", "metric": 1}, + {"net": "fd33:2::/64", "via": "fd12::101", "dev": "enc12", "metric": 1}, + {"net": "fd33:3::/64", "via": "fd13::101", "dev": "enc13", "metric": 1}, + {"net": "fd33:4::/64", "via": "fd13::101", "dev": "enc13", "metric": 1}] + self._test_update_routes(routes, routes, ["enc11", "enc13"]) + + self.assertEqual([ + '10.33.1.0/24 via 10.10.10.101 dev enc10 metric 1', + '10.33.2.0/24 via 10.10.10.101 dev enc10 metric 1', + 'fd33:1::/64 via fd12::101 dev enc12 metric 1', + 'fd33:2::/64 via fd12::101 dev enc12 metric 1', + '10.33.3.0/24 via 10.10.11.101 dev enc11 metric 1', + '10.33.4.0/24 via 10.10.11.101 dev enc11 metric 1', + 'fd33:3::/64 via fd13::101 dev enc13 metric 1', + 'fd33:4::/64 via fd13::101 dev enc13 metric 1'], + self._nwmock.get_routes()) + + self.assertEqual([ + ('info', 'No differences found between /var/run/network-scripts.puppet/routes and ' + '/etc/network/routes'), + ('info', 'Route is associated with and updated interface, adding'), + ('info', 'Adding route: 10.33.3.0/24 via 10.10.11.101 dev enc11 metric 1'), + ('info', 'Route is associated with and updated interface, adding'), + ('info', 'Adding route: 10.33.4.0/24 via 10.10.11.101 dev enc11 metric 1'), + ('info', 'Route is associated with and updated interface, adding'), + ('info', 'Adding route: fd33:3::/64 via fd13::101 dev enc13 metric 1'), + ('info', 'Route is associated with and updated interface, adding'), + ('info', 'Adding route: fd33:4::/64 via fd13::101 dev enc13 metric 1')], + self._log.get_history()) + + self.assertEqual( + self._HEADER + "\n" + "10.33.1.0 255.255.255.0 10.10.10.101 enc10 metric 1\n" + "10.33.2.0 255.255.255.0 10.10.10.101 enc10 metric 1\n" + "10.33.3.0 255.255.255.0 10.10.11.101 enc11 metric 1\n" + "10.33.4.0 255.255.255.0 10.10.11.101 enc11 metric 1\n" + "fd33:1:: ffff:ffff:ffff:ffff:: fd12::101 enc12 metric 1\n" + "fd33:2:: ffff:ffff:ffff:ffff:: fd12::101 enc12 metric 1\n" + "fd33:3:: ffff:ffff:ffff:ffff:: fd13::101 enc13 metric 1\n" + "fd33:4:: ffff:ffff:ffff:ffff:: fd13::101 enc13 metric 1\n", + self._fs.get_file_contents(anc.ETC_ROUTES_FILE)) + + def test_check_cloud_init_valid(self): + static_links = ["lo", "ens1f0"] + self._add_fs_mock({ + anc.ETC_DIR + "/auto": FILE_GEN.generate_auto_file(static_links), + anc.ETC_DIR + "/ifcfg-ens1f0": + FILE_GEN.generate_ifcfg_file("ens1f0", {"address": "fd05::2/64", + "gateway": "fd05::111"}), + anc.SUBCLOUD_ENROLLMENT_FILE: '', + anc.CLOUD_INIT_FILE: + "# This file is generated from information provided by the datasource. Changes\n" + "# to it will not persist across an instance reboot. To disable cloud-init's\n" + "# network configuration capabilities, write a file\n" + "# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:\n" + "# network: {config: disabled}\n" + "auto lo\n" + "iface lo inet loopbackauto vlan401\n" + "iface vlan401 inet6 static\n" + " address 2620:10a:a001:d41::163/64\n" + " gateway 2620:10a:a001:d41::1\n" + " vlan-raw-device ens1f0\n" + " vlan_id 401\n"}) + + self._add_nw_mock(static_links) + self._add_scmd_mock() + self._add_logger_mock() + self._nwmock.set_allow_multiple_default_gateways(True) + self._nwmock.apply_auto() + self._nwmock.ifup("vlan401") + self._nwmock.ifdown("ens1f0") + self._nwmock.ifup("ens1f0") + + self._mocked_call([self._mock_fs, self._mock_syscmd, self._mock_logger], + anc.check_enrollment_config) + + self.assertEqual(['default via 2620:10a:a001:d41::1 dev vlan401 metric 1024'], + self._nwmock.get_routes()) + + self.assertEqual([ + ('info', "Enrollment: Parsing file '/etc/network/interfaces.d/50-cloud-init'"), + ('info', 'Enrollment: Configuring interface vlan401 with gateway ' + '2620:10a:a001:d41::1'), + ('info', 'Adding route: default via 2620:10a:a001:d41::1 dev vlan401'), + ('info', 'Route to specified network already exists, replacing: default via fd05::111 ' + 'dev ens1f0 metric 1024 pref medium')], + self._log.get_history()) + + def test_check_cloud_init_multiple_ifaces(self): + static_links = ["lo", "ens1f0"] + self._add_fs_mock({ + anc.ETC_DIR + "/auto": FILE_GEN.generate_auto_file(static_links), + anc.ETC_DIR + "/ifcfg-ens1f0": + FILE_GEN.generate_ifcfg_file("ens1f0", {"address": "fd05::2/64", + "gateway": "fd05::111"}), + anc.SUBCLOUD_ENROLLMENT_FILE: '', + anc.CLOUD_INIT_FILE: + "auto lo\n" + "iface lo inet loopbackauto vlan401\n" + "iface vlan401 inet6 static\n" + " address 2620:10a:a001:d41::163/64\n" + " gateway 2620:10a:a001:d41::1\n" + " vlan-raw-device ens1f0\n" + " vlan_id 401\n" + "iface vlan402 inet6 static\n" + " address eb22:303::55:2/64\n" + " gateway eb22:303::1\n" + " vlan-raw-device ens1f0\n" + " vlan_id 402\n"}) + + self._add_nw_mock(static_links) + self._add_scmd_mock() + self._add_logger_mock() + self._nwmock.set_allow_multiple_default_gateways(True) + self._nwmock.apply_auto() + self._nwmock.ifup("vlan401") + self._nwmock.ifup("vlan402") + self._nwmock.ifdown("ens1f0") + self._nwmock.ifup("ens1f0") + + self._mocked_call([self._mock_fs, self._mock_syscmd, self._mock_logger], + anc.check_enrollment_config) + + self.assertEqual(['default via eb22:303::1 dev vlan402 metric 1024'], + self._nwmock.get_routes()) + + self.assertEqual([ + ('info', "Enrollment: Parsing file '/etc/network/interfaces.d/50-cloud-init'"), + ('warning', 'Enrollment: Multiple interfaces with gateway for ipv6 found: vlan401, ' + 'vlan402'), + ('info', 'Enrollment: Configuring interface vlan401 with gateway ' + '2620:10a:a001:d41::1'), + ('info', 'Adding route: default via 2620:10a:a001:d41::1 dev vlan401'), + ('info', 'Route to specified network already exists, replacing: default via fd05::111 ' + 'dev ens1f0 metric 1024 pref medium'), + ('info', 'Enrollment: Configuring interface vlan402 with gateway eb22:303::1'), + ('info', 'Adding route: default via eb22:303::1 dev vlan402'), + ('info', 'Route to specified network already exists, replacing: default via ' + '2620:10a:a001:d41::1 dev vlan401 metric 1024 pref medium')], + self._log.get_history()) + + def test_check_cloud_init_empty(self): + self._add_fs_mock({ + anc.SUBCLOUD_ENROLLMENT_FILE: '', + anc.CLOUD_INIT_FILE: ''}) + + self._add_logger_mock() + + self._mocked_call([self._mock_fs, self._mock_logger], anc.check_enrollment_config) + + self.assertEqual([ + ('info', "Enrollment: Parsing file '/etc/network/interfaces.d/50-cloud-init'"), + ('warning', 'Enrollment: Could not find any valid interface config in ' + "'/etc/network/interfaces.d/50-cloud-init'")], + self._log.get_history()) + + def test_check_cloud_init_invalid_gateway(self): + self._add_fs_mock({ + anc.SUBCLOUD_ENROLLMENT_FILE: '', + anc.CLOUD_INIT_FILE: + "auto lo\n" + "iface lo inet loopbackauto vlan401\n" + "iface vlan401 inet6 static\n" + " address 2620:10a:a001:d41::163/64\n" + " gateway h620::1\n" + " vlan-raw-device ens1f0\n" + " vlan_id 401\n"}) + + self._add_logger_mock() + + self._mocked_call([self._mock_fs, self._mock_logger], anc.check_enrollment_config) + + self.assertEqual([ + ('info', "Enrollment: Parsing file '/etc/network/interfaces.d/50-cloud-init'"), + ('warning', "Enrollment: Invalid gateway address 'h620::1' for interface 'vlan401'"), + ('warning', 'Enrollment: No interface with gateway address found, skipping')], + self._log.get_history()) + + def test_disable_kickstart_pxeboot(self): + etc_cfg = { + "interfaces": { + "auto": ["lo", "enp0s8"], + "lo": {}, + "enp0s8": {}, }, + } + + puppet_cfg = { + "interfaces": { + "auto": ["lo", "enp0s8", "enp0s8:2-3", "enp0s8:2-4"], + "lo": {}, + "enp0s8": {"address": "169.254.202.2/24"}, + "enp0s8:2-3": {"address": "192.168.204.2/24"}, + "enp0s8:2-4": {"address": "fd01::2/64"}}, + } + + contents = FILE_GEN.generate_file_tree(puppet_files=puppet_cfg, etc_files=etc_cfg) + contents[anc.ETC_DIR + "/ifcfg-pxeboot"] = ( + "auto enp0s8:2\n" + "iface enp0s8:2 inet dhcp\n" + " post-up echo 0 > /proc/sys/net/ipv6/conf/enp0s8/autoconf; " + "echo 0 > /proc/sys/net/ipv6/conf/enp0s8/accept_ra; " # noqa: E131 + "echo 0 > /proc/sys/net/ipv6/conf/enp0s8/accept_redirects\n") + + self._add_fs_mock(contents) + self._add_nw_mock(["lo", "enp0s8"]) + self._add_scmd_mock() + self._add_logger_mock() + self._nwmock.apply_auto() + + self._mocked_call([self._mock_fs, self._mock_syscmd, + self._mock_sysinv_lock, self._mock_logger], anc.update_interfaces) + + self.assertEqual([ + ('info', 'Turn off pxeboot install config for enp0s8:2, will be turned on later'), + ('info', 'Bringing enp0s8:2 down'), + ('info', 'Remove ifcfg-pxeboot, left from kickstart install phase'), + ('info', 'Removing /etc/network/interfaces.d/ifcfg-pxeboot')], + self._log.get_history()[:4]) + + def test_execute_system_cmd(self): + retcode, stdout = anc.execute_system_cmd('echo "test_execute_system_cmd"') + self.assertEqual(0, retcode) + self.assertEqual("test_execute_system_cmd\n", stdout) + + _OS_GETPGID = os.getpgid + + def test_execute_system_cmd_timeout_retcode_15(self): + subproc_pid = None + subproc_pgid = None + + def getpgid(pid): + nonlocal subproc_pid + nonlocal subproc_pgid + subproc_pid = pid + subproc_pgid = self._OS_GETPGID(pid) + return subproc_pgid + + self._add_logger_mock() + + with mock.patch("os.getpgid", getpgid): + retcode, stdout = self._mocked_call([self._mock_logger], anc.execute_system_cmd, + "tests/system_cmd_test_script.sh 15", 1) + + self.assertEqual(15, retcode) + self.assertEqual("< BEFORE SLEEP >\nTerminated\n< SIGTERM RECEIVED >\n", stdout) + self.assertEqual([ + (LoggerMock.WARNING, + "Execution time exceeded for command 'tests/system_cmd_test_script.sh 15', sending " + f"SIGTERM to subprocess (pid={subproc_pid}, pgid={subproc_pgid})")], + self._log.get_history()) + + def test_execute_system_cmd_timeout_retcode_0(self): + subproc_pid = None + subproc_pgid = None + + def getpgid(pid): + nonlocal subproc_pid + nonlocal subproc_pgid + subproc_pid = pid + subproc_pgid = self._OS_GETPGID(pid) + return subproc_pgid + + self._add_logger_mock() + + with mock.patch("os.getpgid", getpgid): + retcode, stdout = self._mocked_call([self._mock_logger], anc.execute_system_cmd, + "tests/system_cmd_test_script.sh 0", 1) + + self.assertEqual(0, retcode) + self.assertEqual("< BEFORE SLEEP >\nTerminated\n< SIGTERM RECEIVED >\n", stdout) + self.assertEqual([ + (LoggerMock.WARNING, + "Execution time exceeded for command 'tests/system_cmd_test_script.sh 0', sending " + f"SIGTERM to subprocess (pid={subproc_pid}, pgid={subproc_pgid})"), + (LoggerMock.INFO, + "Command 'tests/system_cmd_test_script.sh 0' output:\n" + '< BEFORE SLEEP >\n' + 'Terminated\n' + '< SIGTERM RECEIVED >')], + self._log.get_history()) + + def test_execute_system_cmd_timeout_kill(self): + subproc_pid = None + subproc_pgid = None + + def getpgid(pid): + nonlocal subproc_pid + nonlocal subproc_pgid + subproc_pid = pid + subproc_pgid = self._OS_GETPGID(pid) + return subproc_pgid + + self._add_logger_mock() + + with (mock.patch("os.getpgid", getpgid), + mock.patch("src.bin.apply_network_config.TERM_WAIT_TIME", 1)): + retcode, stdout = self._mocked_call([self._mock_logger], anc.execute_system_cmd, + "tests/system_cmd_test_script.sh 0 -e", 1) + + self.assertEqual(-9, retcode) + self.assertEqual("< BEFORE SLEEP >\nTerminated\n< SIGTERM RECEIVED >\n", stdout) + self.assertEqual([ + (LoggerMock.WARNING, + "Execution time exceeded for command 'tests/system_cmd_test_script.sh 0 -e', sending " + f"SIGTERM to subprocess (pid={subproc_pid}, pgid={subproc_pgid})"), + (LoggerMock.WARNING, + "Command 'tests/system_cmd_test_script.sh 0 -e' has not terminated after " + f"1 seconds, sending SIGKILL to subprocess " + f"(pid={subproc_pid}, pgid={subproc_pgid})")], + self._log.get_history()) + + +class TestInterfaceDependencies(BaseTestCase): + + _AUTO = ["enp0s3", "enp0s3:1-9", "enp0s8", "enp0s8:2-13", "enp0s8:3-15", + "datavlan300", "datavlan300:6-22", "enp0s9", "enp0s10", "bond0", + "bond0:4-12", "vlan200", "vlan200:5-19"] + + _BASE_CFG = { + "interfaces": { + "auto": _AUTO, + "enp0s3": {}, + "enp0s3:1-9": {"address": "12.12.15.67/24", "gateway": "12.12.15.1"}, + "enp0s8": {}, + "enp0s8:2-13": {"address": "192.168.204.2/24"}, + "enp0s8:3-15": {"address": "192.168.206.2/24"}, + "datavlan300": {"raw_dev": "enp0s8", "vlan_id": 300}, + "datavlan300:6-22": {"address": "adad:efef::44:55:66/64", + "raw_dev": "enp0s8", "vlan_id": 300}, + "enp0s9": {"master": "bond0"}, + "enp0s10": {"master": "bond0"}, + "bond0": {"slaves": ["enp0s9", "enp0s10"], "hwaddress": "08:00:27:f2:66:72"}, + "bond0:4-12": {"address": "11.22.3.15/24", "slaves": ["enp0s9", "enp0s10"], + "hwaddress": "08:00:27:f2:66:72"}, + "vlan200": {"raw_dev": "bond0"}, + "vlan200:5-19": {"address": "dead:beef::1:2:3/64", "raw_dev": "bond0"}} + } + + _MODIFIED_CFG = { + "auto": _AUTO, + "enp0s3": {"mtu": 9000}, + "enp0s3:1-9": {"mtu": 9000, "address": "12.12.15.67/24", "gateway": "12.12.15.1"}, + "enp0s8": {"mtu": 9000}, + "enp0s8:2-13": {"mtu": 9000, "address": "192.168.204.2/24"}, + "enp0s8:3-15": {"mtu": 9000, "address": "192.168.206.2/24"}, + "datavlan300": {"mtu": 9000, "raw_dev": "enp0s8", "vlan_id": 300}, + "datavlan300:6-22": {"mtu": 9000, "address": "adad:efef::44:55:66/64", + "raw_dev": "enp0s8", "vlan_id": 300}, + "enp0s9": {"mtu": 9000, "master": "bond0"}, + "enp0s10": {"mtu": 9000, "master": "bond0"}, + "bond0": {"mtu": 9000, "slaves": ["enp0s9", "enp0s10"], "hwaddress": "08:00:27:f2:66:72"}, + "bond0:4-12": {"mtu": 9000, "address": "11.22.3.15/24", "slaves": ["enp0s9", "enp0s10"], + "hwaddress": "08:00:27:f2:66:72"}, + "vlan200": {"mtu": 9000, "raw_dev": "bond0"}, + "vlan200:5-19": {"mtu": 9000, "address": "dead:beef::1:2:3/64", "raw_dev": "bond0"} + } + + _STATIC_LINKS = ["lo", "enp0s3", "enp0s8", "enp0s9", "enp0s10"] + + _FS = ReadOnlyFileContainer(FILE_GEN.generate_file_tree(_BASE_CFG, _BASE_CFG)) + + _MODIFIED_FILES = {k: FILE_GEN.generate_ifcfg_file(k, v) + for k, v in _MODIFIED_CFG.items() if k != "auto"} + + def _setup_scenario(self, modified_ifaces): + contents = dict() + for iface in modified_ifaces: + path = anc.ETC_DIR + "/ifcfg-" + iface + contents[path] = self._MODIFIED_FILES[iface] + self._fs = FilesystemMock(fs=self._FS, contents=contents) + self._add_nw_mock(self._STATIC_LINKS) + self._add_scmd_mock() + self._add_logger_mock() + self._nwmock.apply_auto() + + def _run_update_interfaces(self): + self._mocked_call([self._mock_fs, self._mock_syscmd, + self._mock_sysinv_lock, self._mock_logger], anc.update_interfaces) + + def test_modify_label(self): + self._setup_scenario(["enp0s3:1-9"]) + self._run_update_interfaces() + self.assertEqual([("ifdown", "enp0s3:1-9"), + ("ifup", "enp0s3:1-9")], + self._nwmock.get_history()) + + def test_modify_eth_with_label(self): + self._setup_scenario(["enp0s3"]) + self._run_update_interfaces() + self.assertEqual([('ifdown', 'enp0s3:1-9'), + ('ifdown', 'enp0s3'), + ('ip_link_set_down', 'enp0s3'), + ('ip_addr_flush', 'enp0s3'), + ('ifup', 'enp0s3'), + ('ifup', 'enp0s3:1-9')], + self._nwmock.get_history()) + + def test_modify_vlan_over_eth(self): + self._setup_scenario(["datavlan300"]) + self._run_update_interfaces() + self.assertEqual([("ifdown", "datavlan300:6-22"), + ("ifdown", "datavlan300"), + ("ifup", "datavlan300"), + ("ifup", "datavlan300:6-22")], + self._nwmock.get_history()) + + def test_modify_vlan_over_bonding(self): + self._setup_scenario(["vlan200"]) + self._run_update_interfaces() + self.assertEqual([("ifdown", "vlan200:5-19"), + ("ifdown", "vlan200"), + ("ifup", "vlan200"), + ("ifup", "vlan200:5-19")], + self._nwmock.get_history()) + + def test_modify_eth_with_vlan(self): + self._setup_scenario(["enp0s8"]) + self._run_update_interfaces() + self.assertEqual([('ifdown', 'datavlan300:6-22'), + ('ifdown', 'enp0s8:2-13'), + ('ifdown', 'enp0s8:3-15'), + ('ifdown', 'datavlan300'), + ('ifdown', 'enp0s8'), + ('ip_link_set_down', 'enp0s8'), + ('ip_addr_flush', 'enp0s8'), + ('ifup', 'enp0s8'), + ('ifup', 'datavlan300'), + ('ifup', 'datavlan300:6-22'), + ('ifup', 'enp0s8:2-13'), + ('ifup', 'enp0s8:3-15')], + self._nwmock.get_history()) + + def test_modify_bonding(self): + self._setup_scenario(["bond0"]) + self._run_update_interfaces() + self.assertEqual([('ifdown', 'bond0:4-12'), + ('ifdown', 'vlan200:5-19'), + ('ifdown', 'vlan200'), + ('ifdown', 'bond0'), + ('ifup', 'bond0'), + ('ifup', 'vlan200'), + ('ifup', 'bond0:4-12'), + ('ifup', 'vlan200:5-19')], + self._nwmock.get_history()) + + def test_modify_slave(self): + self._setup_scenario(["enp0s9"]) + self._run_update_interfaces() + self.assertEqual([('ifdown', 'bond0:4-12'), + ('ifdown', 'vlan200:5-19'), + ('ifdown', 'vlan200'), + ('ifdown', 'bond0'), + ('ifup', 'bond0'), + ('ifup', 'vlan200'), + ('ifup', 'bond0:4-12'), + ('ifup', 'vlan200:5-19')], + self._nwmock.get_history()) + + +class MigrationBaseTestCase(BaseTestCase): + def _setup_scenario(self, from_cfg, to_cfg, static_links): + self._add_fs_mock(FILE_GEN.generate_file_tree(to_cfg, from_cfg)) + self._add_nw_mock(static_links) + self._add_scmd_mock() + self._add_logger_mock() + stdout = self._nwmock.apply_auto() + self.assertEqual("", stdout) + + def _run_apply_config(self): + self._mocked_call([self._mock_fs, self._mock_syscmd, + self._mock_sysinv_lock, self._mock_logger], anc.apply_config, False) + + def _check_etc_file_list(self, to_cfg): + files = self._fs.get_file_list(anc.ETC_DIR) + etc_ifaces = [] + has_auto = False + for file in files: + if file.startswith("ifcfg-"): + etc_ifaces.append(file.split("-", 1)[1]) + elif file == "auto": + has_auto = True + else: + raise Exception(f"Unexpected file in ETC dir: '{file}'") + self.assertEqual(True, has_auto, "'auto' file not present in ETC dir") + self.assertEqual(sorted(to_cfg["interfaces"]["auto"]), etc_ifaces) + + +class TestEthAndLoMigration(MigrationBaseTestCase): + + _LEFT = { + "interfaces": { + "auto": ["enp0s3", "enp0s3:1-1", "enp0s3:1-2", "lo", "lo:2-3", "lo:2-4", + "lo:3-5", "lo:3-6", "enp0s9", "enp0s9:7-11", "enp0s9:7-12"], + "enp0s3": {}, + "enp0s3:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s3:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "lo": {}, + "lo:2-3": {"address": "192.168.204.2/24"}, + "lo:2-4": {"address": "fd01::2/64"}, + "lo:3-5": {"address": "192.168.206.2/24"}, + "lo:3-6": {"address": "fd02::2/64"}, + "enp0s9": {}, + "enp0s9:7-11": {"address": "112.44.202.26/24"}, + "enp0s9:7-12": {"address": "ad60:b00::202:26/64"}, + }, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "enp0s3", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "lo", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "lo", "metric": 1}, + {"net": "14.14.4.0/24", "via": "112.44.202.111", "dev": "enp0s9", "metric": 1}], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "enp0s3", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "lo", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "lo", "metric": 1}, + {"net": "fa01:4::/64", "via": "ad60:b00::111", "dev": "enp0s9", "metric": 1}], + } + + _RIGHT = { + "interfaces": { + "auto": ["enp0s9", "enp0s9:1-1", "enp0s9:1-2", "lo", "enp0s8", "enp0s8:2-3", + "enp0s8:2-4", "enp0s8:3-5", "enp0s8:3-6", "enp0s3", "enp0s3:7-11", + "enp0s3:7-12"], + "enp0s9": {}, + "enp0s9:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s9:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "lo": {}, + "enp0s8": {"address": "169.254.202.2/24"}, + "enp0s8:2-3": {"address": "192.168.204.2/24"}, + "enp0s8:2-4": {"address": "fd01::2/64"}, + "enp0s8:3-5": {"address": "192.168.206.2/24"}, + "enp0s8:3-6": {"address": "fd02::2/64"}, + "enp0s3": {}, + "enp0s3:7-11": {"address": "112.44.202.26/24"}, + "enp0s3:7-12": {"address": "ad60:b00::202:26/64"}, + }, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "enp0s9", "metric": 1}, + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.4.0/24", "via": "112.44.202.111", "dev": "enp0s3", "metric": 1}], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "enp0s9", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "enp0s8", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "enp0s8", "metric": 1}, + {"net": "fa01:4::/64", "via": "ad60:b00::111", "dev": "enp0s3", "metric": 1}], + } + + _STATIC_LINKS = ["lo", "enp0s3", "enp0s8", "enp0s9"] + + def test_eth_to_eth_migration_a(self): + self._setup_scenario(self._LEFT, self._RIGHT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual([ + 'enp0s3 UP 112.44.202.26/24 ad60:b00::202:26/64', + 'enp0s8 UP 169.254.202.2/24 192.168.204.2/24 192.168.206.2/24 fd01::2/64 fd02::2/64', + 'enp0s9 UP 10.20.1.2/24 fd00::1:2/64', + 'lo UP'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev enp0s9', + 'default via fd00::1 dev enp0s9 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev enp0s9 metric 1', + '14.15.1.0/24 via 169.254.202.111 dev enp0s8 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev enp0s8 metric 1', + '14.14.3.0/24 via 192.168.206.111 dev enp0s8 metric 1', + '14.14.4.0/24 via 112.44.202.111 dev enp0s3 metric 1', + 'fa01:1::/64 via fd00::111 dev enp0s9 metric 1', + 'fa01:2::/64 via fd01::111 dev enp0s8 metric 1', + 'fa01:3::/64 via fd02::111 dev enp0s8 metric 1', + 'fa01:4::/64 via ad60:b00::111 dev enp0s3 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._RIGHT) + + def test_eth_to_eth_migration_b(self): + self._setup_scenario(self._RIGHT, self._LEFT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual([ + 'enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 DOWN', + 'enp0s9 UP 112.44.202.26/24 ad60:b00::202:26/64', + 'lo UP 192.168.204.2/24 192.168.206.2/24 fd01::2/64 fd02::2/64'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev enp0s3 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev lo metric 1', + '14.14.3.0/24 via 192.168.206.111 dev lo metric 1', + '14.14.4.0/24 via 112.44.202.111 dev enp0s9 metric 1', + 'fa01:1::/64 via fd00::111 dev enp0s3 metric 1', + 'fa01:2::/64 via fd01::111 dev lo metric 1', + 'fa01:3::/64 via fd02::111 dev lo metric 1', + 'fa01:4::/64 via ad60:b00::111 dev enp0s9 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._LEFT) + + +class TestEthToVLANMigration(MigrationBaseTestCase): + + _LEFT = { + "interfaces": { + "auto": ["enp0s3", "enp0s3:1-1", "enp0s3:1-2", "enp0s8", + "enp0s8:2-3", "enp0s8:2-4", "enp0s8:3-5", "enp0s8:3-6"], + "enp0s3": {}, + "enp0s3:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s3:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "enp0s8": {"address": "169.254.202.2/24"}, + "enp0s8:2-3": {"address": "192.168.204.2/24"}, + "enp0s8:2-4": {"address": "fd01::2/64"}, + "enp0s8:3-5": {"address": "192.168.206.2/24"}, + "enp0s8:3-6": {"address": "fd02::2/64"}}, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "enp0s3", "metric": 1}, + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "enp0s8", "metric": 1}], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "enp0s3", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "enp0s8", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "enp0s8", "metric": 1}], + } + + _RIGHT = { + "interfaces": { + "auto": ["enp0s3", "enp0s3:1-1", "enp0s3:1-2", "enp0s8", "vlan100", "vlan200", + "vlan100:2-3", "vlan100:2-4", "vlan200:3-5", "vlan200:3-6"], + "enp0s3": {}, + "enp0s3:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s3:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "enp0s8": {"address": "169.254.202.2/24"}, + "vlan100": {"raw_dev": "enp0s8"}, + "vlan100:2-3": {"address": "192.168.204.2/24", "raw_dev": "enp0s8"}, + "vlan100:2-4": {"address": "fd01::2/64", "raw_dev": "enp0s8"}, + "vlan200": {"raw_dev": "enp0s8"}, + "vlan200:3-5": {"address": "192.168.206.2/24", "raw_dev": "enp0s8"}, + "vlan200:3-6": {"address": "fd02::2/64", "raw_dev": "enp0s8"}}, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "enp0s3", "metric": 1}, + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "vlan100", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "vlan200", "metric": 1}], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "enp0s3", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "vlan100", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "vlan200", "metric": 1}], + } + + _STATIC_LINKS = ["enp0s3", "enp0s8"] + + def test_eth_to_vlan_migration(self): + self._setup_scenario(self._LEFT, self._RIGHT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual(['enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 UP 169.254.202.2/24', + 'vlan100 UP VLAN(enp0s8,100) 192.168.204.2/24 fd01::2/64', + 'vlan200 UP VLAN(enp0s8,200) 192.168.206.2/24 fd02::2/64'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev enp0s3 metric 1', + '14.15.1.0/24 via 169.254.202.111 dev enp0s8 metric 1', + 'fa01:1::/64 via fd00::111 dev enp0s3 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev vlan100 metric 1', + '14.14.3.0/24 via 192.168.206.111 dev vlan200 metric 1', + 'fa01:2::/64 via fd01::111 dev vlan100 metric 1', + 'fa01:3::/64 via fd02::111 dev vlan200 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._RIGHT) + + def test_vlan_to_eth_migration(self): + self._setup_scenario(self._RIGHT, self._LEFT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual(['enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 UP 169.254.202.2/24 192.168.204.2/24 192.168.206.2/24 fd01::2/64 ' + 'fd02::2/64'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev enp0s3 metric 1', + '14.15.1.0/24 via 169.254.202.111 dev enp0s8 metric 1', + 'fa01:1::/64 via fd00::111 dev enp0s3 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev enp0s8 metric 1', + '14.14.3.0/24 via 192.168.206.111 dev enp0s8 metric 1', + 'fa01:2::/64 via fd01::111 dev enp0s8 metric 1', + 'fa01:3::/64 via fd02::111 dev enp0s8 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._LEFT) + + +class TestEthToBondingMigration(MigrationBaseTestCase): + + _LEFT = { + "interfaces": { + "auto": ["enp0s3", "enp0s3:1-1", "enp0s3:1-2", "enp0s8", + "enp0s8:2-3", "enp0s8:2-4", "enp0s8:3-5", "enp0s8:3-6"], + "enp0s3": {}, + "enp0s3:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s3:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "enp0s8": {"address": "169.254.202.2/24"}, + "enp0s8:2-3": {"address": "192.168.204.2/24"}, + "enp0s8:2-4": {"address": "fd01::2/64"}, + "enp0s8:3-5": {"address": "192.168.206.2/24"}, + "enp0s8:3-6": {"address": "fd02::2/64"}}, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "enp0s3", "metric": 1}, + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "enp0s8", "metric": 1}], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "enp0s3", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "enp0s8", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "enp0s8", "metric": 1}], + } + + _RIGHT = { + "interfaces": { + "auto": ["enp0s3", "enp0s8", "oam0", "oam0:1-1", "oam0:1-2", + "enp0s9", "enp0s10", "pxeboot0", "vlan100", "vlan100:2-3", + "vlan100:2-4", "vlan200", "vlan200:3-5", "vlan200:3-6"], + "enp0s3": {"master": "oam0"}, + "enp0s8": {"master": "oam0"}, + "oam0": {"slaves": ["enp0s3", "enp0s8"], "hwaddress": "08:00:27:f2:66:72"}, + "oam0:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1", + "slaves": ["enp0s3", "enp0s8"], "hwaddress": "08:00:27:f2:66:72"}, + "oam0:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1", + "slaves": ["enp0s3", "enp0s8"], "hwaddress": "08:00:27:f2:66:72"}, + "enp0s9": {"master": "pxeboot0"}, + "enp0s10": {"master": "pxeboot0"}, + "pxeboot0": {"address": "169.254.202.2/24", "slaves": ["enp0s9", "enp0s10"], + "hwaddress": "08:00:27:f2:67:11"}, + "vlan100": {"raw_dev": "pxeboot0"}, + "vlan100:2-3": {"address": "192.168.204.2/24", "raw_dev": "pxeboot0"}, + "vlan100:2-4": {"address": "fd01::2/64", "raw_dev": "pxeboot0"}, + "vlan200": {"raw_dev": "pxeboot0"}, + "vlan200:3-5": {"address": "192.168.206.2/24", "raw_dev": "pxeboot0"}, + "vlan200:3-6": {"address": "fd02::2/64", "raw_dev": "pxeboot0"}}, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "oam0", "metric": 1}, + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "pxeboot0", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "vlan100", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "vlan200", "metric": 1}], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "oam0", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "vlan100", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "vlan200", "metric": 1}], + } + + _STATIC_LINKS = ["enp0s3", "enp0s8", "enp0s9", "enp0s10"] + + def test_eth_to_bonding_migration(self): + self._setup_scenario(self._LEFT, self._RIGHT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual(['enp0s10 UP SLAVE(pxeboot0)', + 'enp0s3 UP SLAVE(oam0)', + 'enp0s8 UP SLAVE(oam0)', + 'enp0s9 UP SLAVE(pxeboot0)', + 'oam0 UP BONDING(enp0s3,enp0s8) 10.20.1.2/24 fd00::1:2/64', + 'pxeboot0 UP BONDING(enp0s9,enp0s10) 169.254.202.2/24', + 'vlan100 UP VLAN(pxeboot0,100) 192.168.204.2/24 fd01::2/64', + 'vlan200 UP VLAN(pxeboot0,200) 192.168.206.2/24 fd02::2/64'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev oam0', + 'default via fd00::1 dev oam0 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev oam0 metric 1', + '14.15.1.0/24 via 169.254.202.111 dev pxeboot0 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev vlan100 metric 1', + '14.14.3.0/24 via 192.168.206.111 dev vlan200 metric 1', + 'fa01:1::/64 via fd00::111 dev oam0 metric 1', + 'fa01:2::/64 via fd01::111 dev vlan100 metric 1', + 'fa01:3::/64 via fd02::111 dev vlan200 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._RIGHT) + + def test_bonding_to_eth_migration(self): + self._setup_scenario(self._RIGHT, self._LEFT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual(['enp0s10 DOWN', + 'enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 UP 169.254.202.2/24 192.168.204.2/24 192.168.206.2/24 ' + 'fd01::2/64 fd02::2/64', + 'enp0s9 DOWN'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev enp0s3 metric 1', + '14.15.1.0/24 via 169.254.202.111 dev enp0s8 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev enp0s8 metric 1', + '14.14.3.0/24 via 192.168.206.111 dev enp0s8 metric 1', + 'fa01:1::/64 via fd00::111 dev enp0s3 metric 1', + 'fa01:2::/64 via fd01::111 dev enp0s8 metric 1', + 'fa01:3::/64 via fd02::111 dev enp0s8 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._LEFT) + + +class TestBondingMigration(MigrationBaseTestCase): + _LEFT = { + "interfaces": { + "auto": ["enp0s3", "enp0s3:1-1", "enp0s3:1-2", "enp0s8", "enp0s8:2-3", "enp0s8:2-4", + "enp0s8:3-5", "enp0s8:3-6", "enp0s9", "enp0s10", "data0", "data0:4-7", + "data0:4-8", "data1", "data1:5-9", "data1:5-10"], + "enp0s3": {}, + "enp0s3:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s3:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "enp0s8": {"address": "169.254.202.2/24"}, + "enp0s8:2-3": {"address": "192.168.204.2/24"}, + "enp0s8:2-4": {"address": "fd01::2/64"}, + "enp0s8:3-5": {"address": "192.168.206.2/24"}, + "enp0s8:3-6": {"address": "fd02::2/64"}, + "enp0s9": {"master": "data0"}, + "enp0s10": {"master": "data0"}, + "data0": {"slaves": ["enp0s9", "enp0s10"], "hwaddress": "08:00:27:f2:66:72"}, + "data0:4-7": {"address": "112.154.1.2/24", "slaves": ["enp0s9", "enp0s10"], + "hwaddress": "08:00:27:f2:66:72"}, + "data0:4-8": {"address": "fc01:154:1::2/64", "slaves": ["enp0s9", "enp0s10"], + "hwaddress": "08:00:27:f2:66:72"}, + "data1": {"raw_dev": "data0", "vlan_id": 50}, + "data1:5-9": {"address": "112.155.1.2/24", "raw_dev": "data0", "vlan_id": 50}, + "data1:5-10": {"address": "fc01:155:1::2/64", "raw_dev": "data0", "vlan_id": 50}}, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "enp0s3", "metric": 1}, + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "enp0s8", "metric": 1}, + {"net": "14.14.4.0/24", "via": "112.154.1.111", "dev": "data0", "metric": 1}, + {"net": "14.14.5.0/24", "via": "112.155.1.111", "dev": "data1", "metric": 1}, + ], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "enp0s3", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "enp0s8", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "enp0s8", "metric": 1}, + {"net": "fa01:4::/64", "via": "fc01:154:1::111", "dev": "data0", "metric": 1}, + {"net": "fa01:5::/64", "via": "fc01:155:1::111", "dev": "data1", "metric": 1}, + ], + } + + _RIGHT = { + "interfaces": { + "auto": ["enp0s3", "enp0s3:1-1", "enp0s3:1-2", "enp0s8", "enp0s10:2-3", "enp0s10:2-4", + "enp0s10:3-5", "enp0s10:3-6", "enp0s9", "enp0s10", "data0", "data0:4-7", + "data0:4-8", "data1", "data1:5-9", "data1:5-10"], + "enp0s3": {}, + "enp0s3:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s3:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "enp0s8": {"master": "data0"}, + "enp0s9": {"master": "data0"}, + "enp0s10": {"address": "169.254.202.2/24"}, + "enp0s10:2-3": {"address": "192.168.204.2/24"}, + "enp0s10:2-4": {"address": "fd01::2/64"}, + "enp0s10:3-5": {"address": "192.168.206.2/24"}, + "enp0s10:3-6": {"address": "fd02::2/64"}, + "data0": {"slaves": ["enp0s8", "enp0s9"], "hwaddress": "08:00:27:f2:66:72"}, + "data0:4-7": {"address": "112.154.1.2/24", "slaves": ["enp0s8", "enp0s9"], + "hwaddress": "08:00:27:f2:66:72"}, + "data0:4-8": {"address": "fc01:154:1::2/64", "slaves": ["enp0s8", "enp0s9"], + "hwaddress": "08:00:27:f2:66:72"}, + "data1": {"raw_dev": "data0", "vlan_id": 50}, + "data1:5-9": {"address": "112.155.1.2/24", "raw_dev": "data0", "vlan_id": 50}, + "data1:5-10": {"address": "fc01:155:1::2/64", "raw_dev": "data0", "vlan_id": 50}}, + "routes": [ + {"net": "14.14.1.0/24", "via": "10.20.1.111", "dev": "enp0s3", "metric": 1}, + {"net": "14.15.1.0/24", "via": "169.254.202.111", "dev": "enp0s10", "metric": 1}, + {"net": "14.14.2.0/24", "via": "192.168.204.111", "dev": "enp0s10", "metric": 1}, + {"net": "14.14.3.0/24", "via": "192.168.206.111", "dev": "enp0s10", "metric": 1}, + {"net": "14.14.4.0/24", "via": "112.154.1.111", "dev": "data0", "metric": 1}, + {"net": "14.14.5.0/24", "via": "112.155.1.111", "dev": "data1", "metric": 1}, + ], + "routes6": [ + {"net": "fa01:1::/64", "via": "fd00::111", "dev": "enp0s3", "metric": 1}, + {"net": "fa01:2::/64", "via": "fd01::111", "dev": "enp0s10", "metric": 1}, + {"net": "fa01:3::/64", "via": "fd02::111", "dev": "enp0s10", "metric": 1}, + {"net": "fa01:4::/64", "via": "fc01:154:1::111", "dev": "data0", "metric": 1}, + {"net": "fa01:5::/64", "via": "fc01:155:1::111", "dev": "data1", "metric": 1}, + ], + } + + _STATIC_LINKS = ["enp0s3", "enp0s8", "enp0s9", "enp0s10"] + + def test_bonding_migration_a(self): + self._setup_scenario(self._LEFT, self._RIGHT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual(['data0 UP BONDING(enp0s8,enp0s9) 112.154.1.2/24 fc01:154:1::2/64', + 'data1 UP VLAN(data0,50) 112.155.1.2/24 fc01:155:1::2/64', + 'enp0s10 UP 169.254.202.2/24 192.168.204.2/24 192.168.206.2/24 ' + 'fd01::2/64 fd02::2/64', + 'enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 UP SLAVE(data0)', + 'enp0s9 UP SLAVE(data0)'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev enp0s3 metric 1', + 'fa01:1::/64 via fd00::111 dev enp0s3 metric 1', + '14.15.1.0/24 via 169.254.202.111 dev enp0s10 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev enp0s10 metric 1', + '14.14.3.0/24 via 192.168.206.111 dev enp0s10 metric 1', + '14.14.4.0/24 via 112.154.1.111 dev data0 metric 1', + '14.14.5.0/24 via 112.155.1.111 dev data1 metric 1', + 'fa01:2::/64 via fd01::111 dev enp0s10 metric 1', + 'fa01:3::/64 via fd02::111 dev enp0s10 metric 1', + 'fa01:4::/64 via fc01:154:1::111 dev data0 metric 1', + 'fa01:5::/64 via fc01:155:1::111 dev data1 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._RIGHT) + + def test_bonding_migration_b(self): + self._setup_scenario(self._RIGHT, self._LEFT, self._STATIC_LINKS) + + self._run_apply_config() + + self.assertEqual(['data0 UP BONDING(enp0s9,enp0s10) 112.154.1.2/24 fc01:154:1::2/64', + 'data1 UP VLAN(data0,50) 112.155.1.2/24 fc01:155:1::2/64', + 'enp0s10 UP SLAVE(data0)', + 'enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 UP 169.254.202.2/24 192.168.204.2/24 192.168.206.2/24 ' + 'fd01::2/64 fd02::2/64', + 'enp0s9 UP SLAVE(data0)'], + self._nwmock.get_links_status()) + + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024', + '14.14.1.0/24 via 10.20.1.111 dev enp0s3 metric 1', + 'fa01:1::/64 via fd00::111 dev enp0s3 metric 1', + '14.15.1.0/24 via 169.254.202.111 dev enp0s8 metric 1', + '14.14.2.0/24 via 192.168.204.111 dev enp0s8 metric 1', + '14.14.3.0/24 via 192.168.206.111 dev enp0s8 metric 1', + '14.14.4.0/24 via 112.154.1.111 dev data0 metric 1', + '14.14.5.0/24 via 112.155.1.111 dev data1 metric 1', + 'fa01:2::/64 via fd01::111 dev enp0s8 metric 1', + 'fa01:3::/64 via fd02::111 dev enp0s8 metric 1', + 'fa01:4::/64 via fc01:154:1::111 dev data0 metric 1', + 'fa01:5::/64 via fc01:155:1::111 dev data1 metric 1'], + self._nwmock.get_routes()) + + self._check_etc_file_list(self._LEFT) + + +class TestUpgrade(BaseTestCase): + _CFG = { + "interfaces": { + "auto": ["enp0s3", "enp0s3:1-1", "enp0s3:1-2", "enp0s8", "enp0s8:2-3", "enp0s8:2-4"], + "lo": {}, + "enp0s3": {}, + "enp0s3:1-1": {"address": "10.20.1.2/24", "gateway": "10.20.1.1"}, + "enp0s3:1-2": {"address": "fd00::1:2/64", "gateway": "fd00::1"}, + "enp0s8": {"address": "169.254.202.2/24"}, + "enp0s8:2-3": {"address": "192.168.204.2/24"}, + "enp0s8:2-4": {"address": "fd01::2/64"}}, + } + + _MIN_CFG = { + "interfaces": { + "lo": {}, + } + } + + _STATIC_LINKS = ["enp0s3", "enp0s8"] + + def _setup_scenario(self, fs_contents): + self._add_fs_mock(fs_contents) + self._add_nw_mock(self._STATIC_LINKS) + self._add_scmd_mock() + self._add_logger_mock() + self._fs.set_file_contents(anc.UPGRADE_FILE, '') + stdout = self._nwmock.apply_auto() + self.assertEqual("", stdout) + + def _run_update_interfaces(self): + self._mocked_call([self._mock_fs, self._mock_syscmd, + self._mock_sysinv_lock, self._mock_logger], anc.update_interfaces) + + def test_upgrade_no_change(self): + self._setup_scenario(FILE_GEN.generate_file_tree( + etc_files=self._CFG, puppet_files=self._CFG)) + self._run_update_interfaces() + self.assertEqual([ + ('info', 'Upgrade bootstrap is in execution'), + ('info', 'Configuring interface enp0s3'), + ('info', 'Configuring interface enp0s8'), + ('info', 'Configuring interface enp0s3:1-1'), + ('info', "Link already has address '10.20.1.2/24', no need to set label up"), + ('info', 'Adding route: default via 10.20.1.1 dev enp0s3'), + ('info', 'Route already exists, skipping'), + ('info', 'Configuring interface enp0s3:1-2'), + ('info', "Link already has address 'fd00::1:2/64', no need to set label up"), + ('info', 'Adding route: default via fd00::1 dev enp0s3'), + ('info', 'Route already exists, skipping'), + ('info', 'Configuring interface enp0s8:2-3'), + ('info', "Link already has address '192.168.204.2/24', no need to set label up"), + ('info', 'Configuring interface enp0s8:2-4'), + ('info', "Link already has address 'fd01::2/64', no need to set label up")], + self._log.get_history()) + + def test_upgrade_none_configured(self): + self._setup_scenario(FILE_GEN.generate_file_tree( + etc_files=self._MIN_CFG, puppet_files=self._CFG)) + self._run_update_interfaces() + self.assertEqual(['enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 UP 169.254.202.2/24 192.168.204.2/24 fd01::2/64'], + self._nwmock.get_links_status()) + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024'], + self._nwmock.get_routes()) + self.assertEqual([ + ('info', 'Upgrade bootstrap is in execution'), + ('info', 'Configuring interface enp0s3'), + ('info', "Interface 'enp0s3' is missing or down, flushing IPs and bringing up"), + ('info', 'Bringing enp0s3 up'), + ('info', 'Configuring interface enp0s8'), + ('info', "Interface 'enp0s8' is missing or down, flushing IPs and bringing up"), + ('info', 'Bringing enp0s8 up'), + ('info', 'Configuring interface enp0s3:1-1'), + ('info', 'Bringing enp0s3:1-1 up'), + ('info', 'Configuring interface enp0s3:1-2'), + ('info', 'Bringing enp0s3:1-2 up'), + ('info', 'Configuring interface enp0s8:2-3'), + ('info', 'Bringing enp0s8:2-3 up'), + ('info', 'Configuring interface enp0s8:2-4'), + ('info', 'Bringing enp0s8:2-4 up')], + self._log.get_history()) + + def test_upgrade_already_configured(self): + self._setup_scenario(FILE_GEN.generate_file_tree( + etc_files=self._MIN_CFG, puppet_files=self._CFG)) + self._nwmock.ip_link_set_up("enp0s3") + self._nwmock.ip_addr_add("10.20.1.2/24", "enp0s3") + self._nwmock.ip_addr_add("fd00::1:2/64", "enp0s3") + self._nwmock.ip_route_add("default", "10.20.1.111", "enp0s3", "1") + self._nwmock.ip_link_set_up("enp0s8") + self._nwmock.ip_addr_add("192.168.208.2/24", "enp0s8") + self._run_update_interfaces() + self.assertEqual([ + 'enp0s3 UP 10.20.1.2/24 fd00::1:2/64', + 'enp0s8 UP 169.254.202.2/24 192.168.204.2/24 192.168.208.2/24 fd01::2/64'], + self._nwmock.get_links_status()) + self.assertEqual(['default via 10.20.1.1 dev enp0s3', + 'default via fd00::1 dev enp0s3 metric 1024'], + self._nwmock.get_routes()) + self.assertEqual([ + ('info', 'Upgrade bootstrap is in execution'), + ('info', 'Configuring interface enp0s3'), + ('info', 'Configuring interface enp0s8'), + ('info', 'Adding IP 169.254.202.2/24 to interface enp0s8'), + ('info', 'Configuring interface enp0s3:1-1'), + ('info', "Link already has address '10.20.1.2/24', no need to set label up"), + ('info', 'Adding route: default via 10.20.1.1 dev enp0s3'), + ('info', 'Route to specified network already exists, replacing: default via ' + '10.20.1.111 dev enp0s3 metric 1'), + ('info', 'Configuring interface enp0s3:1-2'), + ('info', "Link already has address 'fd00::1:2/64', no need to set label up"), + ('info', 'Adding route: default via fd00::1 dev enp0s3'), + ('info', 'Configuring interface enp0s8:2-3'), + ('info', 'Bringing enp0s8:2-3 up'), + ('info', 'Configuring interface enp0s8:2-4'), + ('info', 'Bringing enp0s8:2-4 up')], + self._log.get_history()) diff --git a/puppet-manifests/tox.ini b/puppet-manifests/tox.ini index 5345c8efd..8f2550882 100644 --- a/puppet-manifests/tox.ini +++ b/puppet-manifests/tox.ini @@ -10,12 +10,26 @@ # and then run "tox" from this directory. [tox] toxworkdir = /tmp/{env:USER}_puppet-manifests -envlist = puppetlint +envlist = py39,puppetlint skipsdist = True [testenv] recreate = True +[testenv:py39] +basepython = python3.9 +sitepackages = False + +setenv = VIRTUAL_ENV={envdir} + OS_TEST_PATH=./tests + +deps = + -r{toxinidir}/test-requirements.txt + +commands = + stestr run {posargs} + stestr slowest + [testenv:puppetlint] # Note: centos developer env requires ruby-devel # Ubuntu developer env requires ruby-dev diff --git a/pylint.rc b/pylint.rc index 215dcbf3a..08da8b746 100755 --- a/pylint.rc +++ b/pylint.rc @@ -123,8 +123,10 @@ enable=E1603,E1609,E1610,E1602,E1606,E1608,E1607,E1605,E1604,E1601,E1611,W1652, # See "Messages Control" section of # https://pylint.readthedocs.io/en/latest/user_guide # We are disabling (C)onvention +# W0201: attribute-defined-outside-init +# W1202: logging-format-interpolation # W1618: no-absolute-import -disable=C, W1618 +disable=C, W0201,W1202,W1618 [REPORTS] # Set the output format. Available formats are text, parseable, colorized, msvs diff --git a/test-requirements.txt b/test-requirements.txt index b1fba817e..612ccf7b3 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,5 @@ bashate >= 0.2 bandit!=1.6.0,>=1.1.0,<2.0.0;python_version>="3.0" # GPLv2 shellcheck-py;python_version>="3.0" # MIT netaddr >= 0.7.19 +mock>=2.0.0 +testtools>=1.4.0 diff --git a/tox.ini b/tox.ini index 37539c1d7..80595b923 100644 --- a/tox.ini +++ b/tox.ini @@ -141,7 +141,7 @@ description = commands = - flake8 puppet-manifests/src/modules/platform/files + flake8 puppet-manifests [testenv:pylint] basepython = python3 @@ -155,11 +155,17 @@ commands = [flake8] # E123, E125 skipped as they are invalid PEP-8. -# E501 skipped because some of the code files include templates -# that end up quite wide +# E126 continuation line over-indented for hanging indent +# E127 continuation line over-indented for visual indent +# H104: File contains nothing but comments +# H306: imports not in alphabetical order +# H404: multi line docstring should start without a leading new line # H405: multi line docstring summary not separated with an empty line +# W504: line break after binary operator show-source = True -ignore = E123,E125,E501,H405,W504 +ignore = E123,E125,E126,E127,H104,H306,H404,H405,W504 +# Max line length set to 100 to coincide with opendev's code view width +max-line-length = 100 exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build,release-tag-* [testenv:bandit]