diff --git a/salt/modules/linux_ip.py b/salt/modules/linux_ip.py index b56d64f6a1a..01cc0e285d5 100644 --- a/salt/modules/linux_ip.py +++ b/salt/modules/linux_ip.py @@ -18,6 +18,8 @@ def __virtual__(): return (False, "Module linux_ip: Windows systems are not supported.") if __grains__["os_family"] == "RedHat": return (False, "Module linux_ip: RedHat systems are not supported.") + if __grains__["os_family"] == "Suse": + return (False, "Module linux_ip: SUSE systems are not supported.") if __grains__["os_family"] == "Debian": return (False, "Module linux_ip: Debian systems are not supported.") if __grains__["os_family"] == "NILinuxRT": diff --git a/salt/modules/suse_ip.py b/salt/modules/suse_ip.py new file mode 100644 index 00000000000..14175543aca --- /dev/null +++ b/salt/modules/suse_ip.py @@ -0,0 +1,1166 @@ +""" +The networking module for SUSE based distros +""" + +import logging +import os + +import jinja2 +import jinja2.exceptions +import salt.utils.files +import salt.utils.stringutils +import salt.utils.templates +import salt.utils.validate.net +from salt.exceptions import CommandExecutionError + +# Set up logging +log = logging.getLogger(__name__) + +# Set up template environment +JINJA = jinja2.Environment( + loader=jinja2.FileSystemLoader( + os.path.join(salt.utils.templates.TEMPLATE_DIRNAME, "suse_ip") + ) +) + +# Define the module's virtual name +__virtualname__ = "ip" + +# Default values for bonding +_BOND_DEFAULTS = { + # 803.ad aggregation selection logic + # 0 for stable (default) + # 1 for bandwidth + # 2 for count + "ad_select": "0", + # Max number of transmit queues (default = 16) + "tx_queues": "16", + # lacp_rate 0: Slow - every 30 seconds + # lacp_rate 1: Fast - every 1 second + "lacp_rate": "0", + # Max bonds for this driver + "max_bonds": "1", + # Used with miimon. + # On: driver sends mii + # Off: ethtool sends mii + "use_carrier": "0", + # Default. Don't change unless you know what you are doing. + "xmit_hash_policy": "layer2", +} +_SUSE_NETWORK_SCRIPT_DIR = "/etc/sysconfig/network" +_SUSE_NETWORK_FILE = "/etc/sysconfig/network/config" +_SUSE_NETWORK_ROUTES_FILE = "/etc/sysconfig/network/routes" +_CONFIG_TRUE = ("yes", "on", "true", "1", True) +_CONFIG_FALSE = ("no", "off", "false", "0", False) +_IFACE_TYPES = ( + "eth", + "bond", + "alias", + "clone", + "ipsec", + "dialup", + "bridge", + "slave", + "vlan", + "ipip", + "ib", +) + + +def __virtual__(): + """ + Confine this module to SUSE based distros + """ + if __grains__["os_family"] == "Suse": + return __virtualname__ + return ( + False, + "The suse_ip execution module cannot be loaded: " + "this module is only available on SUSE based distributions.", + ) + + +def _error_msg_iface(iface, option, expected): + """ + Build an appropriate error message from a given option and + a list of expected values. + """ + if isinstance(expected, str): + expected = (expected,) + msg = "Invalid option -- Interface: {}, Option: {}, Expected: [{}]" + return msg.format(iface, option, "|".join(str(e) for e in expected)) + + +def _error_msg_routes(iface, option, expected): + """ + Build an appropriate error message from a given option and + a list of expected values. + """ + msg = "Invalid option -- Route interface: {}, Option: {}, Expected: [{}]" + return msg.format(iface, option, expected) + + +def _log_default_iface(iface, opt, value): + log.info( + "Using default option -- Interface: %s Option: %s Value: %s", iface, opt, value + ) + + +def _error_msg_network(option, expected): + """ + Build an appropriate error message from a given option and + a list of expected values. + """ + if isinstance(expected, str): + expected = (expected,) + msg = "Invalid network setting -- Setting: {}, Expected: [{}]" + return msg.format(option, "|".join(str(e) for e in expected)) + + +def _log_default_network(opt, value): + log.info("Using existing setting -- Setting: %s Value: %s", opt, value) + + +def _parse_suse_config(path): + suse_config = _read_file(path) + cv_suse_config = {} + if suse_config: + for line in suse_config: + line = line.strip() + if len(line) == 0 or line.startswith("!") or line.startswith("#"): + continue + pair = [p.rstrip() for p in line.split("=", 1)] + if len(pair) != 2: + continue + name, value = pair + cv_suse_config[name.upper()] = salt.utils.stringutils.dequote(value) + + return cv_suse_config + + +def _parse_ethtool_opts(opts, iface): + """ + Filters given options and outputs valid settings for ETHTOOLS_OPTIONS + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + config = {} + + if "autoneg" in opts: + if opts["autoneg"] in _CONFIG_TRUE: + config.update({"autoneg": "on"}) + elif opts["autoneg"] in _CONFIG_FALSE: + config.update({"autoneg": "off"}) + else: + _raise_error_iface(iface, "autoneg", _CONFIG_TRUE + _CONFIG_FALSE) + + if "duplex" in opts: + valid = ["full", "half"] + if opts["duplex"] in valid: + config.update({"duplex": opts["duplex"]}) + else: + _raise_error_iface(iface, "duplex", valid) + + if "speed" in opts: + valid = ["10", "100", "1000", "10000"] + if str(opts["speed"]) in valid: + config.update({"speed": opts["speed"]}) + else: + _raise_error_iface(iface, opts["speed"], valid) + + if "advertise" in opts: + valid = [ + "0x001", + "0x002", + "0x004", + "0x008", + "0x010", + "0x020", + "0x20000", + "0x8000", + "0x1000", + "0x40000", + "0x80000", + "0x200000", + "0x400000", + "0x800000", + "0x1000000", + "0x2000000", + "0x4000000", + ] + if str(opts["advertise"]) in valid: + config.update({"advertise": opts["advertise"]}) + else: + _raise_error_iface(iface, "advertise", valid) + + if "channels" in opts: + channels_cmd = "-L {}".format(iface.strip()) + channels_params = [] + for option in ("rx", "tx", "other", "combined"): + if option in opts["channels"]: + valid = range(1, __grains__["num_cpus"] + 1) + if opts["channels"][option] in valid: + channels_params.append( + "{} {}".format(option, opts["channels"][option]) + ) + else: + _raise_error_iface(iface, opts["channels"][option], valid) + if channels_params: + config.update({channels_cmd: " ".join(channels_params)}) + + valid = _CONFIG_TRUE + _CONFIG_FALSE + for option in ("rx", "tx", "sg", "tso", "ufo", "gso", "gro", "lro"): + if option in opts: + if opts[option] in _CONFIG_TRUE: + config.update({option: "on"}) + elif opts[option] in _CONFIG_FALSE: + config.update({option: "off"}) + else: + _raise_error_iface(iface, option, valid) + + return config + + +def _parse_settings_bond(opts, iface): + """ + Filters given options and outputs valid settings for requested + operation. If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + if opts["mode"] in ("balance-rr", "0"): + log.info("Device: %s Bonding Mode: load balancing (round-robin)", iface) + return _parse_settings_bond_0(opts, iface) + elif opts["mode"] in ("active-backup", "1"): + log.info("Device: %s Bonding Mode: fault-tolerance (active-backup)", iface) + return _parse_settings_bond_1(opts, iface) + elif opts["mode"] in ("balance-xor", "2"): + log.info("Device: %s Bonding Mode: load balancing (xor)", iface) + return _parse_settings_bond_2(opts, iface) + elif opts["mode"] in ("broadcast", "3"): + log.info("Device: %s Bonding Mode: fault-tolerance (broadcast)", iface) + return _parse_settings_bond_3(opts, iface) + elif opts["mode"] in ("802.3ad", "4"): + log.info( + "Device: %s Bonding Mode: IEEE 802.3ad Dynamic link aggregation", iface + ) + return _parse_settings_bond_4(opts, iface) + elif opts["mode"] in ("balance-tlb", "5"): + log.info("Device: %s Bonding Mode: transmit load balancing", iface) + return _parse_settings_bond_5(opts, iface) + elif opts["mode"] in ("balance-alb", "6"): + log.info("Device: %s Bonding Mode: adaptive load balancing", iface) + return _parse_settings_bond_6(opts, iface) + else: + valid = ( + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "balance-rr", + "active-backup", + "balance-xor", + "broadcast", + "802.3ad", + "balance-tlb", + "balance-alb", + ) + _raise_error_iface(iface, "mode", valid) + + +def _parse_settings_miimon(opts, iface): + """ + Add shared settings for miimon support used by balance-rr, balance-xor + bonding types. + """ + ret = {} + for binding in ("miimon", "downdelay", "updelay"): + if binding in opts: + try: + int(opts[binding]) + ret.update({binding: opts[binding]}) + except Exception: # pylint: disable=broad-except + _raise_error_iface(iface, binding, "integer") + + if "miimon" in opts and "downdelay" not in opts: + ret["downdelay"] = ret["miimon"] * 2 + + if "miimon" in opts: + if not opts["miimon"]: + _raise_error_iface(iface, "miimon", "nonzero integer") + + for binding in ("downdelay", "updelay"): + if binding in ret: + if ret[binding] % ret["miimon"]: + _raise_error_iface( + iface, + binding, + "0 or a multiple of miimon ({})".format(ret["miimon"]), + ) + + if "use_carrier" in opts: + if opts["use_carrier"] in _CONFIG_TRUE: + ret.update({"use_carrier": "1"}) + elif opts["use_carrier"] in _CONFIG_FALSE: + ret.update({"use_carrier": "0"}) + else: + valid = _CONFIG_TRUE + _CONFIG_FALSE + _raise_error_iface(iface, "use_carrier", valid) + else: + _log_default_iface(iface, "use_carrier", _BOND_DEFAULTS["use_carrier"]) + ret.update({"use_carrier": _BOND_DEFAULTS["use_carrier"]}) + + return ret + + +def _parse_settings_arp(opts, iface): + """ + Add shared settings for arp used by balance-rr, balance-xor bonding types. + """ + ret = {} + if "arp_interval" in opts: + try: + int(opts["arp_interval"]) + ret.update({"arp_interval": opts["arp_interval"]}) + except Exception: # pylint: disable=broad-except + _raise_error_iface(iface, "arp_interval", "integer") + + # ARP targets in n.n.n.n form + valid = "list of ips (up to 16)" + if "arp_ip_target" in opts: + if isinstance(opts["arp_ip_target"], list): + if 1 <= len(opts["arp_ip_target"]) <= 16: + ret.update({"arp_ip_target": ",".join(opts["arp_ip_target"])}) + else: + _raise_error_iface(iface, "arp_ip_target", valid) + else: + _raise_error_iface(iface, "arp_ip_target", valid) + else: + _raise_error_iface(iface, "arp_ip_target", valid) + + return ret + + +def _parse_settings_bond_0(opts, iface): + """ + Filters given options and outputs valid settings for bond0. + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + bond = {"mode": "0"} + bond.update(_parse_settings_miimon(opts, iface)) + bond.update(_parse_settings_arp(opts, iface)) + + if "miimon" not in opts and "arp_interval" not in opts: + _raise_error_iface( + iface, "miimon or arp_interval", "at least one of these is required" + ) + + return bond + + +def _parse_settings_bond_1(opts, iface): + + """ + Filters given options and outputs valid settings for bond1. + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + bond = {"mode": "1"} + bond.update(_parse_settings_miimon(opts, iface)) + + if "miimon" not in opts: + _raise_error_iface(iface, "miimon", "integer") + + if "primary" in opts: + bond.update({"primary": opts["primary"]}) + + return bond + + +def _parse_settings_bond_2(opts, iface): + """ + Filters given options and outputs valid settings for bond2. + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + bond = {"mode": "2"} + bond.update(_parse_settings_miimon(opts, iface)) + bond.update(_parse_settings_arp(opts, iface)) + + if "miimon" not in opts and "arp_interval" not in opts: + _raise_error_iface( + iface, "miimon or arp_interval", "at least one of these is required" + ) + + if "hashing-algorithm" in opts: + valid = ("layer2", "layer2+3", "layer3+4") + if opts["hashing-algorithm"] in valid: + bond.update({"xmit_hash_policy": opts["hashing-algorithm"]}) + else: + _raise_error_iface(iface, "hashing-algorithm", valid) + + return bond + + +def _parse_settings_bond_3(opts, iface): + + """ + Filters given options and outputs valid settings for bond3. + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + bond = {"mode": "3"} + bond.update(_parse_settings_miimon(opts, iface)) + + if "miimon" not in opts: + _raise_error_iface(iface, "miimon", "integer") + + return bond + + +def _parse_settings_bond_4(opts, iface): + """ + Filters given options and outputs valid settings for bond4. + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + bond = {"mode": "4"} + bond.update(_parse_settings_miimon(opts, iface)) + + if "miimon" not in opts: + _raise_error_iface(iface, "miimon", "integer") + + for binding in ("lacp_rate", "ad_select"): + if binding in opts: + if binding == "lacp_rate": + valid = ("fast", "1", "slow", "0") + if opts[binding] not in valid: + _raise_error_iface(iface, binding, valid) + if opts[binding] == "fast": + opts.update({binding: "1"}) + if opts[binding] == "slow": + opts.update({binding: "0"}) + else: + valid = "integer" + try: + int(opts[binding]) + bond.update({binding: opts[binding]}) + except Exception: # pylint: disable=broad-except + _raise_error_iface(iface, binding, valid) + else: + _log_default_iface(iface, binding, _BOND_DEFAULTS[binding]) + bond.update({binding: _BOND_DEFAULTS[binding]}) + + if "hashing-algorithm" in opts: + valid = ("layer2", "layer2+3", "layer3+4") + if opts["hashing-algorithm"] in valid: + bond.update({"xmit_hash_policy": opts["hashing-algorithm"]}) + else: + _raise_error_iface(iface, "hashing-algorithm", valid) + + return bond + + +def _parse_settings_bond_5(opts, iface): + + """ + Filters given options and outputs valid settings for bond5. + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + bond = {"mode": "5"} + bond.update(_parse_settings_miimon(opts, iface)) + + if "miimon" not in opts: + _raise_error_iface(iface, "miimon", "integer") + + if "primary" in opts: + bond.update({"primary": opts["primary"]}) + + return bond + + +def _parse_settings_bond_6(opts, iface): + + """ + Filters given options and outputs valid settings for bond6. + If an option has a value that is not expected, this + function will log what the Interface, Setting and what it was + expecting. + """ + bond = {"mode": "6"} + bond.update(_parse_settings_miimon(opts, iface)) + + if "miimon" not in opts: + _raise_error_iface(iface, "miimon", "integer") + + if "primary" in opts: + bond.update({"primary": opts["primary"]}) + + return bond + + +def _parse_settings_vlan(opts, iface): + + """ + Filters given options and outputs valid settings for a vlan + """ + vlan = {} + if "reorder_hdr" in opts: + if opts["reorder_hdr"] in _CONFIG_TRUE + _CONFIG_FALSE: + vlan.update({"reorder_hdr": opts["reorder_hdr"]}) + else: + valid = _CONFIG_TRUE + _CONFIG_FALSE + _raise_error_iface(iface, "reorder_hdr", valid) + + if "vlan_id" in opts: + if opts["vlan_id"] > 0: + vlan.update({"vlan_id": opts["vlan_id"]}) + else: + _raise_error_iface(iface, "vlan_id", "Positive integer") + + if "phys_dev" in opts: + if len(opts["phys_dev"]) > 0: + vlan.update({"phys_dev": opts["phys_dev"]}) + else: + _raise_error_iface(iface, "phys_dev", "Non-empty string") + + return vlan + + +def _parse_settings_eth(opts, iface_type, enabled, iface): + """ + Filters given options and outputs valid settings for a + network interface. + """ + result = {"name": iface} + if "proto" in opts: + valid = ["static", "dhcp", "dhcp4", "dhcp6", "autoip", + "dhcp+autoip", "auto6", "6to4", "none"] + if opts["proto"] in valid: + result["proto"] = opts["proto"] + else: + _raise_error_iface(iface, opts["proto"], valid) + + if "mtu" in opts: + try: + result["mtu"] = int(opts["mtu"]) + except ValueError: + _raise_error_iface(iface, "mtu", ["integer"]) + + if "hwaddr" in opts and "macaddr" in opts: + msg = "Cannot pass both hwaddr and macaddr. Must use either hwaddr or macaddr" + log.error(msg) + raise AttributeError(msg) + + if iface_type not in ("bridge",): + ethtool = _parse_ethtool_opts(opts, iface) + if ethtool: + result["ethtool"] = " ".join( + ["{} {}".format(x, y) for x, y in ethtool.items()] + ) + + if iface_type == "slave": + result["proto"] = "none" + + + if iface_type == "bond": + if "mode" not in opts: + msg = "Missing required option 'mode'" + log.error("%s for bond interface '%s'", msg, iface) + raise AttributeError(msg) + bonding = _parse_settings_bond(opts, iface) + if bonding: + result["bonding"] = " ".join( + ["{}={}".format(x, y) for x, y in bonding.items()] + ) + result["devtype"] = "Bond" + if "slaves" in opts: + if isinstance(opts["slaves"], list): + result["slaves"] = opts["slaves"] + else: + result["slaves"] = opts["slaves"].split() + + if iface_type == "vlan": + vlan = _parse_settings_vlan(opts, iface) + if vlan: + result["devtype"] = "Vlan" + for opt in vlan: + result[opt] = opts[opt] + + if iface_type == "eth": + result["devtype"] = "Ethernet" + + if iface_type == "bridge": + result["devtype"] = "Bridge" + bypassfirewall = True + valid = _CONFIG_TRUE + _CONFIG_FALSE + for opt in ("bypassfirewall",): + if opt in opts: + if opts[opt] in _CONFIG_TRUE: + bypassfirewall = True + elif opts[opt] in _CONFIG_FALSE: + bypassfirewall = False + else: + _raise_error_iface(iface, opts[opt], valid) + + bridgectls = [ + "net.bridge.bridge-nf-call-ip6tables", + "net.bridge.bridge-nf-call-iptables", + "net.bridge.bridge-nf-call-arptables", + ] + + if bypassfirewall: + sysctl_value = 0 + else: + sysctl_value = 1 + + for sysctl in bridgectls: + try: + __salt__["sysctl.persist"](sysctl, sysctl_value) + except CommandExecutionError: + log.warning("Failed to set sysctl: %s", sysctl) + + else: + if "bridge" in opts: + result["bridge"] = opts["bridge"] + + if iface_type == "ipip": + result["devtype"] = "IPIP" + for opt in ("my_inner_ipaddr", "my_outer_ipaddr"): + if opt not in opts: + _raise_error_iface(iface, opt, "1.2.3.4") + else: + result[opt] = opts[opt] + if iface_type == "ib": + result["devtype"] = "InfiniBand" + + if "prefix" in opts: + if "netmask" in opts: + msg = "Cannot use prefix and netmask together" + log.error(msg) + raise AttributeError(msg) + result["prefix"] = opts["prefix"] + elif "netmask" in opts: + result["netmask"] = opts["netmask"] + + for opt in ( + "ipaddr", + "master", + "srcaddr", + "delay", + "domain", + "gateway", + "uuid", + "nickname", + "zone", + ): + if opt in opts: + result[opt] = opts[opt] + + if "ipaddrs" in opts or "ipv6addr" in opts or "ipv6addrs" in opts: + result["ipaddrs"] = [] + if "ipaddrs" in opts: + for opt in opts["ipaddrs"]: + if salt.utils.validate.net.ipv4_addr(opt) or salt.utils.validate.net.ipv6_addr(opt): + result['ipaddrs'].append(opt) + else: + msg = "{} is invalid ipv4 or ipv6 CIDR".format(opt) + log.error(msg) + raise AttributeError(msg) + if "ipv6addr" in opts: + if salt.utils.validate.net.ipv6_addr(opts["ipv6addr"]): + result['ipaddrs'].append(opts["ipv6addr"]) + else: + msg = "{} is invalid ipv6 CIDR".format(opt) + log.error(msg) + raise AttributeError(msg) + if "ipv6addrs" in opts: + for opt in opts["ipv6addrs"]: + if salt.utils.validate.net.ipv6_addr(opt): + result['ipaddrs'].append(opt) + else: + msg = "{} is invalid ipv6 CIDR".format(opt) + log.error(msg) + raise AttributeError(msg) + + if "enable_ipv6" in opts: + result["enable_ipv6"] = opts["enable_ipv6"] + + valid = _CONFIG_TRUE + _CONFIG_FALSE + for opt in ( + "onparent", + "peerdns", + "peerroutes", + "slave", + "vlan", + "defroute", + "stp", + "ipv6_peerdns", + "ipv6_defroute", + "ipv6_peerroutes", + "ipv6_autoconf", + "ipv4_failure_fatal", + "dhcpv6c", + ): + if opt in opts: + if opts[opt] in _CONFIG_TRUE: + result[opt] = "yes" + elif opts[opt] in _CONFIG_FALSE: + result[opt] = "no" + else: + _raise_error_iface(iface, opts[opt], valid) + + if "onboot" in opts: + log.warning( + "The 'onboot' option is controlled by the 'enabled' option. " + "Interface: %s Enabled: %s", + iface, + enabled, + ) + + if "startmode" in opts: + valid = ("manual", "auto", "nfsroot", "hotplug", "off") + if opts["startmode"] in valid: + result["startmode"] = opts["startmode"] + else: + _raise_error_iface(iface, opts["startmode"], valid) + else: + if enabled: + result["startmode"] = "auto" + else: + result["startmode"] = "off" + + # This vlan is in opts, and should be only used in range interface + # will affect jinja template for interface generating + if "vlan" in opts: + if opts["vlan"] in _CONFIG_TRUE: + result["vlan"] = "yes" + elif opts["vlan"] in _CONFIG_FALSE: + result["vlan"] = "no" + else: + _raise_error_iface(iface, opts["vlan"], valid) + + if "arpcheck" in opts: + if opts["arpcheck"] in _CONFIG_FALSE: + result["arpcheck"] = "no" + + if "ipaddr_start" in opts: + result["ipaddr_start"] = opts["ipaddr_start"] + + if "ipaddr_end" in opts: + result["ipaddr_end"] = opts["ipaddr_end"] + + if "clonenum_start" in opts: + result["clonenum_start"] = opts["clonenum_start"] + + if "hwaddr" in opts: + result["hwaddr"] = opts["hwaddr"] + + if "macaddr" in opts: + result["macaddr"] = opts["macaddr"] + + # If NetworkManager is available, we can control whether we use + # it or not + if "nm_controlled" in opts: + if opts["nm_controlled"] in _CONFIG_TRUE: + result["nm_controlled"] = "yes" + elif opts["nm_controlled"] in _CONFIG_FALSE: + result["nm_controlled"] = "no" + else: + _raise_error_iface(iface, opts["nm_controlled"], valid) + else: + result["nm_controlled"] = "no" + + return result + + +def _parse_routes(iface, opts): + """ + Filters given options and outputs valid settings for + the route settings file. + """ + # Normalize keys + opts = {k.lower(): v for (k, v) in opts.items()} + result = {} + if "routes" not in opts: + _raise_error_routes(iface, "routes", "List of routes") + + for opt in opts: + result[opt] = opts[opt] + + return result + + +def _parse_network_settings(opts, current): + """ + Filters given options and outputs valid settings for + the global network settings file. + """ + # Normalize keys + opts = {k.lower(): v for (k, v) in opts.items()} + current = {k.lower(): v for (k, v) in current.items()} + + # Check for supported parameters + retain_settings = opts.get("retain_settings", False) + result = {} + if retain_settings: + for opt in current: + nopt = opt + if opt == "netconfig_dns_static_servers": + nopt = "dns" + result[nopt] = current[opt].split() + elif opt == "netconfig_dns_static_searchlist": + nopt = "dns_search" + result[nopt] = current[opt].split() + elif opt.startswith("netconfig_") and opt not in ("netconfig_modules_order", "netconfig_verbose", "netconfig_force_replace"): + nopt = opt[10:] + result[nopt] = current[opt] + else: + result[nopt] = current[opt] + _log_default_network(nopt, current[opt]) + + for opt in opts: + if opt in ("dns", "dns_search") and not isinstance(opts[opt], list): + result[opt] = opts[opt].split() + else: + result[opt] = opts[opt] + return result + + +def _raise_error_iface(iface, option, expected): + """ + Log and raise an error with a logical formatted message. + """ + msg = _error_msg_iface(iface, option, expected) + log.error(msg) + raise AttributeError(msg) + + +def _raise_error_network(option, expected): + """ + Log and raise an error with a logical formatted message. + """ + msg = _error_msg_network(option, expected) + log.error(msg) + raise AttributeError(msg) + + +def _raise_error_routes(iface, option, expected): + """ + Log and raise an error with a logical formatted message. + """ + msg = _error_msg_routes(iface, option, expected) + log.error(msg) + raise AttributeError(msg) + + +def _read_file(path): + """ + Reads and returns the contents of a file + """ + try: + with salt.utils.files.fopen(path, "rb") as rfh: + lines = salt.utils.stringutils.to_unicode(rfh.read()).splitlines() + try: + lines.remove("") + except ValueError: + pass + return lines + except Exception: # pylint: disable=broad-except + return [] # Return empty list for type consistency + + +def _write_file_iface(iface, data, folder, pattern): + """ + Writes a file to disk + """ + filename = os.path.join(folder, pattern.format(iface)) + if not os.path.exists(folder): + msg = "{} cannot be written. {} does not exist" + msg = msg.format(filename, folder) + log.error(msg) + raise AttributeError(msg) + with salt.utils.files.fopen(filename, "w") as fp_: + fp_.write(salt.utils.stringutils.to_str(data)) + + +def _write_file_network(data, filename): + """ + Writes a file to disk + """ + with salt.utils.files.fopen(filename, "w") as fp_: + fp_.write(salt.utils.stringutils.to_str(data)) + + +def _read_temp(data): + lines = data.splitlines() + try: # Discard newlines if they exist + lines.remove("") + except ValueError: + pass + return lines + + +def build_interface(iface, iface_type, enabled, **settings): + """ + Build an interface script for a network interface. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_interface eth0 eth + """ + iface_type = iface_type.lower() + + if iface_type not in _IFACE_TYPES: + _raise_error_iface(iface, iface_type, _IFACE_TYPES) + + if iface_type == "slave": + settings["slave"] = "yes" + if "master" not in settings: + msg = "master is a required setting for slave interfaces" + log.error(msg) + raise AttributeError(msg) + + if iface_type == "bond": + if "mode" not in settings: + msg = "mode is required for bond interfaces" + log.error(msg) + raise AttributeError(msg) + settings["mode"] = str(settings["mode"]) + + if iface_type == "vlan": + settings["vlan"] = "yes" + + if iface_type == "bridge" and not __salt__["pkg.version"]("bridge-utils"): + __salt__["pkg.install"]("bridge-utils") + + if iface_type in ( + "eth", + "bond", + "bridge", + "slave", + "vlan", + "ipip", + "ib", + "alias", + ): + opts = _parse_settings_eth(settings, iface_type, enabled, iface) + try: + template = JINJA.get_template("ifcfg.jinja") + except jinja2.exceptions.TemplateNotFound: + log.error("Could not load template ifcfg.jinja") + return "" + log.debug("Interface opts: \n %s", opts) + ifcfg = template.render(opts) + + if settings.get("test"): + return _read_temp(ifcfg) + + _write_file_iface(iface, ifcfg, _SUSE_NETWORK_SCRIPT_DIR, "ifcfg-{}") + path = os.path.join(_SUSE_NETWORK_SCRIPT_DIR, "ifcfg-{}".format(iface)) + + return _read_file(path) + + +def build_routes(iface, **settings): + """ + Build a route script for a network interface. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_routes eth0 + """ + + template = "ifroute.jinja" + log.debug("Template name: %s", template) + + opts = _parse_routes(iface, settings) + log.debug("Opts: \n %s", opts) + try: + template = JINJA.get_template(template) + except jinja2.exceptions.TemplateNotFound: + log.error("Could not load template %s", template) + return "" + log.debug("IP routes:\n%s", opts["routes"]) + + if iface == "routes": + routecfg = template.render(routes=opts["routes"]) + else: + routecfg = template.render(routes=opts["routes"], iface=iface) + + if settings["test"]: + return _read_temp(routecfg) + + if iface == "routes": + path = _SUSE_NETWORK_ROUTES_FILE + else: + path = os.path.join(_SUSE_NETWORK_SCRIPT_DIR, "ifroute-{}".format(iface)) + + _write_file_network(routecfg, path) + + return _read_file(path) + + +def down(iface, iface_type=None): + """ + Shutdown a network interface + + CLI Example: + + .. code-block:: bash + + salt '*' ip.down eth0 + """ + # Slave devices are controlled by the master. + if not iface_type or iface_type.lower() != "slave": + return __salt__["cmd.run"]("ifdown {}".format(iface)) + return None + + +def get_interface(iface): + """ + Return the contents of an interface script + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_interface eth0 + """ + path = os.path.join(_SUSE_NETWORK_SCRIPT_DIR, "ifcfg-{}".format(iface)) + return _read_file(path) + + +def up(iface, iface_type=None): + """ + Start up a network interface + + CLI Example: + + .. code-block:: bash + + salt '*' ip.up eth0 + """ + # Slave devices are controlled by the master. + if not iface_type or iface_type.lower() != "slave": + return __salt__["cmd.run"]("ifup {}".format(iface)) + return None + + +def get_routes(iface): + """ + Return the contents of the interface routes script. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_routes eth0 + """ + if iface == "routes": + path = _SUSE_NETWORK_ROUTES_FILE + else: + path = os.path.join(_SUSE_NETWORK_SCRIPT_DIR, "ifroute-{}".format(iface)) + return _read_file(path) + + +def get_network_settings(): + """ + Return the contents of the global network script. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.get_network_settings + """ + return _read_file(_SUSE_NETWORK_FILE) + + +def apply_network_settings(**settings): + """ + Apply global network configuration. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.apply_network_settings + """ + if "require_reboot" not in settings: + settings["require_reboot"] = False + + if "apply_hostname" not in settings: + settings["apply_hostname"] = False + + hostname_res = True + if settings["apply_hostname"] in _CONFIG_TRUE: + if "hostname" in settings: + hostname_res = __salt__["network.mod_hostname"](settings["hostname"]) + else: + log.warning( + "The network state sls is trying to apply hostname " + "changes but no hostname is defined." + ) + hostname_res = False + + res = True + if settings["require_reboot"] in _CONFIG_TRUE: + log.warning( + "The network state sls is requiring a reboot of the system to " + "properly apply network configuration." + ) + res = True + else: + res = __salt__["service.reload"]("network") + + return hostname_res and res + + +def build_network_settings(**settings): + """ + Build the global network script. + + CLI Example: + + .. code-block:: bash + + salt '*' ip.build_network_settings + """ + # Read current configuration and store default values + current_network_settings = _parse_suse_config(_SUSE_NETWORK_FILE) + + # Build settings + opts = _parse_network_settings(settings, current_network_settings) + try: + template = JINJA.get_template("network.jinja") + except jinja2.exceptions.TemplateNotFound: + log.error("Could not load template network.jinja") + return "" + network = template.render(opts) + + if settings["test"]: + return _read_temp(network) + + # Write settings + _write_file_network(network, _SUSE_NETWORK_FILE) + + __salt__["cmd.run"]("netconfig update -f") + + return _read_file(_SUSE_NETWORK_FILE) diff --git a/salt/states/network.py b/salt/states/network.py index 0788ee5655a..aa838528797 100644 --- a/salt/states/network.py +++ b/salt/states/network.py @@ -650,25 +650,30 @@ def managed(name, enabled=True, **kwargs): present_slaves = __salt__["cmd.run"]( ["cat", "/sys/class/net/{}/bonding/slaves".format(name)] ).split() - desired_slaves = kwargs["slaves"].split() + if isinstance(kwargs['slaves'], list): + desired_slaves = kwargs['slaves'] + else: + desired_slaves = kwargs['slaves'].split() missing_slaves = set(desired_slaves) - set(present_slaves) # Enslave only slaves missing in master if missing_slaves: - ifenslave_path = __salt__["cmd.run"](["which", "ifenslave"]).strip() - if ifenslave_path: - log.info( - "Adding slaves '%s' to the master %s", - " ".join(missing_slaves), - name, + log.debug("Missing slaves of {}: {}".format(name, missing_slaves)) + if __grains__["os_family"] != "Suse": + ifenslave_path = __salt__["cmd.run"](["which", "ifenslave"]).strip() + if ifenslave_path: + log.info( + "Adding slaves '%s' to the master %s", + " ".join(missing_slaves), + name, + ) + cmd = [ifenslave_path, name] + list(missing_slaves) + __salt__["cmd.run"](cmd, python_shell=False) + else: + log.error("Command 'ifenslave' not found") + ret["changes"]["enslave"] = "Added slaves '{}' to master '{}'".format( + " ".join(missing_slaves), name ) - cmd = [ifenslave_path, name] + list(missing_slaves) - __salt__["cmd.run"](cmd, python_shell=False) - else: - log.error("Command 'ifenslave' not found") - ret["changes"]["enslave"] = "Added slaves '{}' to master '{}'".format( - " ".join(missing_slaves), name - ) else: log.info( "All slaves '%s' are already added to the master %s" diff --git a/salt/templates/suse_ip/ifcfg.jinja b/salt/templates/suse_ip/ifcfg.jinja new file mode 100644 index 00000000000..8384d0eab75 --- /dev/null +++ b/salt/templates/suse_ip/ifcfg.jinja @@ -0,0 +1,34 @@ +{% if nickname %}NAME='{{nickname}}' +{%endif%}{% if startmode %}STARTMODE='{{startmode}}' +{%endif%}{% if proto %}BOOTPROTO='{{proto}}' +{%endif%}{% if uuid %}UUID='{{uuid}}' +{%endif%}{% if vlan %}VLAN='{{vlan}}' +{%endif%}{% if team_config %}TEAM_CONFIG='{{team_config}}' +{%endif%}{% if team_port_config %}TEAM_PORT_CONFIG='{{team_port_config}}' +{%endif%}{% if team_master %}TEAM_MASTER='{{team_master}}' +{%endif%}{% if ipaddr %}IPADDR='{{ipaddr}}' +{%endif%}{% if netmask %}NETMASK='{{netmask}}' +{%endif%}{% if prefix %}PREFIXLEN="{{prefix}}" +{%endif%}{% if ipaddrs %}{% for i in ipaddrs -%} +IPADDR{{loop.index}}='{{i}}' +{% endfor -%} +{%endif%}{% if clonenum_start %}CLONENUM_START="{{clonenum_start}}" +{%endif%}{% if gateway %}GATEWAY="{{gateway}}" +{%endif%}{% if arpcheck %}ARPCHECK="{{arpcheck}}" +{%endif%}{% if srcaddr %}SRCADDR="{{srcaddr}}" +{%endif%}{% if defroute %}DEFROUTE="{{defroute}}" +{%endif%}{% if bridge %}BRIDGE="{{bridge}}" +{%endif%}{% if stp %}STP="{{stp}}" +{%endif%}{% if delay or delay == 0 %}DELAY="{{delay}}" +{%endif%}{% if mtu %}MTU='{{mtu}}' +{%endif%}{% if zone %}ZONE='{{zone}}' +{%endif%}{% if bonding %}BONDING_MODULE_OPTS='{{bonding}}' +BONDING_MASTER='yes' +{% for sl in slaves -%} +BONDING_SLAVE{{loop.index}}='{{sl}}' +{% endfor -%} +{%endif%}{% if ethtool %}ETHTOOL_OPTIONS='{{ethtool}}' +{%endif%}{% if phys_dev %}ETHERDEVICE='{{phys_dev}}' +{%endif%}{% if vlan_id %}VLAN_ID='{{vlan_id}}' +{%endif%}{% if userctl %}USERCONTROL='{{userctl}}' +{%endif%} diff --git a/salt/templates/suse_ip/ifroute.jinja b/salt/templates/suse_ip/ifroute.jinja new file mode 100644 index 00000000000..0081e4c6881 --- /dev/null +++ b/salt/templates/suse_ip/ifroute.jinja @@ -0,0 +1,8 @@ +{%- for route in routes -%} +{% if route.name %}# {{route.name}} {%- endif %} +{{ route.ipaddr }} +{%- if route.gateway %} {{route.gateway}}{% else %} -{% endif %} +{%- if route.netmask %} {{route.netmask}}{% else %} -{% endif %} +{%- if route.dev %} {{route.dev}}{% else %}{%- if iface and iface != "routes" %} {{iface}}{% else %} -{% endif %}{% endif %} +{%- if route.metric %} metric {{route.metric}} {%- endif %} +{% endfor -%} diff --git a/salt/templates/suse_ip/network.jinja b/salt/templates/suse_ip/network.jinja new file mode 100644 index 00000000000..64ae9112717 --- /dev/null +++ b/salt/templates/suse_ip/network.jinja @@ -0,0 +1,30 @@ +{% if auto6_wait_at_boot %}AUTO6_WAIT_AT_BOOT="{{auto6_wait_at_boot}}" +{%endif%}{% if auto6_update %}AUTO6_UPDATE="{{auto6_update}}" +{%endif%}{% if link_required %}LINK_REQUIRED="{{link_required}}" +{%endif%}{% if wicked_debug %}WICKED_DEBUG="{{wicked_debug}}" +{%endif%}{% if wicked_log_level %}WICKED_LOG_LEVEL="{{wicked_log_level}}" +{%endif%}{% if check_duplicate_ip %}CHECK_DUPLICATE_IP="{{check_duplicate_ip}}" +{%endif%}{% if send_gratuitous_arp %}SEND_GRATUITOUS_ARP="{{send_gratuitous_arp}}" +{%endif%}{% if debug %}DEBUG="{{debug}}" +{%endif%}{% if wait_for_interfaces %}WAIT_FOR_INTERFACES="{{wait_for_interfaces}}" +{%endif%}{% if firewall %}FIREWALL="{{firewall}}" +{%endif%}{% if nm_online_timeout %}NM_ONLINE_TIMEOUT="{{nm_online_timeout}}" +{%endif%}{% if netconfig_modules_order %}NETCONFIG_MODULES_ORDER="{{netconfig_modules_order}}" +{%endif%}{% if netconfig_verbose %}NETCONFIG_VERBOSE="{{netconfig_verbose}}" +{%endif%}{% if netconfig_force_replace %}NETCONFIG_FORCE_REPLACE="{{netconfig_force_replace}}" +{%endif%}{% if dns_policy %}NETCONFIG_DNS_POLICY="{{dns_policy}}" +{%endif%}{% if dns_forwarder %}NETCONFIG_DNS_FORWARDER="{{dns_forwarder}}" +{%endif%}{% if dns_forwarder_fallback %}NETCONFIG_DNS_FORWARDER_FALLBACK="{{dns_forwarder_fallback}}" +{%endif%}{% if dns_search %}NETCONFIG_DNS_STATIC_SEARCHLIST="{{ dns_search|join(' ') }}" +{%endif%}{% if dns %}NETCONFIG_DNS_STATIC_SERVERS="{{ dns|join(' ') }}" +{%endif%}{% if dns_ranking %}NETCONFIG_DNS_RANKING="{{dns_ranking}}" +{%endif%}{% if dns_resolver_options %}NETCONFIG_DNS_RESOLVER_OPTIONS="{{dns_resolver_options}}" +{%endif%}{% if dns_resolver_sortlist %}NETCONFIG_DNS_RESOLVER_SORTLIST="{{dns_resolver_sortlist}}" +{%endif%}{% if ntp_policy %}NETCONFIG_NTP_POLICY="{{ntp_policy}}" +{%endif%}{% if ntp_static_servers %}NETCONFIG_NTP_STATIC_SERVERS="{{ntp_static_servers}}" +{%endif%}{% if nis_policy %}NETCONFIG_NIS_POLICY="{{nis_policy}}" +{%endif%}{% if nis_setdomainname %}NETCONFIG_NIS_SETDOMAINNAME="{{nis_setdomainname}}" +{%endif%}{% if nis_static_domain %}NETCONFIG_NIS_STATIC_DOMAIN="{{nis_static_domain}}" +{%endif%}{% if nis_static_servers %}NETCONFIG_NIS_STATIC_SERVERS="{{nis_static_servers}}" +{%endif%}{% if wireless_regulatory_domain %}WIRELESS_REGULATORY_DOMAIN="{{wireless_regulatory_domain}}" +{%endif%} diff --git a/setup.py b/setup.py index eb28bc7aabb..a87b7f6e134 100755 --- a/setup.py +++ b/setup.py @@ -1115,6 +1115,7 @@ class SaltDistribution(distutils.dist.Distribution): package_data = { "salt.templates": [ "rh_ip/*.jinja", + "suse_ip/*.jinja", "debian_ip/*.jinja", "virt/*.jinja", "git/*", diff --git a/tests/pytests/unit/modules/test_suse_ip.py b/tests/pytests/unit/modules/test_suse_ip.py new file mode 100644 index 00000000000..cd9952e804c --- /dev/null +++ b/tests/pytests/unit/modules/test_suse_ip.py @@ -0,0 +1,706 @@ +""" + :codeauthor: Jayesh Kariya +""" + +import pytest +import copy +import os + +import jinja2.exceptions +import salt.modules.suse_ip as suse_ip +from tests.support.mixins import LoaderModuleMockMixin +from tests.support.mock import MagicMock, patch +from tests.support.unit import TestCase + + +""" +Test cases for salt.modules.suse_ip +""" + +@pytest.fixture +def configure_loader_modules(): + return {suse_ip: {"__grains__": {"os_family": "Suse"}}} + + +def test_error_message_iface_should_process_non_str_expected(): + values = [1, True, False, "no-kaboom"] + iface = "ethtest" + option = "test" + msg = suse_ip._error_msg_iface(iface, option, values) + assert msg + assert msg.endswith("[1|True|False|no-kaboom]") + + +def test_error_message_network_should_process_non_str_expected(): + values = [1, True, False, "no-kaboom"] + msg = suse_ip._error_msg_network("fnord", values) + assert msg + assert msg.endswith("[1|True|False|no-kaboom]") + + +def test_build_interface(): + """ + Test to build an interface script for a network interface. + """ + with patch.object(suse_ip, "_raise_error_iface", return_value=None): + with pytest.raises(AttributeError): + suse_ip.build_interface("iface", "slave", True) + + with patch.dict( + suse_ip.__salt__, {"network.interfaces": lambda: {"eth": True}} + ): + with pytest.raises(AttributeError): + suse_ip.build_interface( + "iface", + "eth", + True, + netmask="255.255.255.255", + prefix=32, + test=True, + ) + suse_ip.build_interface( + "iface", + "eth", + True, + ipaddrs=["A"], + test=True, + ) + suse_ip.build_interface( + "iface", + "eth", + True, + ipv6addrs=["A"], + test=True, + ) + + with patch.object(suse_ip, "_raise_error_iface", return_value=None): + with patch.object(suse_ip, "_parse_settings_bond", MagicMock()): + mock = jinja2.exceptions.TemplateNotFound("foo") + with patch.object( + jinja2.Environment, + "get_template", + MagicMock(side_effect=mock), + ): + assert suse_ip.build_interface("iface", "vlan", True) == "" + + with patch.object(suse_ip, "_read_temp", return_value="A"): + with patch.object( + jinja2.Environment, "get_template", MagicMock() + ): + assert suse_ip.build_interface("iface", "vlan", True, test="A") == "A" + + with patch.object( + suse_ip, "_write_file_iface", return_value=None + ): + with patch.object( + os.path, "join", return_value="A" + ): + with patch.object( + suse_ip, "_read_file", return_value="A" + ): + assert suse_ip.build_interface("iface", "vlan", True) == "A" + with patch.dict( + suse_ip.__salt__, + { + "network.interfaces": lambda: { + "eth": True + } + }, + ): + assert suse_ip.build_interface( + "iface", + "eth", + True, + ipaddrs=["127.0.0.1/8"], + ) == "A" + assert suse_ip.build_interface( + "iface", + "eth", + True, + ipv6addrs=["fc00::1/128"], + ) == "A" + + +def test_build_routes(): + """ + Test to build a route script for a network interface. + """ + with patch.object(suse_ip, "_parse_routes", MagicMock()): + mock = jinja2.exceptions.TemplateNotFound("foo") + with patch.object( + jinja2.Environment, "get_template", MagicMock(side_effect=mock) + ): + assert suse_ip.build_routes("iface") == "" + + with patch.object(jinja2.Environment, "get_template", MagicMock()): + with patch.object(suse_ip, "_read_temp", return_value=["A"]): + assert suse_ip.build_routes("i", test="t") == ["A"] + + with patch.object(suse_ip, "_read_file", return_value=["A"]): + with patch.object(os.path, "join", return_value="A"): + with patch.object( + suse_ip, "_write_file_network", return_value=None + ): + assert suse_ip.build_routes("i", test=None) == ["A"] + + +def test_down(): + """ + Test to shutdown a network interface + """ + with patch.dict(suse_ip.__salt__, {"cmd.run": MagicMock(return_value="A")}): + assert suse_ip.down("iface", "iface_type") == "A" + + assert suse_ip.down("iface", "slave") is None + + +def test_get_interface(): + """ + Test to return the contents of an interface script + """ + with patch.object(os.path, "join", return_value="A"): + with patch.object(suse_ip, "_read_file", return_value="A"): + assert suse_ip.get_interface("iface") == "A" + + +def test__parse_settings_eth_hwaddr_and_macaddr(): + """ + Test that an AttributeError is thrown when hwaddr and macaddr are + passed together. They cannot be used together + """ + opts = {"hwaddr": 1, "macaddr": 2} + + with pytest.raises(AttributeError): + suse_ip._parse_settings_eth( + opts=opts, + iface_type="eth", + enabled=True, + iface="eth0" + ) + + +def test__parse_settings_eth_hwaddr(): + """ + Make sure hwaddr gets added when parsing opts + """ + opts = {"hwaddr": "AA:BB:CC:11:22:33"} + with patch.dict(suse_ip.__salt__, {"network.interfaces": MagicMock()}): + results = suse_ip._parse_settings_eth( + opts=opts, iface_type="eth", enabled=True, iface="eth0" + ) + assert "hwaddr" in results + assert results["hwaddr"] == opts["hwaddr"] + + +def test__parse_settings_eth_macaddr(): + """ + Make sure macaddr gets added when parsing opts + """ + opts = {"macaddr": "AA:BB:CC:11:22:33"} + with patch.dict(suse_ip.__salt__, {"network.interfaces": MagicMock()}): + results = suse_ip._parse_settings_eth( + opts=opts, iface_type="eth", enabled=True, iface="eth0" + ) + assert "macaddr" in results + assert results["macaddr"] == opts["macaddr"] + + +def test__parse_settings_eth_ethtool_channels(): + """ + Make sure channels gets added when parsing opts + """ + opts = {"channels": {"rx": 4, "tx": 4, "combined": 4, "other": 4}} + with patch.dict(suse_ip.__grains__, {"num_cpus": 4}), patch.dict( + suse_ip.__salt__, {"network.interfaces": MagicMock()} + ): + results = suse_ip._parse_settings_eth( + opts=opts, iface_type="eth", enabled=True, iface="eth0" + ) + assert "ethtool" in results + assert results["ethtool"] == "-L eth0 rx 4 tx 4 other 4 combined 4" + + +def test_up(): + """ + Test to start up a network interface + """ + with patch.dict(suse_ip.__salt__, {"cmd.run": MagicMock(return_value="A")}): + assert suse_ip.up("iface", "iface_type") == "A" + + assert suse_ip.up("iface", "slave") is None + + +def test_get_routes(): + """ + Test to return the contents of the interface routes script. + """ + with patch.object(os.path, "join", return_value="A"): + with patch.object(suse_ip, "_read_file", return_value=["A"]): + assert suse_ip.get_routes("iface") == ["A"] + + +def test_get_network_settings(): + """ + Test to return the contents of the global network script. + """ + with patch.object(suse_ip, "_read_file", return_value="A"): + assert suse_ip.get_network_settings() == "A" + + +def test_apply_network_settings(): + """ + Test to apply global network configuration. + """ + with patch.dict( + suse_ip.__salt__, {"service.reload": MagicMock(return_value=True)} + ): + assert suse_ip.apply_network_settings() + + +def test_build_network_settings(): + """ + Test to build the global network script. + """ + with patch.object(suse_ip, "_parse_suse_config", MagicMock()): + with patch.object(suse_ip, "_parse_network_settings", MagicMock()): + + mock = jinja2.exceptions.TemplateNotFound("foo") + with patch.object( + jinja2.Environment, "get_template", MagicMock(side_effect=mock) + ): + assert suse_ip.build_network_settings() == "" + + with patch.object(jinja2.Environment, "get_template", MagicMock()): + with patch.object(suse_ip, "_read_temp", return_value="A"): + assert suse_ip.build_network_settings(test="t") == "A" + + with patch.object( + suse_ip, "_write_file_network", return_value=None + ): + with patch.object(suse_ip, "_read_file", return_value="A"): + cmd_run = MagicMock() + with patch.dict(suse_ip.__salt__, {"cmd.run": cmd_run}): + assert suse_ip.build_network_settings(test=None) == "A" + cmd_run.assert_called_once_with("netconfig update -f") + + +def _check_common_opts_bond(lines): + """ + Reduce code duplication by making sure that the expected options are + present in the config file. Note that this assumes that duplex="full" + was passed in the kwargs. If it wasn't, then there would be no + ETHTOOL_OPTS line. + """ + assert "STARTMODE='auto'" in lines + assert "BONDING_MASTER='yes'" in lines + assert "BONDING_SLAVE1='eth1'" in lines + assert "BONDING_SLAVE2='eth2'" in lines + assert "ETHTOOL_OPTIONS='duplex full'" in lines + + +def _validate_miimon_downdelay(kwargs): + """ + Validate that downdelay that is not a multiple of miimon raises an error + """ + # Make copy of kwargs so we don't modify what was passed in + kwargs = copy.copy(kwargs) + + # Remove miimon and downdelay (if present) to test invalid input + for key in ("miimon", "downdelay"): + kwargs.pop(key, None) + + kwargs["miimon"] = 100 + kwargs["downdelay"] = 201 + try: + suse_ip.build_interface( + "bond0", + "bond", + enabled=True, + **kwargs, + ) + except AttributeError as exc: + assert "multiple of miimon" in str(exc) + else: + raise Exception("AttributeError was not raised") + + +def _validate_miimon_conf(kwargs, required=True): + """ + Validate miimon configuration + """ + # Make copy of kwargs so we don't modify what was passed in + kwargs = copy.copy(kwargs) + + # Remove miimon and downdelay (if present) to test invalid input + for key in ("miimon", "downdelay"): + kwargs.pop(key, None) + + if required: + # Leaving out miimon should raise an error + try: + suse_ip.build_interface( + "bond0", + "bond", + enabled=True, + **kwargs, + ) + except AttributeError as exc: + assert "miimon" in str(exc) + else: + raise Exception("AttributeError was not raised") + + _validate_miimon_downdelay(kwargs) + + +def _get_bonding_opts(kwargs): + results = suse_ip.build_interface( + "bond0", + "bond", + enabled=True, + **kwargs, + ) + _check_common_opts_bond(results) + + for line in results: + if line.startswith("BONDING_MODULE_OPTS="): + return sorted(line.split("=", 1)[-1].strip("'").split()) + raise Exception("BONDING_MODULE_OPTS not found") + + +def _test_mode_0_or_2(mode_num=0): + """ + Modes 0 and 2 share the majority of code, with mode 2 being a superset + of mode 0. This function will do the proper asserts for the common code + in these two modes. + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + } + + if mode_num == 0: + modes = ("balance-rr", mode_num, str(mode_num)) + else: + modes = ("balance-xor", mode_num, str(mode_num)) + + for mode in modes: + kwargs["mode"] = mode + # Remove all miimon/arp settings to test invalid config + for key in ( + "miimon", + "downdelay", + "arp_interval", + "arp_ip_targets", + ): + kwargs.pop(key, None) + + # Check that invalid downdelay is handled correctly + _validate_miimon_downdelay(kwargs) + + # Leaving out miimon and arp_interval should raise an error + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + assert "miimon or arp_interval" in str(exc) + else: + raise Exception("AttributeError was not raised") + + kwargs["miimon"] = 100 + kwargs["downdelay"] = 200 + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode={}".format(mode_num), + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add arp settings, and test again + kwargs["arp_interval"] = 300 + kwargs["arp_ip_target"] = ["1.2.3.4", "5.6.7.8"] + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "arp_interval=300", + "arp_ip_target=1.2.3.4,5.6.7.8", + "downdelay=200", + "miimon=100", + "mode={}".format(mode_num), + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Remove miimon and downdelay and test again + del kwargs["miimon"] + del kwargs["downdelay"] + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "arp_interval=300", + "arp_ip_target=1.2.3.4,5.6.7.8", + "mode={}".format(mode_num), + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_0(): + """ + Test that mode 0 bond interfaces are properly built + """ + _test_mode_0_or_2(0) + + +def test_build_interface_bond_mode_1(): + """ + Test that mode 1 bond interfaces are properly built + """ + kwargs = { + "test": True, + "mode": "active-backup", + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + + for mode in ("active-backup", 1, "1"): + kwargs.pop("primary", None) + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=1", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add a "primary" option and confirm that it shows up in + # the bonding opts. + kwargs["primary"] = "foo" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=1", + "primary=foo", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_2(): + """ + Test that mode 2 bond interfaces are properly built + """ + _test_mode_0_or_2(2) + + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + for mode in ("balance-xor", 2, "2"): + # Using an invalid hashing algorithm should cause an error + # to be raised. + kwargs["mode"] = mode + kwargs["hashing-algorithm"] = "layer42" + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + assert "hashing-algorithm" in str(exc) + else: + raise Exception("AttributeError was not raised") + + # Correct the hashing algorithm and re-run + kwargs["hashing-algorithm"] = "layer2" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=2", + "use_carrier=0", + "xmit_hash_policy=layer2", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_3(): + """ + Test that mode 3 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + + for mode in ("broadcast", 3, "3"): + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=3", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_4(): + """ + Test that mode 4 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + valid_lacp_rate = ("fast", "slow", "1", "0") + valid_ad_select = ("0",) + + for mode in ("802.3ad", 4, "4"): + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + + for lacp_rate in valid_lacp_rate + ("2", "speedy"): + for ad_select in valid_ad_select + ("foo",): + kwargs["lacp_rate"] = lacp_rate + kwargs["ad_select"] = ad_select + try: + bonding_opts = _get_bonding_opts(kwargs) + except AttributeError as exc: + error = str(exc) + # Re-raise the exception only if it was + # unexpected. It should not be expected when + # the lacp_rate or ad_select is valid. + if "lacp_rate" in error: + if lacp_rate in valid_lacp_rate: + raise + elif "ad_select" in error: + if ad_select in valid_ad_select: + raise + else: + raise + else: + expected = [ + "ad_select={}".format(ad_select), + "downdelay=200", + "lacp_rate={}".format( + "1" + if lacp_rate == "fast" + else "0" + if lacp_rate == "slow" + else lacp_rate + ), + "miimon=100", + "mode=4", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_5(): + """ + Test that mode 5 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": "eth1 eth2", + "miimon": 100, + "downdelay": 200, + } + + for mode in ("balance-tlb", 5, "5"): + kwargs.pop("primary", None) + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=5", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add a "primary" option and confirm that it shows up in + # the bonding opts. + kwargs["primary"] = "foo" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=5", + "primary=foo", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_mode_6(): + """ + Test that mode 6 bond interfaces are properly built + """ + kwargs = { + "test": True, + "duplex": "full", + "slaves": ["eth1", "eth2"], + "miimon": 100, + "downdelay": 200, + } + + for mode in ("balance-alb", 6, "6"): + kwargs.pop("primary", None) + kwargs["mode"] = mode + _validate_miimon_conf(kwargs) + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=6", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + # Add a "primary" option and confirm that it shows up in + # the bonding opts. + kwargs["primary"] = "foo" + bonding_opts = _get_bonding_opts(kwargs) + expected = [ + "downdelay=200", + "miimon=100", + "mode=6", + "primary=foo", + "use_carrier=0", + ] + assert bonding_opts == expected, bonding_opts + + +def test_build_interface_bond_slave(): + """ + Test that bond slave interfaces are properly built + """ + results = sorted( + suse_ip.build_interface( + "eth1", + "slave", + enabled=True, + test=True, + master="bond0", + ) + ) + expected = [ + "BOOTPROTO='none'", + "STARTMODE='auto'", + ] + assert results == expected, results